Skip to main content

relon_eval_api/
layout.rs

1//! Static field-offset table for the host <-> wasm binary handshake.
2//!
3//! Spec: `docs/internal/adr/wasm-binary-layout-v1-2026-05-16.md`,
4//! "basic type layout" + "slot alignment rule" sections.
5//!
6//! v1 scope (Phase 2.a / 2.b / 2.c):
7//!
8//! * Phase 2.a / 2.b: the four scalar leaves — `Int`, `Float`, `Bool`,
9//!   `Unit` — are laid out inline. Each field's start offset is rounded
10//!   up to its alignment; the root is padded to `root_align` so the
11//!   next record starts aligned.
12//! * Phase 2.c+: variable fields (`String`, `List<T>`, nested schemas,
13//!   `Option<T>`, and `Result<T, E>`) use pointer indirection. Each
14//!   contributes a fixed-area `u32` pointer slot (size = 4, align = 4);
15//!   the payload lives in a tail area the [`crate::buffer::BufferBuilder`]
16//!   appends after the root record.
17
18use crate::schema_canonical::{Schema, TypeRepr};
19use thiserror::Error;
20
21/// How a field is stored relative to the buffer record.
22///
23/// Phase 2.b only emitted `Inline` slots (the v1 scalar leaves). Phase
24/// 2.c adds `PointerIndirect` for `String` / `List<Int>`: the fixed
25/// area holds a `u32` pointer to a `[len: u32 LE][payload...]` record
26/// appended in the tail area by [`crate::buffer::BufferBuilder`].
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum FieldKind {
29    /// Value is stored directly in the fixed area. `size`/`align` on
30    /// the enclosing [`FieldOffset`] describe the slot. All v1 scalar
31    /// leaves use this shape.
32    Inline {
33        /// Slot size in bytes (excluding any trailing padding).
34        size: usize,
35        /// Required alignment in bytes.
36        align: usize,
37    },
38    /// Fixed area holds a 4-byte (aligned to 4) `u32` pointer to a
39    /// tail-area record. The tail record's leading `[len: u32 LE]`
40    /// covers byte / element count; element alignment is encoded on
41    /// the variant so [`crate::buffer::BufferBuilder`] can pad the
42    /// tail-area cursor before appending the payload.
43    PointerIndirect {
44        /// Required alignment for the tail-area payload, in bytes.
45        /// `String` uses `1` (raw bytes); `List<Int>` uses `8` (i64
46        /// elements). The length prefix itself is always 4-byte
47        /// aligned by the builder.
48        tail_alignment: usize,
49    },
50}
51
52/// Phase 10-c: per-element layout descriptor for a `List<T>` field.
53///
54/// Carried as a sidecar on [`FieldOffset`] for any pointer-indirect
55/// slot whose declared type is a `List<T>`. The buffer writer / reader
56/// dispatches on this to pick the right tail-area shape:
57///
58/// * `InlineFixed { elem_size, elem_align }` — fixed-stride payload
59///   laid out as `[len: u32][padding to elem_align][elem_0][elem_1]...`.
60///   Used by `List<Int>` (`8/8`), `List<Float>` (`8/8`) and
61///   `List<Bool>` (`1/1`, no inter-element padding per spec).
62/// * `PointerArray { elem_alignment }` — variable-stride payload laid
63///   out as `[len: u32][off_0: u32][off_1: u32]...` followed by each
64///   element's own tail record in the same buffer. `elem_alignment` is
65///   the alignment the inner records demand when emitted (`4` for
66///   `String` len-prefixes; `root_align` for sub-record fixed areas).
67///   Used by `List<String>` and `List<branded Schema>`.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum ListElementKind {
70    /// Fixed-size inline elements. The list record's shape is
71    /// `[len: u32][pad to elem_align][elem_0 ..]`; the pad is `0`
72    /// bytes for 1-aligned elements (booleans) and `4` bytes for
73    /// 8-aligned elements (i64 / f64).
74    InlineFixed {
75        /// Element size in bytes.
76        elem_size: usize,
77        /// Element alignment in bytes. Drives the post-`len` padding
78        /// the builder inserts before the first element.
79        elem_align: usize,
80    },
81    /// Variable-size elements addressed through a buffer-relative
82    /// `u32` pointer array immediately after the `len` prefix.
83    PointerArray {
84        /// Alignment required by each inner element record when the
85        /// builder emits it in the tail area. `String` elements pass
86        /// `4` (len-prefix alignment); branded `Schema` elements pass
87        /// the sub-schema's `root_align`.
88        elem_alignment: usize,
89    },
90}
91
92/// One field's slot inside a record.
93///
94/// `offset` is the byte position relative to the buffer base. For
95/// `Inline` slots `size` / `align` reflect the leaf type. For
96/// `PointerIndirect` slots the fixed-area pointer is always 4 bytes
97/// at 4-byte alignment; the `kind` carries the tail-area alignment.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct FieldOffset {
100    /// Field name as declared in the source schema.
101    pub name: String,
102    /// Byte offset from the record base. Always a multiple of the
103    /// slot's effective alignment (either `kind`'s align for `Inline`
104    /// or `4` for `PointerIndirect`).
105    pub offset: usize,
106    /// Fixed-area slot size in bytes (excluding any trailing padding
107    /// to `root_align`). For `PointerIndirect` slots this is always
108    /// `4` — the pointer width — even though the tail-area payload
109    /// can be arbitrarily long.
110    pub size: usize,
111    /// Required alignment of this slot's start in bytes. Mirrors the
112    /// `kind` alignment for `Inline`; `4` for `PointerIndirect`.
113    pub align: usize,
114    /// How the field's payload reaches the wasm side — directly in
115    /// the fixed area (`Inline`), or through a tail-area pointer
116    /// (`PointerIndirect`).
117    pub kind: FieldKind,
118    /// Phase 10-c: per-element layout for `List<T>` fields. `None` for
119    /// non-list fields; `Some` when the field's declared `TypeRepr` is
120    /// `List<T>` and the v1 layout supports `T`. Carried alongside
121    /// [`FieldKind`] so the buffer writer / reader can dispatch on the
122    /// element shape without re-walking the schema.
123    pub list_element: Option<ListElementKind>,
124}
125
126/// Computed offset table for a schema's flat record area.
127///
128/// `root_size` covers the **fixed area only** — the per-field slot
129/// (inline payload or pointer) plus padding to `root_align`. Variable
130/// payloads (Phase 2.c `String` / `List<Int>`) live in a tail area
131/// the [`crate::buffer::BufferBuilder`] appends *after* `root_size`
132/// bytes; the wasm side reaches them through the pointer slots.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct OffsetTable {
135    /// Field slots in declaration order.
136    pub fields: Vec<FieldOffset>,
137    /// Total fixed record area size, padded to `root_align`.
138    pub root_size: usize,
139    /// Maximum alignment required by any field. For an empty schema
140    /// this defaults to `1` so callers can still allocate a zero-size
141    /// buffer with a benign alignment hint.
142    pub root_align: usize,
143}
144
145impl OffsetTable {
146    /// Alias for `root_size` that documents the Phase 2.c split — the
147    /// returned value covers only the fixed (root) area, not any
148    /// `String` / `List<Int>` tail-area bytes the builder appends.
149    pub fn fixed_area_size(&self) -> usize {
150        self.root_size
151    }
152
153    /// `true` when at least one field is `PointerIndirect`, meaning
154    /// the builder needs a tail area beyond `root_size`. Lets the
155    /// codegen pass skip the tail-cursor bookkeeping for purely
156    /// scalar schemas.
157    pub fn requires_tail_area(&self) -> bool {
158        self.fields
159            .iter()
160            .any(|f| matches!(f.kind, FieldKind::PointerIndirect { .. }))
161    }
162}
163
164/// Reasons offset computation can fail.
165///
166/// All variants surface at codegen / host-prep time, never at
167/// runtime; the binary layout is fully static.
168#[derive(Debug, Error, Clone, PartialEq, Eq)]
169pub enum LayoutError {
170    /// The schema references a type the v1 layout does not yet model.
171    /// Type shapes that still have no host-visible v1 binary layout live
172    /// in this bucket.
173    #[error("layout v1 does not yet support type `{kind}` in field `{field}`")]
174    UnsupportedTypeInLayoutV1 {
175        /// Field name that triggered the error.
176        field: String,
177        /// Human-readable type kind (`"String"`, `"List"`, ...).
178        kind: &'static str,
179    },
180    /// `List<T>` with an element type the v1 layout declines. The error
181    /// spells out the inner kind so the host SDK can surface a precise
182    /// message.
183    #[error("layout v1 does not yet support list element `{inner}` in field `{field}`")]
184    UnsupportedListElement {
185        /// Field name that triggered the error.
186        field: String,
187        /// Human-readable name of the inner type.
188        inner: &'static str,
189    },
190    /// Cumulative offset overflowed `usize`. Astronomically unlikely
191    /// on 64-bit hosts but cheap to model so the layout pass never
192    /// quietly wraps.
193    #[error("layout overflow while placing field `{field}` at offset {offset}")]
194    Overflow {
195        /// Field name being placed when overflow happened.
196        field: String,
197        /// Last successfully computed offset before overflow.
198        offset: usize,
199    },
200}
201
202/// Per-field layout decision. Carries the fixed-area slot description
203/// plus the optional element-shape sidecar `List<T>` fields need.
204struct FieldLayoutDecision {
205    kind: FieldKind,
206    list_element: Option<ListElementKind>,
207}
208
209/// Compute the field-kind / fixed-area placement for one [`TypeRepr`].
210///
211/// Returns `Ok(decision)` on success. `Err(label)` carries the human-
212/// readable kind label for [`LayoutError::UnsupportedTypeInLayoutV1`]
213/// when the v1 layout still declines the type at the field level
214/// (`Option`, `Result`). `List<T>` element rejection routes through
215/// a different error variant ([`LayoutError::UnsupportedListElement`])
216/// — those failures bubble up from this function as their own error,
217/// returned in the `Err` arm tagged with the `"List"` label and a
218/// nested cause via the field name (caller assembles the precise
219/// message).
220///
221/// Supported set:
222///
223/// * `Unit`, `Bool` → `Inline { size: 1, align: 1 }`.
224/// * `Int`, `Float` → `Inline { size: 8, align: 8 }`.
225/// * `String` → `PointerIndirect { tail_alignment: 1 }`.
226/// * `List<Int>` / `List<Float>` → `PointerIndirect { tail_alignment: 8 }`
227///   with `InlineFixed { elem_size: 8, elem_align: 8 }`.
228/// * `List<Bool>` → `PointerIndirect { tail_alignment: 4 }` with
229///   `InlineFixed { elem_size: 1, elem_align: 1 }` (booleans pack
230///   tightly per spec, no inter-element padding).
231/// * `List<String>` → `PointerIndirect { tail_alignment: 4 }` with
232///   `PointerArray { elem_alignment: 4 }`.
233/// * `List<Schema>` → `PointerIndirect { tail_alignment: 4 }` with
234///   `PointerArray { elem_alignment: sub.root_align }`.
235/// * `Schema { ... }` → `PointerIndirect { tail_alignment: sub.root_align }`.
236fn field_layout_decision_for(
237    field_name: &str,
238    ty: &TypeRepr,
239) -> Result<FieldLayoutDecision, LayoutError> {
240    match ty {
241        TypeRepr::Unit => Ok(FieldLayoutDecision {
242            kind: FieldKind::Inline { size: 1, align: 1 },
243            list_element: None,
244        }),
245        TypeRepr::Bool => Ok(FieldLayoutDecision {
246            kind: FieldKind::Inline { size: 1, align: 1 },
247            list_element: None,
248        }),
249        TypeRepr::Int => Ok(FieldLayoutDecision {
250            kind: FieldKind::Inline { size: 8, align: 8 },
251            list_element: None,
252        }),
253        TypeRepr::Float => Ok(FieldLayoutDecision {
254            kind: FieldKind::Inline { size: 8, align: 8 },
255            list_element: None,
256        }),
257        TypeRepr::String => Ok(FieldLayoutDecision {
258            kind: FieldKind::PointerIndirect { tail_alignment: 1 },
259            list_element: None,
260        }),
261        TypeRepr::List { element } => list_layout_decision(field_name, element.as_ref()),
262        TypeRepr::Schema { schema } => {
263            // Recursively compute the sub-record's root_align so the
264            // tail-area placement of the sub-record honours its own
265            // alignment requirements (an inner i64 field demands the
266            // sub-record start on an 8-byte boundary).
267            let sub = SchemaLayout::offsets_for(schema)?;
268            Ok(FieldLayoutDecision {
269                kind: FieldKind::PointerIndirect {
270                    tail_alignment: sub.root_align,
271                },
272                list_element: None,
273            })
274        }
275        TypeRepr::Option { .. } | TypeRepr::Result { .. } | TypeRepr::Enum { .. } => {
276            Ok(FieldLayoutDecision {
277                kind: FieldKind::PointerIndirect {
278                    tail_alignment: variant_record_alignment(field_name, ty)?,
279                },
280                list_element: None,
281            })
282        }
283        // Phase F.2: closure fields have no host-visible binary layout
284        // — the runtime handle is a scratch-heap pointer that doesn't
285        // survive the `run_main` boundary. Reject here so the binary
286        // handshake builder doesn't paper over a dangling pointer.
287        TypeRepr::Closure { .. } => Err(LayoutError::UnsupportedTypeInLayoutV1 {
288            field: field_name.to_string(),
289            kind: "Closure",
290        }),
291    }
292}
293
294/// Decide layout for a `List<element>` field. v1 supports the
295/// fixed-stride scalar elements (`Int`, `Float`, `Bool`) inline and
296/// the variable-stride elements (`String`, branded `Schema`) through
297/// a per-element pointer array. Other element shapes route to
298/// [`LayoutError::UnsupportedListElement`].
299fn list_layout_decision(
300    field_name: &str,
301    element: &TypeRepr,
302) -> Result<FieldLayoutDecision, LayoutError> {
303    match element {
304        TypeRepr::Int | TypeRepr::Float => Ok(FieldLayoutDecision {
305            kind: FieldKind::PointerIndirect { tail_alignment: 8 },
306            list_element: Some(ListElementKind::InlineFixed {
307                elem_size: 8,
308                elem_align: 8,
309            }),
310        }),
311        TypeRepr::Bool => Ok(FieldLayoutDecision {
312            // Bool elements are 1-aligned; the record still needs a
313            // 4-byte aligned start so the `[len:u32]` prefix is
314            // naturally aligned. Builder pads to 4 before writing the
315            // record, then writes the len + booleans tightly per spec.
316            kind: FieldKind::PointerIndirect { tail_alignment: 4 },
317            list_element: Some(ListElementKind::InlineFixed {
318                elem_size: 1,
319                elem_align: 1,
320            }),
321        }),
322        TypeRepr::String => Ok(FieldLayoutDecision {
323            kind: FieldKind::PointerIndirect { tail_alignment: 4 },
324            list_element: Some(ListElementKind::PointerArray { elem_alignment: 4 }),
325        }),
326        TypeRepr::Schema { schema } => {
327            let sub = SchemaLayout::offsets_for(schema)?;
328            Ok(FieldLayoutDecision {
329                kind: FieldKind::PointerIndirect { tail_alignment: 4 },
330                list_element: Some(ListElementKind::PointerArray {
331                    elem_alignment: sub.root_align,
332                }),
333            })
334        }
335        // Nested `List<List<…>>`: each element is itself a `[len]
336        // [payload]` list record addressed through a `u32` entry in the
337        // pointer array, exactly like `List<String>` / `List<Schema>`.
338        // The inner list record's own alignment depends on its element
339        // (8 for Int/Float, 4 otherwise); the entry slots are 4-byte
340        // offsets so the pointer array's `elem_alignment` is the inner
341        // record's start alignment. Validating the inner element here
342        // keeps the recursion bounded to shapes the writer/reader and
343        // `relocate_pointers` actually materialise.
344        TypeRepr::List { element: inner } => {
345            let inner_align = inner_list_record_alignment(field_name, inner)?;
346            Ok(FieldLayoutDecision {
347                kind: FieldKind::PointerIndirect { tail_alignment: 4 },
348                list_element: Some(ListElementKind::PointerArray {
349                    elem_alignment: inner_align,
350                }),
351            })
352        }
353        TypeRepr::Option { .. } | TypeRepr::Result { .. } | TypeRepr::Enum { .. } => {
354            Ok(FieldLayoutDecision {
355                kind: FieldKind::PointerIndirect { tail_alignment: 4 },
356                list_element: Some(ListElementKind::PointerArray {
357                    elem_alignment: variant_record_alignment(field_name, element)?,
358                }),
359            })
360        }
361        TypeRepr::Unit => Err(LayoutError::UnsupportedListElement {
362            field: field_name.to_string(),
363            inner: "Unit",
364        }),
365        // Phase F.2: `List<Closure>` is not part of any v1 binary
366        // surface — closures are non-portable scratch-heap pointers.
367        TypeRepr::Closure { .. } => Err(LayoutError::UnsupportedListElement {
368            field: field_name.to_string(),
369            inner: "Closure",
370        }),
371    }
372}
373
374/// Start alignment of the `[len: u32][payload]` record an inner
375/// `List<inner>` element occupies. Mirrors the `tail_alignment` each
376/// scalar/String/Schema list field carries: `List<Int>` / `List<Float>`
377/// records start 8-aligned so the i64/f64 payload lands aligned, every
378/// other list record (Bool / String / Schema / a further nested List)
379/// starts 4-aligned. Rejects element shapes the writer/reader don't
380/// materialise so the nesting can't silently produce an undecodable
381/// buffer.
382fn inner_list_record_alignment(field_name: &str, inner: &TypeRepr) -> Result<usize, LayoutError> {
383    match inner {
384        // Inner *inline-fixed* element lists (`List<List<Int>>` /
385        // `List<List<Float>>` / `List<List<Bool>>`): each inner record
386        // is a self-contained `[len][payload]` with no internal pointer
387        // slots, so the outer pointer array's per-entry rebase is the
388        // only relocation needed. Int/Float records start 8-aligned for
389        // the aligned payload; Bool records start 4-aligned.
390        TypeRepr::Int | TypeRepr::Float => Ok(8),
391        TypeRepr::Bool => Ok(4),
392        // Inner *pointer-array* element lists (`List<List<String>>`,
393        // `List<List<Schema>>`, and deeper `List<List<List<…>>>`): each
394        // inner record is itself a `[len][off_j]…` pointer-array header
395        // carrying internal pointers. The header starts 4-aligned (its
396        // entry slots are 4-byte offsets); the recursive `relocate_list_
397        // pointer_array` walk (driven by the `PtrArrayElem::InnerList`
398        // descriptor) rebases the inner entries — and any String / Schema
399        // they reach — one level deeper, so the buffer stays self-coherent
400        // after a paste / arena rebase. The `List<List<Schema>>` inner
401        // header is still a 4-aligned pointer array (the *entries* point
402        // at sub-records whose own alignment the writer pads to); validate
403        // the inner-inner element so an unsupported leaf is still a loud
404        // cap rather than a mis-laid buffer.
405        TypeRepr::String => Ok(4),
406        TypeRepr::Schema { schema } => {
407            // Force a sub-layout so an unlayoutable element schema is a
408            // loud error here, not a silent mis-encode downstream.
409            SchemaLayout::offsets_for(schema)?;
410            Ok(4)
411        }
412        TypeRepr::List { element: deeper } => {
413            // Validate the still-deeper element recursively; the header
414            // itself is a 4-aligned pointer array.
415            inner_list_record_alignment(field_name, deeper)?;
416            Ok(4)
417        }
418        TypeRepr::Option { .. } | TypeRepr::Result { .. } | TypeRepr::Enum { .. } => {
419            variant_record_alignment(field_name, inner)?;
420            Ok(4)
421        }
422        other => Err(LayoutError::UnsupportedListElement {
423            field: field_name.to_string(),
424            inner: match other {
425                TypeRepr::Unit => "Unit",
426                TypeRepr::Closure { .. } => "Closure",
427                _ => "List",
428            },
429        }),
430    }
431}
432
433/// Fixed payload slot layout inside an Option/Result variant record.
434/// Scalars are stored inline; variable or composite values store a u32
435/// pointer to their own tail record.
436fn variant_payload_slot(field_name: &str, ty: &TypeRepr) -> Result<(usize, usize), LayoutError> {
437    match ty {
438        TypeRepr::Unit | TypeRepr::Bool => Ok((1, 1)),
439        TypeRepr::Int | TypeRepr::Float => Ok((8, 8)),
440        TypeRepr::String
441        | TypeRepr::List { .. }
442        | TypeRepr::Schema { .. }
443        | TypeRepr::Option { .. }
444        | TypeRepr::Result { .. }
445        | TypeRepr::Enum { .. } => {
446            ensure_type_layoutable(field_name, ty)?;
447            Ok((4, 4))
448        }
449        TypeRepr::Closure { .. } => Err(LayoutError::UnsupportedTypeInLayoutV1 {
450            field: field_name.to_string(),
451            kind: "Closure",
452        }),
453    }
454}
455
456/// Deep alignment required by any tail record reachable from `ty` after a
457/// detached record is pasted into a parent. This mirrors buffer.rs' runtime
458/// relocation rule but stays local to layout so unsupported shapes remain
459/// compile-time errors.
460fn type_graph_alignment(field_name: &str, ty: &TypeRepr) -> Result<usize, LayoutError> {
461    match ty {
462        TypeRepr::Unit | TypeRepr::Bool | TypeRepr::String => Ok(1),
463        TypeRepr::Int | TypeRepr::Float => Ok(8),
464        TypeRepr::List { element } => match element.as_ref() {
465            TypeRepr::Int | TypeRepr::Float => Ok(8),
466            TypeRepr::Bool => Ok(1),
467            other => type_graph_alignment(field_name, other),
468        },
469        TypeRepr::Schema { schema } => {
470            let sub = SchemaLayout::offsets_for(schema)?;
471            let tail = schema
472                .fields
473                .iter()
474                .map(|f| type_graph_alignment(&f.name, &f.ty))
475                .collect::<Result<Vec<_>, _>>()?
476                .into_iter()
477                .max()
478                .unwrap_or(1);
479            Ok(sub.root_align.max(tail))
480        }
481        TypeRepr::Option { .. } | TypeRepr::Result { .. } | TypeRepr::Enum { .. } => {
482            variant_record_alignment(field_name, ty)
483        }
484        TypeRepr::Closure { .. } => Err(LayoutError::UnsupportedTypeInLayoutV1 {
485            field: field_name.to_string(),
486            kind: "Closure",
487        }),
488    }
489}
490
491fn ensure_type_layoutable(field_name: &str, ty: &TypeRepr) -> Result<(), LayoutError> {
492    match ty {
493        TypeRepr::List { element } => {
494            list_layout_decision(field_name, element)?;
495            Ok(())
496        }
497        TypeRepr::Schema { schema } => {
498            SchemaLayout::offsets_for(schema)?;
499            Ok(())
500        }
501        TypeRepr::Option { .. } | TypeRepr::Result { .. } | TypeRepr::Enum { .. } => {
502            variant_record_alignment(field_name, ty)?;
503            Ok(())
504        }
505        TypeRepr::Closure { .. } => Err(LayoutError::UnsupportedTypeInLayoutV1 {
506            field: field_name.to_string(),
507            kind: "Closure",
508        }),
509        _ => Ok(()),
510    }
511}
512
513/// Start alignment required by a variant tail record. The first byte is the
514/// tag; payload, when present, starts at the next offset satisfying the
515/// payload slot alignment. The returned alignment also includes any deeper
516/// tail records reachable through pointer payloads so relocation after paste
517/// preserves absolute alignment.
518fn variant_record_alignment(field_name: &str, ty: &TypeRepr) -> Result<usize, LayoutError> {
519    let payloads: Vec<TypeRepr> = match ty {
520        TypeRepr::Option { inner } => vec![inner.as_ref().clone()],
521        TypeRepr::Result { ok, err } => vec![ok.as_ref().clone(), err.as_ref().clone()],
522        TypeRepr::Enum { name, variants } => variants
523            .iter()
524            .filter_map(|variant| {
525                variant.payload_schema(name).map(|schema| TypeRepr::Schema {
526                    schema: Box::new(schema),
527                })
528            })
529            .collect(),
530        other => {
531            return Err(LayoutError::UnsupportedTypeInLayoutV1 {
532                field: field_name.to_string(),
533                kind: match other {
534                    TypeRepr::Closure { .. } => "Closure",
535                    _ => "Variant",
536                },
537            })
538        }
539    };
540
541    let mut align = 4usize;
542    for payload in &payloads {
543        let (_, slot_align) = variant_payload_slot(field_name, payload)?;
544        let graph_align = type_graph_alignment(field_name, payload)?;
545        align = align.max(slot_align).max(graph_align).max(1);
546    }
547    Ok(align)
548}
549
550/// Round `value` up to the next multiple of `align`. `align` is
551/// assumed to be a non-zero power of two (the layout spec guarantees
552/// alignments of 1 / 4 / 8 for v1; std's `checked_next_multiple_of`
553/// works for any non-zero divisor and returns `None` on overflow).
554fn align_up(value: usize, align: usize) -> Option<usize> {
555    debug_assert!(align != 0, "alignment must be non-zero");
556    value.checked_next_multiple_of(align)
557}
558
559/// Schema-level layout entry point. Computes the offset table for the
560/// flat record area; tail-area layout (for String / List in Phase 2.b)
561/// will be returned through a sibling helper once those types land.
562pub struct SchemaLayout;
563
564impl SchemaLayout {
565    /// Compute the [`OffsetTable`] for `schema` under the v1 layout.
566    ///
567    /// The table covers only the fixed (root) area. `PointerIndirect`
568    /// fields contribute a `u32` slot here; the actual tail-area
569    /// payload is placed at write time by
570    /// [`crate::buffer::BufferBuilder`].
571    pub fn offsets_for(schema: &Schema) -> Result<OffsetTable, LayoutError> {
572        let mut fields: Vec<FieldOffset> = Vec::with_capacity(schema.fields.len());
573        let mut cursor: usize = 0;
574        let mut max_align: usize = 1;
575
576        for field in &schema.fields {
577            let decision = field_layout_decision_for(&field.name, &field.ty)?;
578            let kind = decision.kind;
579
580            let (size, align) = match kind {
581                FieldKind::Inline { size, align } => (size, align),
582                // Pointer slot is always 4 bytes, 4-byte aligned, so
583                // the wasm side can issue a single `i32.load offset=N`
584                // regardless of the tail payload's alignment.
585                FieldKind::PointerIndirect { .. } => (4, 4),
586            };
587
588            let offset = align_up(cursor, align).ok_or_else(|| LayoutError::Overflow {
589                field: field.name.clone(),
590                offset: cursor,
591            })?;
592            let next = offset
593                .checked_add(size)
594                .ok_or_else(|| LayoutError::Overflow {
595                    field: field.name.clone(),
596                    offset,
597                })?;
598            cursor = next;
599            if align > max_align {
600                max_align = align;
601            }
602            fields.push(FieldOffset {
603                name: field.name.clone(),
604                offset,
605                size,
606                align,
607                kind,
608                list_element: decision.list_element,
609            });
610        }
611
612        // Pad the record to root_align so the next record after this
613        // one (in a future struct-of-structs layout, or when the host
614        // packs two #main calls back-to-back) starts aligned.
615        let root_size = if schema.fields.is_empty() {
616            0
617        } else {
618            align_up(cursor, max_align).ok_or_else(|| LayoutError::Overflow {
619                field: "<root padding>".to_string(),
620                offset: cursor,
621            })?
622        };
623
624        Ok(OffsetTable {
625            fields,
626            root_size,
627            root_align: max_align,
628        })
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::schema_canonical::{Field, Schema};
636
637    fn field(name: &str, ty: TypeRepr) -> Field {
638        Field {
639            name: name.into(),
640            ty,
641            default: None,
642        }
643    }
644
645    #[test]
646    fn pure_int_fields_pack_at_eight_byte_stride() {
647        let schema = Schema {
648            name: "Trio".into(),
649            generics: vec![],
650            is_tuple: false,
651            fields: vec![
652                field("a", TypeRepr::Int),
653                field("b", TypeRepr::Int),
654                field("c", TypeRepr::Int),
655            ],
656        };
657        let table = SchemaLayout::offsets_for(&schema).expect("layout");
658        assert_eq!(table.fields[0].offset, 0);
659        assert_eq!(table.fields[1].offset, 8);
660        assert_eq!(table.fields[2].offset, 16);
661        assert_eq!(table.root_size, 24);
662        assert_eq!(table.root_align, 8);
663    }
664
665    #[test]
666    fn int_then_bool_pads_after_int() {
667        // Int at 0..8, Bool at 8..9, then pad up to 16 to honour root
668        // alignment of 8 (max field align).
669        let schema = Schema {
670            name: "Pair".into(),
671            generics: vec![],
672            is_tuple: false,
673            fields: vec![field("score", TypeRepr::Int), field("flag", TypeRepr::Bool)],
674        };
675        let table = SchemaLayout::offsets_for(&schema).expect("layout");
676        assert_eq!(table.fields[0].offset, 0);
677        assert_eq!(table.fields[0].size, 8);
678        assert_eq!(table.fields[1].offset, 8);
679        assert_eq!(table.fields[1].size, 1);
680        assert_eq!(table.root_size, 16);
681        assert_eq!(table.root_align, 8);
682    }
683
684    #[test]
685    fn bool_then_int_pads_between_fields() {
686        // Bool at 0..1, then 7 bytes of padding so the Int sits at 8.
687        let schema = Schema {
688            name: "Pair".into(),
689            generics: vec![],
690            is_tuple: false,
691            fields: vec![field("flag", TypeRepr::Bool), field("score", TypeRepr::Int)],
692        };
693        let table = SchemaLayout::offsets_for(&schema).expect("layout");
694        assert_eq!(table.fields[0].offset, 0);
695        assert_eq!(table.fields[1].offset, 8);
696        assert_eq!(table.root_size, 16);
697        assert_eq!(table.root_align, 8);
698    }
699
700    #[test]
701    fn pure_bool_fields_pack_tightly() {
702        // Three bools, all 1-byte aligned: 0, 1, 2, root_size = 3,
703        // root_align = 1 (no trailing padding needed).
704        let schema = Schema {
705            name: "Flags".into(),
706            generics: vec![],
707            is_tuple: false,
708            fields: vec![
709                field("a", TypeRepr::Bool),
710                field("b", TypeRepr::Bool),
711                field("c", TypeRepr::Bool),
712            ],
713        };
714        let table = SchemaLayout::offsets_for(&schema).expect("layout");
715        assert_eq!(table.fields[0].offset, 0);
716        assert_eq!(table.fields[1].offset, 1);
717        assert_eq!(table.fields[2].offset, 2);
718        assert_eq!(table.root_size, 3);
719        assert_eq!(table.root_align, 1);
720    }
721
722    #[test]
723    fn empty_schema_has_zero_size_and_align_one() {
724        let schema = Schema {
725            name: "Unit".into(),
726            generics: vec![],
727            is_tuple: false,
728            fields: vec![],
729        };
730        let table = SchemaLayout::offsets_for(&schema).expect("layout");
731        assert!(table.fields.is_empty());
732        assert_eq!(table.root_size, 0);
733        assert_eq!(table.root_align, 1);
734    }
735
736    #[test]
737    fn string_field_takes_one_pointer_slot() {
738        // Phase 2.c: String contributes a 4-byte pointer slot in the
739        // fixed area. The actual `[len][bytes]` payload lives in the
740        // tail area appended by `BufferBuilder::write_string`.
741        let schema = Schema {
742            name: "Greet".into(),
743            generics: vec![],
744            is_tuple: false,
745            fields: vec![field("name", TypeRepr::String)],
746        };
747        let table = SchemaLayout::offsets_for(&schema).expect("layout");
748        assert_eq!(table.fields[0].offset, 0);
749        assert_eq!(table.fields[0].size, 4);
750        assert_eq!(table.fields[0].align, 4);
751        assert!(matches!(
752            table.fields[0].kind,
753            FieldKind::PointerIndirect { tail_alignment: 1 }
754        ));
755        assert_eq!(table.root_size, 4);
756        assert_eq!(table.root_align, 4);
757        assert!(table.requires_tail_area());
758        assert_eq!(table.fixed_area_size(), 4);
759    }
760
761    #[test]
762    fn list_of_int_field_takes_one_pointer_slot() {
763        // Same shape as String but the tail elements are 8-byte i64s,
764        // so the kind records `tail_alignment: 8` for the builder.
765        let schema = Schema {
766            name: "Nums".into(),
767            generics: vec![],
768            is_tuple: false,
769            fields: vec![field(
770                "nums",
771                TypeRepr::List {
772                    element: Box::new(TypeRepr::Int),
773                },
774            )],
775        };
776        let table = SchemaLayout::offsets_for(&schema).expect("layout");
777        assert_eq!(table.fields[0].offset, 0);
778        assert_eq!(table.fields[0].size, 4);
779        assert_eq!(table.fields[0].align, 4);
780        assert!(matches!(
781            table.fields[0].kind,
782            FieldKind::PointerIndirect { tail_alignment: 8 }
783        ));
784    }
785
786    #[test]
787    fn list_of_string_takes_pointer_array_layout() {
788        // Phase 10-c: List<String> is supported through a pointer
789        // array. The fixed-area slot is the standard 4-byte pointer;
790        // the element kind records `PointerArray { elem_alignment: 4 }`
791        // so the builder pads each String len-prefix to 4 bytes.
792        let schema = Schema {
793            name: "Names".into(),
794            generics: vec![],
795            is_tuple: false,
796            fields: vec![field(
797                "names",
798                TypeRepr::List {
799                    element: Box::new(TypeRepr::String),
800                },
801            )],
802        };
803        let table = SchemaLayout::offsets_for(&schema).expect("layout");
804        assert_eq!(table.fields[0].offset, 0);
805        assert_eq!(table.fields[0].size, 4);
806        assert_eq!(table.fields[0].align, 4);
807        assert!(matches!(
808            table.fields[0].kind,
809            FieldKind::PointerIndirect { tail_alignment: 4 }
810        ));
811        assert!(matches!(
812            table.fields[0].list_element,
813            Some(ListElementKind::PointerArray { elem_alignment: 4 })
814        ));
815    }
816
817    #[test]
818    fn list_of_float_takes_inline_eight_byte_elements() {
819        let schema = Schema {
820            name: "Speeds".into(),
821            generics: vec![],
822            is_tuple: false,
823            fields: vec![field(
824                "xs",
825                TypeRepr::List {
826                    element: Box::new(TypeRepr::Float),
827                },
828            )],
829        };
830        let table = SchemaLayout::offsets_for(&schema).expect("layout");
831        assert!(matches!(
832            table.fields[0].kind,
833            FieldKind::PointerIndirect { tail_alignment: 8 }
834        ));
835        assert!(matches!(
836            table.fields[0].list_element,
837            Some(ListElementKind::InlineFixed {
838                elem_size: 8,
839                elem_align: 8
840            })
841        ));
842    }
843
844    #[test]
845    fn list_of_bool_packs_one_byte_per_element() {
846        let schema = Schema {
847            name: "Flags".into(),
848            generics: vec![],
849            is_tuple: false,
850            fields: vec![field(
851                "xs",
852                TypeRepr::List {
853                    element: Box::new(TypeRepr::Bool),
854                },
855            )],
856        };
857        let table = SchemaLayout::offsets_for(&schema).expect("layout");
858        assert!(matches!(
859            table.fields[0].kind,
860            FieldKind::PointerIndirect { tail_alignment: 4 }
861        ));
862        assert!(matches!(
863            table.fields[0].list_element,
864            Some(ListElementKind::InlineFixed {
865                elem_size: 1,
866                elem_align: 1
867            })
868        ));
869    }
870
871    #[test]
872    fn list_of_nested_scalar_list_is_pointer_array() {
873        // `List<List<Int>>`: each element is an inner `[len][pad][i64]`
874        // record (8-aligned start) addressed through the outer pointer
875        // array, exactly like `List<String>` / `List<Schema>`.
876        let schema = Schema {
877            name: "Nested".into(),
878            generics: vec![],
879            is_tuple: false,
880            fields: vec![field(
881                "xs",
882                TypeRepr::List {
883                    element: Box::new(TypeRepr::List {
884                        element: Box::new(TypeRepr::Int),
885                    }),
886                },
887            )],
888        };
889        let table = SchemaLayout::offsets_for(&schema).expect("nested scalar list accepted");
890        assert_eq!(
891            table.fields[0].list_element,
892            Some(ListElementKind::PointerArray { elem_alignment: 8 })
893        );
894    }
895
896    #[test]
897    fn list_of_inner_pointer_array_list_is_accepted() {
898        // F5: `List<List<String>>` is now materialisable — the inner list
899        // record is a 4-aligned pointer array, rebased by the recursive
900        // `relocate_list_pointer_array` walk (`PtrArrayElem::InnerList`).
901        let schema = Schema {
902            name: "Nested".into(),
903            generics: vec![],
904            is_tuple: false,
905            fields: vec![field(
906                "xs",
907                TypeRepr::List {
908                    element: Box::new(TypeRepr::List {
909                        element: Box::new(TypeRepr::String),
910                    }),
911                },
912            )],
913        };
914        let table = SchemaLayout::offsets_for(&schema).expect("List<List<String>> accepted");
915        // Outer slot is a pointer array; its entries are 4-byte offsets to
916        // inner list headers (themselves 4-aligned pointer arrays).
917        assert_eq!(
918            table.fields[0].list_element,
919            Some(ListElementKind::PointerArray { elem_alignment: 4 })
920        );
921    }
922
923    #[test]
924    fn list_of_inner_list_with_option_leaf_is_accepted() {
925        // `List<List<Option<Int>>>` is a legal nested shape (cf. Rust's
926        // `Vec<Vec<Option<i32>>>`): the layout materialises the outer slot as
927        // a pointer array whose entries point at inner pointer-array list
928        // headers. (Return-side codegen that cannot yet marshal a given
929        // *source* of such a value still caps loudly — never miscompiles.)
930        let schema = Schema {
931            name: "Nested".into(),
932            generics: vec![],
933            is_tuple: false,
934            fields: vec![field(
935                "xs",
936                TypeRepr::List {
937                    element: Box::new(TypeRepr::List {
938                        element: Box::new(TypeRepr::Option {
939                            inner: Box::new(TypeRepr::Int),
940                        }),
941                    }),
942                },
943            )],
944        };
945        let table = SchemaLayout::offsets_for(&schema).expect("nested Option list must layout");
946        assert_eq!(
947            table.fields[0].list_element,
948            Some(ListElementKind::PointerArray { elem_alignment: 4 })
949        );
950    }
951
952    #[test]
953    fn list_of_schema_picks_subrecord_alignment() {
954        // Sub-schema with an Int field demands 8-byte alignment for
955        // its fixed area, so the pointer-array elements need 8-byte
956        // alignment when the builder lays them out.
957        let inner = Schema {
958            name: "Inner".into(),
959            generics: vec![],
960            is_tuple: false,
961            fields: vec![field("v", TypeRepr::Int)],
962        };
963        let schema = Schema {
964            name: "Outer".into(),
965            generics: vec![],
966            is_tuple: false,
967            fields: vec![field(
968                "xs",
969                TypeRepr::List {
970                    element: Box::new(TypeRepr::Schema {
971                        schema: Box::new(inner),
972                    }),
973                },
974            )],
975        };
976        let table = SchemaLayout::offsets_for(&schema).expect("layout");
977        assert!(matches!(
978            table.fields[0].list_element,
979            Some(ListElementKind::PointerArray { elem_alignment: 8 })
980        ));
981    }
982
983    #[test]
984    fn string_then_int_packs_pointer_then_padding_then_int() {
985        // Pointer slot at 0..4, padding 4..8, Int at 8..16. Root
986        // alignment is 8 because the Int slot demands it.
987        let schema = Schema {
988            name: "Mixed".into(),
989            generics: vec![],
990            is_tuple: false,
991            fields: vec![field("name", TypeRepr::String), field("age", TypeRepr::Int)],
992        };
993        let table = SchemaLayout::offsets_for(&schema).expect("layout");
994        assert_eq!(table.fields[0].offset, 0);
995        assert_eq!(table.fields[0].size, 4);
996        assert_eq!(table.fields[1].offset, 8);
997        assert_eq!(table.fields[1].size, 8);
998        assert_eq!(table.root_size, 16);
999        assert_eq!(table.root_align, 8);
1000        assert!(table.requires_tail_area());
1001    }
1002
1003    #[test]
1004    fn null_field_is_one_byte_one_aligned() {
1005        let schema = Schema {
1006            name: "Sentinel".into(),
1007            generics: vec![],
1008            is_tuple: false,
1009            fields: vec![field("nil", TypeRepr::Unit)],
1010        };
1011        let table = SchemaLayout::offsets_for(&schema).expect("layout");
1012        assert_eq!(table.fields[0].offset, 0);
1013        assert_eq!(table.fields[0].size, 1);
1014        assert_eq!(table.fields[0].align, 1);
1015        assert_eq!(table.root_size, 1);
1016        assert_eq!(table.root_align, 1);
1017    }
1018
1019    #[test]
1020    fn float_field_is_eight_byte_aligned() {
1021        let schema = Schema {
1022            name: "Phys".into(),
1023            generics: vec![],
1024            is_tuple: false,
1025            fields: vec![
1026                field("flag", TypeRepr::Bool),
1027                field("mass", TypeRepr::Float),
1028            ],
1029        };
1030        let table = SchemaLayout::offsets_for(&schema).expect("layout");
1031        assert_eq!(table.fields[1].offset, 8);
1032        assert_eq!(table.fields[1].size, 8);
1033        assert_eq!(table.fields[1].align, 8);
1034        assert_eq!(table.root_size, 16);
1035    }
1036}