Skip to main content

runmat_runtime/builtins/structs/core/
struct.rs

1//! MATLAB-compatible `struct` builtin.
2
3use crate::builtins::common::identifiers::is_valid_varname;
4use crate::builtins::common::spec::{
5    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
6    ReductionNaN, ResidencyPolicy, ShapeRequirements,
7};
8use crate::builtins::common::tensor;
9use runmat_builtins::catalog::definitions::{
10    STRUCT_ERROR_ASSEMBLE_FAILED, STRUCT_ERROR_CELL_SIZE_MISMATCH, STRUCT_ERROR_EMPTY_ARRAY_FAILED,
11    STRUCT_ERROR_FIELD_NAME_CHAR_VECTOR, STRUCT_ERROR_FIELD_NAME_EMPTY,
12    STRUCT_ERROR_FIELD_NAME_SCALAR, STRUCT_ERROR_FIELD_NAME_START_CHAR,
13    STRUCT_ERROR_FIELD_NAME_TYPE, STRUCT_ERROR_INVALID_SINGLE_INPUT, STRUCT_ERROR_NAME_VALUE_PAIRS,
14    STRUCT_ERROR_SIZE_OVERFLOW, STRUCT_ERROR_STRUCT_ARRAY_CONTENTS,
15    STRUCT_ERROR_STRUCT_ARRAY_COPY_FAILED,
16};
17use runmat_builtins::BuiltinErrorDescriptor;
18use runmat_macros::runtime_builtin;
19use runmat_value::{CellArray, CharArray, StructValue, Value};
20
21use crate::{build_runtime_error, BuiltinResult, RuntimeError};
22
23#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::structs::core::r#struct")]
24pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
25    name: "struct",
26    op_kind: GpuOpKind::Custom("struct"),
27    supported_precisions: &[],
28    broadcast: BroadcastSemantics::None,
29    provider_hooks: &[],
30    constant_strategy: ConstantStrategy::InlineLiteral,
31    residency: ResidencyPolicy::InheritInputs,
32    nan_mode: ReductionNaN::Include,
33    two_pass_threshold: None,
34    workgroup_size: None,
35    accepts_nan_mode: false,
36    notes: "Host-only construction; GPU values are preserved as handles without gathering.",
37};
38
39#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::structs::core::r#struct")]
40pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
41    name: "struct",
42    shape: ShapeRequirements::Any,
43    constant_strategy: ConstantStrategy::InlineLiteral,
44    elementwise: None,
45    reduction: None,
46    emits_nan: false,
47    notes: "Struct creation breaks fusion planning but retains GPU residency for field values.",
48};
49
50struct FieldEntry {
51    name: String,
52    value: FieldValue,
53}
54
55enum FieldValue {
56    Single(Value),
57    Cell(CellArray),
58}
59
60const BUILTIN_NAME: &str = "struct";
61
62fn struct_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
63    struct_error_with_message(error.message, error)
64}
65
66fn struct_error_with_message(
67    message: impl Into<String>,
68    error: &'static BuiltinErrorDescriptor,
69) -> RuntimeError {
70    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
71    if let Some(identifier) = error.identifier {
72        builder = builder.with_identifier(identifier);
73    }
74    builder.build()
75}
76
77#[runtime_builtin(
78    name = "struct",
79    binding_variant = "default",
80    builtin_path = "crate::builtins::structs::core::r#struct"
81)]
82async fn struct_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
83    match rest.len() {
84        0 => Ok(Value::Struct(StructValue::new())),
85        1 => match rest.into_iter().next().unwrap() {
86            Value::Struct(existing) => Ok(Value::Struct(existing.clone())),
87            Value::Cell(cell) => clone_struct_array(&cell),
88            Value::Tensor(tensor) if tensor::tensor_element_len(&tensor) == 0 => {
89                empty_struct_array()
90            }
91            Value::LogicalArray(logical) if logical.data.is_empty() => empty_struct_array(),
92            other => Err(struct_error_with_message(
93                format!(
94                    "{} (got {other:?})",
95                    STRUCT_ERROR_INVALID_SINGLE_INPUT.message
96                ),
97                &STRUCT_ERROR_INVALID_SINGLE_INPUT,
98            )),
99        },
100        len if len % 2 == 0 => build_from_pairs(rest),
101        _ => Err(struct_error(&STRUCT_ERROR_NAME_VALUE_PAIRS)),
102    }
103}
104
105fn build_from_pairs(args: Vec<Value>) -> BuiltinResult<Value> {
106    let mut entries: Vec<FieldEntry> = Vec::new();
107    let mut target_shape: Option<Vec<usize>> = None;
108
109    let mut iter = args.into_iter();
110    while let (Some(name_value), Some(field_value)) = (iter.next(), iter.next()) {
111        let field_name = parse_field_name(&name_value)?;
112        match field_value {
113            Value::Cell(cell) => {
114                let shape = cell.shape.clone();
115                if let Some(existing) = &target_shape {
116                    if *existing != shape {
117                        return Err(struct_error(&STRUCT_ERROR_CELL_SIZE_MISMATCH));
118                    }
119                } else {
120                    target_shape = Some(shape);
121                }
122                entries.push(FieldEntry {
123                    name: field_name,
124                    value: FieldValue::Cell(cell),
125                });
126            }
127            other => entries.push(FieldEntry {
128                name: field_name,
129                value: FieldValue::Single(other),
130            }),
131        }
132    }
133
134    if let Some(shape) = target_shape {
135        build_struct_array(entries, shape)
136    } else {
137        build_scalar_struct(entries)
138    }
139}
140
141fn build_scalar_struct(entries: Vec<FieldEntry>) -> BuiltinResult<Value> {
142    let mut fields = StructValue::new();
143    for entry in entries {
144        match entry.value {
145            FieldValue::Single(value) => {
146                fields.fields.insert(entry.name, value);
147            }
148            FieldValue::Cell(cell) => {
149                let shape = cell.shape.clone();
150                return build_struct_array(
151                    vec![FieldEntry {
152                        name: entry.name,
153                        value: FieldValue::Cell(cell),
154                    }],
155                    shape,
156                );
157            }
158        }
159    }
160    Ok(Value::Struct(fields))
161}
162
163fn build_struct_array(entries: Vec<FieldEntry>, shape: Vec<usize>) -> BuiltinResult<Value> {
164    let total_len = shape
165        .iter()
166        .try_fold(1usize, |acc, &dim| acc.checked_mul(dim))
167        .ok_or_else(|| struct_error(&STRUCT_ERROR_SIZE_OVERFLOW))?;
168
169    for entry in &entries {
170        if let FieldValue::Cell(cell) = &entry.value {
171            if cell.data.len() != total_len {
172                return Err(struct_error(&STRUCT_ERROR_CELL_SIZE_MISMATCH));
173            }
174        }
175    }
176
177    let mut structs: Vec<Value> = Vec::with_capacity(total_len);
178    for idx in 0..total_len {
179        let mut fields = StructValue::new();
180        for entry in &entries {
181            let value = match &entry.value {
182                FieldValue::Single(val) => val.clone(),
183                FieldValue::Cell(cell) => clone_cell_element(cell, idx)?,
184            };
185            fields.fields.insert(entry.name.clone(), value);
186        }
187        structs.push(Value::Struct(fields));
188    }
189
190    CellArray::new_with_shape(structs, shape)
191        .map(Value::Cell)
192        .map_err(|e| {
193            struct_error_with_message(
194                format!("{}: {e}", STRUCT_ERROR_ASSEMBLE_FAILED.message),
195                &STRUCT_ERROR_ASSEMBLE_FAILED,
196            )
197        })
198}
199
200fn clone_cell_element(cell: &CellArray, index: usize) -> BuiltinResult<Value> {
201    cell.data
202        .get(index)
203        .cloned()
204        .ok_or_else(|| struct_error(&STRUCT_ERROR_CELL_SIZE_MISMATCH))
205}
206
207fn empty_struct_array() -> BuiltinResult<Value> {
208    CellArray::new(Vec::new(), 0, 0)
209        .map(Value::Cell)
210        .map_err(|e| {
211            struct_error_with_message(
212                format!("{}: {e}", STRUCT_ERROR_EMPTY_ARRAY_FAILED.message),
213                &STRUCT_ERROR_EMPTY_ARRAY_FAILED,
214            )
215        })
216}
217
218fn clone_struct_array(array: &CellArray) -> BuiltinResult<Value> {
219    let mut values: Vec<Value> = Vec::with_capacity(array.data.len());
220    for (index, handle) in array.data.iter().enumerate() {
221        let value = handle.clone();
222        if !matches!(value, Value::Struct(_)) {
223            return Err(struct_error_with_message(
224                format!(
225                    "{} (element {} is not a struct)",
226                    STRUCT_ERROR_STRUCT_ARRAY_CONTENTS.message,
227                    index + 1
228                ),
229                &STRUCT_ERROR_STRUCT_ARRAY_CONTENTS,
230            ));
231        }
232        values.push(value);
233    }
234    CellArray::new_with_shape(values, array.shape.clone())
235        .map(Value::Cell)
236        .map_err(|e| {
237            struct_error_with_message(
238                format!("{}: {e}", STRUCT_ERROR_STRUCT_ARRAY_COPY_FAILED.message),
239                &STRUCT_ERROR_STRUCT_ARRAY_COPY_FAILED,
240            )
241        })
242}
243
244fn parse_field_name(value: &Value) -> BuiltinResult<String> {
245    let text = match value {
246        Value::String(s) => s.clone(),
247        Value::StringArray(sa) => {
248            if sa.data.len() == 1 {
249                sa.data[0].clone()
250            } else {
251                return Err(struct_error(&STRUCT_ERROR_FIELD_NAME_SCALAR));
252            }
253        }
254        Value::CharArray(ca) => char_array_to_string(ca)?,
255        _ => return Err(struct_error(&STRUCT_ERROR_FIELD_NAME_TYPE)),
256    };
257
258    validate_field_name(&text)?;
259    Ok(text)
260}
261
262fn char_array_to_string(ca: &CharArray) -> BuiltinResult<String> {
263    if ca.rows > 1 {
264        return Err(struct_error(&STRUCT_ERROR_FIELD_NAME_CHAR_VECTOR));
265    }
266    let mut out = String::with_capacity(ca.data.len());
267    for ch in &ca.data {
268        out.push(*ch);
269    }
270    Ok(out)
271}
272
273fn validate_field_name(name: &str) -> BuiltinResult<()> {
274    if name.is_empty() {
275        return Err(struct_error(&STRUCT_ERROR_FIELD_NAME_EMPTY));
276    }
277
278    if !is_valid_varname(name) {
279        return Err(struct_error_with_message(
280            format!(
281                "{} (got '{name}')",
282                STRUCT_ERROR_FIELD_NAME_START_CHAR.message
283            ),
284            &STRUCT_ERROR_FIELD_NAME_START_CHAR,
285        ));
286    }
287    Ok(())
288}
289
290#[cfg(test)]
291pub(crate) mod tests {
292    use super::*;
293    use crate::builtins::common::identifiers::MATLAB_NAME_LENGTH_MAX;
294    use runmat_accelerate_api::GpuTensorHandle;
295    use runmat_value::{CellArray, IntValue, StringArray, StructValue, Tensor};
296
297    #[cfg(feature = "wgpu")]
298    use runmat_accelerate_api::HostTensorView;
299
300    fn error_message(err: crate::RuntimeError) -> String {
301        err.message().to_string()
302    }
303
304    fn run_struct(args: Vec<Value>) -> BuiltinResult<Value> {
305        futures::executor::block_on(struct_builtin(args))
306    }
307
308    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
309    #[test]
310    fn struct_empty() {
311        let Value::Struct(s) = run_struct(Vec::new()).expect("struct") else {
312            panic!("expected struct value");
313        };
314        assert!(s.fields.is_empty());
315    }
316
317    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
318    #[test]
319    fn struct_empty_from_empty_matrix() {
320        let tensor = Tensor::new(Vec::new(), vec![0, 0]).unwrap();
321        let value = run_struct(vec![Value::Tensor(tensor)]).expect("struct([])");
322        match value {
323            Value::Cell(cell) => {
324                assert_eq!(cell.rows, 0);
325                assert_eq!(cell.cols, 0);
326                assert!(cell.data.is_empty());
327            }
328            other => panic!("expected empty struct array, got {other:?}"),
329        }
330    }
331
332    #[test]
333    fn struct_uses_typed_integer_storage_to_detect_empty_input() {
334        let tensor =
335            Tensor::new_integer(runmat_value::IntegerStorage::U64(Vec::new()), vec![0, 0]).unwrap();
336
337        assert!(matches!(
338            run_struct(vec![Value::Tensor(tensor)]).unwrap(),
339            Value::Cell(_)
340        ));
341    }
342
343    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
344    #[test]
345    fn struct_name_value_pairs() {
346        let args = vec![
347            Value::from("name"),
348            Value::from("Ada"),
349            Value::from("score"),
350            Value::Int(IntValue::I32(42)),
351        ];
352        let Value::Struct(s) = run_struct(args).expect("struct") else {
353            panic!("expected struct value");
354        };
355        assert_eq!(s.fields.len(), 2);
356        assert!(matches!(s.fields.get("name"), Some(Value::String(v)) if v == "Ada"));
357        assert!(matches!(
358            s.fields.get("score"),
359            Some(Value::Int(IntValue::I32(42)))
360        ));
361    }
362
363    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
364    #[test]
365    fn struct_struct_array_from_cells() {
366        let names = CellArray::new(vec![Value::from("Ada"), Value::from("Grace")], 1, 2).unwrap();
367        let ages = CellArray::new(
368            vec![Value::Int(IntValue::I32(36)), Value::Int(IntValue::I32(45))],
369            1,
370            2,
371        )
372        .unwrap();
373        let result = run_struct(vec![
374            Value::from("name"),
375            Value::Cell(names),
376            Value::from("age"),
377            Value::Cell(ages),
378        ])
379        .expect("struct array");
380        let structs = expect_struct_array(result);
381        assert_eq!(structs.len(), 2);
382        assert!(matches!(
383            structs[0].fields.get("name"),
384            Some(Value::String(v)) if v == "Ada"
385        ));
386        assert!(matches!(
387            structs[1].fields.get("age"),
388            Some(Value::Int(IntValue::I32(45)))
389        ));
390    }
391
392    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
393    #[test]
394    fn struct_struct_array_replicates_scalars() {
395        let names = CellArray::new(vec![Value::from("Ada"), Value::from("Grace")], 1, 2).unwrap();
396        let result = run_struct(vec![
397            Value::from("name"),
398            Value::Cell(names),
399            Value::from("department"),
400            Value::from("Research"),
401        ])
402        .expect("struct array");
403        let structs = expect_struct_array(result);
404        assert_eq!(structs.len(), 2);
405        for entry in structs {
406            assert!(matches!(
407                entry.fields.get("department"),
408                Some(Value::String(v)) if v == "Research"
409            ));
410        }
411    }
412
413    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
414    #[test]
415    fn struct_struct_array_cell_size_mismatch_errors() {
416        let names = CellArray::new(vec![Value::from("Ada"), Value::from("Grace")], 1, 2).unwrap();
417        let scores = CellArray::new(vec![Value::Int(IntValue::I32(1))], 1, 1).unwrap();
418        let err = error_message(
419            run_struct(vec![
420                Value::from("name"),
421                Value::Cell(names),
422                Value::from("score"),
423                Value::Cell(scores),
424            ])
425            .unwrap_err(),
426        );
427        assert!(err.contains("matching sizes"));
428    }
429
430    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
431    #[test]
432    fn struct_overwrites_duplicates() {
433        let args = vec![
434            Value::from("version"),
435            Value::Int(IntValue::I32(1)),
436            Value::from("version"),
437            Value::Int(IntValue::I32(2)),
438        ];
439        let Value::Struct(s) = run_struct(args).expect("struct") else {
440            panic!("expected struct value");
441        };
442        assert_eq!(s.fields.len(), 1);
443        assert!(matches!(
444            s.fields.get("version"),
445            Some(Value::Int(IntValue::I32(2)))
446        ));
447    }
448
449    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
450    #[test]
451    fn struct_rejects_odd_arguments() {
452        let err = error_message(run_struct(vec![Value::from("name")]).unwrap_err());
453        assert!(err.contains("name/value pairs"));
454    }
455
456    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
457    #[test]
458    fn struct_rejects_invalid_field_name() {
459        let err = error_message(
460            run_struct(vec![Value::from("1bad"), Value::Int(IntValue::I32(1))]).unwrap_err(),
461        );
462        assert!(err.contains("valid MATLAB identifiers"));
463    }
464
465    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
466    #[test]
467    fn struct_field_names_follow_shared_identifier_rules() {
468        let max_len = "a".repeat(MATLAB_NAME_LENGTH_MAX);
469        let Value::Struct(s) = run_struct(vec![
470            Value::from(max_len.clone()),
471            Value::Int(IntValue::I32(1)),
472        ])
473        .expect("max length field name") else {
474            panic!("expected struct value");
475        };
476        assert!(s.fields.contains_key(&max_len));
477
478        for bad in [
479            "_hidden".to_string(),
480            "for".to_string(),
481            "éclair".to_string(),
482            "a".repeat(MATLAB_NAME_LENGTH_MAX + 1),
483        ] {
484            let err =
485                error_message(run_struct(vec![Value::from(bad), Value::Num(1.0)]).unwrap_err());
486            assert!(err.contains("valid MATLAB identifiers"));
487        }
488    }
489
490    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
491    #[test]
492    fn struct_rejects_non_text_field_name() {
493        let err = error_message(
494            run_struct(vec![Value::Num(1.0), Value::Int(IntValue::I32(1))]).unwrap_err(),
495        );
496        assert!(err.contains("strings or character vectors"));
497    }
498
499    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
500    #[test]
501    fn struct_accepts_char_vector_name() {
502        let chars = CharArray::new("field".chars().collect(), 1, 5).unwrap();
503        let args = vec![Value::CharArray(chars), Value::Num(1.0)];
504        let Value::Struct(s) = run_struct(args).expect("struct") else {
505            panic!("expected struct value");
506        };
507        assert!(s.fields.contains_key("field"));
508    }
509
510    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
511    #[test]
512    fn struct_accepts_string_scalar_name() {
513        let sa = StringArray::new(vec!["field".to_string()], vec![1]).unwrap();
514        let args = vec![Value::StringArray(sa), Value::Num(1.0)];
515        let Value::Struct(s) = run_struct(args).expect("struct") else {
516            panic!("expected struct value");
517        };
518        assert!(s.fields.contains_key("field"));
519    }
520
521    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
522    #[test]
523    fn struct_allows_existing_struct_copy() {
524        let mut base = StructValue::new();
525        base.fields
526            .insert("id".to_string(), Value::Int(IntValue::I32(7)));
527        let copy = run_struct(vec![Value::Struct(base.clone())]).expect("struct");
528        assert_eq!(copy, Value::Struct(base));
529    }
530
531    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
532    #[test]
533    fn struct_copies_struct_array_argument() {
534        let mut proto = StructValue::new();
535        proto
536            .fields
537            .insert("id".into(), Value::Int(IntValue::I32(7)));
538        let struct_array = CellArray::new(
539            vec![
540                Value::Struct(proto.clone()),
541                Value::Struct(proto.clone()),
542                Value::Struct(proto.clone()),
543            ],
544            1,
545            3,
546        )
547        .unwrap();
548        let original = struct_array.clone();
549        let result = run_struct(vec![Value::Cell(struct_array)]).expect("struct array clone");
550        let cloned = expect_struct_array(result);
551        let baseline = expect_struct_array(Value::Cell(original));
552        assert_eq!(cloned, baseline);
553    }
554
555    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
556    #[test]
557    fn struct_rejects_cell_argument_without_structs() {
558        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).unwrap();
559        let err = error_message(run_struct(vec![Value::Cell(cell)]).unwrap_err());
560        assert!(err.contains("must contain structs"));
561    }
562
563    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
564    #[test]
565    fn struct_preserves_gpu_tensor_handles() {
566        let handle = GpuTensorHandle {
567            shape: vec![2, 2],
568            device_id: 1,
569            buffer_id: 99,
570            descriptor: Default::default(),
571        };
572        let args = vec![Value::from("data"), Value::GpuTensor(handle.clone())];
573        let Value::Struct(s) = run_struct(args).expect("struct") else {
574            panic!("expected struct value");
575        };
576        assert!(matches!(s.fields.get("data"), Some(Value::GpuTensor(h)) if h == &handle));
577    }
578
579    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
580    #[test]
581    fn struct_struct_array_preserves_gpu_handles() {
582        let first = GpuTensorHandle {
583            shape: vec![1, 1],
584            device_id: 2,
585            buffer_id: 11,
586            descriptor: Default::default(),
587        };
588        let second = GpuTensorHandle {
589            shape: vec![1, 1],
590            device_id: 2,
591            buffer_id: 12,
592            descriptor: Default::default(),
593        };
594        let cell = CellArray::new(
595            vec![
596                Value::GpuTensor(first.clone()),
597                Value::GpuTensor(second.clone()),
598            ],
599            1,
600            2,
601        )
602        .unwrap();
603        let result = run_struct(vec![Value::from("payload"), Value::Cell(cell)])
604            .expect("struct array gpu handles");
605        let structs = expect_struct_array(result);
606        assert!(matches!(
607            structs[0].fields.get("payload"),
608            Some(Value::GpuTensor(h)) if h == &first
609        ));
610        assert!(matches!(
611            structs[1].fields.get("payload"),
612            Some(Value::GpuTensor(h)) if h == &second
613        ));
614    }
615
616    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
617    #[test]
618    #[cfg(feature = "wgpu")]
619    fn struct_preserves_gpu_handles_with_registered_provider() {
620        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
621            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
622        );
623        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
624        let host = HostTensorView {
625            data: &[1.0, 2.0],
626            shape: &[2, 1],
627        };
628        let handle = provider.upload(&host).expect("upload");
629        let args = vec![Value::from("gpu"), Value::GpuTensor(handle.clone())];
630        let Value::Struct(s) = run_struct(args).expect("struct") else {
631            panic!("expected struct value");
632        };
633        assert!(matches!(s.fields.get("gpu"), Some(Value::GpuTensor(h)) if h == &handle));
634    }
635
636    fn expect_struct_array(value: Value) -> Vec<StructValue> {
637        match value {
638            Value::Cell(cell) => cell
639                .data
640                .into_iter()
641                .map(|value| match value {
642                    Value::Struct(st) => st,
643                    other => panic!("expected struct element, got {other:?}"),
644                })
645                .collect(),
646            Value::Struct(st) => vec![st],
647            other => panic!("expected struct or struct array, got {other:?}"),
648        }
649    }
650}