Skip to main content

runmat_runtime/builtins/structs/core/
structfun.rs

1//! MATLAB-compatible `structfun` builtin.
2
3use crate::builtins::common::spec::{
4    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
5    ReductionNaN, ResidencyPolicy, ShapeRequirements,
6};
7use crate::builtins::structs::type_resolvers::structfun_type;
8use crate::{
9    build_runtime_error, call_feval_async_with_outputs, current_requested_outputs,
10    gather_if_needed_async, BuiltinResult, RuntimeError,
11};
12use runmat_builtins::{
13    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
14    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
15    ComplexTensor, LogicalArray, StringArray, StructValue, Tensor, Value,
16};
17use runmat_macros::runtime_builtin;
18
19#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::structs::core::structfun")]
20pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
21    name: "structfun",
22    op_kind: GpuOpKind::Custom("host-struct-map"),
23    supported_precisions: &[],
24    broadcast: BroadcastSemantics::None,
25    provider_hooks: &[],
26    constant_strategy: ConstantStrategy::InlineLiteral,
27    residency: ResidencyPolicy::GatherImmediately,
28    nan_mode: ReductionNaN::Include,
29    two_pass_threshold: None,
30    workgroup_size: None,
31    accepts_nan_mode: false,
32    notes: "Executes callbacks on the host through the cellfun mapping engine; GPU-resident field values are gathered before callback invocation.",
33};
34
35#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::structs::core::structfun")]
36pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
37    name: "structfun",
38    shape: ShapeRequirements::Any,
39    constant_strategy: ConstantStrategy::InlineLiteral,
40    elementwise: None,
41    reduction: None,
42    emits_nan: true,
43    notes: "Callback execution happens on the host; fusion planners should treat structfun as a fusion barrier.",
44};
45
46const BUILTIN_NAME: &str = "structfun";
47
48const STRUCTFUN_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
49    name: "A",
50    ty: BuiltinParamType::Any,
51    arity: BuiltinParamArity::Required,
52    default: None,
53    description: "Mapped callback outputs.",
54}];
55
56const STRUCTFUN_INPUTS: [BuiltinParamDescriptor; 2] = [
57    BuiltinParamDescriptor {
58        name: "func",
59        ty: BuiltinParamType::Any,
60        arity: BuiltinParamArity::Required,
61        default: None,
62        description: "Function handle or builtin/function name.",
63    },
64    BuiltinParamDescriptor {
65        name: "S",
66        ty: BuiltinParamType::Any,
67        arity: BuiltinParamArity::Required,
68        default: None,
69        description: "Scalar struct input.",
70    },
71];
72
73const STRUCTFUN_UNIFORM_INPUTS: [BuiltinParamDescriptor; 4] = [
74    BuiltinParamDescriptor {
75        name: "func",
76        ty: BuiltinParamType::Any,
77        arity: BuiltinParamArity::Required,
78        default: None,
79        description: "Function handle or builtin/function name.",
80    },
81    BuiltinParamDescriptor {
82        name: "S",
83        ty: BuiltinParamType::Any,
84        arity: BuiltinParamArity::Required,
85        default: None,
86        description: "Scalar struct input.",
87    },
88    BuiltinParamDescriptor {
89        name: "UniformOutput",
90        ty: BuiltinParamType::StringScalar,
91        arity: BuiltinParamArity::Required,
92        default: Some("\"UniformOutput\""),
93        description: "Name-value key for uniform output mode.",
94    },
95    BuiltinParamDescriptor {
96        name: "tf",
97        ty: BuiltinParamType::LogicalArray,
98        arity: BuiltinParamArity::Required,
99        default: Some("true"),
100        description: "Whether callback outputs must be scalar-uniform.",
101    },
102];
103
104const STRUCTFUN_ERROR_HANDLER_INPUTS: [BuiltinParamDescriptor; 4] = [
105    BuiltinParamDescriptor {
106        name: "func",
107        ty: BuiltinParamType::Any,
108        arity: BuiltinParamArity::Required,
109        default: None,
110        description: "Function handle or builtin/function name.",
111    },
112    BuiltinParamDescriptor {
113        name: "S",
114        ty: BuiltinParamType::Any,
115        arity: BuiltinParamArity::Required,
116        default: None,
117        description: "Scalar struct input.",
118    },
119    BuiltinParamDescriptor {
120        name: "ErrorHandler",
121        ty: BuiltinParamType::StringScalar,
122        arity: BuiltinParamArity::Required,
123        default: Some("\"ErrorHandler\""),
124        description: "Name-value key for callback error handler.",
125    },
126    BuiltinParamDescriptor {
127        name: "errfunc",
128        ty: BuiltinParamType::Any,
129        arity: BuiltinParamArity::Required,
130        default: None,
131        description: "Error handler function handle or name.",
132    },
133];
134
135const STRUCTFUN_BOTH_OPTIONS_INPUTS: [BuiltinParamDescriptor; 6] = [
136    BuiltinParamDescriptor {
137        name: "func",
138        ty: BuiltinParamType::Any,
139        arity: BuiltinParamArity::Required,
140        default: None,
141        description: "Function handle or builtin/function name.",
142    },
143    BuiltinParamDescriptor {
144        name: "S",
145        ty: BuiltinParamType::Any,
146        arity: BuiltinParamArity::Required,
147        default: None,
148        description: "Scalar struct input.",
149    },
150    BuiltinParamDescriptor {
151        name: "UniformOutput",
152        ty: BuiltinParamType::StringScalar,
153        arity: BuiltinParamArity::Required,
154        default: Some("\"UniformOutput\""),
155        description: "Name-value key for uniform output mode.",
156    },
157    BuiltinParamDescriptor {
158        name: "tf",
159        ty: BuiltinParamType::LogicalArray,
160        arity: BuiltinParamArity::Required,
161        default: Some("true"),
162        description: "Whether callback outputs must be scalar-uniform.",
163    },
164    BuiltinParamDescriptor {
165        name: "ErrorHandler",
166        ty: BuiltinParamType::StringScalar,
167        arity: BuiltinParamArity::Required,
168        default: Some("\"ErrorHandler\""),
169        description: "Name-value key for callback error handler.",
170    },
171    BuiltinParamDescriptor {
172        name: "errfunc",
173        ty: BuiltinParamType::Any,
174        arity: BuiltinParamArity::Required,
175        default: None,
176        description: "Error handler function handle or name.",
177    },
178];
179
180const STRUCTFUN_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
181    BuiltinSignatureDescriptor {
182        label: "A = structfun(func, S)",
183        inputs: &STRUCTFUN_INPUTS,
184        outputs: &STRUCTFUN_OUTPUT,
185    },
186    BuiltinSignatureDescriptor {
187        label: "A = structfun(func, S, \"UniformOutput\", tf)",
188        inputs: &STRUCTFUN_UNIFORM_INPUTS,
189        outputs: &STRUCTFUN_OUTPUT,
190    },
191    BuiltinSignatureDescriptor {
192        label: "A = structfun(func, S, \"ErrorHandler\", errfunc)",
193        inputs: &STRUCTFUN_ERROR_HANDLER_INPUTS,
194        outputs: &STRUCTFUN_OUTPUT,
195    },
196    BuiltinSignatureDescriptor {
197        label: "A = structfun(func, S, \"UniformOutput\", tf, \"ErrorHandler\", errfunc)",
198        inputs: &STRUCTFUN_BOTH_OPTIONS_INPUTS,
199        outputs: &STRUCTFUN_OUTPUT,
200    },
201];
202
203const STRUCTFUN_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
204    code: "RM.STRUCTFUN.INVALID_INPUT",
205    identifier: Some("RunMat:structfun:InvalidInput"),
206    when: "Input callback, struct argument, or name-value forms are invalid.",
207    message: "structfun: invalid input arguments",
208};
209
210const STRUCTFUN_ERROR_NOT_SCALAR_STRUCT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
211    code: "RM.STRUCTFUN.NOT_SCALAR_STRUCT",
212    identifier: Some("RunMat:structfun:NotScalarStruct"),
213    when: "Second argument is not a scalar struct.",
214    message: "structfun: second input must be a scalar struct",
215};
216
217const STRUCTFUN_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
218    code: "RM.STRUCTFUN.INTERNAL",
219    identifier: Some("RunMat:structfun:Internal"),
220    when: "Internal field vector materialisation fails.",
221    message: "structfun: internal error",
222};
223
224const STRUCTFUN_ERROR_UNIFORM_OUTPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
225    code: "RM.STRUCTFUN.UNIFORM_OUTPUT",
226    identifier: Some("RunMat:structfun:UniformOutput"),
227    when: "UniformOutput mode requirements are violated.",
228    message: "structfun: uniform output contract violated",
229};
230
231const STRUCTFUN_ERROR_FUNCTION_ERROR: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
232    code: "RM.STRUCTFUN.FUNCTION_ERROR",
233    identifier: Some("RunMat:structfun:FunctionError"),
234    when: "Callback execution or callback output arity fails.",
235    message: "structfun: callback execution error",
236};
237
238const STRUCTFUN_ERRORS: [BuiltinErrorDescriptor; 5] = [
239    STRUCTFUN_ERROR_INVALID_INPUT,
240    STRUCTFUN_ERROR_NOT_SCALAR_STRUCT,
241    STRUCTFUN_ERROR_INTERNAL,
242    STRUCTFUN_ERROR_UNIFORM_OUTPUT,
243    STRUCTFUN_ERROR_FUNCTION_ERROR,
244];
245
246pub const STRUCTFUN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
247    signatures: &STRUCTFUN_SIGNATURES,
248    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
249    completion_policy: BuiltinCompletionPolicy::Public,
250    errors: &STRUCTFUN_ERRORS,
251};
252
253#[runtime_builtin(
254    name = "structfun",
255    category = "structs/core",
256    summary = "Apply a function to each field of a scalar struct.",
257    keywords = "structfun,struct,functional,uniformoutput,errorhandler",
258    type_resolver(structfun_type),
259    descriptor(crate::builtins::structs::core::structfun::STRUCTFUN_DESCRIPTOR),
260    builtin_path = "crate::builtins::structs::core::structfun"
261)]
262async fn structfun_builtin(
263    func: Value,
264    struct_value: Value,
265    rest: Vec<Value>,
266) -> BuiltinResult<Value> {
267    let options = parse_options(&rest)?;
268    let fields = match struct_value {
269        Value::Struct(st) => FieldValues::from_struct(st),
270        Value::Cell(_) => {
271            return Err(structfun_error(
272                STRUCTFUN_ERROR_NOT_SCALAR_STRUCT.message,
273                &STRUCTFUN_ERROR_NOT_SCALAR_STRUCT,
274            ))
275        }
276        other => {
277            return Err(structfun_error(
278                format!(
279                    "{} (got {other:?})",
280                    STRUCTFUN_ERROR_NOT_SCALAR_STRUCT.message
281                ),
282                &STRUCTFUN_ERROR_NOT_SCALAR_STRUCT,
283            ))
284        }
285    };
286
287    execute_structfun(func, fields, options).await
288}
289
290struct FieldValues {
291    names: Vec<String>,
292    values: Vec<Value>,
293}
294
295impl FieldValues {
296    fn from_struct(struct_value: StructValue) -> Self {
297        let mut names = Vec::with_capacity(struct_value.fields.len());
298        let mut values = Vec::with_capacity(struct_value.fields.len());
299        for (name, value) in struct_value.fields {
300            names.push(name);
301            values.push(value);
302        }
303        Self { names, values }
304    }
305}
306
307#[derive(Clone)]
308struct StructfunOptions {
309    uniform_output: bool,
310    error_handler: Option<Value>,
311}
312
313fn parse_options(rest: &[Value]) -> BuiltinResult<StructfunOptions> {
314    if !rest.len().is_multiple_of(2) {
315        return Err(structfun_error(
316            "structfun: optional arguments must be name-value pairs",
317            &STRUCTFUN_ERROR_INVALID_INPUT,
318        ));
319    }
320
321    let mut options = StructfunOptions {
322        uniform_output: true,
323        error_handler: None,
324    };
325
326    for pair in rest.chunks_exact(2) {
327        let Some(name) = extract_option_name(&pair[0]) else {
328            return Err(structfun_error(
329                "structfun: option names must be character vectors or string scalars",
330                &STRUCTFUN_ERROR_INVALID_INPUT,
331            ));
332        };
333        match name.trim().to_ascii_lowercase().as_str() {
334            "uniformoutput" => {
335                options.uniform_output = parse_bool_option(&pair[1])?;
336            }
337            "errorhandler" => {
338                options.error_handler = Some(pair[1].clone());
339            }
340            other => {
341                return Err(structfun_error(
342                    format!("structfun: unknown name-value argument '{other}'"),
343                    &STRUCTFUN_ERROR_INVALID_INPUT,
344                ))
345            }
346        }
347    }
348
349    Ok(options)
350}
351
352fn extract_option_name(value: &Value) -> Option<String> {
353    match value {
354        Value::String(s) => Some(s.clone()),
355        Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
356        Value::StringArray(StringArray { data, .. }) if data.len() == 1 => Some(data[0].clone()),
357        _ => None,
358    }
359}
360
361fn parse_bool_option(value: &Value) -> BuiltinResult<bool> {
362    match value {
363        Value::Bool(flag) => Ok(*flag),
364        Value::Num(n) => Ok(*n != 0.0),
365        Value::Int(int) => Ok(int.to_f64() != 0.0),
366        Value::String(text) => parse_bool_text(text).ok_or_else(uniform_option_error),
367        Value::CharArray(chars) if chars.rows == 1 => {
368            let text: String = chars.data.iter().collect();
369            parse_bool_text(&text).ok_or_else(uniform_option_error)
370        }
371        Value::StringArray(StringArray { data, .. }) if data.len() == 1 => {
372            parse_bool_text(&data[0]).ok_or_else(uniform_option_error)
373        }
374        _ => Err(uniform_option_error()),
375    }
376}
377
378fn parse_bool_text(value: &str) -> Option<bool> {
379    match value.trim().to_ascii_lowercase().as_str() {
380        "true" | "on" => Some(true),
381        "false" | "off" => Some(false),
382        _ => None,
383    }
384}
385
386fn uniform_option_error() -> RuntimeError {
387    structfun_error(
388        "structfun: UniformOutput must be logical true or false",
389        &STRUCTFUN_ERROR_UNIFORM_OUTPUT,
390    )
391}
392
393async fn execute_structfun(
394    func: Value,
395    fields: FieldValues,
396    options: StructfunOptions,
397) -> BuiltinResult<Value> {
398    let requested_outputs = current_requested_outputs();
399    if requested_outputs == 0 {
400        return Ok(Value::OutputList(Vec::new()));
401    }
402
403    let mut collectors = (0..requested_outputs)
404        .map(|_| OutputCollector::new(options.uniform_output, &fields.names))
405        .collect::<Vec<_>>();
406
407    for (linear_idx, value) in fields.values.iter().enumerate() {
408        let field_value = gather_if_needed_async(value).await?;
409        let result = match call_feval_async_with_outputs(
410            func.clone(),
411            std::slice::from_ref(&field_value),
412            requested_outputs,
413        )
414        .await
415        {
416            Ok(value) => value,
417            Err(err) => {
418                let Some(handler) = options.error_handler.as_ref() else {
419                    return Err(wrap_callback_error(err));
420                };
421                let err_struct = make_error_struct(&err, linear_idx, &fields.names[linear_idx]);
422                call_feval_async_with_outputs(
423                    handler.clone(),
424                    &[err_struct, field_value.clone()],
425                    requested_outputs,
426                )
427                .await
428                .map_err(wrap_callback_error)?
429            }
430        };
431
432        let outputs = normalize_callback_outputs(result, requested_outputs)?;
433        for (collector, output) in collectors.iter_mut().zip(outputs.into_iter()) {
434            let host_output = gather_if_needed_async(&output).await?;
435            collector.push(&fields.names[linear_idx], host_output)?;
436        }
437    }
438
439    let outputs = collectors
440        .into_iter()
441        .map(OutputCollector::finish)
442        .collect::<BuiltinResult<Vec<_>>>()?;
443    if requested_outputs == 1 {
444        Ok(outputs.into_iter().next().unwrap())
445    } else {
446        Ok(Value::OutputList(outputs))
447    }
448}
449
450fn normalize_callback_outputs(value: Value, requested_outputs: usize) -> BuiltinResult<Vec<Value>> {
451    match value {
452        Value::OutputList(values) if values.len() == requested_outputs => Ok(values),
453        Value::OutputList(values) => Err(structfun_error(
454            format!(
455                "structfun: callback returned {} outputs but {} were requested",
456                values.len(),
457                requested_outputs
458            ),
459            &STRUCTFUN_ERROR_FUNCTION_ERROR,
460        )),
461        other if requested_outputs == 1 => Ok(vec![other]),
462        _ => Err(structfun_error(
463            "structfun: callback did not return the requested number of outputs",
464            &STRUCTFUN_ERROR_FUNCTION_ERROR,
465        )),
466    }
467}
468
469fn make_error_struct(error: &RuntimeError, linear_idx: usize, field_name: &str) -> Value {
470    let mut st = StructValue::new();
471    st.insert(
472        "identifier",
473        Value::String(error.identifier().unwrap_or("").to_string()),
474    );
475    st.insert("message", Value::String(error.message().to_string()));
476    st.insert("index", Value::Num((linear_idx + 1) as f64));
477    st.insert("field", Value::String(field_name.to_string()));
478    Value::Struct(st)
479}
480
481enum OutputCollector {
482    Uniform(UniformCollector),
483    Struct(StructValue),
484}
485
486impl OutputCollector {
487    fn new(uniform_output: bool, names: &[String]) -> Self {
488        if uniform_output {
489            OutputCollector::Uniform(UniformCollector::new(names.len()))
490        } else {
491            OutputCollector::Struct(StructValue::new())
492        }
493    }
494
495    fn push(&mut self, field_name: &str, value: Value) -> BuiltinResult<()> {
496        match self {
497            OutputCollector::Uniform(collector) => collector.push(&value),
498            OutputCollector::Struct(out) => {
499                out.insert(field_name.to_string(), value);
500                Ok(())
501            }
502        }
503    }
504
505    fn finish(self) -> BuiltinResult<Value> {
506        match self {
507            OutputCollector::Uniform(collector) => collector.finish(),
508            OutputCollector::Struct(out) => Ok(Value::Struct(out)),
509        }
510    }
511}
512
513enum UniformCollector {
514    Pending { len: usize },
515    Double { data: Vec<f64>, len: usize },
516    Logical { data: Vec<u8>, len: usize },
517    Complex { data: Vec<(f64, f64)>, len: usize },
518}
519
520impl UniformCollector {
521    fn new(len: usize) -> Self {
522        UniformCollector::Pending { len }
523    }
524
525    fn push(&mut self, value: &Value) -> BuiltinResult<()> {
526        match self {
527            UniformCollector::Pending { len } => match classify_uniform_value(value)? {
528                ClassifiedValue::Logical(flag) => {
529                    *self = UniformCollector::Logical {
530                        data: vec![flag as u8],
531                        len: *len,
532                    };
533                    Ok(())
534                }
535                ClassifiedValue::Double(value) => {
536                    *self = UniformCollector::Double {
537                        data: vec![value],
538                        len: *len,
539                    };
540                    Ok(())
541                }
542                ClassifiedValue::Complex(value) => {
543                    *self = UniformCollector::Complex {
544                        data: vec![value],
545                        len: *len,
546                    };
547                    Ok(())
548                }
549            },
550            UniformCollector::Logical { data, len } => match classify_uniform_value(value)? {
551                ClassifiedValue::Logical(flag) => {
552                    data.push(flag as u8);
553                    Ok(())
554                }
555                ClassifiedValue::Double(value) => {
556                    let mut values = data
557                        .iter()
558                        .map(|&bit| if bit != 0 { 1.0 } else { 0.0 })
559                        .collect::<Vec<_>>();
560                    values.push(value);
561                    *self = UniformCollector::Double {
562                        data: values,
563                        len: *len,
564                    };
565                    Ok(())
566                }
567                ClassifiedValue::Complex(value) => {
568                    let mut values = data
569                        .iter()
570                        .map(|&bit| if bit != 0 { (1.0, 0.0) } else { (0.0, 0.0) })
571                        .collect::<Vec<_>>();
572                    values.push(value);
573                    *self = UniformCollector::Complex {
574                        data: values,
575                        len: *len,
576                    };
577                    Ok(())
578                }
579            },
580            UniformCollector::Double { data, len } => match classify_uniform_value(value)? {
581                ClassifiedValue::Logical(flag) => {
582                    data.push(if flag { 1.0 } else { 0.0 });
583                    Ok(())
584                }
585                ClassifiedValue::Double(value) => {
586                    data.push(value);
587                    Ok(())
588                }
589                ClassifiedValue::Complex(value) => {
590                    let mut values = data.iter().map(|&value| (value, 0.0)).collect::<Vec<_>>();
591                    values.push(value);
592                    *self = UniformCollector::Complex {
593                        data: values,
594                        len: *len,
595                    };
596                    Ok(())
597                }
598            },
599            UniformCollector::Complex { data, .. } => match classify_uniform_value(value)? {
600                ClassifiedValue::Logical(flag) => {
601                    data.push((if flag { 1.0 } else { 0.0 }, 0.0));
602                    Ok(())
603                }
604                ClassifiedValue::Double(value) => {
605                    data.push((value, 0.0));
606                    Ok(())
607                }
608                ClassifiedValue::Complex(value) => {
609                    data.push(value);
610                    Ok(())
611                }
612            },
613        }
614    }
615
616    fn finish(self) -> BuiltinResult<Value> {
617        match self {
618            UniformCollector::Pending { len } => Tensor::new(Vec::new(), vec![len, 1])
619                .map(Value::Tensor)
620                .map_err(|err| {
621                    structfun_error(format!("structfun: {err}"), &STRUCTFUN_ERROR_INTERNAL)
622                }),
623            UniformCollector::Double { data, len } => Tensor::new(data, vec![len, 1])
624                .map(Value::Tensor)
625                .map_err(|err| {
626                    structfun_error(format!("structfun: {err}"), &STRUCTFUN_ERROR_INTERNAL)
627                }),
628            UniformCollector::Logical { data, len } => LogicalArray::new(data, vec![len, 1])
629                .map(Value::LogicalArray)
630                .map_err(|err| {
631                    structfun_error(format!("structfun: {err}"), &STRUCTFUN_ERROR_INTERNAL)
632                }),
633            UniformCollector::Complex { data, len } => ComplexTensor::new(data, vec![len, 1])
634                .map(Value::ComplexTensor)
635                .map_err(|err| {
636                    structfun_error(format!("structfun: {err}"), &STRUCTFUN_ERROR_INTERNAL)
637                }),
638        }
639    }
640}
641
642enum ClassifiedValue {
643    Logical(bool),
644    Double(f64),
645    Complex((f64, f64)),
646}
647
648fn classify_uniform_value(value: &Value) -> BuiltinResult<ClassifiedValue> {
649    match value {
650        Value::Bool(flag) => Ok(ClassifiedValue::Logical(*flag)),
651        Value::Num(value) => Ok(ClassifiedValue::Double(*value)),
652        Value::Int(value) => Ok(ClassifiedValue::Double(value.to_f64())),
653        Value::Complex(re, im) => Ok(ClassifiedValue::Complex((*re, *im))),
654        Value::Tensor(tensor) if tensor.data.len() == 1 => {
655            Ok(ClassifiedValue::Double(tensor.data[0]))
656        }
657        Value::LogicalArray(array) if array.data.len() == 1 => {
658            Ok(ClassifiedValue::Logical(array.data[0] != 0))
659        }
660        Value::ComplexTensor(tensor) if tensor.data.len() == 1 => {
661            Ok(ClassifiedValue::Complex(tensor.data[0]))
662        }
663        _ => Err(structfun_error(
664            "structfun: callback must return scalar values when 'UniformOutput' is true",
665            &STRUCTFUN_ERROR_UNIFORM_OUTPUT,
666        )),
667    }
668}
669
670fn wrap_callback_error(error: RuntimeError) -> RuntimeError {
671    structfun_error(
672        error.message().replace("cellfun:", "structfun:"),
673        &STRUCTFUN_ERROR_FUNCTION_ERROR,
674    )
675}
676
677fn structfun_error(
678    message: impl Into<String>,
679    error: &'static BuiltinErrorDescriptor,
680) -> RuntimeError {
681    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
682    if let Some(identifier) = error.identifier {
683        builder = builder.with_identifier(identifier);
684    }
685    builder.build()
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use futures::executor::block_on;
692    use runmat_builtins::{CellArray, Tensor};
693    use std::sync::Arc;
694
695    fn call(func: Value, st: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
696        block_on(structfun_builtin(func, st, rest))
697    }
698
699    fn sample_struct() -> StructValue {
700        let mut st = StructValue::new();
701        st.insert(
702            "alpha",
703            Value::Tensor(Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap()),
704        );
705        st.insert(
706            "beta",
707            Value::Tensor(Tensor::new(vec![4.0, 5.0], vec![1, 2]).unwrap()),
708        );
709        st
710    }
711
712    #[test]
713    fn structfun_length_uniform_default_returns_column_vector() {
714        let result = call(
715            Value::String("@length".into()),
716            Value::Struct(sample_struct()),
717            Vec::new(),
718        )
719        .expect("structfun");
720        match result {
721            Value::Tensor(tensor) => {
722                assert_eq!(tensor.shape, vec![2, 1]);
723                assert_eq!(tensor.data, vec![3.0, 2.0]);
724            }
725            other => panic!("expected tensor, got {other:?}"),
726        }
727    }
728
729    #[test]
730    fn structfun_uniform_output_false_preserves_field_names() {
731        let mut st = StructValue::new();
732        st.insert("name", Value::String("runmat".into()));
733        st.insert("flag", Value::Bool(true));
734
735        let result = call(
736            Value::String("@class".into()),
737            Value::Struct(st),
738            vec![Value::String("UniformOutput".into()), Value::Bool(false)],
739        )
740        .expect("structfun");
741        match result {
742            Value::Struct(out) => {
743                assert_eq!(
744                    out.fields.get("name"),
745                    Some(&Value::String("string".into()))
746                );
747                assert_eq!(
748                    out.fields.get("flag"),
749                    Some(&Value::String("logical".into()))
750                );
751            }
752            other => panic!("expected struct, got {other:?}"),
753        }
754    }
755
756    #[test]
757    fn structfun_multiple_outputs_collect_each_output() {
758        let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
759            |_function, args, requested_outputs| {
760                assert_eq!(requested_outputs, 2);
761                let Value::Num(input) = args[0] else {
762                    panic!("expected numeric input");
763                };
764                Box::pin(async move {
765                    Ok(Value::OutputList(vec![
766                        Value::Num(input),
767                        Value::Num(input + 10.0),
768                    ]))
769                })
770            },
771        )));
772        let _output_guard = crate::output_count::push_output_count(Some(2));
773        let mut st = StructValue::new();
774        st.insert("a", Value::Num(1.0));
775        st.insert("b", Value::Num(2.0));
776        let handle = Value::BoundFunctionHandle {
777            name: "two_outputs".to_string(),
778            function: 91,
779        };
780
781        let result = call(handle, Value::Struct(st), Vec::new()).expect("structfun");
782        match result {
783            Value::OutputList(outputs) => {
784                assert_eq!(outputs.len(), 2);
785                match &outputs[0] {
786                    Value::Tensor(tensor) => assert_eq!(tensor.data, vec![1.0, 2.0]),
787                    other => panic!("expected first tensor, got {other:?}"),
788                }
789                match &outputs[1] {
790                    Value::Tensor(tensor) => assert_eq!(tensor.data, vec![11.0, 12.0]),
791                    other => panic!("expected second tensor, got {other:?}"),
792                }
793            }
794            other => panic!("expected output list, got {other:?}"),
795        }
796    }
797
798    #[test]
799    fn structfun_multiple_outputs_with_uniform_false_return_structs() {
800        let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
801            |_function, args, requested_outputs| {
802                assert_eq!(requested_outputs, 2);
803                let Value::Num(input) = args[0] else {
804                    panic!("expected numeric input");
805                };
806                Box::pin(async move {
807                    Ok(Value::OutputList(vec![
808                        Value::String(format!("v{input}")),
809                        Value::Bool(input > 1.0),
810                    ]))
811                })
812            },
813        )));
814        let _output_guard = crate::output_count::push_output_count(Some(2));
815        let mut st = StructValue::new();
816        st.insert("a", Value::Num(1.0));
817        st.insert("b", Value::Num(2.0));
818        let handle = Value::BoundFunctionHandle {
819            name: "two_outputs".to_string(),
820            function: 92,
821        };
822
823        let result = call(
824            handle,
825            Value::Struct(st),
826            vec![Value::String("UniformOutput".into()), Value::Bool(false)],
827        )
828        .expect("structfun");
829        match result {
830            Value::OutputList(outputs) => {
831                assert_eq!(outputs.len(), 2);
832                match &outputs[0] {
833                    Value::Struct(out) => {
834                        assert_eq!(out.fields.get("a"), Some(&Value::String("v1".into())));
835                        assert_eq!(out.fields.get("b"), Some(&Value::String("v2".into())));
836                    }
837                    other => panic!("expected first struct, got {other:?}"),
838                }
839                match &outputs[1] {
840                    Value::Struct(out) => {
841                        assert_eq!(out.fields.get("a"), Some(&Value::Bool(false)));
842                        assert_eq!(out.fields.get("b"), Some(&Value::Bool(true)));
843                    }
844                    other => panic!("expected second struct, got {other:?}"),
845                }
846            }
847            other => panic!("expected output list, got {other:?}"),
848        }
849    }
850
851    #[test]
852    fn structfun_callback_errors_use_structfun_identifier() {
853        let err = call(
854            Value::String("@missing_structfun_callback".into()),
855            Value::Struct(sample_struct()),
856            Vec::new(),
857        )
858        .expect_err("missing callback should fail");
859        assert_eq!(err.identifier(), STRUCTFUN_ERROR_FUNCTION_ERROR.identifier);
860        assert!(err.message().contains("Undefined function"));
861    }
862
863    #[test]
864    fn structfun_rejects_non_scalar_struct_array() {
865        let array = CellArray::new(vec![Value::Struct(StructValue::new())], 1, 1).unwrap();
866        let err = call(
867            Value::String("@length".into()),
868            Value::Cell(array),
869            Vec::new(),
870        )
871        .expect_err("struct arrays should fail");
872        assert_eq!(
873            err.identifier(),
874            STRUCTFUN_ERROR_NOT_SCALAR_STRUCT.identifier
875        );
876    }
877
878    #[test]
879    fn structfun_rejects_extra_non_option_arguments() {
880        let err = call(
881            Value::String("@length".into()),
882            Value::Struct(sample_struct()),
883            vec![Value::Num(1.0)],
884        )
885        .expect_err("extra positional args should fail");
886        assert_eq!(err.identifier(), STRUCTFUN_ERROR_INVALID_INPUT.identifier);
887    }
888
889    #[test]
890    fn structfun_descriptor_covers_option_forms() {
891        let labels = STRUCTFUN_DESCRIPTOR
892            .signatures
893            .iter()
894            .map(|signature| signature.label)
895            .collect::<Vec<_>>();
896        assert!(labels.contains(&"A = structfun(func, S)"));
897        assert!(labels.contains(&"A = structfun(func, S, \"UniformOutput\", tf)"));
898        assert!(labels.contains(&"A = structfun(func, S, \"ErrorHandler\", errfunc)"));
899        assert!(labels
900            .contains(&"A = structfun(func, S, \"UniformOutput\", tf, \"ErrorHandler\", errfunc)"));
901    }
902}