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}