Skip to main content

runmat_runtime/builtins/math/optim/
optimoptions.rs

1//! MATLAB-compatible `optimoptions` options struct builder.
2
3use std::collections::VecDeque;
4
5use runmat_builtins::{
6    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
8    CharArray, LogicalArray, StructValue, Tensor, Value,
9};
10use runmat_macros::runtime_builtin;
11
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::math::optim::common::canonical_option_name;
17use crate::builtins::math::optim::type_resolvers::optim_options_type;
18use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
19
20const NAME: &str = "optimoptions";
21
22const OPTIMOPTIONS_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
23    name: "options",
24    ty: BuiltinParamType::Any,
25    arity: BuiltinParamArity::Required,
26    default: None,
27    description: "Options struct for optimization solvers.",
28}];
29
30const OPTIMOPTIONS_INPUTS_SOLVER: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31    name: "solver",
32    ty: BuiltinParamType::StringScalar,
33    arity: BuiltinParamArity::Required,
34    default: None,
35    description: "Solver name, such as fminbnd, fminunc, fzero, fsolve, or lsqcurvefit.",
36}];
37
38const OPTIMOPTIONS_INPUTS_SOLVER_PAIRS: [BuiltinParamDescriptor; 3] = [
39    BuiltinParamDescriptor {
40        name: "solver",
41        ty: BuiltinParamType::StringScalar,
42        arity: BuiltinParamArity::Required,
43        default: None,
44        description: "Solver name, such as fminbnd, fminunc, fzero, fsolve, or lsqcurvefit.",
45    },
46    BuiltinParamDescriptor {
47        name: "name",
48        ty: BuiltinParamType::StringScalar,
49        arity: BuiltinParamArity::Optional,
50        default: None,
51        description: "Option field name.",
52    },
53    BuiltinParamDescriptor {
54        name: "value",
55        ty: BuiltinParamType::Any,
56        arity: BuiltinParamArity::Variadic,
57        default: None,
58        description: "Option value(s) and additional name/value pairs.",
59    },
60];
61
62const OPTIMOPTIONS_INPUTS_EXISTING_PAIRS: [BuiltinParamDescriptor; 3] = [
63    BuiltinParamDescriptor {
64        name: "oldopts",
65        ty: BuiltinParamType::Any,
66        arity: BuiltinParamArity::Required,
67        default: None,
68        description: "Existing options struct to update.",
69    },
70    BuiltinParamDescriptor {
71        name: "name",
72        ty: BuiltinParamType::StringScalar,
73        arity: BuiltinParamArity::Optional,
74        default: None,
75        description: "Option field name.",
76    },
77    BuiltinParamDescriptor {
78        name: "value",
79        ty: BuiltinParamType::Any,
80        arity: BuiltinParamArity::Variadic,
81        default: None,
82        description: "Option value(s), additional name/value pairs, or another options struct.",
83    },
84];
85
86const OPTIMOPTIONS_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
87    BuiltinSignatureDescriptor {
88        label: "options = optimoptions(solver)",
89        inputs: &OPTIMOPTIONS_INPUTS_SOLVER,
90        outputs: &OPTIMOPTIONS_OUTPUT,
91    },
92    BuiltinSignatureDescriptor {
93        label: "options = optimoptions(solver, name, value, ...)",
94        inputs: &OPTIMOPTIONS_INPUTS_SOLVER_PAIRS,
95        outputs: &OPTIMOPTIONS_OUTPUT,
96    },
97    BuiltinSignatureDescriptor {
98        label: "options = optimoptions(oldopts, name, value, ...)",
99        inputs: &OPTIMOPTIONS_INPUTS_EXISTING_PAIRS,
100        outputs: &OPTIMOPTIONS_OUTPUT,
101    },
102];
103
104const OPTIMOPTIONS_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
105    code: "RM.OPTIMOPTIONS.INVALID_ARGUMENT",
106    identifier: Some("RunMat:optimoptions:InvalidArgument"),
107    when: "Argument grammar does not match supported optimoptions forms.",
108    message: "optimoptions: invalid argument",
109};
110const OPTIMOPTIONS_ERROR_INVALID_SOLVER: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
111    code: "RM.OPTIMOPTIONS.INVALID_SOLVER",
112    identifier: Some("RunMat:optimoptions:InvalidSolver"),
113    when: "The solver argument is not one of the supported optimization builtins.",
114    message: "optimoptions: invalid solver",
115};
116const OPTIMOPTIONS_ERROR_INVALID_OPTION_NAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
117    code: "RM.OPTIMOPTIONS.INVALID_OPTION_NAME",
118    identifier: Some("RunMat:optimoptions:InvalidOptionName"),
119    when: "An option name is not a text scalar.",
120    message: "optimoptions: invalid option name",
121};
122const OPTIMOPTIONS_ERROR_MISSING_OPTION_VALUE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
123    code: "RM.OPTIMOPTIONS.MISSING_OPTION_VALUE",
124    identifier: Some("RunMat:optimoptions:MissingOptionValue"),
125    when: "A name-value option key is not followed by a value.",
126    message: "optimoptions: missing option value",
127};
128const OPTIMOPTIONS_ERROR_UNKNOWN_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
129    code: "RM.OPTIMOPTIONS.UNKNOWN_OPTION",
130    identifier: Some("RunMat:optimoptions:UnknownOption"),
131    when: "An option name is not supported by the selected solver.",
132    message: "optimoptions: unknown option",
133};
134const OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
135    code: "RM.OPTIMOPTIONS.INVALID_OPTION_VALUE",
136    identifier: Some("RunMat:optimoptions:InvalidOptionValue"),
137    when: "An option value fails type or domain validation.",
138    message: "optimoptions: invalid option value",
139};
140const OPTIMOPTIONS_ERROR_FLOW: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
141    code: "RM.OPTIMOPTIONS.FLOW",
142    identifier: Some("RunMat:optimoptions:Flow"),
143    when: "Nested flow fails while gathering input values.",
144    message: "optimoptions: flow failure",
145};
146
147const OPTIMOPTIONS_ERRORS: [BuiltinErrorDescriptor; 7] = [
148    OPTIMOPTIONS_ERROR_INVALID_ARGUMENT,
149    OPTIMOPTIONS_ERROR_INVALID_SOLVER,
150    OPTIMOPTIONS_ERROR_INVALID_OPTION_NAME,
151    OPTIMOPTIONS_ERROR_MISSING_OPTION_VALUE,
152    OPTIMOPTIONS_ERROR_UNKNOWN_OPTION,
153    OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
154    OPTIMOPTIONS_ERROR_FLOW,
155];
156
157pub const OPTIMOPTIONS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
158    signatures: &OPTIMOPTIONS_SIGNATURES,
159    output_mode: BuiltinOutputMode::Fixed,
160    completion_policy: BuiltinCompletionPolicy::Public,
161    errors: &OPTIMOPTIONS_ERRORS,
162};
163
164#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::optim::optimoptions")]
165pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
166    name: "optimoptions",
167    op_kind: GpuOpKind::Custom("optimization-options"),
168    supported_precisions: &[],
169    broadcast: BroadcastSemantics::None,
170    provider_hooks: &[],
171    constant_strategy: ConstantStrategy::InlineLiteral,
172    residency: ResidencyPolicy::GatherImmediately,
173    nan_mode: ReductionNaN::Include,
174    two_pass_threshold: None,
175    workgroup_size: None,
176    accepts_nan_mode: false,
177    notes: "Host metadata construction. gpuArray option values are gathered before validation.",
178};
179
180#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::optim::optimoptions")]
181pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
182    name: "optimoptions",
183    shape: ShapeRequirements::Any,
184    constant_strategy: ConstantStrategy::InlineLiteral,
185    elementwise: None,
186    reduction: None,
187    emits_nan: false,
188    notes: "Option struct construction is host metadata work and terminates fusion planning.",
189};
190
191#[runtime_builtin(
192    name = "optimoptions",
193    category = "math/optim",
194    summary = "Create or update a typed optimization options structure for fminbnd, fminunc, fzero, fsolve, and lsqcurvefit.",
195    keywords = "optimoptions,options,TolX,TolFun,FunctionTolerance,StepTolerance,MaxIter,MaxFunEvals,Display,Algorithm,SpecifyObjectiveGradient",
196    accel = "cpu",
197    type_resolver(optim_options_type),
198    descriptor(crate::builtins::math::optim::optimoptions::OPTIMOPTIONS_DESCRIPTOR),
199    builtin_path = "crate::builtins::math::optim::optimoptions"
200)]
201async fn optimoptions_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
202    let mut gathered = Vec::with_capacity(rest.len());
203    for value in rest {
204        gathered.push(gather_if_needed_async(&value).await.map_err(|err| {
205            remap_optimoptions_flow(&OPTIMOPTIONS_ERROR_FLOW, err, |source| {
206                format!("optimoptions: {}", source.message())
207            })
208        })?);
209    }
210
211    let mut queue: VecDeque<Value> = gathered.into();
212    let first = queue.pop_front().ok_or_else(|| {
213        optimoptions_error_with(
214            &OPTIMOPTIONS_ERROR_INVALID_ARGUMENT,
215            "optimoptions: expected a solver name or options struct",
216        )
217    })?;
218
219    let mut solver;
220    let explicit_solver;
221    let mut options = match first {
222        Value::Struct(existing) => {
223            explicit_solver = false;
224            solver = solver_from_options(&existing)?;
225            canonicalize_existing_options(&existing, solver)?
226        }
227        other => {
228            explicit_solver = true;
229            solver = parse_solver(&other)?;
230            default_options(solver)
231        }
232    };
233
234    while let Some(arg) = queue.pop_front() {
235        match arg {
236            Value::Struct(existing) => {
237                if explicit_solver {
238                    let next_solver = solver_from_options(&existing)?;
239                    let skip_defaults_from = match next_solver {
240                        Solver::Generic => None,
241                        other => Some(other),
242                    };
243                    apply_struct_fields(
244                        &existing,
245                        &mut options,
246                        solver,
247                        false,
248                        skip_defaults_from,
249                    )?;
250                    options.insert("Solver", Value::from(solver.name()));
251                    continue;
252                } else {
253                    let next_solver = solver_from_options(&existing)?;
254                    let skip_defaults_from;
255                    if next_solver != Solver::Generic && next_solver != solver {
256                        options = if solver == Solver::Generic {
257                            merge_generic_into_defaults(&options, next_solver)?
258                        } else {
259                            default_options(next_solver)
260                        };
261                        solver = next_solver;
262                        skip_defaults_from = Some(next_solver);
263                    } else if next_solver != Solver::Generic {
264                        solver = next_solver;
265                        skip_defaults_from = Some(next_solver);
266                    } else {
267                        skip_defaults_from = None;
268                    }
269                    apply_struct_fields(&existing, &mut options, solver, true, skip_defaults_from)?;
270                    continue;
271                }
272            }
273            name_value => {
274                let name = expect_string_scalar(
275                    &name_value,
276                    "optimoptions: option names must be character vectors or string scalars",
277                    &OPTIMOPTIONS_ERROR_INVALID_OPTION_NAME,
278                )?;
279                let value = queue.pop_front().ok_or_else(|| {
280                    optimoptions_error_with(
281                        &OPTIMOPTIONS_ERROR_MISSING_OPTION_VALUE,
282                        format!("optimoptions: missing value for option '{name}'"),
283                    )
284                })?;
285                set_option_field(&mut options, solver, &name, &value)?;
286            }
287        }
288    }
289
290    Ok(Value::Struct(options))
291}
292
293fn optimoptions_error_with(
294    error: &'static BuiltinErrorDescriptor,
295    message: impl Into<String>,
296) -> RuntimeError {
297    let mut builder = build_runtime_error(message).with_builtin(NAME);
298    if let Some(identifier) = error.identifier {
299        builder = builder.with_identifier(identifier);
300    }
301    builder.build()
302}
303
304fn remap_optimoptions_flow<F>(
305    error: &'static BuiltinErrorDescriptor,
306    err: RuntimeError,
307    message: F,
308) -> RuntimeError
309where
310    F: FnOnce(&RuntimeError) -> String,
311{
312    let mut builder = build_runtime_error(message(&err))
313        .with_builtin(NAME)
314        .with_source(err);
315    if let Some(identifier) = error.identifier {
316        builder = builder.with_identifier(identifier);
317    }
318    builder.build()
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322enum Solver {
323    Fminbnd,
324    Fminunc,
325    Fzero,
326    Fsolve,
327    Lsqcurvefit,
328    Generic,
329}
330
331impl Solver {
332    fn name(self) -> &'static str {
333        match self {
334            Self::Fminbnd => "fminbnd",
335            Self::Fminunc => "fminunc",
336            Self::Fzero => "fzero",
337            Self::Fsolve => "fsolve",
338            Self::Lsqcurvefit => "lsqcurvefit",
339            Self::Generic => "",
340        }
341    }
342
343    fn default_display(self) -> &'static str {
344        match self {
345            Self::Fminbnd => "notify",
346            Self::Fminunc | Self::Fzero | Self::Fsolve | Self::Lsqcurvefit | Self::Generic => "off",
347        }
348    }
349
350    fn accepts_tol_fun(self) -> bool {
351        matches!(
352            self,
353            Self::Fminunc | Self::Fsolve | Self::Lsqcurvefit | Self::Generic
354        )
355    }
356
357    fn accepts_option(self, canonical: &str) -> bool {
358        match canonical {
359            "TolX" | "MaxIter" | "MaxFunEvals" | "Display" => true,
360            "TolFun" => self.accepts_tol_fun(),
361            "Algorithm" => matches!(self, Self::Fminunc | Self::Lsqcurvefit | Self::Generic),
362            "SpecifyObjectiveGradient" => matches!(self, Self::Fminunc | Self::Generic),
363            _ => false,
364        }
365    }
366
367    fn accepts_display(self, display: &str) -> bool {
368        match self {
369            Self::Fminbnd | Self::Fminunc | Self::Generic => {
370                matches!(display, "off" | "none" | "iter" | "notify" | "final")
371            }
372            Self::Fzero | Self::Fsolve | Self::Lsqcurvefit => {
373                matches!(display, "off" | "none" | "iter" | "final")
374            }
375        }
376    }
377
378    fn accepts_algorithm(self, algorithm: &str) -> bool {
379        match self {
380            Self::Fminunc => matches!(algorithm, "quasi-newton" | "bfgs"),
381            Self::Lsqcurvefit | Self::Generic => {
382                matches!(
383                    algorithm,
384                    "quasi-newton" | "bfgs" | "levenberg-marquardt" | "trust-region-reflective"
385                )
386            }
387            _ => false,
388        }
389    }
390}
391
392fn parse_solver(value: &Value) -> BuiltinResult<Solver> {
393    let text = expect_string_scalar(
394        value,
395        "optimoptions: solver must be a character vector or string scalar",
396        &OPTIMOPTIONS_ERROR_INVALID_SOLVER,
397    )?;
398    parse_solver_name(&text)
399}
400
401fn parse_solver_name(text: &str) -> BuiltinResult<Solver> {
402    match text.trim().to_ascii_lowercase().as_str() {
403        "fminbnd" => Ok(Solver::Fminbnd),
404        "fminunc" => Ok(Solver::Fminunc),
405        "fzero" => Ok(Solver::Fzero),
406        "fsolve" => Ok(Solver::Fsolve),
407        "lsqcurvefit" => Ok(Solver::Lsqcurvefit),
408        other => Err(optimoptions_error_with(
409            &OPTIMOPTIONS_ERROR_INVALID_SOLVER,
410            format!("optimoptions: unsupported solver '{other}'"),
411        )),
412    }
413}
414
415fn solver_from_options(options: &StructValue) -> BuiltinResult<Solver> {
416    let Some(value) = lookup_case_insensitive(options, "Solver") else {
417        return Ok(Solver::Generic);
418    };
419    parse_solver(value)
420}
421
422fn default_options(solver: Solver) -> StructValue {
423    let mut out = StructValue::new();
424    if solver != Solver::Generic {
425        out.insert("Solver", Value::from(solver.name()));
426    }
427    match solver {
428        Solver::Fminbnd => {
429            out.insert("TolX", Value::Num(1.0e-4));
430            out.insert("MaxIter", Value::Num(500.0));
431            out.insert("MaxFunEvals", Value::Num(500.0));
432            out.insert("Display", Value::from(solver.default_display()));
433        }
434        Solver::Fminunc => {
435            out.insert("Algorithm", Value::from("quasi-newton"));
436            out.insert("TolX", Value::Num(1.0e-6));
437            out.insert("TolFun", Value::Num(1.0e-6));
438            out.insert("MaxIter", Value::Num(400.0));
439            out.insert("MaxFunEvals", Value::Num(40000.0));
440            out.insert("Display", Value::from(solver.default_display()));
441            out.insert("SpecifyObjectiveGradient", Value::Bool(false));
442        }
443        Solver::Fzero => {
444            out.insert("TolX", Value::Num(1.0e-6));
445            out.insert("MaxIter", Value::Num(400.0));
446            out.insert("MaxFunEvals", Value::Num(500.0));
447            out.insert("Display", Value::from(solver.default_display()));
448        }
449        Solver::Fsolve => {
450            out.insert("TolX", Value::Num(1.0e-6));
451            out.insert("TolFun", Value::Num(1.0e-6));
452            out.insert("MaxIter", Value::Num(400.0));
453            out.insert("MaxFunEvals", Value::Num(40000.0));
454            out.insert("Display", Value::from(solver.default_display()));
455        }
456        Solver::Lsqcurvefit => {
457            out.insert("Algorithm", Value::from("levenberg-marquardt"));
458            out.insert("TolX", Value::Num(1.0e-6));
459            out.insert("TolFun", Value::Num(1.0e-6));
460            out.insert("MaxIter", Value::Num(400.0));
461            out.insert("MaxFunEvals", Value::Num(40000.0));
462            out.insert("Display", Value::from(solver.default_display()));
463        }
464        Solver::Generic => {}
465    }
466    out
467}
468
469fn canonicalize_existing_options(
470    existing: &StructValue,
471    solver: Solver,
472) -> BuiltinResult<StructValue> {
473    let mut out = if solver == Solver::Generic {
474        StructValue::new()
475    } else {
476        default_options(solver)
477    };
478    apply_struct_fields(existing, &mut out, solver, true, None)?;
479    Ok(out)
480}
481
482fn merge_generic_into_defaults(
483    generic: &StructValue,
484    solver: Solver,
485) -> BuiltinResult<StructValue> {
486    let mut out = default_options(solver);
487    for (key, value) in &generic.fields {
488        if key.eq_ignore_ascii_case("Solver") {
489            continue;
490        }
491        let canonical = canonical_option_name(key);
492        if !solver.accepts_option(&canonical) {
493            continue;
494        }
495        if canonical == "Display" && display_value(solver, value).is_err() {
496            continue;
497        }
498        set_option_field(&mut out, solver, key, value)?;
499    }
500    Ok(out)
501}
502
503fn apply_struct_fields(
504    source: &StructValue,
505    target: &mut StructValue,
506    solver: Solver,
507    copy_solver_field: bool,
508    skip_defaults_from: Option<Solver>,
509) -> BuiltinResult<()> {
510    let source_defaults = skip_defaults_from.map(default_options);
511    for (key, value) in &source.fields {
512        if key.eq_ignore_ascii_case("Solver") {
513            if !copy_solver_field {
514                continue;
515            }
516            let parsed = parse_solver(value)?;
517            target.insert("Solver", Value::from(parsed.name()));
518            continue;
519        }
520        let canonical = canonical_option_name(key);
521        if let Some(defaults) = &source_defaults {
522            if solver.accepts_option(&canonical)
523                && lookup_case_insensitive(defaults, &canonical).is_some_and(|default| {
524                    normalized_option_value(solver, &canonical, value)
525                        .is_ok_and(|normalized| default == &normalized)
526                })
527            {
528                continue;
529            }
530        }
531        set_option_field(target, solver, key, value)?;
532    }
533    Ok(())
534}
535
536fn set_option_field(
537    options: &mut StructValue,
538    solver: Solver,
539    name: &str,
540    value: &Value,
541) -> BuiltinResult<()> {
542    let canonical = canonical_option_name(name);
543    if !solver.accepts_option(&canonical) {
544        return Err(optimoptions_error_with(
545            &OPTIMOPTIONS_ERROR_UNKNOWN_OPTION,
546            format!(
547                "optimoptions: option '{}' is not supported for {}",
548                name,
549                solver_label(solver)
550            ),
551        ));
552    }
553
554    let value = normalized_option_value(solver, &canonical, value)?;
555    options.insert(canonical, value);
556    Ok(())
557}
558
559fn normalized_option_value(solver: Solver, canonical: &str, value: &Value) -> BuiltinResult<Value> {
560    match canonical {
561        "TolX" | "TolFun" => Ok(Value::Num(positive_finite_scalar(canonical, value)?)),
562        "MaxIter" | "MaxFunEvals" => {
563            Ok(Value::Num(positive_integer_scalar(canonical, value)? as f64))
564        }
565        "Display" => Ok(Value::from(display_value(solver, value)?)),
566        "Algorithm" => Ok(Value::from(algorithm_value(solver, value)?)),
567        "SpecifyObjectiveGradient" => Ok(Value::Bool(logical_value(canonical, value)?)),
568        _ => unreachable!("unsupported option passed accepts_option"),
569    }
570}
571
572fn solver_label(solver: Solver) -> &'static str {
573    match solver {
574        Solver::Generic => "optimization solvers",
575        _ => solver.name(),
576    }
577}
578
579fn positive_finite_scalar(field: &str, value: &Value) -> BuiltinResult<f64> {
580    let parsed = numeric_scalar(field, value)?;
581    if parsed > 0.0 {
582        Ok(parsed)
583    } else {
584        Err(optimoptions_error_with(
585            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
586            format!("optimoptions: option {field} must be a finite positive scalar"),
587        ))
588    }
589}
590
591fn positive_integer_scalar(field: &str, value: &Value) -> BuiltinResult<usize> {
592    let parsed = positive_finite_scalar(field, value)?;
593    if parsed.fract() != 0.0 {
594        return Err(optimoptions_error_with(
595            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
596            format!("optimoptions: option {field} must be an integer scalar"),
597        ));
598    }
599    if parsed >= 2f64.powi(usize::BITS as i32) {
600        return Err(optimoptions_error_with(
601            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
602            format!("optimoptions: option {field} is too large"),
603        ));
604    }
605    Ok(parsed as usize)
606}
607
608fn numeric_scalar(field: &str, value: &Value) -> BuiltinResult<f64> {
609    let parsed = match value {
610        Value::Num(n) => *n,
611        Value::Int(i) => i.to_f64(),
612        Value::Tensor(Tensor { data, .. }) if data.len() == 1 => data[0],
613        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => {
614            if data[0] == 0 {
615                0.0
616            } else {
617                1.0
618            }
619        }
620        other => {
621            return Err(optimoptions_error_with(
622                &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
623                format!("optimoptions: option {field} must be a numeric scalar, got {other:?}"),
624            ))
625        }
626    };
627    if parsed.is_finite() {
628        Ok(parsed)
629    } else {
630        Err(optimoptions_error_with(
631            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
632            format!("optimoptions: option {field} must be finite"),
633        ))
634    }
635}
636
637fn logical_value(field: &str, value: &Value) -> BuiltinResult<bool> {
638    match value {
639        Value::Bool(flag) => Ok(*flag),
640        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => Ok(data[0] != 0),
641        Value::Num(n) => logical_from_number(field, *n),
642        Value::Int(i) => logical_from_number(field, i.to_f64()),
643        Value::Tensor(Tensor { data, .. }) if data.len() == 1 => {
644            logical_from_number(field, data[0])
645        }
646        Value::String(s) => logical_from_text(field, s),
647        Value::StringArray(sa) if sa.data.len() == 1 => logical_from_text(field, &sa.data[0]),
648        Value::CharArray(CharArray { data, rows: 1, .. }) => {
649            let text: String = data.iter().collect();
650            logical_from_text(field, &text)
651        }
652        other => Err(optimoptions_error_with(
653            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
654            format!("optimoptions: option {field} must be logical, got {other:?}"),
655        )),
656    }
657}
658
659fn logical_from_number(field: &str, value: f64) -> BuiltinResult<bool> {
660    if value == 0.0 {
661        Ok(false)
662    } else if value == 1.0 {
663        Ok(true)
664    } else {
665        Err(optimoptions_error_with(
666            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
667            format!("optimoptions: option {field} must be logical 0 or 1"),
668        ))
669    }
670}
671
672fn logical_from_text(field: &str, value: &str) -> BuiltinResult<bool> {
673    match value.trim().to_ascii_lowercase().as_str() {
674        "on" | "true" | "yes" => Ok(true),
675        "off" | "false" | "no" => Ok(false),
676        other => Err(optimoptions_error_with(
677            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
678            format!("optimoptions: option {field} must be 'on' or 'off', got '{other}'"),
679        )),
680    }
681}
682
683fn display_value(solver: Solver, value: &Value) -> BuiltinResult<String> {
684    let display = expect_string_scalar(
685        value,
686        "optimoptions: Display must be a character vector or string scalar",
687        &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
688    )?
689    .trim()
690    .to_ascii_lowercase();
691    if solver.accepts_display(&display) {
692        Ok(display)
693    } else {
694        Err(optimoptions_error_with(
695            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
696            format!(
697                "optimoptions: unsupported Display '{}' for {}",
698                display,
699                solver_label(solver)
700            ),
701        ))
702    }
703}
704
705fn algorithm_value(solver: Solver, value: &Value) -> BuiltinResult<String> {
706    let algorithm = expect_string_scalar(
707        value,
708        "optimoptions: Algorithm must be a character vector or string scalar",
709        &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
710    )?
711    .trim()
712    .to_ascii_lowercase();
713    if solver.accepts_algorithm(&algorithm) {
714        Ok(algorithm)
715    } else {
716        Err(optimoptions_error_with(
717            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
718            format!(
719                "optimoptions: unsupported Algorithm '{}' for {}",
720                algorithm,
721                solver_label(solver)
722            ),
723        ))
724    }
725}
726
727fn expect_string_scalar(
728    value: &Value,
729    context: &str,
730    error: &'static BuiltinErrorDescriptor,
731) -> BuiltinResult<String> {
732    match value {
733        Value::String(s) => Ok(s.clone()),
734        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
735        Value::CharArray(CharArray { data, rows: 1, .. }) => Ok(data.iter().collect()),
736        _ => Err(optimoptions_error_with(error, context)),
737    }
738}
739
740fn lookup_case_insensitive<'a>(options: &'a StructValue, name: &str) -> Option<&'a Value> {
741    options
742        .fields
743        .iter()
744        .find(|(key, _)| key.eq_ignore_ascii_case(name))
745        .map(|(_, value)| value)
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use crate::call_builtin_async;
752    use futures::executor::block_on;
753    use runmat_builtins::IntValue;
754
755    fn run_optimoptions(rest: Vec<Value>) -> BuiltinResult<Value> {
756        block_on(optimoptions_builtin(rest))
757    }
758
759    fn run_call_builtin(name: &str, args: &[Value]) -> BuiltinResult<Value> {
760        block_on(call_builtin_async(name, args))
761    }
762
763    fn struct_result(value: Value) -> StructValue {
764        match value {
765            Value::Struct(options) => options,
766            other => panic!("expected struct, got {other:?}"),
767        }
768    }
769
770    fn num_field(options: &StructValue, field: &str) -> f64 {
771        match options.fields.get(field) {
772            Some(Value::Num(value)) => *value,
773            other => panic!("expected numeric field {field}, got {other:?}"),
774        }
775    }
776
777    fn string_field<'a>(options: &'a StructValue, field: &str) -> &'a str {
778        match options.fields.get(field) {
779            Some(Value::String(value)) => value.as_str(),
780            other => panic!("expected string field {field}, got {other:?}"),
781        }
782    }
783
784    fn bool_field(options: &StructValue, field: &str) -> bool {
785        match options.fields.get(field) {
786            Some(Value::Bool(value)) => *value,
787            other => panic!("expected bool field {field}, got {other:?}"),
788        }
789    }
790
791    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
792    #[test]
793    fn optimoptions_descriptor_signatures_and_errors_cover_core_forms() {
794        let labels: Vec<&str> = OPTIMOPTIONS_DESCRIPTOR
795            .signatures
796            .iter()
797            .map(|signature| signature.label)
798            .collect();
799        assert_eq!(
800            labels,
801            vec![
802                "options = optimoptions(solver)",
803                "options = optimoptions(solver, name, value, ...)",
804                "options = optimoptions(oldopts, name, value, ...)",
805            ]
806        );
807
808        let codes: Vec<&str> = OPTIMOPTIONS_DESCRIPTOR
809            .errors
810            .iter()
811            .map(|error| error.code)
812            .collect();
813        assert_eq!(
814            codes,
815            vec![
816                "RM.OPTIMOPTIONS.INVALID_ARGUMENT",
817                "RM.OPTIMOPTIONS.INVALID_SOLVER",
818                "RM.OPTIMOPTIONS.INVALID_OPTION_NAME",
819                "RM.OPTIMOPTIONS.MISSING_OPTION_VALUE",
820                "RM.OPTIMOPTIONS.UNKNOWN_OPTION",
821                "RM.OPTIMOPTIONS.INVALID_OPTION_VALUE",
822                "RM.OPTIMOPTIONS.FLOW",
823            ]
824        );
825    }
826
827    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
828    #[test]
829    fn optimoptions_fminbnd_defaults_match_solver() {
830        let options = struct_result(
831            run_optimoptions(vec![Value::from("fminbnd")]).expect("optimoptions fminbnd"),
832        );
833        assert_eq!(string_field(&options, "Solver"), "fminbnd");
834        assert_eq!(num_field(&options, "TolX"), 1.0e-4);
835        assert_eq!(num_field(&options, "MaxIter"), 500.0);
836        assert_eq!(num_field(&options, "MaxFunEvals"), 500.0);
837        assert_eq!(string_field(&options, "Display"), "notify");
838        assert!(!options.fields.contains_key("TolFun"));
839    }
840
841    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
842    #[test]
843    fn optimoptions_fzero_defaults_match_solver() {
844        let options = struct_result(
845            run_optimoptions(vec![Value::from("fzero")]).expect("optimoptions fzero"),
846        );
847        assert_eq!(string_field(&options, "Solver"), "fzero");
848        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
849        assert_eq!(num_field(&options, "MaxIter"), 400.0);
850        assert_eq!(num_field(&options, "MaxFunEvals"), 500.0);
851        assert_eq!(string_field(&options, "Display"), "off");
852        assert!(!options.fields.contains_key("TolFun"));
853    }
854
855    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
856    #[test]
857    fn optimoptions_fsolve_defaults_match_solver() {
858        let options = struct_result(
859            run_optimoptions(vec![Value::from("fsolve")]).expect("optimoptions fsolve"),
860        );
861        assert_eq!(string_field(&options, "Solver"), "fsolve");
862        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
863        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
864        assert_eq!(num_field(&options, "MaxIter"), 400.0);
865        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
866        assert_eq!(string_field(&options, "Display"), "off");
867    }
868
869    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
870    #[test]
871    fn optimoptions_fminunc_defaults_match_solver() {
872        let options = struct_result(
873            run_optimoptions(vec![Value::from("fminunc")]).expect("optimoptions fminunc"),
874        );
875        assert_eq!(string_field(&options, "Solver"), "fminunc");
876        assert_eq!(string_field(&options, "Algorithm"), "quasi-newton");
877        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
878        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
879        assert_eq!(num_field(&options, "MaxIter"), 400.0);
880        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
881        assert_eq!(string_field(&options, "Display"), "off");
882        assert!(!bool_field(&options, "SpecifyObjectiveGradient"));
883    }
884
885    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
886    #[test]
887    fn optimoptions_fminunc_accepts_gradient_and_algorithm_options() {
888        let options = struct_result(
889            run_optimoptions(vec![
890                Value::from("fminunc"),
891                Value::from("SpecifyObjectiveGradient"),
892                Value::from("on"),
893                Value::from("Algorithm"),
894                Value::from("bfgs"),
895                Value::from("Display"),
896                Value::from("notify"),
897            ])
898            .expect("optimoptions fminunc"),
899        );
900        assert!(bool_field(&options, "SpecifyObjectiveGradient"));
901        assert_eq!(string_field(&options, "Algorithm"), "bfgs");
902        assert_eq!(string_field(&options, "Display"), "notify");
903    }
904
905    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
906    #[test]
907    fn optimoptions_lsqcurvefit_defaults_match_solver() {
908        let options = struct_result(
909            run_optimoptions(vec![Value::from("lsqcurvefit")]).expect("optimoptions lsqcurvefit"),
910        );
911        assert_eq!(string_field(&options, "Solver"), "lsqcurvefit");
912        assert_eq!(string_field(&options, "Algorithm"), "levenberg-marquardt");
913        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
914        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
915        assert_eq!(num_field(&options, "MaxIter"), 400.0);
916        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
917        assert_eq!(string_field(&options, "Display"), "off");
918    }
919
920    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
921    #[test]
922    fn optimoptions_lsqcurvefit_accepts_modern_tolerance_aliases_and_algorithm() {
923        let options = struct_result(
924            run_optimoptions(vec![
925                Value::from("lsqcurvefit"),
926                Value::from("FunctionTolerance"),
927                Value::Num(1.0e-9),
928                Value::from("StepTolerance"),
929                Value::Num(1.0e-8),
930                Value::from("Algorithm"),
931                Value::from("trust-region-reflective"),
932            ])
933            .expect("optimoptions lsqcurvefit aliases"),
934        );
935        assert_eq!(num_field(&options, "TolFun"), 1.0e-9);
936        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
937        assert_eq!(
938            string_field(&options, "Algorithm"),
939            "trust-region-reflective"
940        );
941    }
942
943    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
944    #[test]
945    fn optimoptions_name_value_pairs_are_case_insensitive() {
946        let options = struct_result(
947            run_optimoptions(vec![
948                Value::from("fsolve"),
949                Value::from("tolx"),
950                Value::Num(1.0e-8),
951                Value::from("DISPLAY"),
952                Value::from("Final"),
953            ])
954            .expect("optimoptions overrides"),
955        );
956        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
957        assert_eq!(string_field(&options, "Display"), "final");
958    }
959
960    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
961    #[test]
962    fn optimoptions_updates_existing_options_with_pairs() {
963        let base = run_optimoptions(vec![
964            Value::from("fzero"),
965            Value::from("TolX"),
966            Value::Num(1.0e-5),
967        ])
968        .expect("base options");
969        let options = struct_result(
970            run_optimoptions(vec![base, Value::from("MaxIter"), Value::Num(25.0)])
971                .expect("updated options"),
972        );
973        assert_eq!(string_field(&options, "Solver"), "fzero");
974        assert_eq!(num_field(&options, "TolX"), 1.0e-5);
975        assert_eq!(num_field(&options, "MaxIter"), 25.0);
976    }
977
978    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
979    #[test]
980    fn optimoptions_merges_existing_options_structs() {
981        let first = run_optimoptions(vec![
982            Value::from("fsolve"),
983            Value::from("TolX"),
984            Value::Num(1.0e-5),
985        ])
986        .expect("first");
987        let second = run_optimoptions(vec![
988            Value::from("fsolve"),
989            Value::from("TolX"),
990            Value::Num(1.0e-8),
991            Value::from("MaxIter"),
992            Value::Num(30.0),
993        ])
994        .expect("second");
995        let options = struct_result(run_optimoptions(vec![first, second]).expect("merged options"));
996        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
997        assert_eq!(num_field(&options, "MaxIter"), 30.0);
998    }
999
1000    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1001    #[test]
1002    fn optimoptions_same_solver_struct_merge_preserves_prior_overrides() {
1003        let first = run_optimoptions(vec![
1004            Value::from("fsolve"),
1005            Value::from("MaxFunEvals"),
1006            Value::Num(2000.0),
1007        ])
1008        .expect("first");
1009        let second = run_optimoptions(vec![
1010            Value::from("fsolve"),
1011            Value::from("TolX"),
1012            Value::Num(1.0e-8),
1013        ])
1014        .expect("second");
1015
1016        let options = struct_result(run_optimoptions(vec![first, second]).expect("merged options"));
1017
1018        assert_eq!(string_field(&options, "Solver"), "fsolve");
1019        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1020        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
1021    }
1022
1023    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1024    #[test]
1025    fn optimoptions_solver_form_same_solver_struct_preserves_prior_overrides() {
1026        let later = run_optimoptions(vec![
1027            Value::from("fsolve"),
1028            Value::from("TolX"),
1029            Value::Num(1.0e-8),
1030        ])
1031        .expect("later options");
1032
1033        let options = struct_result(
1034            run_optimoptions(vec![
1035                Value::from("fsolve"),
1036                Value::from("MaxFunEvals"),
1037                Value::Num(2000.0),
1038                later,
1039            ])
1040            .expect("merged options"),
1041        );
1042
1043        assert_eq!(string_field(&options, "Solver"), "fsolve");
1044        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1045        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
1046    }
1047
1048    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1049    #[test]
1050    fn optimoptions_default_skipping_compares_normalized_values() {
1051        let first = run_optimoptions(vec![
1052            Value::from("fsolve"),
1053            Value::from("MaxFunEvals"),
1054            Value::Num(2000.0),
1055            Value::from("Display"),
1056            Value::from("final"),
1057        ])
1058        .expect("first");
1059
1060        let mut later = StructValue::new();
1061        later.insert("Solver", Value::from("fsolve"));
1062        later.insert("TolX", Value::Num(1.0e-8));
1063        later.insert("MaxFunEvals", Value::Int(IntValue::I32(40000)));
1064        later.insert("Display", Value::CharArray(CharArray::new_row("off")));
1065
1066        let options = struct_result(
1067            run_optimoptions(vec![first, Value::Struct(later)]).expect("merged options"),
1068        );
1069
1070        assert_eq!(string_field(&options, "Solver"), "fsolve");
1071        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1072        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
1073        assert_eq!(string_field(&options, "Display"), "final");
1074    }
1075
1076    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1077    #[test]
1078    fn optimoptions_generic_to_concrete_solver_preserves_valid_generic_overrides() {
1079        let mut generic = StructValue::new();
1080        generic.insert("MaxFunEvals", Value::Num(2000.0));
1081        generic.insert("Display", Value::from("final"));
1082
1083        let later = run_optimoptions(vec![
1084            Value::from("fsolve"),
1085            Value::from("TolX"),
1086            Value::Num(1.0e-8),
1087        ])
1088        .expect("later options");
1089
1090        let options = struct_result(
1091            run_optimoptions(vec![Value::Struct(generic), later]).expect("merged options"),
1092        );
1093
1094        assert_eq!(string_field(&options, "Solver"), "fsolve");
1095        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1096        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
1097        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
1098        assert_eq!(string_field(&options, "Display"), "final");
1099    }
1100
1101    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1102    #[test]
1103    fn optimoptions_solver_form_keeps_requested_solver_when_struct_has_solver() {
1104        let fzero_options = run_optimoptions(vec![
1105            Value::from("fzero"),
1106            Value::from("TolX"),
1107            Value::Num(1.0e-8),
1108            Value::from("MaxIter"),
1109            Value::Num(30.0),
1110        ])
1111        .expect("fzero options");
1112
1113        let options = struct_result(
1114            run_optimoptions(vec![Value::from("fsolve"), fzero_options])
1115                .expect("merged into fsolve options"),
1116        );
1117
1118        assert_eq!(string_field(&options, "Solver"), "fsolve");
1119        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1120        assert_eq!(num_field(&options, "MaxIter"), 30.0);
1121        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
1122        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
1123    }
1124
1125    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1126    #[test]
1127    fn optimoptions_rejects_unknown_option_names() {
1128        let err = run_optimoptions(vec![
1129            Value::from("fzero"),
1130            Value::from("TolFun"),
1131            Value::Num(1.0e-8),
1132        ])
1133        .expect_err("TolFun is not accepted by fzero");
1134        assert_eq!(err.identifier(), Some("RunMat:optimoptions:UnknownOption"));
1135    }
1136
1137    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1138    #[test]
1139    fn optimoptions_rejects_missing_option_values() {
1140        let err = run_optimoptions(vec![Value::from("fsolve"), Value::from("TolX")])
1141            .expect_err("missing option value");
1142        assert_eq!(
1143            err.identifier(),
1144            Some("RunMat:optimoptions:MissingOptionValue")
1145        );
1146    }
1147
1148    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1149    #[test]
1150    fn optimoptions_rejects_invalid_option_values() {
1151        let err = run_optimoptions(vec![
1152            Value::from("fsolve"),
1153            Value::from("MaxIter"),
1154            Value::Num(1.5),
1155        ])
1156        .expect_err("noninteger MaxIter should fail");
1157        assert_eq!(
1158            err.identifier(),
1159            Some("RunMat:optimoptions:InvalidOptionValue")
1160        );
1161    }
1162
1163    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1164    #[test]
1165    fn optimoptions_rejects_out_of_range_integer_options() {
1166        let err = run_optimoptions(vec![
1167            Value::from("fsolve"),
1168            Value::from("MaxIter"),
1169            Value::Num(2f64.powi(usize::BITS as i32)),
1170        ])
1171        .expect_err("out-of-range MaxIter should fail");
1172        assert_eq!(
1173            err.identifier(),
1174            Some("RunMat:optimoptions:InvalidOptionValue")
1175        );
1176    }
1177
1178    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1179    #[test]
1180    fn fminbnd_accepts_optimoptions_output() {
1181        let options = run_optimoptions(vec![
1182            Value::from("fminbnd"),
1183            Value::from("TolX"),
1184            Value::Num(1.0e-8),
1185            Value::from("Display"),
1186            Value::from("off"),
1187        ])
1188        .expect("optimoptions");
1189        let result = run_call_builtin(
1190            "fminbnd",
1191            &[
1192                Value::FunctionHandle("cos".into()),
1193                Value::Num(0.0),
1194                Value::Num(std::f64::consts::PI),
1195                options,
1196            ],
1197        )
1198        .expect("fminbnd");
1199        match result {
1200            Value::Num(value) => assert!((value - std::f64::consts::PI).abs() < 1.0e-4),
1201            other => panic!("unexpected fminbnd result {other:?}"),
1202        }
1203    }
1204
1205    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1206    #[test]
1207    fn fzero_accepts_optimoptions_output() {
1208        let options = run_optimoptions(vec![
1209            Value::from("fzero"),
1210            Value::from("TolX"),
1211            Value::Num(1.0e-8),
1212        ])
1213        .expect("optimoptions");
1214        let bracket = Tensor::new(vec![3.0, 4.0], vec![1, 2]).unwrap();
1215        let result = run_call_builtin(
1216            "fzero",
1217            &[
1218                Value::FunctionHandle("sin".into()),
1219                Value::Tensor(bracket),
1220                options,
1221            ],
1222        )
1223        .expect("fzero");
1224        match result {
1225            Value::Num(value) => assert!((value - std::f64::consts::PI).abs() < 1.0e-6),
1226            other => panic!("unexpected fzero result {other:?}"),
1227        }
1228    }
1229
1230    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1231    #[test]
1232    fn fsolve_accepts_optimoptions_output() {
1233        let options = run_optimoptions(vec![
1234            Value::from("fsolve"),
1235            Value::from("TolX"),
1236            Value::Num(1.0e-8),
1237            Value::from("TolFun"),
1238            Value::Num(1.0e-8),
1239        ])
1240        .expect("optimoptions");
1241        let result = run_call_builtin(
1242            "fsolve",
1243            &[
1244                Value::FunctionHandle("sin".into()),
1245                Value::Num(3.0),
1246                options,
1247            ],
1248        )
1249        .expect("fsolve");
1250        match result {
1251            Value::Num(value) => assert!((value - std::f64::consts::PI).abs() < 1.0e-6),
1252            other => panic!("unexpected fsolve result {other:?}"),
1253        }
1254    }
1255}