runmat_runtime/builtins/structs/core/
struct.rs

1//! MATLAB-compatible `struct` builtin.
2
3use crate::builtins::common::spec::{
4    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
5    ReductionNaN, ResidencyPolicy, ShapeRequirements,
6};
7use runmat_builtins::{CellArray, CharArray, StructValue, Value};
8use runmat_macros::runtime_builtin;
9
10#[cfg(feature = "doc_export")]
11use crate::register_builtin_doc_text;
12use crate::{register_builtin_fusion_spec, register_builtin_gpu_spec};
13
14#[cfg(feature = "doc_export")]
15pub const DOC_MD: &str = r#"---
16title: "struct"
17category: "structs/core"
18keywords: ["struct", "structure", "name-value", "record", "struct array"]
19summary: "Create scalar structs or struct arrays from name/value pairs."
20references: []
21gpu_support:
22  elementwise: false
23  reduction: false
24  precisions: []
25  broadcasting: "none"
26  notes: "Struct construction runs on the host. GPU tensors stay as handles inside the resulting struct or struct array."
27fusion:
28  elementwise: false
29  reduction: false
30  max_inputs: 0
31  constants: "inline"
32requires_feature: null
33tested:
34  unit: "builtins::structs::core::r#struct::tests"
35  integration: "builtins::structs::core::r#struct::tests::struct_preserves_gpu_handles_with_registered_provider"
36---
37
38# What does the `struct` function do in MATLAB / RunMat?
39`S = struct(...)` creates scalar structs or struct arrays by pairing field names with values. The
40inputs can be simple name/value pairs, existing structs, or cell arrays whose elements are expanded
41into struct array entries.
42
43## How does the `struct` function behave in MATLAB / RunMat?
44- Field names must satisfy the MATLAB `isvarname` rules: they start with a letter or underscore and
45  contain only letters, digits, or underscores.
46- The last occurrence of a repeated field name wins and overwrites earlier values.
47- String scalars, character vectors, and single-element string arrays are accepted as field names.
48- `struct()` returns a scalar struct with no fields, while `struct([])` yields a `0×0` struct array.
49- When any value input is a cell array, every cell array input must share the same size. Non-cell
50  inputs are replicated across every element of the resulting struct array.
51- Passing an existing struct or struct array (`struct(S)`) creates a deep copy; the original data is
52  untouched.
53
54## `struct` Function GPU Execution Behaviour
55`struct` performs all bookkeeping on the host. GPU-resident values—such as tensors created with
56`gpuArray`—are stored as-is inside the resulting struct or struct array. No kernels are launched and
57no data is implicitly gathered back to the CPU.
58
59## GPU residency in RunMat (Do I need `gpuArray`?)
60Usually not. RunMat's planner keeps GPU values resident as long as downstream operations can profit
61from them. You can still seed GPU residency explicitly with `gpuArray` for MATLAB compatibility; the
62handles remain untouched inside the struct until another builtin decides to gather or operate on
63them.
64
65## Examples
66
67### Creating a simple structure for named fields
68```matlab
69s = struct("name", "Ada", "score", 42);
70disp(s.name);
71disp(s.score);
72```
73
74Expected output:
75```matlab
76Ada
77    42
78```
79
80### Building a struct array from paired cell inputs
81```matlab
82names = {"Ada", "Grace"};
83ages = {36, 45};
84people = struct("name", names, "age", ages);
85{people.name}
86```
87
88Expected output:
89```matlab
90    {'Ada'}    {'Grace'}
91```
92
93### Broadcasting scalars across a struct array
94```matlab
95ids = struct("id", {101, 102, 103}, "department", "Research");
96{ids.department}
97```
98
99Expected output:
100```matlab
101    {'Research'}    {'Research'}    {'Research'}
102```
103
104### Copying an existing structure
105```matlab
106a = struct("id", 7, "label", "demo");
107b = struct(a);
108b.id = 8;
109disp([a.id b.id]);
110```
111
112Expected output:
113```matlab
114     7     8
115```
116
117### Building an empty struct array
118```matlab
119s = struct([]);
120disp(size(s));
121```
122
123Expected output:
124```matlab
125     0     0
126```
127
128## FAQ
129
130### Do field names have to be valid identifiers?
131Yes. RunMat mirrors MATLAB and requires names to satisfy `isvarname`. Names must begin with a letter
132or underscore and may contain letters, digits, and underscores.
133
134### How do I create a struct array?
135Provide one or more value arguments as cell arrays with identical sizes. Each cell contributes the
136value for the corresponding struct element. Non-cell values are replicated across all elements.
137
138### What happens when the same field name appears more than once?
139The last value wins; earlier values for the same field are overwritten.
140
141### Does `struct` gather GPU data back to the CPU?
142No. GPU tensors remain device-resident handles inside the resulting struct or struct array.
143
144### Can I pass non-string objects as field names?
145No. Field names must be provided as string scalars, character vectors, or single-element string
146arrays. Passing other types raises an error.
147
148## See Also
149[load](../../io/mat/load), [whos](../../introspection/whos), [gpuArray](../../acceleration/gpu/gpuArray), [gather](../../acceleration/gpu/gather)
150"#;
151
152pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
153    name: "struct",
154    op_kind: GpuOpKind::Custom("struct"),
155    supported_precisions: &[],
156    broadcast: BroadcastSemantics::None,
157    provider_hooks: &[],
158    constant_strategy: ConstantStrategy::InlineLiteral,
159    residency: ResidencyPolicy::InheritInputs,
160    nan_mode: ReductionNaN::Include,
161    two_pass_threshold: None,
162    workgroup_size: None,
163    accepts_nan_mode: false,
164    notes: "Host-only construction; GPU values are preserved as handles without gathering.",
165};
166
167register_builtin_gpu_spec!(GPU_SPEC);
168
169pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
170    name: "struct",
171    shape: ShapeRequirements::Any,
172    constant_strategy: ConstantStrategy::InlineLiteral,
173    elementwise: None,
174    reduction: None,
175    emits_nan: false,
176    notes: "Struct creation breaks fusion planning but retains GPU residency for field values.",
177};
178
179register_builtin_fusion_spec!(FUSION_SPEC);
180
181#[cfg(feature = "doc_export")]
182register_builtin_doc_text!("struct", DOC_MD);
183
184struct FieldEntry {
185    name: String,
186    value: FieldValue,
187}
188
189enum FieldValue {
190    Single(Value),
191    Cell(CellArray),
192}
193
194#[runtime_builtin(
195    name = "struct",
196    category = "structs/core",
197    summary = "Create scalar structs or struct arrays from name/value pairs.",
198    keywords = "struct,structure,name-value,record"
199)]
200fn struct_builtin(rest: Vec<Value>) -> Result<Value, String> {
201    match rest.len() {
202        0 => Ok(Value::Struct(StructValue::new())),
203        1 => match rest.into_iter().next().unwrap() {
204            Value::Struct(existing) => Ok(Value::Struct(existing.clone())),
205            Value::Cell(cell) => clone_struct_array(&cell),
206            Value::Tensor(tensor) if tensor.data.is_empty() => empty_struct_array(),
207            Value::LogicalArray(logical) if logical.data.is_empty() => empty_struct_array(),
208            other => Err(format!(
209                "struct: expected name/value pairs, an existing struct or struct array, or [] to create an empty struct array (got {other:?})"
210            )),
211        },
212        len if len % 2 == 0 => build_from_pairs(rest),
213        _ => Err("struct: expected name/value pairs".to_string()),
214    }
215}
216
217fn build_from_pairs(args: Vec<Value>) -> Result<Value, String> {
218    let mut entries: Vec<FieldEntry> = Vec::new();
219    let mut target_shape: Option<Vec<usize>> = None;
220
221    let mut iter = args.into_iter();
222    while let (Some(name_value), Some(field_value)) = (iter.next(), iter.next()) {
223        let field_name = parse_field_name(&name_value)?;
224        match field_value {
225            Value::Cell(cell) => {
226                let shape = cell.shape.clone();
227                if let Some(existing) = &target_shape {
228                    if *existing != shape {
229                        return Err("struct: cell inputs must have matching sizes".to_string());
230                    }
231                } else {
232                    target_shape = Some(shape);
233                }
234                entries.push(FieldEntry {
235                    name: field_name,
236                    value: FieldValue::Cell(cell),
237                });
238            }
239            other => entries.push(FieldEntry {
240                name: field_name,
241                value: FieldValue::Single(other),
242            }),
243        }
244    }
245
246    if let Some(shape) = target_shape {
247        build_struct_array(entries, shape)
248    } else {
249        build_scalar_struct(entries)
250    }
251}
252
253fn build_scalar_struct(entries: Vec<FieldEntry>) -> Result<Value, String> {
254    let mut fields = StructValue::new();
255    for entry in entries {
256        match entry.value {
257            FieldValue::Single(value) => {
258                fields.fields.insert(entry.name, value);
259            }
260            FieldValue::Cell(cell) => {
261                let shape = cell.shape.clone();
262                return build_struct_array(
263                    vec![FieldEntry {
264                        name: entry.name,
265                        value: FieldValue::Cell(cell),
266                    }],
267                    shape,
268                );
269            }
270        }
271    }
272    Ok(Value::Struct(fields))
273}
274
275fn build_struct_array(entries: Vec<FieldEntry>, shape: Vec<usize>) -> Result<Value, String> {
276    let total_len = shape
277        .iter()
278        .try_fold(1usize, |acc, &dim| acc.checked_mul(dim))
279        .ok_or_else(|| "struct: struct array size exceeds platform limits".to_string())?;
280
281    for entry in &entries {
282        if let FieldValue::Cell(cell) = &entry.value {
283            if cell.data.len() != total_len {
284                return Err("struct: cell inputs must have matching sizes".to_string());
285            }
286        }
287    }
288
289    let mut structs: Vec<Value> = Vec::with_capacity(total_len);
290    for idx in 0..total_len {
291        let mut fields = StructValue::new();
292        for entry in &entries {
293            let value = match &entry.value {
294                FieldValue::Single(val) => val.clone(),
295                FieldValue::Cell(cell) => clone_cell_element(cell, idx)?,
296            };
297            fields.fields.insert(entry.name.clone(), value);
298        }
299        structs.push(Value::Struct(fields));
300    }
301
302    CellArray::new_with_shape(structs, shape)
303        .map(Value::Cell)
304        .map_err(|e| format!("struct: failed to assemble struct array: {e}"))
305}
306
307fn clone_cell_element(cell: &CellArray, index: usize) -> Result<Value, String> {
308    cell.data
309        .get(index)
310        .map(|ptr| unsafe { &*ptr.as_raw() }.clone())
311        .ok_or_else(|| "struct: cell inputs must have matching sizes".to_string())
312}
313
314fn empty_struct_array() -> Result<Value, String> {
315    CellArray::new(Vec::new(), 0, 0)
316        .map(Value::Cell)
317        .map_err(|e| format!("struct: failed to create empty struct array: {e}"))
318}
319
320fn clone_struct_array(array: &CellArray) -> Result<Value, String> {
321    let mut values: Vec<Value> = Vec::with_capacity(array.data.len());
322    for (index, handle) in array.data.iter().enumerate() {
323        let value = unsafe { &*handle.as_raw() }.clone();
324        if !matches!(value, Value::Struct(_)) {
325            return Err(format!(
326                "struct: single argument cell input must contain structs (element {} is not a struct)",
327                index + 1
328            ));
329        }
330        values.push(value);
331    }
332    CellArray::new_with_shape(values, array.shape.clone())
333        .map(Value::Cell)
334        .map_err(|e| format!("struct: failed to copy struct array: {e}"))
335}
336
337fn parse_field_name(value: &Value) -> Result<String, String> {
338    let text = match value {
339        Value::String(s) => s.clone(),
340        Value::StringArray(sa) => {
341            if sa.data.len() == 1 {
342                sa.data[0].clone()
343            } else {
344                return Err(
345                    "struct: field names must be scalar string arrays or character vectors"
346                        .to_string(),
347                );
348            }
349        }
350        Value::CharArray(ca) => char_array_to_string(ca)?,
351        _ => return Err("struct: field names must be strings or character vectors".to_string()),
352    };
353
354    validate_field_name(&text)?;
355    Ok(text)
356}
357
358fn char_array_to_string(ca: &CharArray) -> Result<String, String> {
359    if ca.rows > 1 {
360        return Err("struct: field names must be 1-by-N character vectors".to_string());
361    }
362    let mut out = String::with_capacity(ca.data.len());
363    for ch in &ca.data {
364        out.push(*ch);
365    }
366    Ok(out)
367}
368
369fn validate_field_name(name: &str) -> Result<(), String> {
370    if name.is_empty() {
371        return Err("struct: field names must be nonempty".to_string());
372    }
373    let mut chars = name.chars();
374    let Some(first) = chars.next() else {
375        return Err("struct: field names must be nonempty".to_string());
376    };
377    if !is_first_char_valid(first) {
378        return Err(format!(
379            "struct: field names must begin with a letter or underscore (got '{name}')"
380        ));
381    }
382    if let Some(bad) = chars.find(|c| !is_subsequent_char_valid(*c)) {
383        return Err(format!(
384            "struct: invalid character '{bad}' in field name '{name}'"
385        ));
386    }
387    Ok(())
388}
389
390fn is_first_char_valid(c: char) -> bool {
391    c == '_' || c.is_ascii_alphabetic()
392}
393
394fn is_subsequent_char_valid(c: char) -> bool {
395    c == '_' || c.is_ascii_alphanumeric()
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use runmat_accelerate_api::GpuTensorHandle;
402    use runmat_builtins::{CellArray, IntValue, StringArray, StructValue, Tensor};
403
404    #[cfg(feature = "doc_export")]
405    use crate::builtins::common::test_support;
406    #[cfg(feature = "wgpu")]
407    use runmat_accelerate_api::HostTensorView;
408
409    #[test]
410    fn struct_empty() {
411        let Value::Struct(s) = struct_builtin(Vec::new()).expect("struct") else {
412            panic!("expected struct value");
413        };
414        assert!(s.fields.is_empty());
415    }
416
417    #[test]
418    fn struct_empty_from_empty_matrix() {
419        let tensor = Tensor::new(Vec::new(), vec![0, 0]).unwrap();
420        let value = struct_builtin(vec![Value::Tensor(tensor)]).expect("struct([])");
421        match value {
422            Value::Cell(cell) => {
423                assert_eq!(cell.rows, 0);
424                assert_eq!(cell.cols, 0);
425                assert!(cell.data.is_empty());
426            }
427            other => panic!("expected empty struct array, got {other:?}"),
428        }
429    }
430
431    #[test]
432    fn struct_name_value_pairs() {
433        let args = vec![
434            Value::from("name"),
435            Value::from("Ada"),
436            Value::from("score"),
437            Value::Int(IntValue::I32(42)),
438        ];
439        let Value::Struct(s) = struct_builtin(args).expect("struct") else {
440            panic!("expected struct value");
441        };
442        assert_eq!(s.fields.len(), 2);
443        assert!(matches!(s.fields.get("name"), Some(Value::String(v)) if v == "Ada"));
444        assert!(matches!(
445            s.fields.get("score"),
446            Some(Value::Int(IntValue::I32(42)))
447        ));
448    }
449
450    #[test]
451    fn struct_struct_array_from_cells() {
452        let names = CellArray::new(vec![Value::from("Ada"), Value::from("Grace")], 1, 2).unwrap();
453        let ages = CellArray::new(
454            vec![Value::Int(IntValue::I32(36)), Value::Int(IntValue::I32(45))],
455            1,
456            2,
457        )
458        .unwrap();
459        let result = struct_builtin(vec![
460            Value::from("name"),
461            Value::Cell(names),
462            Value::from("age"),
463            Value::Cell(ages),
464        ])
465        .expect("struct array");
466        let structs = expect_struct_array(result);
467        assert_eq!(structs.len(), 2);
468        assert!(matches!(
469            structs[0].fields.get("name"),
470            Some(Value::String(v)) if v == "Ada"
471        ));
472        assert!(matches!(
473            structs[1].fields.get("age"),
474            Some(Value::Int(IntValue::I32(45)))
475        ));
476    }
477
478    #[test]
479    fn struct_struct_array_replicates_scalars() {
480        let names = CellArray::new(vec![Value::from("Ada"), Value::from("Grace")], 1, 2).unwrap();
481        let result = struct_builtin(vec![
482            Value::from("name"),
483            Value::Cell(names),
484            Value::from("department"),
485            Value::from("Research"),
486        ])
487        .expect("struct array");
488        let structs = expect_struct_array(result);
489        assert_eq!(structs.len(), 2);
490        for entry in structs {
491            assert!(matches!(
492                entry.fields.get("department"),
493                Some(Value::String(v)) if v == "Research"
494            ));
495        }
496    }
497
498    #[test]
499    fn struct_struct_array_cell_size_mismatch_errors() {
500        let names = CellArray::new(vec![Value::from("Ada"), Value::from("Grace")], 1, 2).unwrap();
501        let scores = CellArray::new(vec![Value::Int(IntValue::I32(1))], 1, 1).unwrap();
502        let err = struct_builtin(vec![
503            Value::from("name"),
504            Value::Cell(names),
505            Value::from("score"),
506            Value::Cell(scores),
507        ])
508        .unwrap_err();
509        assert!(err.contains("matching sizes"));
510    }
511
512    #[test]
513    fn struct_overwrites_duplicates() {
514        let args = vec![
515            Value::from("version"),
516            Value::Int(IntValue::I32(1)),
517            Value::from("version"),
518            Value::Int(IntValue::I32(2)),
519        ];
520        let Value::Struct(s) = struct_builtin(args).expect("struct") else {
521            panic!("expected struct value");
522        };
523        assert_eq!(s.fields.len(), 1);
524        assert!(matches!(
525            s.fields.get("version"),
526            Some(Value::Int(IntValue::I32(2)))
527        ));
528    }
529
530    #[test]
531    fn struct_rejects_odd_arguments() {
532        let err = struct_builtin(vec![Value::from("name")]).unwrap_err();
533        assert!(err.contains("name/value pairs"));
534    }
535
536    #[test]
537    fn struct_rejects_invalid_field_name() {
538        let err =
539            struct_builtin(vec![Value::from("1bad"), Value::Int(IntValue::I32(1))]).unwrap_err();
540        assert!(err.contains("begin with a letter or underscore"));
541    }
542
543    #[test]
544    fn struct_rejects_non_text_field_name() {
545        let err = struct_builtin(vec![Value::Num(1.0), Value::Int(IntValue::I32(1))]).unwrap_err();
546        assert!(err.contains("strings or character vectors"));
547    }
548
549    #[test]
550    fn struct_accepts_char_vector_name() {
551        let chars = CharArray::new("field".chars().collect(), 1, 5).unwrap();
552        let args = vec![Value::CharArray(chars), Value::Num(1.0)];
553        let Value::Struct(s) = struct_builtin(args).expect("struct") else {
554            panic!("expected struct value");
555        };
556        assert!(s.fields.contains_key("field"));
557    }
558
559    #[test]
560    fn struct_accepts_string_scalar_name() {
561        let sa = StringArray::new(vec!["field".to_string()], vec![1]).unwrap();
562        let args = vec![Value::StringArray(sa), Value::Num(1.0)];
563        let Value::Struct(s) = struct_builtin(args).expect("struct") else {
564            panic!("expected struct value");
565        };
566        assert!(s.fields.contains_key("field"));
567    }
568
569    #[test]
570    fn struct_allows_existing_struct_copy() {
571        let mut base = StructValue::new();
572        base.fields
573            .insert("id".to_string(), Value::Int(IntValue::I32(7)));
574        let copy = struct_builtin(vec![Value::Struct(base.clone())]).expect("struct");
575        assert_eq!(copy, Value::Struct(base));
576    }
577
578    #[test]
579    fn struct_copies_struct_array_argument() {
580        let mut proto = StructValue::new();
581        proto
582            .fields
583            .insert("id".into(), Value::Int(IntValue::I32(7)));
584        let struct_array = CellArray::new(
585            vec![
586                Value::Struct(proto.clone()),
587                Value::Struct(proto.clone()),
588                Value::Struct(proto.clone()),
589            ],
590            1,
591            3,
592        )
593        .unwrap();
594        let original = struct_array.clone();
595        let result = struct_builtin(vec![Value::Cell(struct_array)]).expect("struct array clone");
596        let cloned = expect_struct_array(result);
597        let baseline = expect_struct_array(Value::Cell(original));
598        assert_eq!(cloned, baseline);
599    }
600
601    #[test]
602    fn struct_rejects_cell_argument_without_structs() {
603        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).unwrap();
604        let err = struct_builtin(vec![Value::Cell(cell)]).unwrap_err();
605        assert!(err.contains("must contain structs"));
606    }
607
608    #[test]
609    fn struct_preserves_gpu_tensor_handles() {
610        let handle = GpuTensorHandle {
611            shape: vec![2, 2],
612            device_id: 1,
613            buffer_id: 99,
614        };
615        let args = vec![Value::from("data"), Value::GpuTensor(handle.clone())];
616        let Value::Struct(s) = struct_builtin(args).expect("struct") else {
617            panic!("expected struct value");
618        };
619        assert!(matches!(s.fields.get("data"), Some(Value::GpuTensor(h)) if h == &handle));
620    }
621
622    #[test]
623    fn struct_struct_array_preserves_gpu_handles() {
624        let first = GpuTensorHandle {
625            shape: vec![1, 1],
626            device_id: 2,
627            buffer_id: 11,
628        };
629        let second = GpuTensorHandle {
630            shape: vec![1, 1],
631            device_id: 2,
632            buffer_id: 12,
633        };
634        let cell = CellArray::new(
635            vec![
636                Value::GpuTensor(first.clone()),
637                Value::GpuTensor(second.clone()),
638            ],
639            1,
640            2,
641        )
642        .unwrap();
643        let result = struct_builtin(vec![Value::from("payload"), Value::Cell(cell)])
644            .expect("struct array gpu handles");
645        let structs = expect_struct_array(result);
646        assert!(matches!(
647            structs[0].fields.get("payload"),
648            Some(Value::GpuTensor(h)) if h == &first
649        ));
650        assert!(matches!(
651            structs[1].fields.get("payload"),
652            Some(Value::GpuTensor(h)) if h == &second
653        ));
654    }
655
656    #[test]
657    #[cfg(feature = "wgpu")]
658    fn struct_preserves_gpu_handles_with_registered_provider() {
659        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
660            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
661        );
662        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
663        let host = HostTensorView {
664            data: &[1.0, 2.0],
665            shape: &[2, 1],
666        };
667        let handle = provider.upload(&host).expect("upload");
668        let args = vec![Value::from("gpu"), Value::GpuTensor(handle.clone())];
669        let Value::Struct(s) = struct_builtin(args).expect("struct") else {
670            panic!("expected struct value");
671        };
672        assert!(matches!(s.fields.get("gpu"), Some(Value::GpuTensor(h)) if h == &handle));
673    }
674
675    #[test]
676    #[cfg(feature = "doc_export")]
677    fn doc_examples_present() {
678        let blocks = test_support::doc_examples(DOC_MD);
679        assert!(!blocks.is_empty());
680    }
681
682    fn expect_struct_array(value: Value) -> Vec<StructValue> {
683        match value {
684            Value::Cell(cell) => cell
685                .data
686                .iter()
687                .map(|ptr| unsafe { &*ptr.as_raw() }.clone())
688                .map(|value| match value {
689                    Value::Struct(st) => st,
690                    other => panic!("expected struct element, got {other:?}"),
691                })
692                .collect(),
693            Value::Struct(st) => vec![st],
694            other => panic!("expected struct or struct array, got {other:?}"),
695        }
696    }
697}