1use 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}