praxis_runtime/tuples.rs
1//! The `Tuple` value descriptor (§4.5 structural tuples).
2//!
3//! A tuple is an anonymous, positional product: a fixed number of elements, each
4//! a `GcRef`, in source order. Unlike records (§4.5) tuples carry no field names
5//! — identity is the element-type sequence alone (so `(Int, Int)` and `(Int, Bool)`
6//! are distinct, and `(Int, Int)` is one shape regardless of where it appears).
7//!
8//! Each distinct tuple *shape* (element descriptor sequence) gets a
9//! [`TupleSchema`]. The schema is leaked to `&'static` (one per shape) because a
10//! tuple's descriptor callbacks need a type-stable home for the element
11//! descriptors; this mirrors how the codegen leaks `RecordSchema` and function
12//! names.
13//!
14//! The descriptor dispatches element-wise through the schema (§11.4) — there are
15//! no scattered type switches. A single `TUPLE`-shaped descriptor serves every
16//! tuple because the per-shape knowledge lives in the schema referenced from the
17//! payload. Structural equality and hashing (§5.5) recurse element-wise; a tuple
18//! is equatable/hashable iff every element is. The ordering a container imposes
19//! (ADR-138) recurses the same way — a `Map[(Int, Int), V]` walks its keys
20//! element-wise — which is a different question from the source-level `<`, and
21//! `(1, 2) < (1, 3)` is still refused at check time.
22
23use std::fmt::Write as _;
24
25use crate::GcRef;
26use crate::descriptor::{BuiltinTypeId, DynamicHasher, FormatSink, Tracer, TypeDescriptor};
27
28/// The static shape of a tuple: an ordered list of element descriptors (positional,
29/// no names). Leaked to `&'static` once per distinct shape by the codegen.
30///
31/// A slot may be **null**, meaning the compiler had no static type for that
32/// element — the same honest encoding a `Vec`'s element descriptor already uses
33/// (`var m = Map()` generalizes at the `var`, so a program that never inspects
34/// the elements leaves them unresolved). The arity is still exact, so nothing
35/// is lost; the *value's own* descriptor answers for a null slot, and it is
36/// read from the object's header, so it is never wrong.
37#[repr(C)]
38pub struct TupleSchema {
39 pub descriptors: &'static [*const TypeDescriptor],
40}
41
42impl TupleSchema {
43 /// The number of elements in this tuple shape (its arity).
44 pub fn arity(&self) -> usize {
45 self.descriptors.len()
46 }
47
48 /// The descriptor to dispatch slot `i` through for `value`: the static one
49 /// when the compiler had it, and the value's own otherwise.
50 ///
51 /// Falling back to the header is what makes a null slot safe rather than
52 /// merely tolerated: the alternative, refusing to compile, would reject
53 /// `var m = Map()` followed by a `for` that never looks inside the pair.
54 fn descriptor_at(&self, i: usize, value: GcRef) -> &'static TypeDescriptor {
55 match self.descriptors.get(i).copied() {
56 Some(d) if !d.is_null() => {
57 // SAFETY: a non-null slot is a `'static` descriptor pointer.
58 unsafe { &*d }
59 }
60 _ => value.descriptor(),
61 }
62 }
63
64 /// Whether two schemas describe the *same* tuple shape: equal arity and the
65 /// same element descriptor in every slot.
66 ///
67 /// Shape, not allocation identity. Schemas are interned per shape *within*
68 /// a producer, but there are three producers — the codegen's
69 /// `tuple_schema_for` cache, the runtime's `point_schema`, and the input
70 /// parser — so two of them minting an `(Int, Int)` must still yield tuples
71 /// that compare equal. Descriptors are `static`, so slot comparison is
72 /// pointer comparison (ADR-038).
73 ///
74 /// A **null** slot is unknown, not a fourth type: it agrees with whatever
75 /// the other side says, and the values decide (see `tuple_equals`, which
76 /// compares the two objects' own descriptors for such a slot).
77 #[must_use]
78 pub fn same_shape(&self, other: &TupleSchema) -> bool {
79 self.descriptors.len() == other.descriptors.len()
80 && self
81 .descriptors
82 .iter()
83 .zip(other.descriptors.iter())
84 .all(|(a, b)| a.is_null() || b.is_null() || std::ptr::eq(*a, *b))
85 }
86}
87
88/// The `Tuple` payload: a pointer to the static schema plus the element values
89/// (one `GcRef` per element, in schema order).
90#[repr(C)]
91pub struct TuplePayload {
92 /// The static element shape. `items.len()` must equal `schema.arity()`.
93 pub schema: *const TupleSchema,
94 /// Element values in schema (positional) order.
95 pub items: Vec<GcRef>,
96}
97
98unsafe fn tuple_trace(payload: *mut u8, tracer: &mut dyn Tracer) {
99 // SAFETY: caller guarantees `payload` points at an initialized TuplePayload.
100 let p = unsafe { &*(payload as *const TuplePayload) };
101 for item in p.items.iter() {
102 tracer.trace(*item);
103 }
104}
105
106unsafe fn tuple_drop(payload: *mut u8) {
107 // SAFETY: caller guarantees `payload` points at an initialized TuplePayload.
108 // `drop_in_place` frees the items Vec; the schema is static and not owned.
109 unsafe { std::ptr::drop_in_place(payload as *mut TuplePayload) };
110}
111
112unsafe fn tuple_format(payload: *const u8, out: &mut FormatSink<'_>) {
113 // SAFETY: caller guarantees `payload` points at an initialized TuplePayload.
114 let p = unsafe { &*(payload as *const TuplePayload) };
115 let schema = unsafe { &*p.schema };
116 let _ = out.write_str("(");
117 for (i, item) in p.items.iter().enumerate() {
118 if i > 0 {
119 let _ = out.write_str(", ");
120 }
121 let elem_desc = schema.descriptor_at(i, *item);
122 // SAFETY: the descriptor came from the schema for this slot, so the slot's
123 // payload is the type its `format` expects.
124 unsafe { (elem_desc.format)(item.payload::<u8>() as *const u8, out) };
125 }
126 let _ = out.write_str(")");
127}
128
129unsafe fn tuple_equals(a: *const u8, b: *const u8) -> bool {
130 // SAFETY: caller guarantees both pointers point at initialized TuplePayloads
131 // with compatible element descriptors.
132 let pa = unsafe { &*(a as *const TuplePayload) };
133 let pb = unsafe { &*(b as *const TuplePayload) };
134 // Structural equality is shape + element-wise equality (§5.5). Shape is
135 // compared slot by slot, not by schema *address*: three independent
136 // producers intern schemas, so two `(Int, Int)` tuples can hold different
137 // pointers to the same shape.
138 if pa.schema.is_null() || pb.schema.is_null() {
139 return false;
140 }
141 if !unsafe { (*pa.schema).same_shape(&*pb.schema) } {
142 return false;
143 }
144 if pa.items.len() != pb.items.len() {
145 return false;
146 }
147 let schema = unsafe { &*pa.schema };
148 // Element-wise equality through the element descriptor (§11.4). If the
149 // element type is not equatable, the tuple is not equatable (§5.5).
150 for (i, (x, y)) in pa.items.iter().zip(pb.items.iter()).enumerate() {
151 let desc = schema.descriptor_at(i, *x);
152 // For a slot the compiler had no type for, `desc` is `x`'s own — so `y`
153 // must carry the same one before its payload is read through it. Two
154 // values of different types are unequal; reading one as the other would
155 // be a wrong-payload read.
156 if !std::ptr::eq(desc, schema.descriptor_at(i, *y)) {
157 return false;
158 }
159 let Some(eq) = desc.equals else {
160 return false;
161 };
162 let xe = x.payload::<u8>() as *const u8;
163 let ye = y.payload::<u8>() as *const u8;
164 // SAFETY: both slots were just checked to carry the same descriptor, and it
165 // is the one whose `equals` this is.
166 if !unsafe { eq(xe, ye) } {
167 return false;
168 }
169 }
170 true
171}
172
173unsafe fn tuple_hash(payload: *const u8, hasher: &mut dyn DynamicHasher) {
174 // SAFETY: caller guarantees `payload` points at an initialized TuplePayload.
175 let p = unsafe { &*(payload as *const TuplePayload) };
176 let schema = unsafe { &*p.schema };
177 // Length first to distinguish prefixes (standard sequence-hash practice).
178 hasher.write_bytes(&(p.items.len() as u64).to_le_bytes());
179 for (i, item) in p.items.iter().enumerate() {
180 // The slot's type is part of the shape `eq` compares, so it is part of
181 // the hash too — two tuples that differ only in shape must be free to
182 // land in different buckets. Reading it off the *value* for an unknown
183 // slot is what keeps hash and eq agreeing there: both ask the object.
184 let elem_desc = schema.descriptor_at(i, *item);
185 hasher.write_bytes(&elem_desc.id().to_u32().to_le_bytes());
186 // If the element type is not hashable, the tuple is not hashable (§5.5).
187 let Some(hash_elem) = elem_desc.hash else {
188 return;
189 };
190 let elem_payload = item.payload::<u8>() as *const u8;
191 // SAFETY: the descriptor came from the schema for this slot, so the slot's
192 // payload is the type its `hash` expects.
193 unsafe { hash_elem(elem_payload, hasher) };
194 }
195}
196
197unsafe fn tuple_compare(a: *const u8, b: *const u8) -> std::cmp::Ordering {
198 use std::cmp::Ordering;
199 // SAFETY: caller guarantees both pointers point at initialized TuplePayloads.
200 let pa = unsafe { &*(a as *const TuplePayload) };
201 let pb = unsafe { &*(b as *const TuplePayload) };
202 // A null schema is a producer bug rather than a user-reachable state, but it
203 // still has to get an answer, and the answer has to be the same one twice —
204 // so it sorts first, by a rule and not by whatever the hash table happened
205 // to yield (ADR-138).
206 match (pa.schema.is_null(), pb.schema.is_null()) {
207 (true, true) => return Ordering::Equal,
208 (true, false) => return Ordering::Less,
209 (false, true) => return Ordering::Greater,
210 (false, false) => {}
211 }
212 // Arity first, so a prefix orders before its extension — the same reason
213 // `tuple_hash` writes the length first, and what keeps `(1,)` and `(1, 0)`
214 // from colliding on their shared first element.
215 match pa.items.len().cmp(&pb.items.len()) {
216 Ordering::Equal => {}
217 other => return other,
218 }
219 // SAFETY: both checked non-null above.
220 let (schema_a, schema_b) = unsafe { (&*pa.schema, &*pb.schema) };
221 // Element-wise, short-circuiting at the first difference. Each side is
222 // dispatched through *its own* schema slot, falling back to the value's own
223 // descriptor for a null one, exactly as `tuple_equals` and `tuple_format`
224 // do — and `slot_cmp` separates two slots of different types by descriptor
225 // id before it reads either payload, so a mismatched pair is ordered rather
226 // than misread.
227 for (i, (x, y)) in pa.items.iter().zip(pb.items.iter()).enumerate() {
228 let dx = schema_a.descriptor_at(i, *x);
229 let dy = schema_b.descriptor_at(i, *y);
230 // SAFETY: each element's payload matches the descriptor its schema slot
231 // names, or its own header's when the slot is null.
232 match unsafe { crate::ordering::slot_cmp(*x, *y, dx, dy) } {
233 Ordering::Equal => {}
234 other => return other,
235 }
236 }
237 Ordering::Equal
238}
239
240/// Descriptor for the `Tuple` value type (§4.5). Structural equality,
241/// hashing (§5.5) and the container ordering (ADR-138) all recurse element-wise
242/// through the per-shape schema's element descriptors. A tuple is
243/// equatable/hashable iff every element is; functions never are, so a tuple
244/// containing a function is neither.
245pub static TUPLE: TypeDescriptor = TypeDescriptor::builtin::<TuplePayload>(
246 BuiltinTypeId::Tuple,
247 "Tuple",
248 tuple_trace,
249 tuple_drop,
250 tuple_format,
251 Some(tuple_equals),
252 Some(tuple_hash),
253 // A tuple is the workhorse composite key — `Map[(Int, Int), V]` is how a
254 // grid memo is spelled — so a container has to order one (ADR-138).
255 // `(1, 2) < (1, 3)` in source is still Y006: see `capability::supports_ord`.
256 Some(tuple_compare),
257)
258.with_owned_bytes(tuple_owned_bytes);
259
260/// The heap bytes a tuple owns beyond its payload, for GC pacing.
261/// `capacity`, not `len`: the buffer's real footprint is what the collector is
262/// paced against.
263///
264/// # Safety
265/// `payload` must point at an initialized `TuplePayload`.
266unsafe fn tuple_owned_bytes(payload: *const u8) -> usize {
267 // SAFETY: caller guarantees `payload` points at an initialized TuplePayload.
268 let p = unsafe { &*(payload as *const TuplePayload) };
269 p.items.capacity() * std::mem::size_of::<GcRef>()
270}
271
272/// The cached `'static` schema for a `(Int, Int)` point tuple. Used by Grid
273/// methods that return `(x, y)` points (§6.4). Built once and leaked; the two
274/// element descriptors are both `INT`. This avoids the codegen round-trip for
275/// tuple schemas when the runtime allocates points directly.
276pub fn point_schema() -> &'static TupleSchema {
277 use std::sync::OnceLock;
278 // `*const TypeDescriptor` is not `Sync`, so wrap the leaked slice pointer in
279 // a `Send + Sync` newtype (the underlying static descriptors outlive all
280 // threads — mirroring the `SendPtr` idiom used by the codegen's tuple cache).
281 struct SyncPtr(&'static TupleSchema);
282 unsafe impl Send for SyncPtr {}
283 unsafe impl Sync for SyncPtr {}
284 static POINT: OnceLock<SyncPtr> = OnceLock::new();
285 POINT
286 .get_or_init(|| {
287 let descriptors: &'static [*const TypeDescriptor] = Box::leak(
288 vec![
289 &crate::scalars::INT as *const _,
290 &crate::scalars::INT as *const _,
291 ]
292 .into_boxed_slice(),
293 );
294 SyncPtr(Box::leak(Box::new(TupleSchema { descriptors })))
295 })
296 .0
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::abi::{praxis_alloc_tuple, praxis_tuple_set};
303
304 #[test]
305 fn tuple_descriptor_reports_capabilities() {
306 assert!(TUPLE.is_equatable());
307 assert!(TUPLE.is_hashable());
308 assert_eq!(TUPLE.name, "Tuple");
309 assert_eq!(TUPLE.as_builtin(), Some(BuiltinTypeId::Tuple));
310 }
311
312 #[test]
313 fn alloc_tuple_round_trips_arity() {
314 // Allocate a 2-tuple via the ABI wrapper and verify both element slots
315 // round-trip through praxis_tuple_get.
316 let mut rt = crate::Runtime::new();
317 let mut ctx = rt.context();
318 // Build a 2-element schema of INT descriptors.
319 let descriptors: &'static [*const TypeDescriptor] =
320 Box::leak(vec![&crate::scalars::INT as *const TypeDescriptor; 2].into_boxed_slice());
321 let schema = Box::leak(Box::new(TupleSchema { descriptors }));
322 let tref = unsafe { praxis_alloc_tuple(&mut ctx, schema) };
323 // The schema pointer must be embedded in the payload.
324 let payload = tref.payload::<u8>() as *const TuplePayload;
325 let embedded = unsafe { (*payload).schema };
326 assert_eq!(embedded, schema as *const TupleSchema);
327 assert_eq!(unsafe { (*payload).items.len() }, 2);
328 }
329
330 /// A schema slot may be **null** — the compiler had no static type for that
331 /// element — and the value's own descriptor answers for it.
332 ///
333 /// `var m = Map()` followed by a `for kv in m` whose body never opens the
334 /// pair is the program that produces one: nothing ever resolves K or V, and
335 /// refusing to compile it would reject a working program. It is the same
336 /// answer the codegen's `nullable_descriptor_for_type` gives a collection's
337 /// unresolved element type, and the header is why it is safe rather than
338 /// merely permissive — an object always knows what it is.
339 #[test]
340 fn an_unknown_schema_slot_reads_the_values_own_descriptor() {
341 let mut rt = crate::Runtime::new();
342 let mut ctx = rt.context();
343 let unknown: &'static TupleSchema = Box::leak(Box::new(TupleSchema {
344 descriptors: Box::leak(vec![std::ptr::null(); 2].into_boxed_slice()),
345 }));
346 let build = |ctx: &mut crate::RuntimeContext, a: GcRef, b: GcRef| {
347 let t = unsafe { praxis_alloc_tuple(ctx, unknown) };
348 unsafe {
349 praxis_tuple_set(ctx, t, 0, a);
350 praxis_tuple_set(ctx, t, 1, b);
351 }
352 t
353 };
354
355 // Arity is still exact, so both elements are stored rather than dropped.
356 let (one, txt) = (rt.alloc_int(1), rt.alloc_text("hi"));
357 let mixed = build(&mut ctx, one, txt);
358 assert_eq!(
359 unsafe { (*(mixed.payload::<TuplePayload>())).items.len() },
360 2
361 );
362 // Formatting reads each element through its own descriptor, so the Text
363 // renders as a Text and not as an `i64` read of its buffer pointer.
364 let mut rendered = String::new();
365 unsafe {
366 tuple_format(
367 mixed.payload::<u8>() as *const u8,
368 &mut crate::FormatSink::display(&mut rendered),
369 );
370 }
371 assert_eq!(rendered, "(1, hi)");
372
373 // Equality still works, and still distinguishes.
374 let same = build(&mut ctx, rt.alloc_int(1), rt.alloc_text("hi"));
375 let other = build(&mut ctx, rt.alloc_int(1), rt.alloc_text("no"));
376 assert!(mixed.equals(&same));
377 assert!(!mixed.equals(&other));
378
379 // Two values of *different* types in one slot are unequal rather than
380 // one being read as the other.
381 let swapped = build(&mut ctx, rt.alloc_text("hi"), rt.alloc_int(1));
382 assert!(!mixed.equals(&swapped));
383
384 // An unknown slot agrees with a known one rather than contradicting it,
385 // so a `(?, ?)` and an `(Int, Int)` holding the same values are equal.
386 let known = unsafe { praxis_alloc_tuple(&mut ctx, point_schema()) };
387 for (index, value) in [3_i64, 4].into_iter().enumerate() {
388 let v = rt.alloc_int(value);
389 unsafe { praxis_tuple_set(&mut ctx, known, index as i64, v) };
390 }
391 let unknown_pair = build(&mut ctx, rt.alloc_int(3), rt.alloc_int(4));
392 assert!(known.equals(&unknown_pair));
393 assert!(unknown_pair.equals(&known));
394 }
395
396 #[test]
397 fn tuple_equality_uses_shape_not_schema_allocation_identity() {
398 let mut rt = crate::Runtime::new();
399 let mut ctx = rt.context();
400 let runtime_schema = point_schema();
401 let independently_interned_schema = Box::leak(Box::new(TupleSchema {
402 descriptors: Box::leak(
403 vec![
404 &crate::scalars::INT as *const TypeDescriptor,
405 &crate::scalars::INT as *const TypeDescriptor,
406 ]
407 .into_boxed_slice(),
408 ),
409 }));
410 let left = unsafe { praxis_alloc_tuple(&mut ctx, runtime_schema) };
411 let right = unsafe { praxis_alloc_tuple(&mut ctx, independently_interned_schema) };
412 for (index, value) in [3_i64, 4].into_iter().enumerate() {
413 let left_value = rt.alloc_int(value);
414 let right_value = rt.alloc_int(value);
415 unsafe {
416 praxis_tuple_set(&mut ctx, left, index as i64, left_value);
417 praxis_tuple_set(&mut ctx, right, index as i64, right_value);
418 }
419 }
420
421 assert!(
422 left.equals(&right),
423 "equivalent (Int, Int) schemas from runtime and codegen must describe the same tuple type"
424 );
425 }
426
427 /// A tuple's container order is arity first, then element-wise left to
428 /// right (ADR-138). Arity first is what keeps `(1,)` ahead of `(1, 0)` — a
429 /// prefix before its extension, the same reason `tuple_hash` writes the
430 /// length first — and element-wise is what makes a `Map[(Int, Int), V]`
431 /// come out in reading order instead of by the printed pair.
432 #[test]
433 fn tuple_compare_is_arity_first_then_element_wise() {
434 let mut rt = crate::Runtime::new();
435 let ints = |n: usize| -> &'static TupleSchema {
436 Box::leak(Box::new(TupleSchema {
437 descriptors: Box::leak(
438 vec![&crate::scalars::INT as *const TypeDescriptor; n].into_boxed_slice(),
439 ),
440 }))
441 };
442 let one_slot = ints(1);
443 let two_slots = ints(2);
444 let unknown: &'static TupleSchema = Box::leak(Box::new(TupleSchema {
445 descriptors: Box::leak(vec![std::ptr::null(); 1].into_boxed_slice()),
446 }));
447
448 let values: Vec<GcRef> = [1_i64, 0, 1, 2, 10]
449 .iter()
450 .map(|&n| rt.alloc_int(n))
451 .collect();
452 let text = rt.alloc_text("hi");
453 let mut ctx = rt.context();
454 let build =
455 |ctx: &mut crate::RuntimeContext, schema: &'static TupleSchema, items: &[GcRef]| {
456 // SAFETY: a live context, and each value matches the slot the
457 // schema names (or the slot is null and the value answers).
458 unsafe {
459 let t = praxis_alloc_tuple(ctx, schema);
460 for (i, v) in items.iter().enumerate() {
461 praxis_tuple_set(ctx, t, i as i64, *v);
462 }
463 t
464 }
465 };
466 let cmp = |a: GcRef, b: GcRef| unsafe {
467 tuple_compare(
468 a.payload::<u8>() as *const u8,
469 b.payload::<u8>() as *const u8,
470 )
471 };
472
473 // Arity first: a one-element tuple precedes any two-element one.
474 let single = build(&mut ctx, one_slot, &[values[0]]);
475 let pair = build(&mut ctx, two_slots, &[values[0], values[1]]);
476 assert_eq!(cmp(single, pair), std::cmp::Ordering::Less);
477 assert_eq!(cmp(pair, single), std::cmp::Ordering::Greater);
478
479 // Then element-wise, through each element's own order: `(1, 2)` before
480 // `(1, 10)`, which the rendered pair would reverse.
481 let low = build(&mut ctx, two_slots, &[values[2], values[3]]);
482 let high = build(&mut ctx, two_slots, &[values[2], values[4]]);
483 assert_eq!(cmp(low, high), std::cmp::Ordering::Less);
484 assert_eq!(cmp(low, low), std::cmp::Ordering::Equal);
485
486 // A null slot whose two values are of different types is separated by
487 // descriptor id rather than read as one another's layout — the same
488 // rule `tuple_equals` applies, arriving at an order instead of `false`.
489 let an_int = build(&mut ctx, unknown, &[values[0]]);
490 let a_text = build(&mut ctx, unknown, &[text]);
491 assert_eq!(cmp(an_int, a_text), cmp(an_int, a_text));
492 assert_eq!(cmp(an_int, a_text), cmp(a_text, an_int).reverse());
493 assert_ne!(cmp(an_int, a_text), std::cmp::Ordering::Equal);
494 }
495}