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