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}