Skip to main content

praxis_runtime/
records.rs

1//! The `Record` descriptor (§7.8).
2//!
3//! A record is a fixed set of named fields, each a `GcRef`. It backs both
4//! declared record types and the anonymous structural records the input
5//! parser's named-capture templates produce, e.g. `lines(`{x:int},{y:int}`)` →
6//! `Vec[{x:Int,y:Int}]`.
7//!
8//! Each distinct record *shape* (field names + element descriptors) gets a
9//! [`RecordSchema`]. The schema is leaked to `&'static` (one per parser plan)
10//! because a record's descriptor callbacks need a type-stable home for the
11//! field descriptors; this matches how the JIT leaks function-name strings.
12//!
13//! The descriptor dispatches element-wise through the schema (§11.4) — there are
14//! no scattered type switches in formatting/tracing. A single `RECORD`-shaped
15//! descriptor serves every record because the per-shape knowledge lives in the
16//! schema referenced from the payload.
17
18use std::fmt::Write as _;
19
20use crate::GcRef;
21use crate::descriptor::{BuiltinTypeId, DynamicHasher, FormatSink, Tracer, TypeDescriptor};
22
23/// One field of a record shape: its source name plus the descriptor for the
24/// values stored at that field. The descriptor pointer is `const` data shared
25/// across all records of this shape.
26#[repr(C)]
27pub struct RecordField {
28    pub name: &'static str,
29    pub descriptor: *const TypeDescriptor,
30}
31
32/// Which *type* a record schema describes — the half of a record's identity
33/// that its field list cannot express.
34///
35/// `struct Point { x: Int, y: Int }` and `struct Vector { x: Int, y: Int }` are
36/// different types with one shape, and §5.6's anonymous records are the
37/// opposite case: the same shape *is* the same type, however many times it is
38/// built. One enum distinguishes them.
39///
40/// A nominal identity is the declared *name*, so it is compared alongside the
41/// shape (see [`RecordSchema::same_type`]) to keep a generic record's two
42/// instantiations from colliding.
43#[derive(Clone, Copy, PartialEq, Eq, Debug)]
44#[repr(C)]
45pub enum SchemaIdentity {
46    /// A structural record (§5.6): identity is the field shape alone. What the
47    /// input parser's named-capture templates produce.
48    Anonymous,
49    /// A declared record type. Two schemas are the same type only if they name
50    /// the same one.
51    Nominal(&'static str),
52}
53
54impl SchemaIdentity {
55    /// A deterministic sort key over type identity, for the container ordering
56    /// (ADR-138). Anonymous shapes come first, then nominal ones by name.
57    ///
58    /// Derived `Ord` would do the same thing, but it would also make identity
59    /// *silently* orderable everywhere and would move whenever a variant is
60    /// added. This is the one place an order over it is wanted, so it is spelled
61    /// once, here, and the reason travels with it. The name is the key rather
62    /// than the address because a schema is interned per producer — the JIT
63    /// generation, the parser registry, the runtime — and two `Point` schemas
64    /// from different producers must order identically.
65    pub(crate) fn order_key(self) -> (u8, &'static str) {
66        match self {
67            SchemaIdentity::Anonymous => (0, ""),
68            SchemaIdentity::Nominal(name) => (1, name),
69        }
70    }
71}
72
73/// The static shape of a record: what type it is, plus an ordered list of named
74/// fields, each with its value descriptor. Allocated in the JIT generation that
75/// built it (or, for parser templates, in the runtime's schema registry).
76#[repr(C)]
77pub struct RecordSchema {
78    pub identity: SchemaIdentity,
79    pub fields: &'static [RecordField],
80}
81
82impl RecordSchema {
83    /// The number of fields in this record shape.
84    pub fn arity(&self) -> usize {
85        self.fields.len()
86    }
87
88    /// The descriptor to dispatch field `i` through for `value`: the static one
89    /// when the producer had it, and the value's own otherwise.
90    ///
91    /// The same rule [`TupleSchema::descriptor_at`](crate::tuples::TupleSchema)
92    /// states — an object always knows what it is — so a producer that had no
93    /// static type for a field leaves a null rather than guessing one.
94    fn descriptor_at(&self, i: usize, value: GcRef) -> &'static TypeDescriptor {
95        match self.fields.get(i).map(|f| f.descriptor) {
96            Some(d) if !d.is_null() => {
97                // SAFETY: a non-null slot is a `'static` descriptor pointer.
98                unsafe { &*d }
99            }
100            _ => value.descriptor(),
101        }
102    }
103
104    /// Whether two schemas describe the *same record type* — the same identity
105    /// and the same field shape.
106    ///
107    /// Type identity, not allocation identity. Schemas are interned per def
108    /// *within a generation*, and there are three producers — every JIT
109    /// generation, the runtime's parser registry, and test fixtures — so a
110    /// `pa.schema != pb.schema` test would call two records of one type unequal
111    /// as soon as they came from different compiles. The debugger depends on
112    /// this directly: `p` evaluates in its own module, and its result is
113    /// compared against program values.
114    ///
115    /// The shape is compared even for a `Nominal` pair, which the name alone
116    /// would settle. It costs an arity check and a slice walk, and it is what
117    /// keeps two instantiations of a generic record (one name, different field
118    /// descriptors) apart, and what stops a debugger session that reloaded a
119    /// *changed* definition from comparing old values field-wise through new
120    /// descriptors.
121    #[must_use]
122    pub fn same_type(&self, other: &RecordSchema) -> bool {
123        if self.identity != other.identity {
124            return false;
125        }
126        self.fields.len() == other.fields.len()
127            && self
128                .fields
129                .iter()
130                .zip(other.fields.iter())
131                .all(|(a, b)| a.name == b.name && std::ptr::eq(a.descriptor, b.descriptor))
132    }
133}
134
135/// The `Record` payload: a pointer to the static schema plus the field values
136/// (one `GcRef` per field, in schema order).
137#[repr(C)]
138pub struct RecordPayload {
139    /// The static field shape. `items.len()` must equal `schema.arity()`.
140    pub schema: *const RecordSchema,
141    /// Field values in schema order.
142    pub items: Vec<GcRef>,
143}
144
145unsafe fn record_trace(payload: *mut u8, tracer: &mut dyn Tracer) {
146    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
147    let p = unsafe { &*(payload as *const RecordPayload) };
148    for item in p.items.iter() {
149        tracer.trace(*item);
150    }
151}
152
153unsafe fn record_drop(payload: *mut u8) {
154    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
155    // `drop_in_place` frees the items Vec; the schema is static and not owned.
156    unsafe { std::ptr::drop_in_place(payload as *mut RecordPayload) };
157}
158
159unsafe fn record_format(payload: *const u8, out: &mut FormatSink<'_>) {
160    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
161    let p = unsafe { &*(payload as *const RecordPayload) };
162    let schema = unsafe { &*p.schema };
163    let _ = out.write_str("{ ");
164    for (i, item) in p.items.iter().enumerate() {
165        if i > 0 {
166            let _ = out.write_str(", ");
167        }
168        let field = &schema.fields[i];
169        let _ = out.write_str(field.name);
170        let _ = out.write_str(": ");
171        let elem_desc = unsafe { &*field.descriptor };
172        // SAFETY: the descriptor came from the schema for this slot, so the slot's
173        // payload is the type its `format` expects.
174        unsafe { (elem_desc.format)(item.payload::<u8>() as *const u8, out) };
175    }
176    let _ = out.write_str(" }");
177}
178
179unsafe fn record_equals(a: *const u8, b: *const u8) -> bool {
180    // SAFETY: caller guarantees both pointers point at initialized RecordPayloads
181    // with compatible schemas.
182    let pa = unsafe { &*(a as *const RecordPayload) };
183    let pb = unsafe { &*(b as *const RecordPayload) };
184    // Equality is same-type + field-wise equality (§5.5). "Same type" is the
185    // schema's identity and shape, not its *address*: each JIT generation
186    // interns its own schemas, so comparing pointers would call two
187    // `Point { x: 1, y: 2 }`s from different compiles unequal.
188    if pa.schema.is_null() || pb.schema.is_null() {
189        return false;
190    }
191    if !unsafe { (*pa.schema).same_type(&*pb.schema) } {
192        return false;
193    }
194    if pa.items.len() != pb.items.len() {
195        return false;
196    }
197    let schema = unsafe { &*pa.schema };
198    // Field-wise equality through each field's descriptor (§11.4), short-circuiting
199    // on the first non-equal field. If a field type is not equatable, the record is
200    // not equatable (§5.5).
201    for (i, (x, y)) in pa.items.iter().zip(pb.items.iter()).enumerate() {
202        let Some(eq) = unsafe { &*schema.fields[i].descriptor }.equals else {
203            return false;
204        };
205        let xe = x.payload::<u8>() as *const u8;
206        let ye = y.payload::<u8>() as *const u8;
207        // SAFETY: both slots were just checked to carry the same descriptor, and it
208        // is the one whose `equals` this is.
209        if !unsafe { eq(xe, ye) } {
210            return false;
211        }
212    }
213    true
214}
215
216unsafe fn record_hash(payload: *const u8, hasher: &mut dyn DynamicHasher) {
217    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
218    let p = unsafe { &*(payload as *const RecordPayload) };
219    let schema = unsafe { &*p.schema };
220    // Everything `same_type` compares is hashed, so `Eq` and `Hash` agree: two
221    // records that differ only in which type they are must be free to land in
222    // different buckets.
223    match schema.identity {
224        SchemaIdentity::Anonymous => hasher.write_bytes(b"anon"),
225        SchemaIdentity::Nominal(name) => {
226            hasher.write_bytes(b"nom");
227            hasher.write_bytes(name.as_bytes());
228        }
229    }
230    // Arity first to distinguish records of different field counts.
231    hasher.write_bytes(&(p.items.len() as u64).to_le_bytes());
232    for (i, item) in p.items.iter().enumerate() {
233        hasher.write_bytes(schema.fields[i].name.as_bytes());
234        let field_desc = unsafe { &*schema.fields[i].descriptor };
235        hasher.write_bytes(&field_desc.id().to_u32().to_le_bytes());
236        // If the field type is not hashable, the record is not hashable (§5.5).
237        let Some(hash_field) = field_desc.hash else {
238            return;
239        };
240        let elem_payload = item.payload::<u8>() as *const u8;
241        // SAFETY: the descriptor came from the schema for this slot, so the slot's
242        // payload is the type its `hash` expects.
243        unsafe { hash_field(elem_payload, hasher) };
244    }
245}
246
247unsafe fn record_compare(a: *const u8, b: *const u8) -> std::cmp::Ordering {
248    use std::cmp::Ordering;
249    // SAFETY: caller guarantees both pointers point at initialized RecordPayloads.
250    let pa = unsafe { &*(a as *const RecordPayload) };
251    let pb = unsafe { &*(b as *const RecordPayload) };
252    // A null schema is a producer bug and not a user-reachable state, but it
253    // still needs a deterministic answer rather than a hash-order one (ADR-138).
254    match (pa.schema.is_null(), pb.schema.is_null()) {
255        (true, true) => return Ordering::Equal,
256        (true, false) => return Ordering::Less,
257        (false, true) => return Ordering::Greater,
258        (false, false) => {}
259    }
260    // SAFETY: both checked non-null above.
261    let (schema_a, schema_b) = unsafe { (&*pa.schema, &*pb.schema) };
262    // Type identity first, so two record *types* in one collection never
263    // interleave — and by name, never by schema address, for the reason
264    // `same_type` gives: there are three producers of a schema and their
265    // addresses differ.
266    match schema_a
267        .identity
268        .order_key()
269        .cmp(&schema_b.identity.order_key())
270    {
271        Ordering::Equal => {}
272        other => return other,
273    }
274    match pa.items.len().cmp(&pb.items.len()) {
275        Ordering::Equal => {}
276        other => return other,
277    }
278    // Field-wise in schema order, short-circuiting at the first difference. The
279    // field *name* participates because two anonymous shapes with one arity are
280    // different types, and `record_hash` already mixes the names for the same
281    // reason.
282    for (i, (x, y)) in pa.items.iter().zip(pb.items.iter()).enumerate() {
283        let (na, nb) = (schema_a.fields[i].name, schema_b.fields[i].name);
284        match na.cmp(nb) {
285            Ordering::Equal => {}
286            other => return other,
287        }
288        let dx = schema_a.descriptor_at(i, *x);
289        let dy = schema_b.descriptor_at(i, *y);
290        // SAFETY: each field's payload matches the descriptor its schema slot
291        // names, or its own header's when that slot is null.
292        match unsafe { crate::ordering::slot_cmp(*x, *y, dx, dy) } {
293            Ordering::Equal => {}
294            other => return other,
295        }
296    }
297    Ordering::Equal
298}
299
300/// Descriptor for the structural `Record` type (§4.5/§7.8). Structural equality,
301/// hashing (§5.5) and the container ordering (ADR-138) recurse field-wise
302/// through the per-shape schema's field descriptors. A record is
303/// equatable/hashable iff every field is; functions never are, so a record
304/// containing a function field is neither. This lets records serve as map/set
305/// keys — which is why a container has to be able to order one.
306pub static RECORD: TypeDescriptor = TypeDescriptor::builtin::<RecordPayload>(
307    BuiltinTypeId::Record,
308    "Record",
309    record_trace,
310    record_drop,
311    record_format,
312    Some(record_equals),
313    Some(record_hash),
314    // A record can be a key, so a container orders one (ADR-138). `p < q` on
315    // two records is still Y006: that is `capability::supports_ord`'s question.
316    Some(record_compare),
317)
318.with_owned_bytes(record_owned_bytes);
319
320// --- the grid neighbourhood records (§6.4) ---------------------------------
321//
322// `Around4` and `Around8` are the two nominal records the *runtime* builds:
323// `g.around4(p)` and `g.around8(p)` answer one, a field per direction, each an
324// `Option[(Int, Int)]` whose `None` is a direction that leaves the grid. They
325// live here rather than in `abi.rs` because the field order is the schema's,
326// and the schema is a record.
327
328/// One direction of a neighbourhood record: the field's name and the `(dx, dy)`
329/// step it names.
330///
331/// Name and offset are **one tuple** on purpose. Two parallel lists — field
332/// names here, offsets in the wrapper — is exactly the shape that lets slot *i*
333/// hold the neighbour of direction *j*, which no test of either list alone
334/// would catch.
335pub struct Direction {
336    /// The record field's name, and therefore the schema's slot at this index.
337    pub name: &'static str,
338    /// Column step. `y` grows downward, so `down` is `+1`.
339    pub dx: i64,
340    /// Row step.
341    pub dy: i64,
342}
343
344/// `Around4`'s directions, **in field order**: the plus read off the page,
345/// centre skipped — up, left, right, down.
346///
347/// # The order is load-bearing
348///
349/// A field read compiles to a slot index taken from the *static* type's field
350/// order (the method catalog's `Around4` row), while a value built here is laid
351/// out in *this* order. ADR-152's permutation into a first-written canonical
352/// order applies only to anonymous shapes, so `Around4` is nominal and these
353/// two lists are simply required to agree. `around_schemas_match_the_catalog`
354/// is what holds them together; a disagreement reads the wrong field and says
355/// nothing.
356///
357/// Note this is **not** `praxis_grid_neighbors4`'s order, which is up, down,
358/// left, right. That wrapper answers a clipped `Vec` in which position carries
359/// no meaning, so nothing depended on its order and nothing changes it.
360pub static AROUND4_DIRECTIONS: &[Direction] = &[
361    Direction {
362        name: "up",
363        dx: 0,
364        dy: -1,
365    },
366    Direction {
367        name: "left",
368        dx: -1,
369        dy: 0,
370    },
371    Direction {
372        name: "right",
373        dx: 1,
374        dy: 0,
375    },
376    Direction {
377        name: "down",
378        dx: 0,
379        dy: 1,
380    },
381];
382
383/// `Around8`'s directions, **in field order**: the eight cells of a 3×3 block
384/// in reading order, centre skipped. Already `praxis_grid_neighbors8`'s order,
385/// which is what makes a printed `Around8` look like the block it describes.
386///
387/// See [`AROUND4_DIRECTIONS`] for why the order is load-bearing.
388pub static AROUND8_DIRECTIONS: &[Direction] = &[
389    Direction {
390        name: "up_left",
391        dx: -1,
392        dy: -1,
393    },
394    Direction {
395        name: "up",
396        dx: 0,
397        dy: -1,
398    },
399    Direction {
400        name: "up_right",
401        dx: 1,
402        dy: -1,
403    },
404    Direction {
405        name: "left",
406        dx: -1,
407        dy: 0,
408    },
409    Direction {
410        name: "right",
411        dx: 1,
412        dy: 0,
413    },
414    Direction {
415        name: "down_left",
416        dx: -1,
417        dy: 1,
418    },
419    Direction {
420        name: "down",
421        dx: 0,
422        dy: 1,
423    },
424    Direction {
425        name: "down_right",
426        dx: 1,
427        dy: 1,
428    },
429];
430
431/// Leak the `'static` schema for a neighbourhood record: one field per
432/// direction, in `directions` order, every field an `Option[(Int, Int)]`.
433///
434/// The field descriptor is [`crate::enums::ENUM`] rather than a null. A null is
435/// legal in a *tuple* slot and [`RecordSchema::descriptor_at`] honours it, but
436/// `record_format` and `record_equals` dereference `fields[i].descriptor`
437/// directly — and here there is nothing to be honest about anyway: every field
438/// of both shapes is an `Option`, statically, for every value ever built.
439fn leak_around_schema(
440    name: &'static str,
441    directions: &'static [Direction],
442) -> &'static RecordSchema {
443    let fields: Vec<RecordField> = directions
444        .iter()
445        .map(|d| RecordField {
446            name: d.name,
447            descriptor: &crate::enums::ENUM,
448        })
449        .collect();
450    Box::leak(Box::new(RecordSchema {
451        identity: SchemaIdentity::Nominal(name),
452        fields: Box::leak(fields.into_boxed_slice()),
453    }))
454}
455
456/// The runtime's own `'static` schema for `Around4` (§6.4).
457///
458/// Nominal, so [`RecordSchema::same_type`] compares the name as well as the
459/// shape — the runtime is the only producer of an `Around4`, and it stays that
460/// way because no source syntax declares one.
461///
462/// A plain `static` will not do: `*const TypeDescriptor` is neither `Send` nor
463/// `Sync`. This is the `OnceLock<SyncPtr>` + `Box::leak` idiom
464/// [`crate::enums::option_schema`] and `tuples::point_schema` already use.
465#[must_use]
466pub fn around4_schema() -> &'static RecordSchema {
467    use std::sync::OnceLock;
468    struct SyncPtr(&'static RecordSchema);
469    // SAFETY: the leaked schema and every descriptor it points at are immutable
470    // and outlive every thread.
471    unsafe impl Send for SyncPtr {}
472    unsafe impl Sync for SyncPtr {}
473    static AROUND4: OnceLock<SyncPtr> = OnceLock::new();
474    AROUND4
475        .get_or_init(|| SyncPtr(leak_around_schema("Around4", AROUND4_DIRECTIONS)))
476        .0
477}
478
479/// The runtime's own `'static` schema for `Around8` (§6.4). See
480/// [`around4_schema`].
481#[must_use]
482pub fn around8_schema() -> &'static RecordSchema {
483    use std::sync::OnceLock;
484    struct SyncPtr(&'static RecordSchema);
485    // SAFETY: as `around4_schema`.
486    unsafe impl Send for SyncPtr {}
487    unsafe impl Sync for SyncPtr {}
488    static AROUND8: OnceLock<SyncPtr> = OnceLock::new();
489    AROUND8
490        .get_or_init(|| SyncPtr(leak_around_schema("Around8", AROUND8_DIRECTIONS)))
491        .0
492}
493
494/// The heap bytes a record owns beyond its payload, for GC pacing.
495/// `capacity`, not `len`: the buffer's real footprint is what the collector is
496/// paced against.
497///
498/// # Safety
499/// `payload` must point at an initialized `RecordPayload`.
500unsafe fn record_owned_bytes(payload: *const u8) -> usize {
501    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
502    let p = unsafe { &*(payload as *const RecordPayload) };
503    p.items.capacity() * std::mem::size_of::<GcRef>()
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn record_descriptor_reports_capabilities() {
512        assert!(RECORD.is_equatable());
513        assert!(RECORD.is_hashable());
514        assert_eq!(RECORD.name, "Record");
515        assert_eq!(RECORD.as_builtin(), Some(BuiltinTypeId::Record));
516    }
517
518    #[test]
519    fn grid_descriptor_reports_capabilities() {
520        // A grid is equatable and hashable, so it can be a map key.
521        assert!(crate::collections::GRID.is_equatable());
522        assert!(crate::collections::GRID.is_hashable());
523        assert_eq!(crate::collections::GRID.name, "Grid");
524        assert_eq!(
525            crate::collections::GRID.as_builtin(),
526            Some(BuiltinTypeId::Grid)
527        );
528    }
529
530    /// A leaked schema of `(name, Int)` fields, standing in for one a JIT
531    /// generation or the parser registry would build. Each call leaks its own,
532    /// which is the point wherever two are compared: same shape, different
533    /// address.
534    fn leak_schema(identity: SchemaIdentity, names: &[&'static str]) -> &'static RecordSchema {
535        let fields: Vec<RecordField> = names
536            .iter()
537            .map(|name| RecordField {
538                name,
539                descriptor: &crate::scalars::INT,
540            })
541            .collect();
542        Box::leak(Box::new(RecordSchema {
543            identity,
544            fields: Box::leak(fields.into_boxed_slice()),
545        }))
546    }
547
548    /// Allocate a record of `schema` and fill it with `values` as `Int`s.
549    fn record_of(
550        ctx: &mut crate::RuntimeContext,
551        schema: &'static RecordSchema,
552        values: &[i64],
553    ) -> GcRef {
554        let r = unsafe { crate::abi::praxis_alloc_record(ctx, schema) };
555        for (i, v) in values.iter().enumerate() {
556            let boxed = unsafe { crate::abi::praxis_alloc_int(ctx, *v) };
557            unsafe { crate::abi::praxis_record_set_field(ctx, r, i as u32, boxed) };
558        }
559        r
560    }
561
562    fn equal(a: GcRef, b: GcRef) -> bool {
563        unsafe {
564            record_equals(
565                a.payload::<u8>() as *const u8,
566                b.payload::<u8>() as *const u8,
567            )
568        }
569    }
570
571    fn hash_of(r: GcRef) -> u64 {
572        let mut h = crate::descriptor::StructHasher::new();
573        unsafe { record_hash(r.payload::<u8>() as *const u8, &mut h) };
574        h.finish()
575    }
576
577    /// Two schemas of one anonymous shape, separately allocated — what two JIT
578    /// generations, or a generation and the parser registry, produce for the
579    /// same `{x: Int, y: Int}`. Records built through them are one value, so
580    /// equality cannot rest on the schema *address*.
581    #[test]
582    fn anonymous_records_of_one_shape_are_equal_across_schema_allocations() {
583        let mut rt = crate::Runtime::new();
584        let mut ctx = rt.context();
585        let first = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);
586        let second = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);
587        assert!(
588            !std::ptr::eq(first, second),
589            "the two schemas must really be distinct allocations"
590        );
591
592        let a = record_of(&mut ctx, first, &[1, 2]);
593        let b = record_of(&mut ctx, second, &[1, 2]);
594        assert!(equal(a, b));
595        assert_eq!(hash_of(a), hash_of(b), "equal records must hash equally");
596
597        // Same shape, different values: still not equal.
598        let c = record_of(&mut ctx, second, &[1, 3]);
599        assert!(!equal(a, c));
600    }
601
602    /// The ordering analogue of the test above (ADR-138). A record's container
603    /// order is its type identity, then its fields — and identity is compared
604    /// by *name*, never by schema address, for the same reason equality is:
605    /// there are three producers of a schema and their allocations differ, so an
606    /// address order would sort two `Point`s from two generations differently
607    /// between runs.
608    #[test]
609    fn record_compare_is_identity_then_fields() {
610        let mut rt = crate::Runtime::new();
611        let mut ctx = rt.context();
612        let cmp = |a: GcRef, b: GcRef| unsafe {
613            record_compare(
614                a.payload::<u8>() as *const u8,
615                b.payload::<u8>() as *const u8,
616            )
617        };
618
619        let first = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);
620        let second = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);
621        assert!(!std::ptr::eq(first, second));
622        let a = record_of(&mut ctx, first, &[1, 2]);
623        let same = record_of(&mut ctx, second, &[1, 2]);
624        assert_eq!(
625            cmp(a, same),
626            std::cmp::Ordering::Equal,
627            "one shape, one value"
628        );
629
630        // Fields decide, left to right, through each field's own order — so
631        // `2` precedes `10` rather than trailing it as `"10"` would.
632        let bigger = record_of(&mut ctx, second, &[1, 10]);
633        let smaller = record_of(&mut ctx, second, &[1, 2]);
634        assert_eq!(cmp(smaller, bigger), std::cmp::Ordering::Less);
635
636        // Identity comes first: an anonymous shape sorts before a nominal one,
637        // whatever its fields say.
638        let point = leak_schema(SchemaIdentity::Nominal("Point"), &["x", "y"]);
639        let p = record_of(&mut ctx, point, &[0, 0]);
640        assert_eq!(cmp(a, p), std::cmp::Ordering::Less);
641        assert_eq!(cmp(p, a), std::cmp::Ordering::Greater);
642    }
643
644    /// A *nominal* record is its declared type, so two records with identical
645    /// fields and different type names are not equal — and a nominal record is
646    /// never equal to a structural one of the same shape (§5.6).
647    #[test]
648    fn nominal_records_of_different_types_are_never_equal() {
649        let mut rt = crate::Runtime::new();
650        let mut ctx = rt.context();
651        let point = leak_schema(SchemaIdentity::Nominal("Point"), &["x", "y"]);
652        let vector = leak_schema(SchemaIdentity::Nominal("Vector"), &["x", "y"]);
653        let anon = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);
654
655        let p = record_of(&mut ctx, point, &[1, 2]);
656        let v = record_of(&mut ctx, vector, &[1, 2]);
657        let a = record_of(&mut ctx, anon, &[1, 2]);
658        assert!(!equal(p, v), "two record types are not one type");
659        assert!(!equal(p, a), "a declared type is not a structural shape");
660
661        // And the same nominal type from two generations *is* one type.
662        let point_again = leak_schema(SchemaIdentity::Nominal("Point"), &["x", "y"]);
663        let p2 = record_of(&mut ctx, point_again, &[1, 2]);
664        assert!(equal(p, p2));
665        assert_eq!(hash_of(p), hash_of(p2));
666    }
667
668    /// A shape check rides along with the name, so one nominal name over two
669    /// different field shapes — a generic record's instantiations, or a
670    /// debugger session that reloaded a changed definition — does not compare
671    /// field-wise through the wrong descriptors.
672    #[test]
673    fn one_nominal_name_over_two_shapes_is_two_types() {
674        let mut rt = crate::Runtime::new();
675        let mut ctx = rt.context();
676        let two_fields = leak_schema(SchemaIdentity::Nominal("P"), &["x", "y"]);
677        let renamed = leak_schema(SchemaIdentity::Nominal("P"), &["x", "z"]);
678
679        let a = record_of(&mut ctx, two_fields, &[1, 2]);
680        let b = record_of(&mut ctx, renamed, &[1, 2]);
681        assert!(!equal(a, b));
682    }
683
684    /// **ADR-152, and the reason `Around4`/`Around8` are nominal.**
685    ///
686    /// A field read compiles to a slot index taken from the *catalog* row's
687    /// field order; a value is assembled in the *schema's*. Nothing derives one
688    /// from the other — the permutation into a canonical order that keeps the
689    /// two honest for an anonymous shape applies only when the def has no name
690    /// — so the agreement has to be asserted, and this is where.
691    ///
692    /// A drift here is silent: `a.up` would answer the neighbour to the left,
693    /// with no diagnostic anywhere and no crash. That is the whole failure mode
694    /// ADR-152 exists about.
695    #[test]
696    fn around_schemas_match_the_catalog() {
697        use praxis_stdlib::type_pattern::{CollectionCtor, TypePattern};
698
699        let catalog = praxis_stdlib::builtin_catalog();
700        let grid = TypePattern::Collection {
701            ctor: CollectionCtor::Grid,
702            args: vec![TypePattern::var("T")],
703        };
704
705        for (method, schema, directions) in [
706            (
707                "around4",
708                super::around4_schema(),
709                super::AROUND4_DIRECTIONS,
710            ),
711            (
712                "around8",
713                super::around8_schema(),
714                super::AROUND8_DIRECTIONS,
715            ),
716        ] {
717            let entry = catalog
718                .by_receiver_and_name(&grid, method)
719                .next()
720                .unwrap_or_else(|| panic!("`Grid[T].{method}` is a catalog row"));
721            let TypePattern::Record { name, fields } = &entry.result else {
722                panic!("`Grid[T].{method}` answers a nominal record");
723            };
724
725            // The name is the record's identity, and a `Nominal` schema is what
726            // keeps two `Around4`s from comparing equal to two `Around8`s.
727            assert_eq!(
728                schema.identity,
729                SchemaIdentity::Nominal(name),
730                "`{method}`'s schema must name the type its row does"
731            );
732
733            let from_catalog: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
734            let from_schema: Vec<&str> = schema.fields.iter().map(|f| f.name).collect();
735            assert_eq!(
736                from_catalog, from_schema,
737                "`{method}`'s catalog field order is the slot index a field read \
738                 compiles to, and the schema's is where the value's fields land"
739            );
740
741            // …and the direction table is the third list that must agree: it is
742            // what the wrapper iterates, so slot *i* holds `directions[i]`.
743            let from_directions: Vec<&str> = directions.iter().map(|d| d.name).collect();
744            assert_eq!(from_directions, from_schema);
745
746            // Every field is an `Option[(Int, Int)]`, which is what makes
747            // `crate::enums::ENUM` the right descriptor for all of them.
748            for (fname, fpat) in fields {
749                assert!(
750                    matches!(fpat, TypePattern::Option(_)),
751                    "`{method}.{fname}` must be an Option: a direction that \
752                     leaves the grid has no point"
753                );
754            }
755            for field in schema.fields {
756                assert!(
757                    std::ptr::eq(field.descriptor, &crate::enums::ENUM),
758                    "`{method}.{}` holds an Option, so its slot dispatches \
759                     through ENUM",
760                    field.name
761                );
762            }
763        }
764    }
765
766    /// The two shapes are different *types*, not one shape at two arities.
767    ///
768    /// `same_type` compares the identity before the field list, so this is
769    /// really a check that the identities were not copy-pasted — an `Around8`
770    /// schema calling itself `Around4` would make an eight-field record compare
771    /// against a four-field one, and `record_equals` would answer on the arity
772    /// rather than on the type.
773    #[test]
774    fn around4_and_around8_are_two_types() {
775        let four = super::around4_schema();
776        let eight = super::around8_schema();
777        assert_eq!(four.identity, SchemaIdentity::Nominal("Around4"));
778        assert_eq!(eight.identity, SchemaIdentity::Nominal("Around8"));
779        assert_eq!(four.arity(), 4);
780        assert_eq!(eight.arity(), 8);
781        assert!(!four.same_type(eight));
782        // Each is interned once, so a second call is the same allocation and
783        // two values built in one process are one type by pointer as well as by
784        // name.
785        assert!(std::ptr::eq(four, super::around4_schema()));
786        assert!(std::ptr::eq(eight, super::around8_schema()));
787    }
788
789    #[test]
790    fn record_equals_identical_int_fields() {
791        // Build two records with the same schema and equal Int fields; their
792        // structural equals must be true, and unequal fields must be false.
793        let mut rt = crate::Runtime::new();
794        let mut ctx = rt.context();
795        let descriptors: &'static [*const TypeDescriptor] =
796            Box::leak(vec![&crate::scalars::INT as *const TypeDescriptor; 2].into_boxed_slice());
797        let schema = Box::leak(Box::new(RecordSchema {
798            identity: SchemaIdentity::Anonymous,
799            fields: Box::leak(
800                vec![
801                    RecordField {
802                        name: "x",
803                        descriptor: descriptors[0],
804                    },
805                    RecordField {
806                        name: "y",
807                        descriptor: descriptors[1],
808                    },
809                ]
810                .into_boxed_slice(),
811            ),
812        }));
813        // Allocate two records and fill with Int 1, 2.
814        let a = unsafe { crate::abi::praxis_alloc_record(&mut ctx, schema) };
815        let b = unsafe { crate::abi::praxis_alloc_record(&mut ctx, schema) };
816        let one = unsafe { crate::abi::praxis_alloc_int(&mut ctx, 1) };
817        let two = unsafe { crate::abi::praxis_alloc_int(&mut ctx, 2) };
818        unsafe {
819            crate::abi::praxis_record_set_field(&mut ctx, a, 0, one);
820            crate::abi::praxis_record_set_field(&mut ctx, a, 1, two);
821            crate::abi::praxis_record_set_field(&mut ctx, b, 0, one);
822            crate::abi::praxis_record_set_field(&mut ctx, b, 1, two);
823        }
824        assert!(unsafe {
825            record_equals(
826                a.payload::<u8>() as *const u8,
827                b.payload::<u8>() as *const u8,
828            )
829        });
830
831        // Now make b's second field differ (3) → not equal.
832        let three = unsafe { crate::abi::praxis_alloc_int(&mut ctx, 3) };
833        unsafe { crate::abi::praxis_record_set_field(&mut ctx, b, 1, three) };
834        assert!(!unsafe {
835            record_equals(
836                a.payload::<u8>() as *const u8,
837                b.payload::<u8>() as *const u8,
838            )
839        });
840    }
841}