Skip to main content

relon_eval_api/
inplace_return.rs

1//! Backend-shared host side of the in-place region-walk return ABI
2//! (S1/S2 `List<List<scalar>>`, S3 `List<String>`, S4 `List<Schema>`,
3//! and pointer-array variant lists such as `List<Enum>`).
4//! When a compiled
5//! `#main` returns a parameter-sourced pointer-array list, the machine
6//! code does **not** copy the value into `out_buf`. Instead the epilogue
7//! reports the arena-absolute offset of the return root via the negative
8//! sentinel `-(root_abs + 1)`. The host then:
9//!
10//! 1. recovers `root_abs` from the sentinel ([`decode_inplace_sentinel`]),
11//! 2. selects the arena region `root_abs` lands in,
12//! 3. runs the bounds [`verify_value_at`] over the whole reachable graph
13//!    confined to that region — **a verify failure aborts the decode**,
14//! 4. only on a clean verify decodes the value in place via the matching
15//!    positional reader (`read_list_list_at` / `read_list_string_at` /
16//!    `read_list_record_at`).
17//!
18//! Both the cranelift and llvm backends call into this one
19//! implementation, so the sentinel → region-select → verifier → decode
20//! pipeline is genuinely backend-shared rather than mirrored per crate.
21//! The machine-code side (which negative sentinel to emit) is the only
22//! per-backend half; the host decode is here.
23
24use std::sync::Arc;
25
26use crate::buffer::BufferReader;
27use crate::layout::{OffsetTable, SchemaLayout};
28use crate::schema_canonical::{Field, Schema, TypeRepr};
29use crate::smol_str::SmolStr;
30use crate::value::Value;
31use crate::verifier::{verify_record_multi, verify_value_at_multi, MultiRegion, VerifyError};
32use crate::RuntimeError;
33
34/// Arena region boundaries the in-place decode selects between. The
35/// arena layout (shared by both AOT backends) is
36/// `[const_data | pad | in_buf | pad | out_buf | pad | scratch]`; the
37/// returned root may live in any region (S1 only ever sees the
38/// param-sourced `in_buf` list, but the selection is generic so S2+ can
39/// return `out_buf` / `scratch` roots through the same gate).
40#[derive(Debug, Clone, Copy)]
41pub struct ArenaRegions {
42    /// Length of the const-data section at arena offset 0.
43    pub const_data_len: usize,
44    /// Input region start (`in_ptr`) and length.
45    pub in_ptr: u32,
46    pub in_len: u32,
47    /// Output region start (`out_ptr`) and capacity.
48    pub out_ptr: u32,
49    pub out_cap: u32,
50    /// Scratch region start; it runs to `arena_size`.
51    pub scratch_base: u32,
52    /// Total arena size in bytes.
53    pub arena_size: usize,
54}
55
56impl ArenaRegions {
57    /// Build the four-region [`MultiRegion`] map in absolute arena
58    /// coordinates from the dispatch's region boundaries. Every slot
59    /// pointer the F1 ABI emits is arena-absolute, so the verifier walks
60    /// the **whole arena** and classifies each followed span into one of
61    /// `const` / `in` / `out` / `scratch`. The ABI lays the regions out
62    /// disjointly as `[const | pad | in | pad | out | pad | scratch]`; we
63    /// pass each as a half-open `[start, end)` window.
64    pub fn multi_region(&self) -> Result<MultiRegion, VerifyError> {
65        let in_start = self.in_ptr as usize;
66        let in_end = in_start + self.in_len as usize;
67        let out_start = self.out_ptr as usize;
68        let out_end = out_start + self.out_cap as usize;
69        let scratch_start = self.scratch_base as usize;
70        MultiRegion::new(
71            (0, self.const_data_len),
72            (in_start, in_end),
73            (out_start, out_end),
74            (scratch_start, self.arena_size),
75        )
76    }
77}
78
79/// Decode the in-place region-walk return sentinel. The machine code
80/// encodes an in-place return as `-(root_abs + 1)` (a value `<= -9`,
81/// since `root_abs >= in_ptr >= 8`). Recover `root_abs = -ret - 1`,
82/// rejecting a sentinel that doesn't round-trip into a non-negative
83/// offset (a corrupt / impossible encoding).
84///
85/// `ret` must already be known negative (the caller distinguishes the
86/// non-negative `bytes_written` path before calling).
87pub fn decode_inplace_sentinel(ret: i32) -> Result<usize, RuntimeError> {
88    // `ret` is negative here. `-(ret as i64) - 1` is the root offset;
89    // i64 math avoids the `i32::MIN` negation overflow.
90    let root = -(ret as i64) - 1;
91    if root < 0 {
92        return Err(RuntimeError::IoError(format!(
93            "in-place return sentinel {ret} decodes to a negative root offset"
94        )));
95    }
96    usize::try_from(root).map_err(|_| {
97        RuntimeError::IoError(format!(
98            "in-place return sentinel {ret} decodes to an out-of-range root offset"
99        ))
100    })
101}
102
103/// Decode an in-place region-walk return for a single-value return whose
104/// root is a parameter-sourced pointer-array list: `List<List<scalar>>`
105/// (S1/S2), `List<String>` (S3), `List<Schema>` (S4), or a variant list
106/// such as `List<Option<T>>` / `List<Result<T, E>>` / `List<Enum>`. The machine
107/// code reported `root_abs`
108/// (the arena-absolute offset of the return root) via the negative
109/// sentinel; the caller already recovered it with
110/// [`decode_inplace_sentinel`].
111///
112/// `return_field` is the single return-schema field; `return_layout` /
113/// `return_fields` are the matching return [`OffsetTable`] and fields the
114/// [`BufferReader`] decodes against. `backend` is a short label
115/// (`"cranelift"` / `"llvm"`) for diagnostics.
116///
117/// This is backend-agnostic: it owns the region-select + verifier +
118/// decode pipeline so both AOT backends share exactly one host
119/// implementation. It dispatches on the return field's declared type so a
120/// single sentinel path covers every in-place shape; an unexpected type
121/// reaching here is a lowering/ABI drift bug surfaced loudly.
122pub fn decode_inplace_return(
123    backend: &str,
124    arena: &[u8],
125    regions: ArenaRegions,
126    root_abs: usize,
127    return_field: &Field,
128    return_layout: &OffsetTable,
129    return_fields: &[Field],
130) -> Result<Value, RuntimeError> {
131    // The return must be a pointer-array list the in-place ABI emits:
132    // `List<List<scalar>>`, `List<String>`, `List<Schema>`, or a variant list. Classify it
133    // up front so the region/verify pipeline below is shape-agnostic and
134    // only the final decode branches. Anything else is ABI drift — surface
135    // it loudly.
136    // The `List` prefix on every variant is intentional: each names the
137    // concrete pointer-array return shape (`List<List>` / `List<String>` /
138    // `List<Schema>` / `List<Variant>`) the sentinel can carry, so the shared prefix is the
139    // point rather than noise.
140    #[allow(clippy::enum_variant_names)]
141    enum InplaceShape<'a> {
142        /// `List<List<scalar>>`; carries the innermost scalar element type.
143        ListListScalar(TypeRepr),
144        /// `List<String>`.
145        ListString,
146        /// `List<Schema>`; carries the per-element sub-record schema.
147        ListSchema(&'a Schema),
148        /// F5: a doubly-nested pointer-array list — `List<List<String>>`
149        /// / `List<List<Schema>>` (and deeper). Carries the **outer list
150        /// element** type (`List<String>` / `List<Schema>`); the unified
151        /// recursive reader walks one level deeper than the scalar path.
152        ListListPointerArray(&'a TypeRepr),
153        /// A pointer-array list of variant records: `List<Option<T>>`,
154        /// `List<Result<T, E>>`, or `List<CustomEnum>`.
155        ListVariant(&'a TypeRepr),
156    }
157    let shape = match &return_field.ty {
158        TypeRepr::List { element } => match element.as_ref() {
159            TypeRepr::List { element: inner } => match inner.as_ref() {
160                // Inner inline-fixed scalar: the S1/S2 path.
161                TypeRepr::Int | TypeRepr::Float | TypeRepr::Bool => {
162                    InplaceShape::ListListScalar(inner.as_ref().clone())
163                }
164                // Inner pointer-array element (String / Schema / deeper
165                // List): the F5 recursive path. `element` is the outer
166                // list element (`List<String>` / `List<Schema>`).
167                _ => InplaceShape::ListListPointerArray(element.as_ref()),
168            },
169            TypeRepr::String => InplaceShape::ListString,
170            TypeRepr::Schema { schema } => InplaceShape::ListSchema(schema.as_ref()),
171            TypeRepr::Option { .. } | TypeRepr::Result { .. } | TypeRepr::Enum { .. } => {
172                InplaceShape::ListVariant(element.as_ref())
173            }
174            other => {
175                return Err(RuntimeError::IoError(format!(
176                    "{backend} in-place return: unsupported list element {other:?} \
177                     (expected List<scalar>, String, Schema, Option, Result, or Enum)"
178                )));
179            }
180        },
181        other => {
182            return Err(RuntimeError::IoError(format!(
183                "{backend} in-place return: expected a pointer-array List, got {other:?}"
184            )));
185        }
186    };
187
188    // 1. Build the multi-region map over the whole arena. Under the F1
189    // arena-absolute slot convention every pointer the in-place root
190    // reaches is an arena-absolute offset, so the verifier and reader
191    // both walk the **whole arena** rather than a region slice; the
192    // multi-region map classifies each followed span into the one region
193    // it belongs to (param-sourced data in `in`, copied tails in `out`,
194    // const-pool literals in `const`, scratch). The single-value root
195    // still lives in exactly one region, but a cross-region link is now
196    // legal and bounds-checked region-by-region rather than rejected.
197    let arena_size = regions.arena_size;
198    if arena_size > arena.len() {
199        return Err(RuntimeError::IoError(format!(
200            "{backend} in-place return arena_size {arena_size} exceeds arena slice {}",
201            arena.len()
202        )));
203    }
204    let arena = &arena[..arena_size];
205    let multi = regions.multi_region().map_err(|e| {
206        RuntimeError::IoError(format!(
207            "{backend} in-place return arena regions invalid: {e}"
208        ))
209    })?;
210    if root_abs >= arena_size {
211        return Err(RuntimeError::IoError(format!(
212            "{backend} in-place return root {root_abs} is past arena end {arena_size}"
213        )));
214    }
215
216    // 2. Recompute the `ListElementKind` sidecar for the outer
217    // `List<List<…>>` from the return layout so the verifier knows the
218    // root is a pointer-array header.
219    let list_element = return_layout.fields.first().and_then(|fo| fo.list_element);
220
221    // 3. Verify the whole reachable graph stays inside the arena regions
222    // BEFORE any decode. A failure is loud and aborts — never a wild read.
223    verify_value_at_multi(arena, &return_field.ty, list_element, root_abs, multi).map_err(|e| {
224        RuntimeError::IoError(format!(
225            "{backend} in-place return verifier rejected the buffer (root_abs={root_abs}): {e}"
226        ))
227    })?;
228
229    // 4. Verified — decode in place. The reader walks the whole arena the
230    // verifier certified; every slot value is arena-absolute, so an
231    // in-place decode is byte-equal (post-decode) to the field-slot
232    // reader the tree-walk oracle's writer produced.
233    let reader = BufferReader::new(return_layout, return_fields, arena)
234        .map_err(|e| RuntimeError::IoError(format!("{backend} buffer: {e}")))?;
235    match shape {
236        InplaceShape::ListListScalar(inner) => {
237            let rows = reader
238                .read_list_list_at(root_abs, &inner)
239                .map_err(|e| RuntimeError::IoError(format!("{backend} buffer: {e}")))?;
240            Ok(Value::List(Arc::new(
241                rows.into_iter().map(|r| Value::List(Arc::new(r))).collect(),
242            )))
243        }
244        InplaceShape::ListString => {
245            let items = reader
246                .read_list_string_at(root_abs)
247                .map_err(|e| RuntimeError::IoError(format!("{backend} buffer: {e}")))?;
248            Ok(Value::List(Arc::new(
249                items.into_iter().map(|s| Value::String(s.into())).collect(),
250            )))
251        }
252        InplaceShape::ListListPointerArray(outer_element) => {
253            // The verifier already certified the whole graph (outer
254            // entries → inner list headers → inner entries → String /
255            // sub-record / String-field layer). Decode in place via the
256            // unified recursive reader: `root_abs` is the outer header, and
257            // `outer_element` (`List<String>` / `List<Schema>`) drives one
258            // level of recursion per outer entry.
259            let rows = reader
260                .read_list_value_at(root_abs, outer_element)
261                .map_err(|e| RuntimeError::IoError(format!("{backend} buffer: {e}")))?;
262            Ok(Value::List(Arc::new(rows)))
263        }
264        InplaceShape::ListVariant(element) => {
265            let rows = reader
266                .read_list_value_at(root_abs, element)
267                .map_err(|e| RuntimeError::IoError(format!("{backend} buffer: {e}")))?;
268            Ok(Value::List(Arc::new(rows)))
269        }
270        InplaceShape::ListSchema(schema) => {
271            // The verifier already certified the whole graph: outer
272            // entries, each sub-record's fixed area, and every String /
273            // List field pointer the sub-record carries (see
274            // `verify_pointer_target` -> `TypeRepr::Schema`). Decode each
275            // entry's sub-record into a branded dict, positionally, sharing
276            // the same region slice — bit-identical to the field-slot
277            // `read_list_record` path the tree-walk oracle's writer feeds.
278            let elem_layout = SchemaLayout::offsets_for(schema).map_err(|e| {
279                RuntimeError::IoError(format!(
280                    "{backend} in-place List<Schema> element `{}` layout: {e}",
281                    schema.name
282                ))
283            })?;
284            let sub_readers = reader
285                .read_list_record_at(root_abs, &elem_layout, schema)
286                .map_err(|e| RuntimeError::IoError(format!("{backend} buffer: {e}")))?;
287            let mut items = Vec::with_capacity(sub_readers.len());
288            for sub in &sub_readers {
289                if schema.is_tuple {
290                    items.push(read_tuple_record_into_tuple(backend, sub, schema)?);
291                } else {
292                    let map = read_record_into_branded_map(backend, sub, schema)?;
293                    items.push(Value::branded_dict(map, Some(schema.name.clone())));
294                }
295            }
296            Ok(Value::List(Arc::new(items)))
297        }
298    }
299}
300
301/// Gate the **object** (positive-`bytes_written`) return path through the
302/// multi-region bounds verifier before its `BufferReader` decode runs —
303/// closing the red-line gap the S5 design flagged (an object return
304/// previously trusted `out_buf` self-containment and ran no verifier at
305/// all).
306///
307/// Under the F1 arena-absolute slot convention the object head (anchored
308/// at the arena-absolute `record_base`, i.e. `out_ptr`) and every pointer
309/// it reaches are arena-absolute offsets into the whole `arena`. The
310/// `multi` map confines each followed span to one of `const` / `in` /
311/// `out` / `scratch` and bounds-checks it there: today every object field
312/// is still self-contained in `out_buf` (cross-region object fields stay
313/// capped — F1b releases them), so every span lands in `out`, but the
314/// gate is already cross-region-correct so F1b is a cap flip, not a
315/// verifier change. A verify failure is a loud error — the decode must
316/// not run.
317pub fn verify_object_return_multi(
318    backend: &str,
319    arena: &[u8],
320    record_base: usize,
321    multi: MultiRegion,
322    return_layout: &OffsetTable,
323    return_fields: &[Field],
324) -> Result<(), RuntimeError> {
325    verify_record_multi(arena, return_layout, return_fields, record_base, multi).map_err(|e| {
326        RuntimeError::IoError(format!(
327            "{backend} cross-region object return verifier rejected the arena (base={record_base}): {e}"
328        ))
329    })
330}
331
332/// Single central entry for the **object** (positive-`bytes_written`)
333/// return path, shared by both AOT backends. It enforces the one correct
334/// order — `verify_object_return_multi` **before** any decode — so no
335/// present or future object-return caller can reach a `BufferReader`
336/// without the multi-region bounds gate having run first. A verify
337/// failure aborts loudly; the decode never runs on an unverified arena.
338///
339/// `arena` must already be sliced to `regions.arena_size` by the caller
340/// (each backend owns the read-length bounds check against its own arena
341/// allocation). `record_base` is the object head (`out_ptr`).
342/// `single_value_wrapper` is the backend's `is_single_value_wrapper`
343/// decision (it depends on `relon-ir` schema-name constants this leaf
344/// crate intentionally does not pull in); when set, the single
345/// return-schema field is decoded directly, otherwise the whole record is
346/// drained into a branded dict.
347///
348/// The decode walks the shared [`read_object_field`] / [`BufferReader`]
349/// path for both backends, so the produced [`Value`] is byte-identical
350/// regardless of which AOT backend invoked it.
351#[allow(clippy::too_many_arguments)]
352pub fn decode_object_return(
353    backend: &str,
354    arena: &[u8],
355    record_base: usize,
356    regions: ArenaRegions,
357    return_layout: &OffsetTable,
358    return_schema: &Schema,
359    single_value_wrapper: bool,
360) -> Result<Value, RuntimeError> {
361    let multi = regions.multi_region().map_err(|e| {
362        RuntimeError::IoError(format!(
363            "{backend} object return arena regions invalid: {e}"
364        ))
365    })?;
366    // Bounds gate FIRST — must precede every decode below.
367    verify_object_return_multi(
368        backend,
369        arena,
370        record_base,
371        multi,
372        return_layout,
373        &return_schema.fields,
374    )?;
375    let reader =
376        BufferReader::new_at_base(return_layout, &return_schema.fields, arena, record_base)
377            .map_err(|e| RuntimeError::IoError(format!("{backend} buffer: {e}")))?;
378    if single_value_wrapper {
379        let field = &return_schema.fields[0];
380        read_object_field(backend, &reader, field, return_schema)
381    } else if return_schema.is_tuple {
382        // A tuple schema is laid out and verified exactly like any other
383        // record, but it decodes **positionally** to a `Value::Tuple`
384        // (declaration-order slots, no field names). JSON projection still
385        // collapses it to an array.
386        read_tuple_record_into_tuple(backend, &reader, return_schema)
387    } else {
388        let map = read_object_record_into_map(backend, &reader, return_schema)?;
389        Ok(Value::branded_dict(map, Some(return_schema.name.clone())))
390    }
391}
392
393/// Drain a tuple schema's fields in declaration order into a positional
394/// `Value::Tuple`. The slot readers are the same
395/// [`read_object_field`] arms a branded record uses; only the container
396/// shape differs (ordered tuple vs named map), which is exactly the
397/// tuple-vs-record decode fork.
398fn read_tuple_record_into_tuple(
399    backend: &str,
400    reader: &BufferReader<'_>,
401    schema: &Schema,
402) -> Result<Value, RuntimeError> {
403    let mut items = Vec::with_capacity(schema.fields.len());
404    for field in &schema.fields {
405        items.push(read_object_field(backend, reader, field, schema)?);
406    }
407    Ok(Value::Tuple(Arc::new(items)))
408}
409
410/// Drain every field of `schema` from the object record `reader` into a
411/// sorted `BTreeMap<SmolStr, Value>`. Backend-shared body of the object /
412/// nested-record decode (replaces the two per-crate `read_record_into_map`
413/// copies the cranelift / llvm evaluators carried).
414fn read_object_record_into_map(
415    backend: &str,
416    reader: &BufferReader<'_>,
417    schema: &Schema,
418) -> Result<std::collections::BTreeMap<SmolStr, Value>, RuntimeError> {
419    let mut map = std::collections::BTreeMap::new();
420    for field in &schema.fields {
421        let value = read_object_field(backend, reader, field, schema)?;
422        map.insert(SmolStr::from(field.name.as_str()), value);
423    }
424    Ok(map)
425}
426
427/// Decode one object-return field via [`BufferReader`] into a [`Value`].
428/// This is the single backend-shared copy of the object-path
429/// `read_value_from_reader` the cranelift and llvm evaluators each carried
430/// (debt 1): adding a new field type now changes exactly this one arm set.
431///
432/// It covers the full object-field type space — scalars (`Int` / `Float` /
433/// `Bool` / internal unit), `String`, every `List<…>` shape, and a bare nested
434/// `Schema` (sub-record) — by mapping each leaf onto the matching
435/// [`BufferReader`] reader. The `List<List<scalar>>` arm keeps the
436/// dedicated `read_list_list` reader (inline-fixed inner rows); every
437/// pointer-array list (`List<Schema>` / `List<List<String|Schema>>`)
438/// routes through the unified recursive `read_list_value`, exactly as both
439/// backends did. `parent_schema` only feeds the unsupported-type
440/// diagnostic. The verifier in [`decode_object_return`] has already
441/// certified every pointer this reader follows.
442fn read_object_field(
443    backend: &str,
444    reader: &BufferReader<'_>,
445    field: &Field,
446    parent_schema: &Schema,
447) -> Result<Value, RuntimeError> {
448    let map_err =
449        |e: crate::buffer::BufferError| RuntimeError::IoError(format!("{backend} buffer: {e}"));
450    match &field.ty {
451        TypeRepr::Int => reader
452            .read_int(&field.name)
453            .map(Value::Int)
454            .map_err(map_err),
455        TypeRepr::Float => reader
456            .read_float(&field.name)
457            .map(|f| Value::Float(ordered_float::OrderedFloat(f)))
458            .map_err(map_err),
459        TypeRepr::Bool => reader
460            .read_bool(&field.name)
461            .map(Value::Bool)
462            .map_err(map_err),
463        TypeRepr::Unit => reader
464            .read_unit(&field.name)
465            .map(|()| Value::option_none())
466            .map_err(map_err),
467        TypeRepr::String => reader
468            .read_string(&field.name)
469            .map(|s| Value::String(s.into()))
470            .map_err(map_err),
471        // Bare nested Schema: anchor a sub-reader at the slot's
472        // sub-record fixed area and recurse into a branded dict.
473        TypeRepr::Schema { schema } => {
474            let sub_layout = SchemaLayout::offsets_for(schema).map_err(|e| {
475                RuntimeError::IoError(format!(
476                    "{backend} object return field `{}` layout: {e}",
477                    field.name
478                ))
479            })?;
480            let sub_reader = reader
481                .sub_record(&field.name, &sub_layout, &schema.fields)
482                .map_err(map_err)?;
483            let map = read_object_record_into_map(backend, &sub_reader, schema)?;
484            Ok(Value::branded_dict(map, Some(schema.name.clone())))
485        }
486        TypeRepr::Option { .. } | TypeRepr::Result { .. } | TypeRepr::Enum { .. } => {
487            reader.read_value(&field.name, &field.ty).map_err(map_err)
488        }
489        TypeRepr::List { element } => match element.as_ref() {
490            TypeRepr::Int => reader
491                .read_list_int(&field.name)
492                .map(|v| Value::List(Arc::new(v.into_iter().map(Value::Int).collect())))
493                .map_err(map_err),
494            TypeRepr::Float => reader
495                .read_list_float(&field.name)
496                .map(|v| {
497                    Value::List(Arc::new(
498                        v.into_iter()
499                            .map(|f| Value::Float(ordered_float::OrderedFloat(f)))
500                            .collect(),
501                    ))
502                })
503                .map_err(map_err),
504            TypeRepr::Bool => reader
505                .read_list_bool(&field.name)
506                .map(|v| Value::List(Arc::new(v.into_iter().map(Value::Bool).collect())))
507                .map_err(map_err),
508            TypeRepr::String => reader
509                .read_list_string(&field.name)
510                .map(|v| {
511                    Value::List(Arc::new(
512                        v.into_iter().map(|s| Value::String(s.into())).collect(),
513                    ))
514                })
515                .map_err(map_err),
516            TypeRepr::Schema { schema } => {
517                let elem_layout = SchemaLayout::offsets_for(schema).map_err(|e| {
518                    RuntimeError::IoError(format!(
519                        "{backend} object return List<Schema> element `{}` layout: {e}",
520                        schema.name
521                    ))
522                })?;
523                let sub_readers = reader
524                    .read_list_record(&field.name, &elem_layout, schema)
525                    .map_err(map_err)?;
526                let mut items = Vec::with_capacity(sub_readers.len());
527                for sub in &sub_readers {
528                    if schema.is_tuple {
529                        items.push(read_tuple_record_into_tuple(backend, sub, schema)?);
530                    } else {
531                        let map = read_object_record_into_map(backend, sub, schema)?;
532                        items.push(Value::branded_dict(map, Some(schema.name.clone())));
533                    }
534                }
535                Ok(Value::List(Arc::new(items)))
536            }
537            TypeRepr::Option { .. } | TypeRepr::Result { .. } | TypeRepr::Enum { .. } => reader
538                .read_list_value(&field.name, element.as_ref())
539                .map(|rows| Value::List(Arc::new(rows)))
540                .map_err(map_err),
541            // `List<List<…>>`: the inner inline-fixed scalar case keeps the
542            // dedicated `read_list_list` reader; `List<List<String|Schema>>`
543            // routes through the recursive `read_list_value` one level
544            // deeper. Byte-identical to both backends' prior arms.
545            TypeRepr::List { element: inner } => match inner.as_ref() {
546                TypeRepr::Int | TypeRepr::Float | TypeRepr::Bool => reader
547                    .read_list_list(&field.name)
548                    .map(|rows| {
549                        Value::List(Arc::new(
550                            rows.into_iter().map(|r| Value::List(Arc::new(r))).collect(),
551                        ))
552                    })
553                    .map_err(map_err),
554                _ => reader
555                    .read_list_value(&field.name, element.as_ref())
556                    .map(|rows| Value::List(Arc::new(rows)))
557                    .map_err(map_err),
558            },
559            other => Err(RuntimeError::IoError(format!(
560                "{backend} object return: cannot decode list field `{field}` of element type \
561                 `{ty:?}` in schema `{schema}`",
562                field = field.name,
563                ty = other,
564                schema = parent_schema.name,
565            ))),
566        },
567        // ----- add new object-return field type arm above this line -----
568        other => Err(RuntimeError::IoError(format!(
569            "{backend} object return: cannot decode field `{field}` of type `{ty:?}` in schema \
570             `{schema}`",
571            field = field.name,
572            ty = other,
573            schema = parent_schema.name,
574        ))),
575    }
576}
577
578/// Drain every field of `schema` from the sub-record `reader` into a
579/// sorted `BTreeMap<SmolStr, Value>` — the branded-dict body for one
580/// `List<Schema>` element. Mirrors the codegen evaluators'
581/// `read_record_into_map`, but lives here so both AOT backends share the
582/// one in-place sub-record decode. The field decode reuses the existing
583/// [`BufferReader`] field-slot readers, which the verifier has already
584/// proven stay in-region for every pointer they follow.
585fn read_record_into_branded_map(
586    backend: &str,
587    reader: &BufferReader<'_>,
588    schema: &Schema,
589) -> Result<std::collections::BTreeMap<SmolStr, Value>, RuntimeError> {
590    let mut map = std::collections::BTreeMap::new();
591    for field in &schema.fields {
592        let value = read_record_field(backend, reader, field)?;
593        map.insert(SmolStr::from(field.name.as_str()), value);
594    }
595    Ok(map)
596}
597
598/// Decode one sub-record field (`reader` is the per-element sub-record
599/// reader) into a [`Value`]. Covers the scalar leaves plus every
600/// pointer-indirect list field a `List<Schema>` element can carry —
601/// **recursively, to any depth** (F7): `String`, `List<scalar>`,
602/// `List<String>`, and (new) `List<Schema>` / `List<List<…>>` element
603/// fields whose own element schemas again carry such fields. The list
604/// arms route through the buffer reader's unified recursive list reader
605/// ([`BufferReader::read_list_value`]), which follows the pointer array
606/// one level per element type — exactly the depth the multi-region
607/// verifier already certified before this decode runs. The produced
608/// values are bit-identical to the codegen evaluators' `read_list_value`
609/// path the tree-walk oracle's writer feeds. A bare nested `Schema`
610/// field, or any non-list non-scalar leaf, is capped at lowering, so a
611/// reach here is ABI drift surfaced loudly rather than mis-decoded.
612fn read_record_field(
613    backend: &str,
614    reader: &BufferReader<'_>,
615    field: &Field,
616) -> Result<Value, RuntimeError> {
617    let name = field.name.as_str();
618    let map_err = |e: crate::buffer::BufferError| {
619        RuntimeError::IoError(format!(
620            "{backend} in-place List<Schema> field `{name}`: {e}"
621        ))
622    };
623    match &field.ty {
624        TypeRepr::Int => reader.read_int(name).map(Value::Int).map_err(map_err),
625        TypeRepr::Float => reader
626            .read_float(name)
627            .map(|f| Value::Float(ordered_float::OrderedFloat(f)))
628            .map_err(map_err),
629        TypeRepr::Bool => reader.read_bool(name).map(Value::Bool).map_err(map_err),
630        TypeRepr::Unit => reader
631            .read_unit(name)
632            .map(|()| Value::option_none())
633            .map_err(map_err),
634        TypeRepr::String => reader
635            .read_string(name)
636            .map(|s| Value::String(s.into()))
637            .map_err(map_err),
638        TypeRepr::Option { .. } | TypeRepr::Result { .. } | TypeRepr::Enum { .. } => {
639            reader.read_value(name, &field.ty).map_err(map_err)
640        }
641        // Every list field — `List<scalar>` / `List<String>` /
642        // `List<Schema>` / `List<List<…>>` — decodes through the unified
643        // recursive list reader. It dispatches on `element` and recurses
644        // per pointer-array entry (into sub-records via `read_record_at`,
645        // into inner lists via itself), so nested object arrays and nested
646        // lists inside the element schema are decoded to any depth.
647        TypeRepr::List { element } => reader
648            .read_list_value(name, element.as_ref())
649            .map(|rows| Value::List(Arc::new(rows)))
650            .map_err(map_err),
651        other => Err(RuntimeError::IoError(format!(
652            "{backend} in-place List<Schema> sub-record field `{name}` has unsupported type \
653             {other:?}"
654        ))),
655    }
656}