Skip to main content

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}