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, fzero, or fsolve.",
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, fzero, or fsolve.",
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, fzero, and fsolve.",
195    keywords = "optimoptions,options,TolX,TolFun,MaxIter,MaxFunEvals,Display",
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    Fzero,
325    Fsolve,
326    Generic,
327}
328
329impl Solver {
330    fn name(self) -> &'static str {
331        match self {
332            Self::Fminbnd => "fminbnd",
333            Self::Fzero => "fzero",
334            Self::Fsolve => "fsolve",
335            Self::Generic => "",
336        }
337    }
338
339    fn default_display(self) -> &'static str {
340        match self {
341            Self::Fminbnd => "notify",
342            Self::Fzero | Self::Fsolve | Self::Generic => "off",
343        }
344    }
345
346    fn accepts_tol_fun(self) -> bool {
347        matches!(self, Self::Fsolve | Self::Generic)
348    }
349
350    fn accepts_option(self, canonical: &str) -> bool {
351        match canonical {
352            "TolX" | "MaxIter" | "MaxFunEvals" | "Display" => true,
353            "TolFun" => self.accepts_tol_fun(),
354            _ => false,
355        }
356    }
357
358    fn accepts_display(self, display: &str) -> bool {
359        match self {
360            Self::Fminbnd | Self::Generic => {
361                matches!(display, "off" | "none" | "iter" | "notify" | "final")
362            }
363            Self::Fzero | Self::Fsolve => matches!(display, "off" | "none" | "iter" | "final"),
364        }
365    }
366}
367
368fn parse_solver(value: &Value) -> BuiltinResult<Solver> {
369    let text = expect_string_scalar(
370        value,
371        "optimoptions: solver must be a character vector or string scalar",
372        &OPTIMOPTIONS_ERROR_INVALID_SOLVER,
373    )?;
374    parse_solver_name(&text)
375}
376
377fn parse_solver_name(text: &str) -> BuiltinResult<Solver> {
378    match text.trim().to_ascii_lowercase().as_str() {
379        "fminbnd" => Ok(Solver::Fminbnd),
380        "fzero" => Ok(Solver::Fzero),
381        "fsolve" => Ok(Solver::Fsolve),
382        other => Err(optimoptions_error_with(
383            &OPTIMOPTIONS_ERROR_INVALID_SOLVER,
384            format!("optimoptions: unsupported solver '{other}'"),
385        )),
386    }
387}
388
389fn solver_from_options(options: &StructValue) -> BuiltinResult<Solver> {
390    let Some(value) = lookup_case_insensitive(options, "Solver") else {
391        return Ok(Solver::Generic);
392    };
393    parse_solver(value)
394}
395
396fn default_options(solver: Solver) -> StructValue {
397    let mut out = StructValue::new();
398    if solver != Solver::Generic {
399        out.insert("Solver", Value::from(solver.name()));
400    }
401    match solver {
402        Solver::Fminbnd => {
403            out.insert("TolX", Value::Num(1.0e-4));
404            out.insert("MaxIter", Value::Num(500.0));
405            out.insert("MaxFunEvals", Value::Num(500.0));
406            out.insert("Display", Value::from(solver.default_display()));
407        }
408        Solver::Fzero => {
409            out.insert("TolX", Value::Num(1.0e-6));
410            out.insert("MaxIter", Value::Num(400.0));
411            out.insert("MaxFunEvals", Value::Num(500.0));
412            out.insert("Display", Value::from(solver.default_display()));
413        }
414        Solver::Fsolve => {
415            out.insert("TolX", Value::Num(1.0e-6));
416            out.insert("TolFun", Value::Num(1.0e-6));
417            out.insert("MaxIter", Value::Num(400.0));
418            out.insert("MaxFunEvals", Value::Num(40000.0));
419            out.insert("Display", Value::from(solver.default_display()));
420        }
421        Solver::Generic => {}
422    }
423    out
424}
425
426fn canonicalize_existing_options(
427    existing: &StructValue,
428    solver: Solver,
429) -> BuiltinResult<StructValue> {
430    let mut out = if solver == Solver::Generic {
431        StructValue::new()
432    } else {
433        default_options(solver)
434    };
435    apply_struct_fields(existing, &mut out, solver, true, None)?;
436    Ok(out)
437}
438
439fn merge_generic_into_defaults(
440    generic: &StructValue,
441    solver: Solver,
442) -> BuiltinResult<StructValue> {
443    let mut out = default_options(solver);
444    for (key, value) in &generic.fields {
445        if key.eq_ignore_ascii_case("Solver") {
446            continue;
447        }
448        let canonical = canonical_option_name(key);
449        if !solver.accepts_option(&canonical) {
450            continue;
451        }
452        if canonical == "Display" && display_value(solver, value).is_err() {
453            continue;
454        }
455        set_option_field(&mut out, solver, key, value)?;
456    }
457    Ok(out)
458}
459
460fn apply_struct_fields(
461    source: &StructValue,
462    target: &mut StructValue,
463    solver: Solver,
464    copy_solver_field: bool,
465    skip_defaults_from: Option<Solver>,
466) -> BuiltinResult<()> {
467    let source_defaults = skip_defaults_from.map(default_options);
468    for (key, value) in &source.fields {
469        if key.eq_ignore_ascii_case("Solver") {
470            if !copy_solver_field {
471                continue;
472            }
473            let parsed = parse_solver(value)?;
474            target.insert("Solver", Value::from(parsed.name()));
475            continue;
476        }
477        let canonical = canonical_option_name(key);
478        if let Some(defaults) = &source_defaults {
479            if solver.accepts_option(&canonical)
480                && lookup_case_insensitive(defaults, &canonical).is_some_and(|default| {
481                    normalized_option_value(solver, &canonical, value)
482                        .is_ok_and(|normalized| default == &normalized)
483                })
484            {
485                continue;
486            }
487        }
488        set_option_field(target, solver, key, value)?;
489    }
490    Ok(())
491}
492
493fn set_option_field(
494    options: &mut StructValue,
495    solver: Solver,
496    name: &str,
497    value: &Value,
498) -> BuiltinResult<()> {
499    let canonical = canonical_option_name(name);
500    if !solver.accepts_option(&canonical) {
501        return Err(optimoptions_error_with(
502            &OPTIMOPTIONS_ERROR_UNKNOWN_OPTION,
503            format!(
504                "optimoptions: option '{}' is not supported for {}",
505                name,
506                solver_label(solver)
507            ),
508        ));
509    }
510
511    let value = normalized_option_value(solver, &canonical, value)?;
512    options.insert(canonical, value);
513    Ok(())
514}
515
516fn normalized_option_value(solver: Solver, canonical: &str, value: &Value) -> BuiltinResult<Value> {
517    match canonical {
518        "TolX" | "TolFun" => Ok(Value::Num(positive_finite_scalar(canonical, value)?)),
519        "MaxIter" | "MaxFunEvals" => {
520            Ok(Value::Num(positive_integer_scalar(canonical, value)? as f64))
521        }
522        "Display" => Ok(Value::from(display_value(solver, value)?)),
523        _ => unreachable!("unsupported option passed accepts_option"),
524    }
525}
526
527fn solver_label(solver: Solver) -> &'static str {
528    match solver {
529        Solver::Generic => "optimization solvers",
530        _ => solver.name(),
531    }
532}
533
534fn positive_finite_scalar(field: &str, value: &Value) -> BuiltinResult<f64> {
535    let parsed = numeric_scalar(field, value)?;
536    if parsed > 0.0 {
537        Ok(parsed)
538    } else {
539        Err(optimoptions_error_with(
540            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
541            format!("optimoptions: option {field} must be a finite positive scalar"),
542        ))
543    }
544}
545
546fn positive_integer_scalar(field: &str, value: &Value) -> BuiltinResult<usize> {
547    let parsed = positive_finite_scalar(field, value)?;
548    if parsed.fract() != 0.0 {
549        return Err(optimoptions_error_with(
550            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
551            format!("optimoptions: option {field} must be an integer scalar"),
552        ));
553    }
554    if parsed >= 2f64.powi(usize::BITS as i32) {
555        return Err(optimoptions_error_with(
556            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
557            format!("optimoptions: option {field} is too large"),
558        ));
559    }
560    Ok(parsed as usize)
561}
562
563fn numeric_scalar(field: &str, value: &Value) -> BuiltinResult<f64> {
564    let parsed = match value {
565        Value::Num(n) => *n,
566        Value::Int(i) => i.to_f64(),
567        Value::Tensor(Tensor { data, .. }) if data.len() == 1 => data[0],
568        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => {
569            if data[0] == 0 {
570                0.0
571            } else {
572                1.0
573            }
574        }
575        other => {
576            return Err(optimoptions_error_with(
577                &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
578                format!("optimoptions: option {field} must be a numeric scalar, got {other:?}"),
579            ))
580        }
581    };
582    if parsed.is_finite() {
583        Ok(parsed)
584    } else {
585        Err(optimoptions_error_with(
586            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
587            format!("optimoptions: option {field} must be finite"),
588        ))
589    }
590}
591
592fn display_value(solver: Solver, value: &Value) -> BuiltinResult<String> {
593    let display = expect_string_scalar(
594        value,
595        "optimoptions: Display must be a character vector or string scalar",
596        &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
597    )?
598    .trim()
599    .to_ascii_lowercase();
600    if solver.accepts_display(&display) {
601        Ok(display)
602    } else {
603        Err(optimoptions_error_with(
604            &OPTIMOPTIONS_ERROR_INVALID_OPTION_VALUE,
605            format!(
606                "optimoptions: unsupported Display '{}' for {}",
607                display,
608                solver_label(solver)
609            ),
610        ))
611    }
612}
613
614fn expect_string_scalar(
615    value: &Value,
616    context: &str,
617    error: &'static BuiltinErrorDescriptor,
618) -> BuiltinResult<String> {
619    match value {
620        Value::String(s) => Ok(s.clone()),
621        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
622        Value::CharArray(CharArray { data, rows: 1, .. }) => Ok(data.iter().collect()),
623        _ => Err(optimoptions_error_with(error, context)),
624    }
625}
626
627fn lookup_case_insensitive<'a>(options: &'a StructValue, name: &str) -> Option<&'a Value> {
628    options
629        .fields
630        .iter()
631        .find(|(key, _)| key.eq_ignore_ascii_case(name))
632        .map(|(_, value)| value)
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use crate::call_builtin_async;
639    use futures::executor::block_on;
640    use runmat_builtins::IntValue;
641
642    fn run_optimoptions(rest: Vec<Value>) -> BuiltinResult<Value> {
643        block_on(optimoptions_builtin(rest))
644    }
645
646    fn run_call_builtin(name: &str, args: &[Value]) -> BuiltinResult<Value> {
647        block_on(call_builtin_async(name, args))
648    }
649
650    fn struct_result(value: Value) -> StructValue {
651        match value {
652            Value::Struct(options) => options,
653            other => panic!("expected struct, got {other:?}"),
654        }
655    }
656
657    fn num_field(options: &StructValue, field: &str) -> f64 {
658        match options.fields.get(field) {
659            Some(Value::Num(value)) => *value,
660            other => panic!("expected numeric field {field}, got {other:?}"),
661        }
662    }
663
664    fn string_field<'a>(options: &'a StructValue, field: &str) -> &'a str {
665        match options.fields.get(field) {
666            Some(Value::String(value)) => value.as_str(),
667            other => panic!("expected string field {field}, got {other:?}"),
668        }
669    }
670
671    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
672    #[test]
673    fn optimoptions_descriptor_signatures_and_errors_cover_core_forms() {
674        let labels: Vec<&str> = OPTIMOPTIONS_DESCRIPTOR
675            .signatures
676            .iter()
677            .map(|signature| signature.label)
678            .collect();
679        assert_eq!(
680            labels,
681            vec![
682                "options = optimoptions(solver)",
683                "options = optimoptions(solver, name, value, ...)",
684                "options = optimoptions(oldopts, name, value, ...)",
685            ]
686        );
687
688        let codes: Vec<&str> = OPTIMOPTIONS_DESCRIPTOR
689            .errors
690            .iter()
691            .map(|error| error.code)
692            .collect();
693        assert_eq!(
694            codes,
695            vec![
696                "RM.OPTIMOPTIONS.INVALID_ARGUMENT",
697                "RM.OPTIMOPTIONS.INVALID_SOLVER",
698                "RM.OPTIMOPTIONS.INVALID_OPTION_NAME",
699                "RM.OPTIMOPTIONS.MISSING_OPTION_VALUE",
700                "RM.OPTIMOPTIONS.UNKNOWN_OPTION",
701                "RM.OPTIMOPTIONS.INVALID_OPTION_VALUE",
702                "RM.OPTIMOPTIONS.FLOW",
703            ]
704        );
705    }
706
707    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
708    #[test]
709    fn optimoptions_fminbnd_defaults_match_solver() {
710        let options = struct_result(
711            run_optimoptions(vec![Value::from("fminbnd")]).expect("optimoptions fminbnd"),
712        );
713        assert_eq!(string_field(&options, "Solver"), "fminbnd");
714        assert_eq!(num_field(&options, "TolX"), 1.0e-4);
715        assert_eq!(num_field(&options, "MaxIter"), 500.0);
716        assert_eq!(num_field(&options, "MaxFunEvals"), 500.0);
717        assert_eq!(string_field(&options, "Display"), "notify");
718        assert!(!options.fields.contains_key("TolFun"));
719    }
720
721    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
722    #[test]
723    fn optimoptions_fzero_defaults_match_solver() {
724        let options = struct_result(
725            run_optimoptions(vec![Value::from("fzero")]).expect("optimoptions fzero"),
726        );
727        assert_eq!(string_field(&options, "Solver"), "fzero");
728        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
729        assert_eq!(num_field(&options, "MaxIter"), 400.0);
730        assert_eq!(num_field(&options, "MaxFunEvals"), 500.0);
731        assert_eq!(string_field(&options, "Display"), "off");
732        assert!(!options.fields.contains_key("TolFun"));
733    }
734
735    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
736    #[test]
737    fn optimoptions_fsolve_defaults_match_solver() {
738        let options = struct_result(
739            run_optimoptions(vec![Value::from("fsolve")]).expect("optimoptions fsolve"),
740        );
741        assert_eq!(string_field(&options, "Solver"), "fsolve");
742        assert_eq!(num_field(&options, "TolX"), 1.0e-6);
743        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
744        assert_eq!(num_field(&options, "MaxIter"), 400.0);
745        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
746        assert_eq!(string_field(&options, "Display"), "off");
747    }
748
749    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
750    #[test]
751    fn optimoptions_name_value_pairs_are_case_insensitive() {
752        let options = struct_result(
753            run_optimoptions(vec![
754                Value::from("fsolve"),
755                Value::from("tolx"),
756                Value::Num(1.0e-8),
757                Value::from("DISPLAY"),
758                Value::from("Final"),
759            ])
760            .expect("optimoptions overrides"),
761        );
762        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
763        assert_eq!(string_field(&options, "Display"), "final");
764    }
765
766    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
767    #[test]
768    fn optimoptions_updates_existing_options_with_pairs() {
769        let base = run_optimoptions(vec![
770            Value::from("fzero"),
771            Value::from("TolX"),
772            Value::Num(1.0e-5),
773        ])
774        .expect("base options");
775        let options = struct_result(
776            run_optimoptions(vec![base, Value::from("MaxIter"), Value::Num(25.0)])
777                .expect("updated options"),
778        );
779        assert_eq!(string_field(&options, "Solver"), "fzero");
780        assert_eq!(num_field(&options, "TolX"), 1.0e-5);
781        assert_eq!(num_field(&options, "MaxIter"), 25.0);
782    }
783
784    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
785    #[test]
786    fn optimoptions_merges_existing_options_structs() {
787        let first = run_optimoptions(vec![
788            Value::from("fsolve"),
789            Value::from("TolX"),
790            Value::Num(1.0e-5),
791        ])
792        .expect("first");
793        let second = run_optimoptions(vec![
794            Value::from("fsolve"),
795            Value::from("TolX"),
796            Value::Num(1.0e-8),
797            Value::from("MaxIter"),
798            Value::Num(30.0),
799        ])
800        .expect("second");
801        let options = struct_result(run_optimoptions(vec![first, second]).expect("merged options"));
802        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
803        assert_eq!(num_field(&options, "MaxIter"), 30.0);
804    }
805
806    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
807    #[test]
808    fn optimoptions_same_solver_struct_merge_preserves_prior_overrides() {
809        let first = run_optimoptions(vec![
810            Value::from("fsolve"),
811            Value::from("MaxFunEvals"),
812            Value::Num(2000.0),
813        ])
814        .expect("first");
815        let second = run_optimoptions(vec![
816            Value::from("fsolve"),
817            Value::from("TolX"),
818            Value::Num(1.0e-8),
819        ])
820        .expect("second");
821
822        let options = struct_result(run_optimoptions(vec![first, second]).expect("merged options"));
823
824        assert_eq!(string_field(&options, "Solver"), "fsolve");
825        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
826        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
827    }
828
829    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
830    #[test]
831    fn optimoptions_solver_form_same_solver_struct_preserves_prior_overrides() {
832        let later = run_optimoptions(vec![
833            Value::from("fsolve"),
834            Value::from("TolX"),
835            Value::Num(1.0e-8),
836        ])
837        .expect("later options");
838
839        let options = struct_result(
840            run_optimoptions(vec![
841                Value::from("fsolve"),
842                Value::from("MaxFunEvals"),
843                Value::Num(2000.0),
844                later,
845            ])
846            .expect("merged options"),
847        );
848
849        assert_eq!(string_field(&options, "Solver"), "fsolve");
850        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
851        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
852    }
853
854    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
855    #[test]
856    fn optimoptions_default_skipping_compares_normalized_values() {
857        let first = run_optimoptions(vec![
858            Value::from("fsolve"),
859            Value::from("MaxFunEvals"),
860            Value::Num(2000.0),
861            Value::from("Display"),
862            Value::from("final"),
863        ])
864        .expect("first");
865
866        let mut later = StructValue::new();
867        later.insert("Solver", Value::from("fsolve"));
868        later.insert("TolX", Value::Num(1.0e-8));
869        later.insert("MaxFunEvals", Value::Int(IntValue::I32(40000)));
870        later.insert("Display", Value::CharArray(CharArray::new_row("off")));
871
872        let options = struct_result(
873            run_optimoptions(vec![first, Value::Struct(later)]).expect("merged options"),
874        );
875
876        assert_eq!(string_field(&options, "Solver"), "fsolve");
877        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
878        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
879        assert_eq!(string_field(&options, "Display"), "final");
880    }
881
882    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
883    #[test]
884    fn optimoptions_generic_to_concrete_solver_preserves_valid_generic_overrides() {
885        let mut generic = StructValue::new();
886        generic.insert("MaxFunEvals", Value::Num(2000.0));
887        generic.insert("Display", Value::from("final"));
888
889        let later = run_optimoptions(vec![
890            Value::from("fsolve"),
891            Value::from("TolX"),
892            Value::Num(1.0e-8),
893        ])
894        .expect("later options");
895
896        let options = struct_result(
897            run_optimoptions(vec![Value::Struct(generic), later]).expect("merged options"),
898        );
899
900        assert_eq!(string_field(&options, "Solver"), "fsolve");
901        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
902        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
903        assert_eq!(num_field(&options, "MaxFunEvals"), 2000.0);
904        assert_eq!(string_field(&options, "Display"), "final");
905    }
906
907    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
908    #[test]
909    fn optimoptions_solver_form_keeps_requested_solver_when_struct_has_solver() {
910        let fzero_options = run_optimoptions(vec![
911            Value::from("fzero"),
912            Value::from("TolX"),
913            Value::Num(1.0e-8),
914            Value::from("MaxIter"),
915            Value::Num(30.0),
916        ])
917        .expect("fzero options");
918
919        let options = struct_result(
920            run_optimoptions(vec![Value::from("fsolve"), fzero_options])
921                .expect("merged into fsolve options"),
922        );
923
924        assert_eq!(string_field(&options, "Solver"), "fsolve");
925        assert_eq!(num_field(&options, "TolX"), 1.0e-8);
926        assert_eq!(num_field(&options, "MaxIter"), 30.0);
927        assert_eq!(num_field(&options, "TolFun"), 1.0e-6);
928        assert_eq!(num_field(&options, "MaxFunEvals"), 40000.0);
929    }
930
931    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
932    #[test]
933    fn optimoptions_rejects_unknown_option_names() {
934        let err = run_optimoptions(vec![
935            Value::from("fzero"),
936            Value::from("TolFun"),
937            Value::Num(1.0e-8),
938        ])
939        .expect_err("TolFun is not accepted by fzero");
940        assert_eq!(err.identifier(), Some("RunMat:optimoptions:UnknownOption"));
941    }
942
943    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
944    #[test]
945    fn optimoptions_rejects_missing_option_values() {
946        let err = run_optimoptions(vec![Value::from("fsolve"), Value::from("TolX")])
947            .expect_err("missing option value");
948        assert_eq!(
949            err.identifier(),
950            Some("RunMat:optimoptions:MissingOptionValue")
951        );
952    }
953
954    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
955    #[test]
956    fn optimoptions_rejects_invalid_option_values() {
957        let err = run_optimoptions(vec![
958            Value::from("fsolve"),
959            Value::from("MaxIter"),
960            Value::Num(1.5),
961        ])
962        .expect_err("noninteger MaxIter should fail");
963        assert_eq!(
964            err.identifier(),
965            Some("RunMat:optimoptions:InvalidOptionValue")
966        );
967    }
968
969    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
970    #[test]
971    fn optimoptions_rejects_out_of_range_integer_options() {
972        let err = run_optimoptions(vec![
973            Value::from("fsolve"),
974            Value::from("MaxIter"),
975            Value::Num(2f64.powi(usize::BITS as i32)),
976        ])
977        .expect_err("out-of-range MaxIter should fail");
978        assert_eq!(
979            err.identifier(),
980            Some("RunMat:optimoptions:InvalidOptionValue")
981        );
982    }
983
984    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
985    #[test]
986    fn fminbnd_accepts_optimoptions_output() {
987        let options = run_optimoptions(vec![
988            Value::from("fminbnd"),
989            Value::from("TolX"),
990            Value::Num(1.0e-8),
991            Value::from("Display"),
992            Value::from("off"),
993        ])
994        .expect("optimoptions");
995        let result = run_call_builtin(
996            "fminbnd",
997            &[
998                Value::FunctionHandle("cos".into()),
999                Value::Num(0.0),
1000                Value::Num(std::f64::consts::PI),
1001                options,
1002            ],
1003        )
1004        .expect("fminbnd");
1005        match result {
1006            Value::Num(value) => assert!((value - std::f64::consts::PI).abs() < 1.0e-4),
1007            other => panic!("unexpected fminbnd result {other:?}"),
1008        }
1009    }
1010
1011    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1012    #[test]
1013    fn fzero_accepts_optimoptions_output() {
1014        let options = run_optimoptions(vec![
1015            Value::from("fzero"),
1016            Value::from("TolX"),
1017            Value::Num(1.0e-8),
1018        ])
1019        .expect("optimoptions");
1020        let bracket = Tensor::new(vec![3.0, 4.0], vec![1, 2]).unwrap();
1021        let result = run_call_builtin(
1022            "fzero",
1023            &[
1024                Value::FunctionHandle("sin".into()),
1025                Value::Tensor(bracket),
1026                options,
1027            ],
1028        )
1029        .expect("fzero");
1030        match result {
1031            Value::Num(value) => assert!((value - std::f64::consts::PI).abs() < 1.0e-6),
1032            other => panic!("unexpected fzero result {other:?}"),
1033        }
1034    }
1035
1036    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1037    #[test]
1038    fn fsolve_accepts_optimoptions_output() {
1039        let options = run_optimoptions(vec![
1040            Value::from("fsolve"),
1041            Value::from("TolX"),
1042            Value::Num(1.0e-8),
1043            Value::from("TolFun"),
1044            Value::Num(1.0e-8),
1045        ])
1046        .expect("optimoptions");
1047        let result = run_call_builtin(
1048            "fsolve",
1049            &[
1050                Value::FunctionHandle("sin".into()),
1051                Value::Num(3.0),
1052                options,
1053            ],
1054        )
1055        .expect("fsolve");
1056        match result {
1057            Value::Num(value) => assert!((value - std::f64::consts::PI).abs() < 1.0e-6),
1058            other => panic!("unexpected fsolve result {other:?}"),
1059        }
1060    }
1061}