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, BuiltinExtensionDescriptor,
7    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
8    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
9    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
10    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
11    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
12};
13use runmat_macros::runtime_builtin;
14use runmat_value::{CharArray, LogicalArray, StructValue, Value};
15
16use crate::builtins::common::spec::{
17    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
18    ReductionNaN, ResidencyPolicy, ShapeRequirements,
19};
20use crate::builtins::common::tensor;
21use crate::builtins::math::optim::common::canonical_option_name;
22use crate::builtins::math::optim::type_resolvers::optim_options_type;
23use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
24
25const NAME: &str = "optimoptions";
26
27const INTEGER_OPTION_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
28    id: "optimoptions-integer-option",
29    mode: BuiltinExtensionMode::RunMatOnly,
30    description: "optimoptions with native-class integer option values is a RunMat extension",
31    error_identifier: Some("RunMat:compatibility:OptimoptionsIntegerOptionExtension"),
32};
33const RESIDENT_OPTION_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
34    id: "optimoptions-resident-option",
35    mode: BuiltinExtensionMode::RunMatOnly,
36    description: "optimoptions with explicit gpuArray option values is a RunMat extension",
37    error_identifier: Some("RunMat:compatibility:OptimoptionsResidentOptionExtension"),
38};
39pub const EXTENSIONS: [BuiltinExtensionDescriptor; 2] =
40    [INTEGER_OPTION_EXTENSION, RESIDENT_OPTION_EXTENSION];
41
42const INTEGER_FLOATING_OPTION_INPUT: [BuiltinIntegerInputCapability; 1] =
43    [BuiltinIntegerInputCapability {
44        name: "floating option value",
45        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
46        availability: BuiltinIntegerInputAvailability::RunMatOnly,
47        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
48        notes: "Optimization tolerances are documented in floating classes; typed integers are gated and must convert exactly.",
49    }];
50const INTEGER_STRUCTURAL_OPTION_INPUT: [BuiltinIntegerInputCapability; 1] =
51    [BuiltinIntegerInputCapability {
52        name: "count or logical option value",
53        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
54        availability: BuiltinIntegerInputAvailability::RunMatOnly,
55        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
56        notes: "Typed iteration counts and logical flags are RunMat-only option-builder inputs parsed exactly before normalized storage.",
57    }];
58pub const INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 2] = [
59    BuiltinIntegerCapabilityDescriptor { form: "options = optimoptions(___, floating_name, integer_value, ___)", inputs: &INTEGER_FLOATING_OPTION_INPUT, computation_domain: BuiltinIntegerComputationDomain::FloatingPoint, output_class: BuiltinIntegerOutputClassRule::FunctionSpecific, overflow: BuiltinIntegerOverflowRule::Error, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::StructuralParameter, notes: "RunMat's supported compatibility subset normalizes accepted integer tolerances to double only after an exact representability check." },
60    BuiltinIntegerCapabilityDescriptor { form: "options = optimoptions(___, structural_name, integer_value, ___)", inputs: &INTEGER_STRUCTURAL_OPTION_INPUT, computation_domain: BuiltinIntegerComputationDomain::Structural, output_class: BuiltinIntegerOutputClassRule::FunctionSpecific, overflow: BuiltinIntegerOverflowRule::Error, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::StructuralParameter, notes: "Counts use exact integer-to-usize parsing and 0/1 logical controls become logical fields; current RunMat returns a struct rather than MATLAB's options object." },
61];
62
63const OPTIMOPTIONS_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
64    name: "options",
65    ty: BuiltinParamType::Any,
66    arity: BuiltinParamArity::Required,
67    default: None,
68    description: "Options struct for optimization solvers.",
69}];
70
71const OPTIMOPTIONS_INPUTS_SOLVER: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
72    name: "solver",
73    ty: BuiltinParamType::StringScalar,
74    arity: BuiltinParamArity::Required,
75    default: None,
76    description:
77        "Solver name, such as coneprog, fminbnd, fminunc, fzero, fsolve, lsqcurvefit, or lsqnonlin.",
78}];
79
80const OPTIMOPTIONS_INPUTS_SOLVER_PAIRS: [BuiltinParamDescriptor; 3] = [
81    BuiltinParamDescriptor {
82        name: "solver",
83        ty: BuiltinParamType::StringScalar,
84        arity: BuiltinParamArity::Required,
85        default: None,
86        description:
87            "Solver name, such as coneprog, fminbnd, fminunc, fzero, fsolve, lsqcurvefit, or lsqnonlin.",
88    },
89    BuiltinParamDescriptor {
90        name: "name",
91        ty: BuiltinParamType::StringScalar,
92        arity: BuiltinParamArity::Optional,
93        default: None,
94        description: "Option field name.",
95    },
96    BuiltinParamDescriptor {
97        name: "value",
98        ty: BuiltinParamType::Any,
99        arity: BuiltinParamArity::Variadic,
100        default: None,
101        description: "Option value(s) and additional name/value pairs.",
102    },
103];
104
105const OPTIMOPTIONS_INPUTS_EXISTING_PAIRS: [BuiltinParamDescriptor; 3] = [
106    BuiltinParamDescriptor {
107        name: "oldopts",
108        ty: BuiltinParamType::Any,
109        arity: BuiltinParamArity::Required,
110        default: None,
111        description: "Existing options struct to update.",
112    },
113    BuiltinParamDescriptor {
114        name: "name",
115        ty: BuiltinParamType::StringScalar,
116        arity: BuiltinParamArity::Optional,
117        default: None,
118        description: "Option field name.",
119    },
120    BuiltinParamDescriptor {
121        name: "value",
122        ty: BuiltinParamType::Any,
123        arity: BuiltinParamArity::Variadic,
124        default: None,
125        description: "Option value(s), additional name/value pairs, or another options struct.",
126    },
127];
128
129const OPTIMOPTIONS_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
130    BuiltinSignatureDescriptor {
131        label: "options = optimoptions(solver)",
132        inputs: &OPTIMOPTIONS_INPUTS_SOLVER,
133        outputs: &OPTIMOPTIONS_OUTPUT,
134    },
135    BuiltinSignatureDescriptor {
136        label: "options = optimoptions(solver, name, value, ...)",
137        inputs: &OPTIMOPTIONS_INPUTS_SOLVER_PAIRS,
138        outputs: &OPTIMOPTIONS_OUTPUT,
139    },
140    BuiltinSignatureDescriptor {
141        label: "options = optimoptions(oldopts, name, value, ...)",
142        inputs: &OPTIMOPTIONS_INPUTS_EXISTING_PAIRS,
143        outputs: &OPTIMOPTIONS_OUTPUT,
144    },
145];
146
147const OPTIMOPTIONS_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
148    code: "RM.OPTIMOPTIONS.INVALID_ARGUMENT",
149    identifier: Some("RunMat:optimoptions:InvalidArgument"),
150    when: "Argument grammar does not match supported optimoptions forms.",
151    message: "optimoptions: invalid argument",
152};
153const OPTIMOPTIONS_ERROR_INVALID_SOLVER: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
154    code: "RM.OPTIMOPTIONS.INVALID_SOLVER",
155    identifier: Some("RunMat:optimoptions:InvalidSolver"),
156    when: "The solver argument is not one of the supported optimization builtins.",
157    message: "optimoptions: invalid solver",
158};
159const OPTIMOPTIONS_ERROR_INVALID_OPTION_NAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
160    code: "RM.OPTIMOPTIONS.INVALID_OPTION_NAME",
161    identifier: Some("RunMat:optimoptions:InvalidOptionName"),
162    when: "An option name is not a text scalar.",
163    message: "optimoptions: invalid option name",
164};
165const OPTIMOPTIONS_ERROR_MISSING_OPTION_VALUE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
166    code: "RM.OPTIMOPTIONS.MISSING_OPTION_VALUE",
167    identifier: Some("RunMat:optimoptions:MissingOptionValue"),
168    when: "A name-value option key is not followed by a value.",
169    message: "optimoptions: missing option value",
170};
171const OPTIMOPTIONS_ERROR_UNKNOWN_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
172    code: "RM.OPTIMOPTIONS.UNKNOWN_OPTION",
173    identifier: Some("RunMat:optimoptions:UnknownOption"),
174    when: "An option name is not supported by the selected solver.",
175    message: "optimoptions: unknown option",
176};
177const OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
178    code: "RM.OPTIMOPTIONS.INVALID_OPTION_VALUE",
179    identifier: Some("RunMat:optimoptions:InvalidOptionValue"),
180    when: "An option value fails type or domain validation.",
181    message: "optimoptions: invalid option value",
182};
183const OPTIMOPTIONS_ERROR_FLOW: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
184    code: "RM.OPTIMOPTIONS.FLOW",
185    identifier: Some("RunMat:optimoptions:Flow"),
186    when: "Nested flow fails while gathering input values.",
187    message: "optimoptions: flow failure",
188};
189
190const OPTIMOPTIONS_ERRORS: [BuiltinErrorDescriptor; 7] = [
191    OPTIMOPTIONS_ERROR_INVALID_ARGUMENT,
192    OPTIMOPTIONS_ERROR_INVALID_SOLVER,
193    OPTIMOPTIONS_ERROR_INVALID_OPTION_NAME,
194    OPTIMOPTIONS_ERROR_MISSING_OPTION_VALUE,
195    OPTIMOPTIONS_ERROR_UNKNOWN_OPTION,
196    OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
197    OPTIMOPTIONS_ERROR_FLOW,
198];
199
200pub const OPTIMOPTIONS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
201    signatures: &OPTIMOPTIONS_SIGNATURES,
202    output_mode: BuiltinOutputMode::Fixed,
203    completion_policy: BuiltinCompletionPolicy::Public,
204    errors: &OPTIMOPTIONS_ERRORS,
205};
206
207#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::optim::optimoptions")]
208pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
209    name: "optimoptions",
210    op_kind: GpuOpKind::Custom("optimization-options"),
211    supported_precisions: &[],
212    broadcast: BroadcastSemantics::None,
213    provider_hooks: &[],
214    constant_strategy: ConstantStrategy::InlineLiteral,
215    residency: ResidencyPolicy::GatherImmediately,
216    nan_mode: ReductionNaN::Include,
217    two_pass_threshold: None,
218    workgroup_size: None,
219    accepts_nan_mode: false,
220    notes: "Host metadata construction. gpuArray option values are gathered before validation.",
221};
222
223#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::optim::optimoptions")]
224pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
225    name: "optimoptions",
226    shape: ShapeRequirements::Any,
227    constant_strategy: ConstantStrategy::InlineLiteral,
228    elementwise: None,
229    reduction: None,
230    emits_nan: false,
231    notes: "Option struct construction is host metadata work and terminates fusion planning.",
232};
233
234#[runtime_builtin(
235    name = "optimoptions",
236    category = "math/optim",
237    summary = "Create or update a typed optimization options structure for coneprog, fminbnd, fminunc, fzero, fsolve, lsqcurvefit, and lsqnonlin.",
238    keywords = "optimoptions,options,TolX,TolFun,FunctionTolerance,StepTolerance,MaxIter,MaxFunEvals,Display,Algorithm,SpecifyObjectiveGradient,coneprog,lsqnonlin",
239    accel = "cpu",
240    type_resolver(optim_options_type),
241    descriptor(crate::builtins::math::optim::optimoptions::OPTIMOPTIONS_DESCRIPTOR),
242    extensions(crate::builtins::math::optim::optimoptions::EXTENSIONS),
243    integer_capabilities(crate::builtins::math::optim::optimoptions::INTEGER_CAPABILITIES),
244    builtin_path = "crate::builtins::math::optim::optimoptions"
245)]
246async fn optimoptions_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
247    ensure_optimoptions_extensions(&rest)?;
248    let mut gathered = Vec::with_capacity(rest.len());
249    for value in rest {
250        gathered.push(gather_if_needed_async(&value).await.map_err(|err| {
251            remap_optimoptions_flow(&OPTIMOPTIONS_ERROR_FLOW, err, |source| {
252                format!("optimoptions: {}", source.message())
253            })
254        })?);
255    }
256
257    let mut queue: VecDeque<Value> = gathered.into();
258    let first = queue.pop_front().ok_or_else(|| {
259        optimoptions_error_with(
260            &OPTIMOPTIONS_ERROR_INVALID_ARGUMENT,
261            "optimoptions: expected a solver name or options struct",
262        )
263    })?;
264
265    let mut solver;
266    let explicit_solver;
267    let mut options = match first {
268        Value::Struct(existing) => {
269            explicit_solver = false;
270            solver = solver_from_options(&existing)?;
271            canonicalize_existing_options(&existing, solver)?
272        }
273        other => {
274            explicit_solver = true;
275            solver = parse_solver(&other)?;
276            default_options(solver)
277        }
278    };
279
280    while let Some(arg) = queue.pop_front() {
281        match arg {
282            Value::Struct(existing) => {
283                if explicit_solver {
284                    let next_solver = solver_from_options(&existing)?;
285                    let skip_defaults_from = match next_solver {
286                        Solver::Generic => None,
287                        other => Some(other),
288                    };
289                    apply_struct_fields(
290                        &existing,
291                        &mut options,
292                        solver,
293                        false,
294                        skip_defaults_from,
295                    )?;
296                    options.insert("Solver", Value::from(solver.name()));
297                    continue;
298                } else {
299                    let next_solver = solver_from_options(&existing)?;
300                    let skip_defaults_from;
301                    if next_solver != Solver::Generic && next_solver != solver {
302                        options = if solver == Solver::Generic {
303                            merge_generic_into_defaults(&options, next_solver)?
304                        } else {
305                            default_options(next_solver)
306                        };
307                        solver = next_solver;
308                        skip_defaults_from = Some(next_solver);
309                    } else if next_solver != Solver::Generic {
310                        solver = next_solver;
311                        skip_defaults_from = Some(next_solver);
312                    } else {
313                        skip_defaults_from = None;
314                    }
315                    apply_struct_fields(&existing, &mut options, solver, true, skip_defaults_from)?;
316                    continue;
317                }
318            }
319            name_value => {
320                let name = expect_string_scalar(
321                    &name_value,
322                    "optimoptions: option names must be character vectors or string scalars",
323                    &OPTIMOPTIONS_ERROR_INVALID_OPTION_NAME,
324                )?;
325                let value = queue.pop_front().ok_or_else(|| {
326                    optimoptions_error_with(
327                        &OPTIMOPTIONS_ERROR_MISSING_OPTION_VALUE,
328                        format!("optimoptions: missing value for option '{name}'"),
329                    )
330                })?;
331                set_option_field(&mut options, solver, &name, &value)?;
332            }
333        }
334    }
335
336    Ok(Value::Struct(options))
337}
338
339fn ensure_optimoptions_extensions(args: &[Value]) -> BuiltinResult<()> {
340    for (index, value) in args.iter().enumerate() {
341        let is_payload = index > 0 || matches!(value, Value::Struct(_));
342        if !is_payload {
343            continue;
344        }
345        if crate::builtins::common::validation::value_contains_native_integer_class(value) {
346            crate::compatibility::ensure_builtin_extension_enabled(
347                &INTEGER_OPTION_EXTENSION,
348                NAME,
349            )?;
350        }
351        if crate::builtins::common::validation::value_contains_explicit_gpu(value) {
352            crate::compatibility::ensure_builtin_extension_enabled(
353                &RESIDENT_OPTION_EXTENSION,
354                NAME,
355            )?;
356        }
357    }
358    Ok(())
359}
360
361fn optimoptions_error_with(
362    error: &'static BuiltinErrorDescriptor,
363    message: impl Into<String>,
364) -> RuntimeError {
365    let mut builder = build_runtime_error(message).with_builtin(NAME);
366    if let Some(identifier) = error.identifier {
367        builder = builder.with_identifier(identifier);
368    }
369    builder.build()
370}
371
372fn remap_optimoptions_flow<F>(
373    error: &'static BuiltinErrorDescriptor,
374    err: RuntimeError,
375    message: F,
376) -> RuntimeError
377where
378    F: FnOnce(&RuntimeError) -> String,
379{
380    let mut builder = build_runtime_error(message(&err))
381        .with_builtin(NAME)
382        .with_source(err);
383    if let Some(identifier) = error.identifier {
384        builder = builder.with_identifier(identifier);
385    }
386    builder.build()
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390enum Solver {
391    Coneprog,
392    Fminbnd,
393    Fminunc,
394    Fzero,
395    Fsolve,
396    Lsqcurvefit,
397    Lsqnonlin,
398    Generic,
399}
400
401impl Solver {
402    fn name(self) -> &'static str {
403        match self {
404            Self::Coneprog => "coneprog",
405            Self::Fminbnd => "fminbnd",
406            Self::Fminunc => "fminunc",
407            Self::Fzero => "fzero",
408            Self::Fsolve => "fsolve",
409            Self::Lsqcurvefit => "lsqcurvefit",
410            Self::Lsqnonlin => "lsqnonlin",
411            Self::Generic => "",
412        }
413    }
414
415    fn default_display(self) -> &'static str {
416        match self {
417            Self::Fminbnd => "notify",
418            Self::Coneprog
419            | Self::Fminunc
420            | Self::Fzero
421            | Self::Fsolve
422            | Self::Lsqcurvefit
423            | Self::Lsqnonlin
424            | Self::Generic => "off",
425        }
426    }
427
428    fn accepts_tol_fun(self) -> bool {
429        matches!(
430            self,
431            Self::Coneprog
432                | Self::Fminunc
433                | Self::Fsolve
434                | Self::Lsqcurvefit
435                | Self::Lsqnonlin
436                | Self::Generic
437        )
438    }
439
440    fn accepts_option(self, canonical: &str) -> bool {
441        match canonical {
442            "TolX" | "MaxIter" | "MaxFunEvals" | "Display" => true,
443            "TolFun" => self.accepts_tol_fun(),
444            "Algorithm" => matches!(
445                self,
446                Self::Coneprog
447                    | Self::Fminunc
448                    | Self::Lsqcurvefit
449                    | Self::Lsqnonlin
450                    | Self::Generic
451            ),
452            "SpecifyObjectiveGradient" => matches!(self, Self::Fminunc | Self::Generic),
453            _ => false,
454        }
455    }
456
457    fn accepts_display(self, display: &str) -> bool {
458        match self {
459            Self::Fminbnd | Self::Fminunc | Self::Generic => {
460                matches!(display, "off" | "none" | "iter" | "notify" | "final")
461            }
462            Self::Coneprog | Self::Fzero | Self::Fsolve | Self::Lsqcurvefit | Self::Lsqnonlin => {
463                matches!(display, "off" | "none" | "iter" | "final")
464            }
465        }
466    }
467
468    fn accepts_algorithm(self, algorithm: &str) -> bool {
469        match self {
470            Self::Fminunc => matches!(algorithm, "quasi-newton" | "bfgs"),
471            Self::Coneprog => matches!(algorithm, "interior-point" | "interior-point-convex"),
472            Self::Lsqcurvefit | Self::Lsqnonlin | Self::Generic => {
473                matches!(
474                    algorithm,
475                    "quasi-newton" | "bfgs" | "levenberg-marquardt" | "trust-region-reflective"
476                )
477            }
478            _ => false,
479        }
480    }
481}
482
483fn parse_solver(value: &Value) -> BuiltinResult<Solver> {
484    let text = expect_string_scalar(
485        value,
486        "optimoptions: solver must be a character vector or string scalar",
487        &OPTIMOPTIONS_ERROR_INVALID_SOLVER,
488    )?;
489    parse_solver_name(&text)
490}
491
492fn parse_solver_name(text: &str) -> BuiltinResult<Solver> {
493    match text.trim().to_ascii_lowercase().as_str() {
494        "fminbnd" => Ok(Solver::Fminbnd),
495        "coneprog" => Ok(Solver::Coneprog),
496        "fminunc" => Ok(Solver::Fminunc),
497        "fzero" => Ok(Solver::Fzero),
498        "fsolve" => Ok(Solver::Fsolve),
499        "lsqcurvefit" => Ok(Solver::Lsqcurvefit),
500        "lsqnonlin" => Ok(Solver::Lsqnonlin),
501        other => Err(optimoptions_error_with(
502            &OPTIMOPTIONS_ERROR_INVALID_SOLVER,
503            format!("optimoptions: unsupported solver '{other}'"),
504        )),
505    }
506}
507
508fn solver_from_options(options: &StructValue) -> BuiltinResult<Solver> {
509    let Some(value) = lookup_case_insensitive(options, "Solver") else {
510        return Ok(Solver::Generic);
511    };
512    parse_solver(value)
513}
514
515fn default_options(solver: Solver) -> StructValue {
516    let mut out = StructValue::new();
517    if solver != Solver::Generic {
518        out.insert("Solver", Value::from(solver.name()));
519    }
520    match solver {
521        Solver::Coneprog => {
522            out.insert("Algorithm", Value::from("interior-point"));
523            out.insert("TolX", Value::Num(1.0e-7));
524            out.insert("TolFun", Value::Num(1.0e-7));
525            out.insert("MaxIter", Value::Num(200.0));
526            out.insert("MaxFunEvals", Value::Num(20000.0));
527            out.insert("Display", Value::from(solver.default_display()));
528        }
529        Solver::Fminbnd => {
530            out.insert("TolX", Value::Num(1.0e-4));
531            out.insert("MaxIter", Value::Num(500.0));
532            out.insert("MaxFunEvals", Value::Num(500.0));
533            out.insert("Display", Value::from(solver.default_display()));
534        }
535        Solver::Fminunc => {
536            out.insert("Algorithm", Value::from("quasi-newton"));
537            out.insert("TolX", Value::Num(1.0e-6));
538            out.insert("TolFun", Value::Num(1.0e-6));
539            out.insert("MaxIter", Value::Num(400.0));
540            out.insert("MaxFunEvals", Value::Num(40000.0));
541            out.insert("Display", Value::from(solver.default_display()));
542            out.insert("SpecifyObjectiveGradient", Value::Bool(false));
543        }
544        Solver::Fzero => {
545            out.insert("TolX", Value::Num(1.0e-6));
546            out.insert("MaxIter", Value::Num(400.0));
547            out.insert("MaxFunEvals", Value::Num(500.0));
548            out.insert("Display", Value::from(solver.default_display()));
549        }
550        Solver::Fsolve => {
551            out.insert("TolX", Value::Num(1.0e-6));
552            out.insert("TolFun", Value::Num(1.0e-6));
553            out.insert("MaxIter", Value::Num(400.0));
554            out.insert("MaxFunEvals", Value::Num(40000.0));
555            out.insert("Display", Value::from(solver.default_display()));
556        }
557        Solver::Lsqcurvefit => {
558            out.insert("Algorithm", Value::from("levenberg-marquardt"));
559            out.insert("TolX", Value::Num(1.0e-6));
560            out.insert("TolFun", Value::Num(1.0e-6));
561            out.insert("MaxIter", Value::Num(400.0));
562            out.insert("MaxFunEvals", Value::Num(40000.0));
563            out.insert("Display", Value::from(solver.default_display()));
564        }
565        Solver::Lsqnonlin => {
566            out.insert("Algorithm", Value::from("trust-region-reflective"));
567            out.insert("TolX", Value::Num(1.0e-6));
568            out.insert("TolFun", Value::Num(1.0e-6));
569            out.insert("MaxIter", Value::Num(400.0));
570            out.insert("MaxFunEvals", Value::Num(40000.0));
571            out.insert("Display", Value::from(solver.default_display()));
572        }
573        Solver::Generic => {}
574    }
575    out
576}
577
578fn canonicalize_existing_options(
579    existing: &StructValue,
580    solver: Solver,
581) -> BuiltinResult<StructValue> {
582    let mut out = if solver == Solver::Generic {
583        StructValue::new()
584    } else {
585        default_options(solver)
586    };
587    apply_struct_fields(existing, &mut out, solver, true, None)?;
588    Ok(out)
589}
590
591fn merge_generic_into_defaults(
592    generic: &StructValue,
593    solver: Solver,
594) -> BuiltinResult<StructValue> {
595    let mut out = default_options(solver);
596    for (key, value) in &generic.fields {
597        if key.eq_ignore_ascii_case("Solver") {
598            continue;
599        }
600        let canonical = canonical_option_name(key);
601        if !solver.accepts_option(&canonical) {
602            continue;
603        }
604        if canonical == "Display" && display_value(solver, value).is_err() {
605            continue;
606        }
607        set_option_field(&mut out, solver, key, value)?;
608    }
609    Ok(out)
610}
611
612fn apply_struct_fields(
613    source: &StructValue,
614    target: &mut StructValue,
615    solver: Solver,
616    copy_solver_field: bool,
617    skip_defaults_from: Option<Solver>,
618) -> BuiltinResult<()> {
619    let source_defaults = skip_defaults_from.map(default_options);
620    for (key, value) in &source.fields {
621        if key.eq_ignore_ascii_case("Solver") {
622            if !copy_solver_field {
623                continue;
624            }
625            let parsed = parse_solver(value)?;
626            target.insert("Solver", Value::from(parsed.name()));
627            continue;
628        }
629        let canonical = canonical_option_name(key);
630        if let Some(defaults) = &source_defaults {
631            if solver.accepts_option(&canonical)
632                && lookup_case_insensitive(defaults, &canonical).is_some_and(|default| {
633                    normalized_option_value(solver, &canonical, value)
634                        .is_ok_and(|normalized| default == &normalized)
635                })
636            {
637                continue;
638            }
639        }
640        set_option_field(target, solver, key, value)?;
641    }
642    Ok(())
643}
644
645fn set_option_field(
646    options: &mut StructValue,
647    solver: Solver,
648    name: &str,
649    value: &Value,
650) -> BuiltinResult<()> {
651    let canonical = canonical_option_name(name);
652    if !solver.accepts_option(&canonical) {
653        return Err(optimoptions_error_with(
654            &OPTIMOPTIONS_ERROR_UNKNOWN_OPTION,
655            format!(
656                "optimoptions: option '{}' is not supported for {}",
657                name,
658                solver_label(solver)
659            ),
660        ));
661    }
662
663    let value = normalized_option_value(solver, &canonical, value)?;
664    options.insert(canonical, value);
665    Ok(())
666}
667
668fn normalized_option_value(solver: Solver, canonical: &str, value: &Value) -> BuiltinResult<Value> {
669    match canonical {
670        "TolX" | "TolFun" => Ok(Value::Num(positive_finite_scalar(canonical, value)?)),
671        "MaxIter" | "MaxFunEvals" => {
672            Ok(Value::Num(positive_integer_scalar(canonical, value)? as f64))
673        }
674        "Display" => Ok(Value::from(display_value(solver, value)?)),
675        "Algorithm" => Ok(Value::from(algorithm_value(solver, value)?)),
676        "SpecifyObjectiveGradient" => Ok(Value::Bool(logical_value(canonical, value)?)),
677        _ => unreachable!("unsupported option passed accepts_option"),
678    }
679}
680
681fn solver_label(solver: Solver) -> &'static str {
682    match solver {
683        Solver::Generic => "optimization solvers",
684        _ => solver.name(),
685    }
686}
687
688fn positive_finite_scalar(field: &str, value: &Value) -> BuiltinResult<f64> {
689    let parsed = numeric_scalar(field, value)?;
690    if parsed > 0.0 {
691        Ok(parsed)
692    } else {
693        Err(optimoptions_error_with(
694            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
695            format!("optimoptions: option {field} must be a finite positive scalar"),
696        ))
697    }
698}
699
700fn positive_integer_scalar(field: &str, value: &Value) -> BuiltinResult<usize> {
701    if let Some(integer) = tensor::scalar_integer_value(value) {
702        if let Some(parsed) = integer.try_to_usize() {
703            if parsed == 0 {
704                return Err(optimoptions_error_with(
705                    &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
706                    format!("optimoptions: option {field} must be a finite positive scalar"),
707                ));
708            }
709            return Ok(parsed);
710        }
711        if integer.try_to_i64().is_some_and(|value| value < 0) {
712            return Err(optimoptions_error_with(
713                &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
714                format!("optimoptions: option {field} must be a finite positive scalar"),
715            ));
716        }
717        return Err(optimoptions_error_with(
718            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
719            format!("optimoptions: option {field} is too large"),
720        ));
721    }
722    let parsed = positive_finite_scalar(field, value)?;
723    if parsed.fract() != 0.0 {
724        return Err(optimoptions_error_with(
725            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
726            format!("optimoptions: option {field} must be an integer scalar"),
727        ));
728    }
729    if parsed >= 2f64.powi(usize::BITS as i32) {
730        return Err(optimoptions_error_with(
731            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
732            format!("optimoptions: option {field} is too large"),
733        ));
734    }
735    Ok(parsed as usize)
736}
737
738fn numeric_scalar(field: &str, value: &Value) -> BuiltinResult<f64> {
739    if crate::builtins::common::validation::value_contains_native_integer_class(value)
740        && !crate::builtins::common::validation::native_integer_value_is_exact_f64(value)
741    {
742        return Err(optimoptions_error_with(
743            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
744            format!("optimoptions: integer option {field} must be exactly representable as double"),
745        ));
746    }
747    let parsed = match value {
748        Value::Num(n) => *n,
749        Value::Int(i) => i.to_f64(),
750        Value::Tensor(tensor) if tensor::is_scalar_tensor(tensor) => {
751            tensor::tensor_value_f64(tensor, 0)
752        }
753        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => {
754            if data[0] == 0 {
755                0.0
756            } else {
757                1.0
758            }
759        }
760        other => {
761            return Err(optimoptions_error_with(
762                &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
763                format!("optimoptions: option {field} must be a numeric scalar, got {other:?}"),
764            ))
765        }
766    };
767    if parsed.is_finite() {
768        Ok(parsed)
769    } else {
770        Err(optimoptions_error_with(
771            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
772            format!("optimoptions: option {field} must be finite"),
773        ))
774    }
775}
776
777fn logical_value(field: &str, value: &Value) -> BuiltinResult<bool> {
778    if let Some(integer) = tensor::scalar_integer_value(value) {
779        return match integer.try_to_u64() {
780            Some(0) => Ok(false),
781            Some(1) => Ok(true),
782            _ => Err(optimoptions_error_with(
783                &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
784                format!("optimoptions: option {field} must be logical 0 or 1"),
785            )),
786        };
787    }
788    match value {
789        Value::Bool(flag) => Ok(*flag),
790        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => Ok(data[0] != 0),
791        Value::Num(n) => logical_from_number(field, *n),
792        Value::Tensor(tensor) if tensor::is_scalar_tensor(tensor) => {
793            logical_from_number(field, tensor::tensor_value_f64(tensor, 0))
794        }
795        Value::String(s) => logical_from_text(field, s),
796        Value::StringArray(sa) if sa.data.len() == 1 => logical_from_text(field, &sa.data[0]),
797        Value::CharArray(CharArray { data, rows: 1, .. }) => {
798            let text: String = data.iter().collect();
799            logical_from_text(field, &text)
800        }
801        other => Err(optimoptions_error_with(
802            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
803            format!("optimoptions: option {field} must be logical, got {other:?}"),
804        )),
805    }
806}
807
808fn logical_from_number(field: &str, value: f64) -> BuiltinResult<bool> {
809    if value == 0.0 {
810        Ok(false)
811    } else if value == 1.0 {
812        Ok(true)
813    } else {
814        Err(optimoptions_error_with(
815            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
816            format!("optimoptions: option {field} must be logical 0 or 1"),
817        ))
818    }
819}
820
821fn logical_from_text(field: &str, value: &str) -> BuiltinResult<bool> {
822    match value.trim().to_ascii_lowercase().as_str() {
823        "on" | "true" | "yes" => Ok(true),
824        "off" | "false" | "no" => Ok(false),
825        other => Err(optimoptions_error_with(
826            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
827            format!("optimoptions: option {field} must be 'on' or 'off', got '{other}'"),
828        )),
829    }
830}
831
832fn display_value(solver: Solver, value: &Value) -> BuiltinResult<String> {
833    let display = expect_string_scalar(
834        value,
835        "optimoptions: Display must be a character vector or string scalar",
836        &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
837    )?
838    .trim()
839    .to_ascii_lowercase();
840    if solver.accepts_display(&display) {
841        Ok(display)
842    } else {
843        Err(optimoptions_error_with(
844            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
845            format!(
846                "optimoptions: unsupported Display '{}' for {}",
847                display,
848                solver_label(solver)
849            ),
850        ))
851    }
852}
853
854fn algorithm_value(solver: Solver, value: &Value) -> BuiltinResult<String> {
855    let algorithm = expect_string_scalar(
856        value,
857        "optimoptions: Algorithm must be a character vector or string scalar",
858        &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
859    )?
860    .trim()
861    .to_ascii_lowercase();
862    if solver.accepts_algorithm(&algorithm) {
863        Ok(algorithm)
864    } else {
865        Err(optimoptions_error_with(
866            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
867            format!(
868                "optimoptions: unsupported Algorithm '{}' for {}",
869                algorithm,
870                solver_label(solver)
871            ),
872        ))
873    }
874}
875
876fn expect_string_scalar(
877    value: &Value,
878    context: &str,
879    error: &'static BuiltinErrorDescriptor,
880) -> BuiltinResult<String> {
881    match value {
882        Value::String(s) => Ok(s.clone()),
883        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
884        Value::CharArray(CharArray { data, rows: 1, .. }) => Ok(data.iter().collect()),
885        _ => Err(optimoptions_error_with(error, context)),
886    }
887}
888
889fn lookup_case_insensitive<'a>(options: &'a StructValue, name: &str) -> Option<&'a Value> {
890    options
891        .fields
892        .iter()
893        .find(|(key, _)| key.eq_ignore_ascii_case(name))
894        .map(|(_, value)| value)
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900    use crate::builtins::common::test_support;
901    use crate::call_builtin_async;
902    use futures::executor::block_on;
903    use runmat_accelerate_api::HostTensorView;
904    use runmat_value::{IntValue, IntegerStorage, Tensor};
905
906    fn run_optimoptions(rest: Vec<Value>) -> BuiltinResult<Value> {
907        block_on(optimoptions_builtin(rest))
908    }
909
910    fn run_call_builtin(name: &str, args: &[Value]) -> BuiltinResult<Value> {
911        block_on(call_builtin_async(name, args))
912    }
913
914    fn struct_result(value: Value) -> StructValue {
915        match value {
916            Value::Struct(options) => options,
917            other => panic!("expected struct, got {other:?}"),
918        }
919    }
920
921    fn num_field(options: &StructValue, field: &str) -> f64 {
922        match options.fields.get(field) {
923            Some(Value::Num(value)) => *value,
924            other => panic!("expected numeric field {field}, got {other:?}"),
925        }
926    }
927
928    fn string_field<'a>(options: &'a StructValue, field: &str) -> &'a str {
929        match options.fields.get(field) {
930            Some(Value::String(value)) => value.as_str(),
931            other => panic!("expected string field {field}, got {other:?}"),
932        }
933    }
934
935    fn bool_field(options: &StructValue, field: &str) -> bool {
936        match options.fields.get(field) {
937            Some(Value::Bool(value)) => *value,
938            other => panic!("expected bool field {field}, got {other:?}"),
939        }
940    }
941
942    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
943    #[test]
944    fn optimoptions_descriptor_signatures_and_errors_cover_core_forms() {
945        let labels: Vec<&str> = OPTIMOPTIONS_DESCRIPTOR
946            .signatures
947            .iter()
948            .map(|signature| signature.label)
949            .collect();
950        assert_eq!(
951            labels,
952            vec![
953                "options = optimoptions(solver)",
954                "options = optimoptions(solver, name, value, ...)",
955                "options = optimoptions(oldopts, name, value, ...)",
956            ]
957        );
958
959        let codes: Vec<&str> = OPTIMOPTIONS_DESCRIPTOR
960            .errors
961            .iter()
962            .map(|error| error.code)
963            .collect();
964        assert_eq!(
965            codes,
966            vec![
967                "RM.OPTIMOPTIONS.INVALID_ARGUMENT",
968                "RM.OPTIMOPTIONS.INVALID_SOLVER",
969                "RM.OPTIMOPTIONS.INVALID_OPTION_NAME",
970                "RM.OPTIMOPTIONS.MISSING_OPTION_VALUE",
971                "RM.OPTIMOPTIONS.UNKNOWN_OPTION",
972                "RM.OPTIMOPTIONS.INVALID_OPTION_VALUE",
973                "RM.OPTIMOPTIONS.FLOW",
974            ]
975        );
976    }
977
978    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
979    #[test]
980    fn optimoptions_fminbnd_defaults_match_solver() {
981        let options = struct_result(
982            run_optimoptions(vec![Value::from("fminbnd")]).expect("optimoptions fminbnd"),
983        );
984        assert_eq!(string_field(&options, "Solver"), "fminbnd");
985        assert_eq!(num_field(&options, "TolX"), 1.0e-4);
986        assert_eq!(num_field(&options, "MaxIter"), 500.0);
987        assert_eq!(num_field(&options, "MaxFunEvals"), 500.0);
988        assert_eq!(string_field(&options, "Display"), "notify");
989        assert!(!options.fields.contains_key("TolFun"));
990    }
991
992    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
993    #[test]
994    fn optimoptions_coneprog_defaults_match_solver() {
995        let options = struct_result(
996            run_optimoptions(vec![Value::from("coneprog")]).expect("optimoptions coneprog"),
997        );
998        assert_eq!(string_field(&options, "Solver"), "coneprog");
999        assert_eq!(string_field(&options, "Algorithm"), "interior-point");
1000        assert_eq!(num_field(&options, "TolX"), 1.0e-7);
1001        assert_eq!(num_field(&options, "TolFun"), 1.0e-7);
1002        assert_eq!(num_field(&options, "MaxIter"), 200.0);
1003    }
1004
1005    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1006    #[test]
1007    fn optimoptions_fzero_defaults_match_solver() {
1008        let options = struct_result(
1009            run_optimoptions(vec![Value::from("fzero")]).expect("optimoptions fzero"),
1010        );
1011        assert_eq!(string_field(&options, "Solver"), "fzero");
1012        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
1013        assert_eq!(num_field(&options, "MaxIter"), 400.0);
1014        assert_eq!(num_field(&options, "MaxFunEvals"), 500.0);
1015        assert_eq!(string_field(&options, "Display"), "off");
1016        assert!(!options.fields.contains_key("TolFun"));
1017    }
1018
1019    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1020    #[test]
1021    fn optimoptions_fsolve_defaults_match_solver() {
1022        let options = struct_result(
1023            run_optimoptions(vec![Value::from("fsolve")]).expect("optimoptions fsolve"),
1024        );
1025        assert_eq!(string_field(&options, "Solver"), "fsolve");
1026        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
1027        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
1028        assert_eq!(num_field(&options, "MaxIter"), 400.0);
1029        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
1030        assert_eq!(string_field(&options, "Display"), "off");
1031    }
1032
1033    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1034    #[test]
1035    fn optimoptions_fminunc_defaults_match_solver() {
1036        let options = struct_result(
1037            run_optimoptions(vec![Value::from("fminunc")]).expect("optimoptions fminunc"),
1038        );
1039        assert_eq!(string_field(&options, "Solver"), "fminunc");
1040        assert_eq!(string_field(&options, "Algorithm"), "quasi-newton");
1041        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
1042        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
1043        assert_eq!(num_field(&options, "MaxIter"), 400.0);
1044        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
1045        assert_eq!(string_field(&options, "Display"), "off");
1046        assert!(!bool_field(&options, "SpecifyObjectiveGradient"));
1047    }
1048
1049    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1050    #[test]
1051    fn optimoptions_fminunc_accepts_gradient_and_algorithm_options() {
1052        let options = struct_result(
1053            run_optimoptions(vec![
1054                Value::from("fminunc"),
1055                Value::from("SpecifyObjectiveGradient"),
1056                Value::from("on"),
1057                Value::from("Algorithm"),
1058                Value::from("bfgs"),
1059                Value::from("Display"),
1060                Value::from("notify"),
1061            ])
1062            .expect("optimoptions fminunc"),
1063        );
1064        assert!(bool_field(&options, "SpecifyObjectiveGradient"));
1065        assert_eq!(string_field(&options, "Algorithm"), "bfgs");
1066        assert_eq!(string_field(&options, "Display"), "notify");
1067    }
1068
1069    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1070    #[test]
1071    fn optimoptions_lsqcurvefit_defaults_match_solver() {
1072        let options = struct_result(
1073            run_optimoptions(vec![Value::from("lsqcurvefit")]).expect("optimoptions lsqcurvefit"),
1074        );
1075        assert_eq!(string_field(&options, "Solver"), "lsqcurvefit");
1076        assert_eq!(string_field(&options, "Algorithm"), "levenberg-marquardt");
1077        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
1078        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
1079        assert_eq!(num_field(&options, "MaxIter"), 400.0);
1080        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
1081        assert_eq!(string_field(&options, "Display"), "off");
1082    }
1083
1084    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1085    #[test]
1086    fn optimoptions_lsqcurvefit_accepts_modern_tolerance_aliases_and_algorithm() {
1087        let options = struct_result(
1088            run_optimoptions(vec![
1089                Value::from("lsqcurvefit"),
1090                Value::from("FunctionTolerance"),
1091                Value::Num(1.0e-9),
1092                Value::from("StepTolerance"),
1093                Value::Num(1.0e-8),
1094                Value::from("Algorithm"),
1095                Value::from("trust-region-reflective"),
1096            ])
1097            .expect("optimoptions lsqcurvefit aliases"),
1098        );
1099        assert_eq!(num_field(&options, "TolFun"), 1.0e-9);
1100        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1101        assert_eq!(
1102            string_field(&options, "Algorithm"),
1103            "trust-region-reflective"
1104        );
1105    }
1106
1107    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1108    #[test]
1109    fn optimoptions_lsqnonlin_defaults_and_aliases_match_solver() {
1110        let options = struct_result(
1111            run_optimoptions(vec![
1112                Value::from("lsqnonlin"),
1113                Value::from("FunctionTolerance"),
1114                Value::Num(1.0e-9),
1115                Value::from("StepTolerance"),
1116                Value::Num(1.0e-8),
1117                Value::from("Algorithm"),
1118                Value::from("levenberg-marquardt"),
1119            ])
1120            .expect("optimoptions lsqnonlin"),
1121        );
1122        assert_eq!(string_field(&options, "Solver"), "lsqnonlin");
1123        assert_eq!(string_field(&options, "Algorithm"), "levenberg-marquardt");
1124        assert_eq!(num_field(&options, "TolFun"), 1.0e-9);
1125        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1126        assert_eq!(num_field(&options, "MaxIter"), 400.0);
1127        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
1128        assert_eq!(string_field(&options, "Display"), "off");
1129    }
1130
1131    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1132    #[test]
1133    fn optimoptions_name_value_pairs_are_case_insensitive() {
1134        let options = struct_result(
1135            run_optimoptions(vec![
1136                Value::from("fsolve"),
1137                Value::from("tolx"),
1138                Value::Num(1.0e-8),
1139                Value::from("DISPLAY"),
1140                Value::from("Final"),
1141            ])
1142            .expect("optimoptions overrides"),
1143        );
1144        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1145        assert_eq!(string_field(&options, "Display"), "final");
1146    }
1147
1148    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1149    #[test]
1150    fn optimoptions_updates_existing_options_with_pairs() {
1151        let base = run_optimoptions(vec![
1152            Value::from("fzero"),
1153            Value::from("TolX"),
1154            Value::Num(1.0e-5),
1155        ])
1156        .expect("base options");
1157        let options = struct_result(
1158            run_optimoptions(vec![base, Value::from("MaxIter"), Value::Num(25.0)])
1159                .expect("updated options"),
1160        );
1161        assert_eq!(string_field(&options, "Solver"), "fzero");
1162        assert_eq!(num_field(&options, "TolX"), 1.0e-5);
1163        assert_eq!(num_field(&options, "MaxIter"), 25.0);
1164    }
1165
1166    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1167    #[test]
1168    fn optimoptions_merges_existing_options_structs() {
1169        let first = run_optimoptions(vec![
1170            Value::from("fsolve"),
1171            Value::from("TolX"),
1172            Value::Num(1.0e-5),
1173        ])
1174        .expect("first");
1175        let second = run_optimoptions(vec![
1176            Value::from("fsolve"),
1177            Value::from("TolX"),
1178            Value::Num(1.0e-8),
1179            Value::from("MaxIter"),
1180            Value::Num(30.0),
1181        ])
1182        .expect("second");
1183        let options = struct_result(run_optimoptions(vec![first, second]).expect("merged options"));
1184        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1185        assert_eq!(num_field(&options, "MaxIter"), 30.0);
1186    }
1187
1188    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1189    #[test]
1190    fn optimoptions_same_solver_struct_merge_preserves_prior_overrides() {
1191        let first = run_optimoptions(vec![
1192            Value::from("fsolve"),
1193            Value::from("MaxFunEvals"),
1194            Value::Num(2000.0),
1195        ])
1196        .expect("first");
1197        let second = run_optimoptions(vec![
1198            Value::from("fsolve"),
1199            Value::from("TolX"),
1200            Value::Num(1.0e-8),
1201        ])
1202        .expect("second");
1203
1204        let options = struct_result(run_optimoptions(vec![first, second]).expect("merged options"));
1205
1206        assert_eq!(string_field(&options, "Solver"), "fsolve");
1207        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1208        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
1209    }
1210
1211    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1212    #[test]
1213    fn optimoptions_solver_form_same_solver_struct_preserves_prior_overrides() {
1214        let later = run_optimoptions(vec![
1215            Value::from("fsolve"),
1216            Value::from("TolX"),
1217            Value::Num(1.0e-8),
1218        ])
1219        .expect("later options");
1220
1221        let options = struct_result(
1222            run_optimoptions(vec![
1223                Value::from("fsolve"),
1224                Value::from("MaxFunEvals"),
1225                Value::Num(2000.0),
1226                later,
1227            ])
1228            .expect("merged options"),
1229        );
1230
1231        assert_eq!(string_field(&options, "Solver"), "fsolve");
1232        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1233        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
1234    }
1235
1236    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1237    #[test]
1238    fn optimoptions_default_skipping_compares_normalized_values() {
1239        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1240        let first = run_optimoptions(vec![
1241            Value::from("fsolve"),
1242            Value::from("MaxFunEvals"),
1243            Value::Num(2000.0),
1244            Value::from("Display"),
1245            Value::from("final"),
1246        ])
1247        .expect("first");
1248
1249        let mut later = StructValue::new();
1250        later.insert("Solver", Value::from("fsolve"));
1251        later.insert("TolX", Value::Num(1.0e-8));
1252        later.insert("MaxFunEvals", Value::Int(IntValue::I32(40000)));
1253        later.insert("Display", Value::CharArray(CharArray::new_row("off")));
1254
1255        let options = struct_result(
1256            run_optimoptions(vec![first, Value::Struct(later)]).expect("merged options"),
1257        );
1258
1259        assert_eq!(string_field(&options, "Solver"), "fsolve");
1260        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1261        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
1262        assert_eq!(string_field(&options, "Display"), "final");
1263    }
1264
1265    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1266    #[test]
1267    fn optimoptions_generic_to_concrete_solver_preserves_valid_generic_overrides() {
1268        let mut generic = StructValue::new();
1269        generic.insert("MaxFunEvals", Value::Num(2000.0));
1270        generic.insert("Display", Value::from("final"));
1271
1272        let later = run_optimoptions(vec![
1273            Value::from("fsolve"),
1274            Value::from("TolX"),
1275            Value::Num(1.0e-8),
1276        ])
1277        .expect("later options");
1278
1279        let options = struct_result(
1280            run_optimoptions(vec![Value::Struct(generic), later]).expect("merged options"),
1281        );
1282
1283        assert_eq!(string_field(&options, "Solver"), "fsolve");
1284        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1285        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
1286        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
1287        assert_eq!(string_field(&options, "Display"), "final");
1288    }
1289
1290    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1291    #[test]
1292    fn optimoptions_solver_form_keeps_requested_solver_when_struct_has_solver() {
1293        let fzero_options = run_optimoptions(vec![
1294            Value::from("fzero"),
1295            Value::from("TolX"),
1296            Value::Num(1.0e-8),
1297            Value::from("MaxIter"),
1298            Value::Num(30.0),
1299        ])
1300        .expect("fzero options");
1301
1302        let options = struct_result(
1303            run_optimoptions(vec![Value::from("fsolve"), fzero_options])
1304                .expect("merged into fsolve options"),
1305        );
1306
1307        assert_eq!(string_field(&options, "Solver"), "fsolve");
1308        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
1309        assert_eq!(num_field(&options, "MaxIter"), 30.0);
1310        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
1311        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
1312    }
1313
1314    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1315    #[test]
1316    fn optimoptions_rejects_unknown_option_names() {
1317        let err = run_optimoptions(vec![
1318            Value::from("fzero"),
1319            Value::from("TolFun"),
1320            Value::Num(1.0e-8),
1321        ])
1322        .expect_err("TolFun is not accepted by fzero");
1323        assert_eq!(err.identifier(), Some("RunMat:optimoptions:UnknownOption"));
1324    }
1325
1326    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1327    #[test]
1328    fn optimoptions_rejects_missing_option_values() {
1329        let err = run_optimoptions(vec![Value::from("fsolve"), Value::from("TolX")])
1330            .expect_err("missing option value");
1331        assert_eq!(
1332            err.identifier(),
1333            Some("RunMat:optimoptions:MissingOptionValue")
1334        );
1335    }
1336
1337    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1338    #[test]
1339    fn optimoptions_rejects_invalid_option_values() {
1340        let err = run_optimoptions(vec![
1341            Value::from("fsolve"),
1342            Value::from("MaxIter"),
1343            Value::Num(1.5),
1344        ])
1345        .expect_err("noninteger MaxIter should fail");
1346        assert_eq!(
1347            err.identifier(),
1348            Some("RunMat:optimoptions:InvalidOptionValue")
1349        );
1350    }
1351
1352    #[test]
1353    fn optimoptions_numeric_options_read_typed_integer_storage_exactly() {
1354        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1355        let max_iter =
1356            Tensor::new_integer(IntegerStorage::U16(vec![5]), vec![1, 1]).expect("MaxIter");
1357
1358        let options = struct_result(
1359            run_optimoptions(vec![
1360                Value::from("fsolve"),
1361                Value::from("MaxIter"),
1362                Value::Tensor(max_iter),
1363            ])
1364            .expect("optimoptions"),
1365        );
1366        assert_eq!(num_field(&options, "MaxIter"), 5.0);
1367    }
1368
1369    #[test]
1370    fn optimoptions_strict_mode_rejects_typed_integer_option_before_normalization() {
1371        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1372        let max_iter =
1373            Tensor::new_integer(IntegerStorage::U16(vec![5]), vec![1, 1]).expect("MaxIter");
1374
1375        let error = run_optimoptions(vec![
1376            Value::from("fsolve"),
1377            Value::from("MaxIter"),
1378            Value::Tensor(max_iter),
1379        ])
1380        .expect_err("typed integer option is a RunMat-only extension");
1381
1382        assert_eq!(
1383            error.identifier(),
1384            INTEGER_OPTION_EXTENSION.error_identifier
1385        );
1386    }
1387
1388    #[test]
1389    fn optimoptions_rejects_wide_integer_tolerance_before_float_conversion() {
1390        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1391        let tolerance =
1392            Tensor::new_integer(IntegerStorage::U64(vec![(1_u64 << 53) + 1]), vec![1, 1])
1393                .expect("TolX");
1394
1395        let error = run_optimoptions(vec![
1396            Value::from("fsolve"),
1397            Value::from("TolX"),
1398            Value::Tensor(tolerance),
1399        ])
1400        .expect_err("wide integer tolerance cannot cross exactly");
1401
1402        assert_eq!(
1403            error.identifier(),
1404            Some("RunMat:optimoptions:InvalidOptionValue")
1405        );
1406        assert!(error.message().contains("exactly representable"));
1407    }
1408
1409    #[test]
1410    fn optimoptions_automatic_resident_option_gathers_and_explicit_option_is_gated() {
1411        test_support::with_test_provider(|provider| {
1412            let values = [0.25];
1413            let shape = [1, 1];
1414            let automatic = provider
1415                .upload(&HostTensorView {
1416                    data: &values,
1417                    shape: &shape,
1418                })
1419                .expect("automatic upload");
1420            let automatic =
1421                automatic.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Automatic);
1422            let options = struct_result(
1423                run_optimoptions(vec![
1424                    Value::from("fsolve"),
1425                    Value::from("TolX"),
1426                    Value::GpuTensor(automatic),
1427                ])
1428                .expect("automatic option gathers"),
1429            );
1430            assert_eq!(num_field(&options, "TolX"), 0.25);
1431
1432            let explicit = provider
1433                .upload(&HostTensorView {
1434                    data: &values,
1435                    shape: &shape,
1436                })
1437                .expect("explicit upload");
1438            let explicit =
1439                explicit.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Explicit);
1440            let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1441            let error = run_optimoptions(vec![
1442                Value::from("fsolve"),
1443                Value::from("TolX"),
1444                Value::GpuTensor(explicit),
1445            ])
1446            .expect_err("explicit option is gated before gather");
1447            assert_eq!(
1448                error.identifier(),
1449                RESIDENT_OPTION_EXTENSION.error_identifier
1450            );
1451        });
1452    }
1453
1454    #[test]
1455    fn optimoptions_rejects_negative_typed_integer_options_exactly() {
1456        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1457        let max_iter =
1458            Tensor::new_integer(IntegerStorage::I16(vec![-1]), vec![1, 1]).expect("MaxIter");
1459
1460        let err = run_optimoptions(vec![
1461            Value::from("fsolve"),
1462            Value::from("MaxIter"),
1463            Value::Tensor(max_iter),
1464        ])
1465        .expect_err("negative MaxIter should fail");
1466        assert_eq!(
1467            err.identifier(),
1468            Some("RunMat:optimoptions:InvalidOptionValue")
1469        );
1470    }
1471
1472    #[test]
1473    fn optimoptions_logical_options_read_typed_integer_storage_exactly() {
1474        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1475        let gradient = Tensor::new_integer(IntegerStorage::U16(vec![1]), vec![1, 1])
1476            .expect("SpecifyObjectiveGradient");
1477
1478        let options = struct_result(
1479            run_optimoptions(vec![
1480                Value::from("fminunc"),
1481                Value::from("SpecifyObjectiveGradient"),
1482                Value::Tensor(gradient),
1483            ])
1484            .expect("optimoptions"),
1485        );
1486        assert_eq!(
1487            options.fields.get("SpecifyObjectiveGradient"),
1488            Some(&Value::Bool(true))
1489        );
1490    }
1491
1492    #[test]
1493    fn optimoptions_rejects_wide_typed_integer_logicals_despite_poisoned_mirror() {
1494        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1495        let gradient =
1496            Tensor::new_integer(IntegerStorage::U64(vec![9_007_199_254_740_993]), vec![1, 1])
1497                .expect("SpecifyObjectiveGradient");
1498
1499        let err = run_optimoptions(vec![
1500            Value::from("fminunc"),
1501            Value::from("SpecifyObjectiveGradient"),
1502            Value::Tensor(gradient),
1503        ])
1504        .expect_err("wide integer is not a logical scalar");
1505        assert_eq!(
1506            err.identifier(),
1507            Some("RunMat:optimoptions:InvalidOptionValue")
1508        );
1509    }
1510
1511    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1512    #[test]
1513    fn optimoptions_rejects_out_of_range_integer_options() {
1514        let err = run_optimoptions(vec![
1515            Value::from("fsolve"),
1516            Value::from("MaxIter"),
1517            Value::Num(2f64.powi(usize::BITS as i32)),
1518        ])
1519        .expect_err("out-of-range MaxIter should fail");
1520        assert_eq!(
1521            err.identifier(),
1522            Some("RunMat:optimoptions:InvalidOptionValue")
1523        );
1524    }
1525
1526    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1527    #[test]
1528    fn fminbnd_accepts_optimoptions_output() {
1529        let options = run_optimoptions(vec![
1530            Value::from("fminbnd"),
1531            Value::from("TolX"),
1532            Value::Num(1.0e-8),
1533            Value::from("Display"),
1534            Value::from("off"),
1535        ])
1536        .expect("optimoptions");
1537        let result = run_call_builtin(
1538            "fminbnd",
1539            &[
1540                Value::FunctionHandle("cos".into()),
1541                Value::Num(0.0),
1542                Value::Num(std::f64::consts::PI),
1543                options,
1544            ],
1545        )
1546        .expect("fminbnd");
1547        match result {
1548            Value::Num(value) => assert!((value - std::f64::consts::PI).abs() < 1.0e-4),
1549            other => panic!("unexpected fminbnd result {other:?}"),
1550        }
1551    }
1552
1553    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1554    #[test]
1555    fn fzero_accepts_optimoptions_output() {
1556        let options = run_optimoptions(vec![
1557            Value::from("fzero"),
1558            Value::from("TolX"),
1559            Value::Num(1.0e-8),
1560        ])
1561        .expect("optimoptions");
1562        let bracket = Tensor::new(vec![3.0, 4.0], vec![1, 2]).unwrap();
1563        let result = run_call_builtin(
1564            "fzero",
1565            &[
1566                Value::FunctionHandle("sin".into()),
1567                Value::Tensor(bracket),
1568                options,
1569            ],
1570        )
1571        .expect("fzero");
1572        match result {
1573            Value::Num(value) => assert!((value - std::f64::consts::PI).abs() < 1.0e-6),
1574            other => panic!("unexpected fzero result {other:?}"),
1575        }
1576    }
1577
1578    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1579    #[test]
1580    fn fsolve_accepts_optimoptions_output() {
1581        let options = run_optimoptions(vec![
1582            Value::from("fsolve"),
1583            Value::from("TolX"),
1584            Value::Num(1.0e-8),
1585            Value::from("TolFun"),
1586            Value::Num(1.0e-8),
1587        ])
1588        .expect("optimoptions");
1589        let result = run_call_builtin(
1590            "fsolve",
1591            &[
1592                Value::FunctionHandle("sin".into()),
1593                Value::Num(3.0),
1594                options,
1595            ],
1596        )
1597        .expect("fsolve");
1598        match result {
1599            Value::Num(value) => assert!((value - std::f64::consts::PI).abs() < 1.0e-6),
1600            other => panic!("unexpected fsolve result {other:?}"),
1601        }
1602    }
1603}