1use runmat_builtins::{
4 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6 LogicalArray, StructValue, Tensor, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::spec::{
11 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
12 ReductionNaN, ResidencyPolicy, ShapeRequirements,
13};
14use crate::builtins::math::optim::common::{
15 call_function, initial_guess, lookup_option, option_f64, option_string, value_to_real_vector,
16 value_to_scalar, vector_to_value,
17};
18use crate::builtins::math::optim::type_resolvers::nonlinear_solve_type;
19use crate::{build_runtime_error, BuiltinResult, RuntimeError};
20
21const NAME: &str = "fminunc";
22const ALGORITHM: &str = "quasi-newton bfgs";
23const DEFAULT_TOL_X: f64 = 1.0e-6;
24const DEFAULT_TOL_FUN: f64 = 1.0e-6;
25const DEFAULT_MAX_ITER: usize = 400;
26const DEFAULT_MAX_FUN_EVALS: usize = 40000;
27const MAX_ITER_LIMIT: usize = 1_000_000;
28const MAX_FUN_EVAL_LIMIT: usize = 10_000_000;
29const MAX_DENSE_BFGS_VARIABLES: usize = 2048;
30const C1: f64 = 1.0e-4;
31const C2: f64 = 0.9;
32const MAX_LINE_SEARCH_ITERS: usize = 24;
33
34macro_rules! output_x {
35 () => {
36 BuiltinParamDescriptor {
37 name: "x",
38 ty: BuiltinParamType::NumericArray,
39 arity: BuiltinParamArity::Required,
40 default: None,
41 description: "Estimated local minimizer.",
42 }
43 };
44}
45
46macro_rules! output_fval {
47 () => {
48 BuiltinParamDescriptor {
49 name: "fval",
50 ty: BuiltinParamType::NumericScalar,
51 arity: BuiltinParamArity::Required,
52 default: None,
53 description: "Objective value at x.",
54 }
55 };
56}
57
58macro_rules! output_exitflag {
59 () => {
60 BuiltinParamDescriptor {
61 name: "exitflag",
62 ty: BuiltinParamType::NumericScalar,
63 arity: BuiltinParamArity::Required,
64 default: None,
65 description: "Convergence status code.",
66 }
67 };
68}
69
70macro_rules! output_output {
71 () => {
72 BuiltinParamDescriptor {
73 name: "output",
74 ty: BuiltinParamType::Any,
75 arity: BuiltinParamArity::Required,
76 default: None,
77 description: "Iteration/function-count metadata struct.",
78 }
79 };
80}
81
82macro_rules! output_grad {
83 () => {
84 BuiltinParamDescriptor {
85 name: "grad",
86 ty: BuiltinParamType::NumericArray,
87 arity: BuiltinParamArity::Required,
88 default: None,
89 description: "Gradient at x.",
90 }
91 };
92}
93
94macro_rules! output_hessian {
95 () => {
96 BuiltinParamDescriptor {
97 name: "hessian",
98 ty: BuiltinParamType::NumericArray,
99 arity: BuiltinParamArity::Required,
100 default: None,
101 description: "Final approximate Hessian matrix.",
102 }
103 };
104}
105
106const FMINUNC_OUTPUT_X: [BuiltinParamDescriptor; 1] = [output_x!()];
107
108const FMINUNC_OUTPUT_X_FVAL: [BuiltinParamDescriptor; 2] = [output_x!(), output_fval!()];
109
110const FMINUNC_OUTPUT_X_FVAL_EXITFLAG: [BuiltinParamDescriptor; 3] =
111 [output_x!(), output_fval!(), output_exitflag!()];
112
113const FMINUNC_OUTPUT_X_FVAL_EXITFLAG_OUTPUT: [BuiltinParamDescriptor; 4] = [
114 output_x!(),
115 output_fval!(),
116 output_exitflag!(),
117 output_output!(),
118];
119
120const FMINUNC_OUTPUT_X_FVAL_EXITFLAG_OUTPUT_GRAD: [BuiltinParamDescriptor; 5] = [
121 output_x!(),
122 output_fval!(),
123 output_exitflag!(),
124 output_output!(),
125 output_grad!(),
126];
127
128const FMINUNC_OUTPUT_ALL: [BuiltinParamDescriptor; 6] = [
129 output_x!(),
130 output_fval!(),
131 output_exitflag!(),
132 output_output!(),
133 output_grad!(),
134 output_hessian!(),
135];
136
137macro_rules! input_fun {
138 () => {
139 BuiltinParamDescriptor {
140 name: "fun",
141 ty: BuiltinParamType::Any,
142 arity: BuiltinParamArity::Required,
143 default: None,
144 description: "Scalar objective callback.",
145 }
146 };
147}
148
149macro_rules! input_x0 {
150 () => {
151 BuiltinParamDescriptor {
152 name: "x0",
153 ty: BuiltinParamType::Any,
154 arity: BuiltinParamArity::Required,
155 default: None,
156 description: "Initial guess scalar/vector/array.",
157 }
158 };
159}
160
161const FMINUNC_INPUTS_CORE: [BuiltinParamDescriptor; 2] = [input_fun!(), input_x0!()];
162
163const FMINUNC_INPUTS_WITH_OPTIONS: [BuiltinParamDescriptor; 3] = [
164 input_fun!(),
165 input_x0!(),
166 BuiltinParamDescriptor {
167 name: "options",
168 ty: BuiltinParamType::Any,
169 arity: BuiltinParamArity::Optional,
170 default: None,
171 description: "Options struct from optimoptions or optimset.",
172 },
173];
174
175const FMINUNC_SIGNATURES: [BuiltinSignatureDescriptor; 12] = [
176 BuiltinSignatureDescriptor {
177 label: "x = fminunc(fun, x0)",
178 inputs: &FMINUNC_INPUTS_CORE,
179 outputs: &FMINUNC_OUTPUT_X,
180 },
181 BuiltinSignatureDescriptor {
182 label: "x = fminunc(fun, x0, options)",
183 inputs: &FMINUNC_INPUTS_WITH_OPTIONS,
184 outputs: &FMINUNC_OUTPUT_X,
185 },
186 BuiltinSignatureDescriptor {
187 label: "[x, fval] = fminunc(fun, x0)",
188 inputs: &FMINUNC_INPUTS_CORE,
189 outputs: &FMINUNC_OUTPUT_X_FVAL,
190 },
191 BuiltinSignatureDescriptor {
192 label: "[x, fval] = fminunc(fun, x0, options)",
193 inputs: &FMINUNC_INPUTS_WITH_OPTIONS,
194 outputs: &FMINUNC_OUTPUT_X_FVAL,
195 },
196 BuiltinSignatureDescriptor {
197 label: "[x, fval, exitflag] = fminunc(fun, x0)",
198 inputs: &FMINUNC_INPUTS_CORE,
199 outputs: &FMINUNC_OUTPUT_X_FVAL_EXITFLAG,
200 },
201 BuiltinSignatureDescriptor {
202 label: "[x, fval, exitflag] = fminunc(fun, x0, options)",
203 inputs: &FMINUNC_INPUTS_WITH_OPTIONS,
204 outputs: &FMINUNC_OUTPUT_X_FVAL_EXITFLAG,
205 },
206 BuiltinSignatureDescriptor {
207 label: "[x, fval, exitflag, output] = fminunc(fun, x0)",
208 inputs: &FMINUNC_INPUTS_CORE,
209 outputs: &FMINUNC_OUTPUT_X_FVAL_EXITFLAG_OUTPUT,
210 },
211 BuiltinSignatureDescriptor {
212 label: "[x, fval, exitflag, output] = fminunc(fun, x0, options)",
213 inputs: &FMINUNC_INPUTS_WITH_OPTIONS,
214 outputs: &FMINUNC_OUTPUT_X_FVAL_EXITFLAG_OUTPUT,
215 },
216 BuiltinSignatureDescriptor {
217 label: "[x, fval, exitflag, output, grad] = fminunc(fun, x0)",
218 inputs: &FMINUNC_INPUTS_CORE,
219 outputs: &FMINUNC_OUTPUT_X_FVAL_EXITFLAG_OUTPUT_GRAD,
220 },
221 BuiltinSignatureDescriptor {
222 label: "[x, fval, exitflag, output, grad] = fminunc(fun, x0, options)",
223 inputs: &FMINUNC_INPUTS_WITH_OPTIONS,
224 outputs: &FMINUNC_OUTPUT_X_FVAL_EXITFLAG_OUTPUT_GRAD,
225 },
226 BuiltinSignatureDescriptor {
227 label: "[x, fval, exitflag, output, grad, hessian] = fminunc(fun, x0)",
228 inputs: &FMINUNC_INPUTS_CORE,
229 outputs: &FMINUNC_OUTPUT_ALL,
230 },
231 BuiltinSignatureDescriptor {
232 label: "[x, fval, exitflag, output, grad, hessian] = fminunc(fun, x0, options)",
233 inputs: &FMINUNC_INPUTS_WITH_OPTIONS,
234 outputs: &FMINUNC_OUTPUT_ALL,
235 },
236];
237
238const FMINUNC_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
239 code: "RM.FMINUNC.INVALID_ARGUMENT",
240 identifier: Some("RunMat:fminunc:InvalidArgument"),
241 when: "Argument grammar/options parsing is invalid.",
242 message: "fminunc: invalid argument",
243};
244
245const FMINUNC_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
246 code: "RM.FMINUNC.INVALID_INPUT",
247 identifier: Some("RunMat:fminunc:InvalidInput"),
248 when: "Initial guess/callback/iteration semantics are invalid.",
249 message: "fminunc: invalid input",
250};
251
252const FMINUNC_ERRORS: [BuiltinErrorDescriptor; 2] =
253 [FMINUNC_ERROR_INVALID_ARGUMENT, FMINUNC_ERROR_INVALID_INPUT];
254
255pub const FMINUNC_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
256 signatures: &FMINUNC_SIGNATURES,
257 output_mode: BuiltinOutputMode::ByRequestedOutputCount,
258 completion_policy: BuiltinCompletionPolicy::Public,
259 errors: &FMINUNC_ERRORS,
260};
261
262fn fminunc_error_with_detail(
263 error: &'static BuiltinErrorDescriptor,
264 detail: impl AsRef<str>,
265) -> RuntimeError {
266 let detail = detail.as_ref();
267 let message = if detail.starts_with("fminunc:") {
268 detail.to_string()
269 } else {
270 format!("{}: {detail}", error.message)
271 };
272 let mut builder = build_runtime_error(message).with_builtin(NAME);
273 if let Some(identifier) = error.identifier {
274 builder = builder.with_identifier(identifier);
275 }
276 builder.build()
277}
278
279fn fminunc_map_error(err: RuntimeError, fallback: &'static BuiltinErrorDescriptor) -> RuntimeError {
280 if err.identifier().is_some() {
281 err
282 } else {
283 fminunc_error_with_detail(fallback, err.message())
284 }
285}
286
287#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::optim::fminunc")]
288pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
289 name: "fminunc",
290 op_kind: GpuOpKind::Custom("unconstrained-min"),
291 supported_precisions: &[],
292 broadcast: BroadcastSemantics::None,
293 provider_hooks: &[],
294 constant_strategy: ConstantStrategy::InlineLiteral,
295 residency: ResidencyPolicy::GatherImmediately,
296 nan_mode: ReductionNaN::Include,
297 two_pass_threshold: None,
298 workgroup_size: None,
299 accepts_nan_mode: false,
300 notes: "Host BFGS solver with strong-Wolfe line search. Callback computations may use GPU-aware builtins, but optimizer state is gathered on the CPU.",
301};
302
303#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::optim::fminunc")]
304pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
305 name: "fminunc",
306 shape: ShapeRequirements::Any,
307 constant_strategy: ConstantStrategy::InlineLiteral,
308 elementwise: None,
309 reduction: None,
310 emits_nan: false,
311 notes: "Unconstrained minimization repeatedly invokes user callbacks and terminates fusion planning.",
312};
313
314#[runtime_builtin(
315 name = "fminunc",
316 category = "math/optim",
317 summary = "Find an unconstrained local minimum of a smooth scalar objective.",
318 keywords = "fminunc,unconstrained minimization,bfgs,quasi-newton,strong wolfe,optimization",
319 accel = "sink",
320 type_resolver(nonlinear_solve_type),
321 descriptor(crate::builtins::math::optim::fminunc::FMINUNC_DESCRIPTOR),
322 builtin_path = "crate::builtins::math::optim::fminunc"
323)]
324async fn fminunc_builtin(function: Value, x0: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
325 if rest.len() > 1 {
326 return Err(fminunc_error_with_detail(
327 &FMINUNC_ERROR_INVALID_ARGUMENT,
328 "too many input arguments",
329 ));
330 }
331 let options_struct = parse_options(rest.first())
332 .map_err(|err| fminunc_map_error(err, &FMINUNC_ERROR_INVALID_ARGUMENT))?;
333 let guess = initial_guess(NAME, x0)
334 .await
335 .map_err(|err| fminunc_map_error(err, &FMINUNC_ERROR_INVALID_INPUT))?;
336 let options = FminuncOptions::from_struct(options_struct.as_ref(), guess.values.len())
337 .map_err(|err| fminunc_map_error(err, &FMINUNC_ERROR_INVALID_ARGUMENT))?;
338 let outcome = minimize(
339 &function,
340 guess.values,
341 &guess.shape,
342 guess.scalar,
343 &options,
344 )
345 .await
346 .map_err(|err| fminunc_map_error(err, &FMINUNC_ERROR_INVALID_INPUT))?;
347 finalize(outcome, &guess.shape, guess.scalar, &options)
348}
349
350fn parse_options(value: Option<&Value>) -> BuiltinResult<Option<StructValue>> {
351 match value {
352 None => Ok(None),
353 Some(Value::Struct(options)) => Ok(Some(options.clone())),
354 Some(other) => Err(fminunc_error_with_detail(
355 &FMINUNC_ERROR_INVALID_ARGUMENT,
356 format!("options must be a struct, got {other:?}"),
357 )),
358 }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362enum DisplayMode {
363 Off,
364 Iter,
365 Notify,
366 Final,
367}
368
369impl DisplayMode {
370 fn parse(text: &str) -> BuiltinResult<Self> {
371 match text.to_ascii_lowercase().as_str() {
372 "off" | "none" => Ok(Self::Off),
373 "iter" => Ok(Self::Iter),
374 "notify" => Ok(Self::Notify),
375 "final" => Ok(Self::Final),
376 other => Err(fminunc_error_with_detail(
377 &FMINUNC_ERROR_INVALID_ARGUMENT,
378 format!(
379 "option Display must be 'off', 'iter', 'notify', or 'final', got '{other}'"
380 ),
381 )),
382 }
383 }
384}
385
386#[derive(Debug, Clone)]
387struct FminuncOptions {
388 tol_x: f64,
389 tol_fun: f64,
390 max_iter: usize,
391 max_fun_evals: usize,
392 display: DisplayMode,
393 specify_objective_gradient: bool,
394}
395
396impl FminuncOptions {
397 fn from_struct(options: Option<&StructValue>, variables: usize) -> BuiltinResult<Self> {
398 let display = DisplayMode::parse(&option_string(options, "Display", "off")?)?;
399 let algorithm = option_string(options, "Algorithm", "quasi-newton")?;
400 if !matches!(algorithm.as_str(), "quasi-newton" | "bfgs") {
401 return Err(fminunc_error_with_detail(
402 &FMINUNC_ERROR_INVALID_ARGUMENT,
403 "fminunc: only Algorithm='quasi-newton' is supported",
404 ));
405 }
406 let tol_x = option_f64(NAME, options, "TolX", DEFAULT_TOL_X)?;
407 let tol_fun = option_f64(NAME, options, "TolFun", DEFAULT_TOL_FUN)?;
408 if tol_x <= 0.0 || tol_fun <= 0.0 {
409 return Err(fminunc_error_with_detail(
410 &FMINUNC_ERROR_INVALID_ARGUMENT,
411 "options TolX and TolFun must be positive",
412 ));
413 }
414 let max_iter =
415 bounded_option_usize(options, "MaxIter", DEFAULT_MAX_ITER, MAX_ITER_LIMIT)?.max(1);
416 let max_fun_evals = bounded_option_usize(
417 options,
418 "MaxFunEvals",
419 DEFAULT_MAX_FUN_EVALS,
420 MAX_FUN_EVAL_LIMIT,
421 )?
422 .max(1);
423 let specify_objective_gradient = option_bool(options, "SpecifyObjectiveGradient", false)?;
424 validate_problem_size(variables)?;
425 Ok(Self {
426 tol_x,
427 tol_fun,
428 max_iter,
429 max_fun_evals,
430 display,
431 specify_objective_gradient,
432 })
433 }
434}
435
436fn bounded_option_usize(
437 options: Option<&StructValue>,
438 field: &str,
439 default: usize,
440 maximum: usize,
441) -> BuiltinResult<usize> {
442 let value = option_f64(NAME, options, field, default as f64)?;
443 if value < 0.0 {
444 return Err(fminunc_error_with_detail(
445 &FMINUNC_ERROR_INVALID_ARGUMENT,
446 format!("option {field} must be non-negative"),
447 ));
448 }
449 if value > maximum as f64 {
450 return Err(fminunc_error_with_detail(
451 &FMINUNC_ERROR_INVALID_ARGUMENT,
452 format!("option {field} must be no greater than {maximum}"),
453 ));
454 }
455 if value.fract() != 0.0 {
456 return Err(fminunc_error_with_detail(
457 &FMINUNC_ERROR_INVALID_ARGUMENT,
458 format!("option {field} must be an integer"),
459 ));
460 }
461 Ok(value.floor() as usize)
462}
463
464fn validate_problem_size(variables: usize) -> BuiltinResult<()> {
465 if variables > MAX_DENSE_BFGS_VARIABLES {
466 return Err(fminunc_error_with_detail(
467 &FMINUNC_ERROR_INVALID_INPUT,
468 format!(
469 "problem has {variables} variables; current dense BFGS implementation supports at most {MAX_DENSE_BFGS_VARIABLES}"
470 ),
471 ));
472 }
473 dense_len(variables)?;
474 Ok(())
475}
476
477fn option_bool(options: Option<&StructValue>, field: &str, default: bool) -> BuiltinResult<bool> {
478 let Some(options) = options else {
479 return Ok(default);
480 };
481 let Some(value) = lookup_option(options, field) else {
482 return Ok(default);
483 };
484 bool_value(field, value)
485}
486
487fn bool_value(field: &str, value: &Value) -> BuiltinResult<bool> {
488 match value {
489 Value::Bool(flag) => Ok(*flag),
490 Value::Num(n) => bool_from_number(field, *n),
491 Value::Int(i) => bool_from_number(field, i.to_f64()),
492 Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => Ok(data[0] != 0),
493 Value::Tensor(Tensor { data, .. }) if data.len() == 1 => bool_from_number(field, data[0]),
494 Value::String(s) => bool_from_text(field, s),
495 Value::StringArray(sa) if sa.data.len() == 1 => bool_from_text(field, &sa.data[0]),
496 Value::CharArray(chars) if chars.rows == 1 => {
497 let text: String = chars.data.iter().collect();
498 bool_from_text(field, &text)
499 }
500 other => Err(fminunc_error_with_detail(
501 &FMINUNC_ERROR_INVALID_ARGUMENT,
502 format!("option {field} must be logical, got {other:?}"),
503 )),
504 }
505}
506
507fn bool_from_number(field: &str, value: f64) -> BuiltinResult<bool> {
508 if !value.is_finite() {
509 return Err(fminunc_error_with_detail(
510 &FMINUNC_ERROR_INVALID_ARGUMENT,
511 format!("option {field} must be finite"),
512 ));
513 }
514 if value == 0.0 {
515 Ok(false)
516 } else if value == 1.0 {
517 Ok(true)
518 } else {
519 Err(fminunc_error_with_detail(
520 &FMINUNC_ERROR_INVALID_ARGUMENT,
521 format!("option {field} must be logical 0 or 1"),
522 ))
523 }
524}
525
526fn bool_from_text(field: &str, value: &str) -> BuiltinResult<bool> {
527 match value.trim().to_ascii_lowercase().as_str() {
528 "on" | "true" | "yes" => Ok(true),
529 "off" | "false" | "no" => Ok(false),
530 other => Err(fminunc_error_with_detail(
531 &FMINUNC_ERROR_INVALID_ARGUMENT,
532 format!("option {field} must be 'on' or 'off', got '{other}'"),
533 )),
534 }
535}
536
537#[derive(Debug, Clone)]
538struct ObjectiveState {
539 x: Vec<f64>,
540 f: f64,
541 grad: Vec<f64>,
542}
543
544#[derive(Debug, Clone)]
545struct MinimizeOutcome {
546 x: Vec<f64>,
547 fval: f64,
548 grad: Vec<f64>,
549 inverse_hessian: Vec<f64>,
550 iterations: usize,
551 func_count: usize,
552 exitflag: i32,
553 firstorderopt: f64,
554 stepsize: f64,
555 message: String,
556}
557
558struct ObjectiveEvaluator<'a> {
559 function: &'a Value,
560 shape: Vec<usize>,
561 scalar: bool,
562 specify_gradient: bool,
563 max_fun_evals: usize,
564 func_count: usize,
565}
566
567impl<'a> ObjectiveEvaluator<'a> {
568 fn new(
569 function: &'a Value,
570 shape: &[usize],
571 scalar: bool,
572 specify_gradient: bool,
573 max_fun_evals: usize,
574 ) -> Self {
575 Self {
576 function,
577 shape: shape.to_vec(),
578 scalar,
579 specify_gradient,
580 max_fun_evals,
581 func_count: 0,
582 }
583 }
584
585 async fn evaluate(&mut self, x: &[f64]) -> BuiltinResult<ObjectiveState> {
586 self.ensure_state_budget(x.len())?;
587 if self.specify_gradient {
588 self.evaluate_with_gradient(x).await
589 } else {
590 let f = self.evaluate_value(x).await?;
591 let grad = self.forward_difference_gradient(x, f).await?;
592 Ok(ObjectiveState {
593 x: x.to_vec(),
594 f,
595 grad,
596 })
597 }
598 }
599
600 async fn evaluate_value(&mut self, x: &[f64]) -> BuiltinResult<f64> {
601 self.ensure_value_budget()?;
602 let arg = vector_to_value(NAME, x.to_vec(), &self.shape, self.scalar)?;
603 let value = call_function(self.function, vec![arg]).await?;
604 let value = crate::dispatcher::gather_if_needed_async(&value).await?;
605 self.func_count += 1;
606 value_to_scalar(NAME, value)
607 }
608
609 async fn evaluate_with_gradient(&mut self, x: &[f64]) -> BuiltinResult<ObjectiveState> {
610 let callback =
611 crate::canonicalize_callback_handle_for_semantic_resolution(self.function.clone());
612 let arg = vector_to_value(NAME, x.to_vec(), &self.shape, self.scalar)?;
613 let value = crate::call_feval_async_with_outputs(callback, &[arg], 2).await?;
614 let value = crate::dispatcher::gather_if_needed_async(&value).await?;
615 self.func_count += 1;
616 let Value::OutputList(outputs) = value else {
617 return Err(fminunc_error_with_detail(
618 &FMINUNC_ERROR_INVALID_INPUT,
619 "objective must return [f, g] when SpecifyObjectiveGradient is true",
620 ));
621 };
622 if outputs.len() < 2 {
623 return Err(fminunc_error_with_detail(
624 &FMINUNC_ERROR_INVALID_INPUT,
625 "objective must return both objective value and gradient",
626 ));
627 }
628 let f = value_to_scalar(NAME, outputs[0].clone())?;
629 let grad = value_to_real_vector(NAME, outputs[1].clone()).await?;
630 if grad.len() != x.len() {
631 return Err(fminunc_error_with_detail(
632 &FMINUNC_ERROR_INVALID_INPUT,
633 format!(
634 "objective gradient length {} does not match x length {}",
635 grad.len(),
636 x.len()
637 ),
638 ));
639 }
640 Ok(ObjectiveState {
641 x: x.to_vec(),
642 f,
643 grad,
644 })
645 }
646
647 async fn forward_difference_gradient(&mut self, x: &[f64], f0: f64) -> BuiltinResult<Vec<f64>> {
648 let mut grad = vec![0.0; x.len()];
649 for i in 0..x.len() {
650 let step = finite_difference_step(x[i]);
651 let mut trial = x.to_vec();
652 trial[i] += step;
653 let f_step = self.evaluate_value(&trial).await?;
654 grad[i] = (f_step - f0) / step;
655 }
656 Ok(grad)
657 }
658
659 fn state_evaluation_cost(&self, variables: usize) -> Option<usize> {
660 if self.specify_gradient {
661 Some(1)
662 } else {
663 variables.checked_add(1)
664 }
665 }
666
667 fn remaining_func_evals(&self) -> usize {
668 self.max_fun_evals.saturating_sub(self.func_count)
669 }
670
671 fn can_evaluate_state(&self, variables: usize) -> bool {
672 self.state_evaluation_cost(variables)
673 .is_some_and(|cost| cost <= self.remaining_func_evals())
674 }
675
676 fn can_evaluate_value(&self) -> bool {
677 self.remaining_func_evals() >= 1
678 }
679
680 fn ensure_state_budget(&self, variables: usize) -> BuiltinResult<()> {
681 if self.can_evaluate_state(variables) {
682 return Ok(());
683 }
684 Err(fminunc_error_with_detail(
685 &FMINUNC_ERROR_INVALID_INPUT,
686 format!(
687 "MaxFunEvals exceeded before completing objective/gradient evaluation (FuncCount {}, MaxFunEvals {})",
688 self.func_count, self.max_fun_evals
689 ),
690 ))
691 }
692
693 fn ensure_value_budget(&self) -> BuiltinResult<()> {
694 if self.can_evaluate_value() {
695 return Ok(());
696 }
697 Err(fminunc_error_with_detail(
698 &FMINUNC_ERROR_INVALID_INPUT,
699 format!(
700 "MaxFunEvals exceeded before objective evaluation (FuncCount {}, MaxFunEvals {})",
701 self.func_count, self.max_fun_evals
702 ),
703 ))
704 }
705}
706
707fn finite_difference_step(x: f64) -> f64 {
708 f64::EPSILON.sqrt() * x.abs().max(1.0)
709}
710
711async fn minimize(
712 function: &Value,
713 x0: Vec<f64>,
714 shape: &[usize],
715 scalar: bool,
716 options: &FminuncOptions,
717) -> BuiltinResult<MinimizeOutcome> {
718 let n = x0.len();
719 validate_problem_size(n)?;
720 let mut evaluator = ObjectiveEvaluator::new(
721 function,
722 shape,
723 scalar,
724 options.specify_objective_gradient,
725 options.max_fun_evals,
726 );
727 let mut state = evaluator.evaluate(&x0).await?;
728 let mut inverse_hessian = identity(n)?;
729 let mut iterations = 0usize;
730 let mut stepsize = 0.0f64;
731 let mut exitflag = 0i32;
732 let mut line_search_failed = false;
733
734 if norm_inf(&state.grad) <= options.tol_fun {
735 exitflag = 1;
736 }
737
738 while exitflag == 0
739 && iterations < options.max_iter
740 && evaluator.func_count < options.max_fun_evals
741 {
742 let mut direction = mat_vec(&inverse_hessian, &state.grad, n);
743 for value in &mut direction {
744 *value = -*value;
745 }
746 if dot(&direction, &state.grad) >= -1.0e-14 || !all_finite(&direction) {
747 inverse_hessian = identity(n)?;
748 direction = state.grad.iter().map(|value| -value).collect();
749 }
750
751 let next = match line_search(&mut evaluator, &state, &direction).await? {
752 LineSearchResult::Step(next) => next,
753 LineSearchResult::BudgetExhausted => {
754 exitflag = 0;
755 break;
756 }
757 LineSearchResult::Failed => {
758 line_search_failed = true;
759 exitflag = -3;
760 break;
761 }
762 };
763
764 let s = subtract(&next.x, &state.x);
765 stepsize = norm_inf(&s);
766 let y = subtract(&next.grad, &state.grad);
767 update_inverse_hessian(&mut inverse_hessian, &s, &y, n);
768 let f_delta = (state.f - next.f).abs();
769 let x_scale = 1.0 + norm_inf(&state.x);
770 let f_scale = 1.0 + state.f.abs();
771 state = next;
772 iterations += 1;
773
774 if norm_inf(&state.grad) <= options.tol_fun {
775 exitflag = 1;
776 } else if stepsize <= options.tol_x * x_scale {
777 exitflag = 2;
778 } else if f_delta <= options.tol_fun * f_scale {
779 exitflag = 3;
780 }
781 }
782
783 let firstorderopt = norm_inf(&state.grad);
784 let message = build_message(
785 exitflag,
786 iterations,
787 evaluator.func_count,
788 line_search_failed,
789 );
790 emit_summary(
791 &state,
792 exitflag,
793 &message,
794 options,
795 iterations,
796 evaluator.func_count,
797 );
798
799 Ok(MinimizeOutcome {
800 x: state.x,
801 fval: state.f,
802 grad: state.grad,
803 inverse_hessian,
804 iterations,
805 func_count: evaluator.func_count,
806 exitflag,
807 firstorderopt,
808 stepsize,
809 message,
810 })
811}
812
813async fn line_search(
814 evaluator: &mut ObjectiveEvaluator<'_>,
815 state: &ObjectiveState,
816 direction: &[f64],
817) -> BuiltinResult<LineSearchResult> {
818 let dphi0 = dot(&state.grad, direction);
819 if dphi0 >= 0.0 || !dphi0.is_finite() {
820 return Ok(LineSearchResult::Failed);
821 }
822
823 let mut alpha_prev = 0.0;
824 let mut f_prev = state.f;
825 let mut alpha = 1.0;
826 for iter in 0..MAX_LINE_SEARCH_ITERS {
827 if !evaluator.can_evaluate_state(state.x.len()) {
828 return Ok(LineSearchResult::BudgetExhausted);
829 }
830 let trial_x = add_scaled(&state.x, direction, alpha);
831 let trial = evaluator.evaluate(&trial_x).await?;
832 if trial.f > state.f + C1 * alpha * dphi0 || (iter > 0 && trial.f >= f_prev) {
833 return zoom(evaluator, state, direction, alpha_prev, alpha).await;
834 }
835 let dphi = dot(&trial.grad, direction);
836 if dphi.abs() <= -C2 * dphi0 {
837 return Ok(LineSearchResult::Step(trial));
838 }
839 if dphi >= 0.0 {
840 return zoom(evaluator, state, direction, alpha, alpha_prev).await;
841 }
842 alpha_prev = alpha;
843 f_prev = trial.f;
844 alpha = (alpha * 2.0).min(64.0);
845 }
846 Ok(LineSearchResult::Failed)
847}
848
849enum LineSearchResult {
850 Step(ObjectiveState),
851 BudgetExhausted,
852 Failed,
853}
854
855async fn zoom(
856 evaluator: &mut ObjectiveEvaluator<'_>,
857 state: &ObjectiveState,
858 direction: &[f64],
859 mut lo: f64,
860 mut hi: f64,
861) -> BuiltinResult<LineSearchResult> {
862 let dphi0 = dot(&state.grad, direction);
863 let mut f_lo = if lo == 0.0 {
864 state.f
865 } else {
866 if !evaluator.can_evaluate_value() {
867 return Ok(LineSearchResult::BudgetExhausted);
868 }
869 let x_lo = add_scaled(&state.x, direction, lo);
870 evaluator.evaluate_value(&x_lo).await?
871 };
872 for _ in 0..MAX_LINE_SEARCH_ITERS {
873 if !evaluator.can_evaluate_state(state.x.len()) {
874 return Ok(LineSearchResult::BudgetExhausted);
875 }
876 let alpha = 0.5 * (lo + hi);
877 let trial_x = add_scaled(&state.x, direction, alpha);
878 let trial = evaluator.evaluate(&trial_x).await?;
879 if trial.f > state.f + C1 * alpha * dphi0 || trial.f >= f_lo {
880 hi = alpha;
881 } else {
882 let dphi = dot(&trial.grad, direction);
883 if dphi.abs() <= -C2 * dphi0 {
884 return Ok(LineSearchResult::Step(trial));
885 }
886 if dphi * (hi - lo) >= 0.0 {
887 hi = lo;
888 }
889 lo = alpha;
890 f_lo = trial.f;
891 }
892 if (hi - lo).abs() <= 1.0e-12 * (1.0 + lo.abs() + hi.abs()) {
893 if trial.f <= state.f + C1 * alpha * dphi0 {
894 return Ok(LineSearchResult::Step(trial));
895 }
896 return Ok(LineSearchResult::Failed);
897 }
898 }
899 Ok(LineSearchResult::Failed)
900}
901
902fn update_inverse_hessian(h: &mut [f64], s: &[f64], y: &[f64], n: usize) {
903 let ys = dot(y, s);
904 if ys <= 1.0e-14 || !ys.is_finite() {
905 return;
906 }
907 let rho = 1.0 / ys;
908 let hy = mat_vec(h, y, n);
909 let yhy = dot(y, &hy);
910 let coeff = (1.0 + yhy * rho) * rho;
911 for row in 0..n {
912 for col in 0..n {
913 h[row * n + col] +=
914 coeff * s[row] * s[col] - rho * (s[row] * hy[col] + hy[row] * s[col]);
915 }
916 }
917}
918
919fn finalize(
920 outcome: MinimizeOutcome,
921 shape: &[usize],
922 scalar: bool,
923 _options: &FminuncOptions,
924) -> BuiltinResult<Value> {
925 let x = vector_to_value(NAME, outcome.x.clone(), shape, scalar)?;
926 let fval = Value::Num(outcome.fval);
927 let exitflag = Value::Num(outcome.exitflag as f64);
928 let output = Value::Struct(build_output_struct(&outcome));
929 let grad = vector_to_value(NAME, outcome.grad.clone(), shape, scalar)?;
930 let hessian = hessian_value(&outcome.inverse_hessian, outcome.grad.len(), scalar)?;
931
932 match crate::output_count::current_output_count() {
933 None => Ok(x),
934 Some(0) => Ok(Value::OutputList(Vec::new())),
935 Some(1) => Ok(crate::output_count::output_list_with_padding(1, vec![x])),
936 Some(2) => Ok(crate::output_count::output_list_with_padding(
937 2,
938 vec![x, fval],
939 )),
940 Some(3) => Ok(crate::output_count::output_list_with_padding(
941 3,
942 vec![x, fval, exitflag],
943 )),
944 Some(4) => Ok(crate::output_count::output_list_with_padding(
945 4,
946 vec![x, fval, exitflag, output],
947 )),
948 Some(5) => Ok(crate::output_count::output_list_with_padding(
949 5,
950 vec![x, fval, exitflag, output, grad],
951 )),
952 Some(n) if n >= 6 => Ok(crate::output_count::output_list_with_padding(
953 n,
954 vec![x, fval, exitflag, output, grad, hessian],
955 )),
956 Some(_) => unreachable!("output count cases above are exhaustive"),
957 }
958}
959
960fn hessian_value(inverse_hessian: &[f64], n: usize, scalar: bool) -> BuiltinResult<Value> {
961 let hessian = match invert_matrix(inverse_hessian, n) {
962 Some(hessian) => hessian,
963 None => identity(n)?,
964 };
965 if scalar {
966 Ok(Value::Num(hessian[0]))
967 } else {
968 Tensor::new(hessian, vec![n, n])
969 .map(Value::Tensor)
970 .map_err(|e| fminunc_error_with_detail(&FMINUNC_ERROR_INVALID_INPUT, e))
971 }
972}
973
974fn build_output_struct(outcome: &MinimizeOutcome) -> StructValue {
975 let mut fields = StructValue::new();
976 fields.insert("iterations", Value::Num(outcome.iterations as f64));
977 fields.insert("funcCount", Value::Num(outcome.func_count as f64));
978 fields.insert("algorithm", Value::from(ALGORITHM));
979 fields.insert("firstorderopt", Value::Num(outcome.firstorderopt));
980 fields.insert("stepsize", Value::Num(outcome.stepsize));
981 fields.insert("message", Value::from(outcome.message.clone()));
982 fields
983}
984
985fn build_message(
986 exitflag: i32,
987 iterations: usize,
988 func_count: usize,
989 line_search_failed: bool,
990) -> String {
991 match exitflag {
992 1 => format!(
993 "Optimization terminated: first-order optimality is below OPTIONS.TolFun. Iterations: {iterations}, FuncCount: {func_count}."
994 ),
995 2 => format!(
996 "Optimization terminated: step size is below OPTIONS.TolX. Iterations: {iterations}, FuncCount: {func_count}."
997 ),
998 3 => format!(
999 "Optimization terminated: objective change is below OPTIONS.TolFun. Iterations: {iterations}, FuncCount: {func_count}."
1000 ),
1001 -3 if line_search_failed => format!(
1002 "Exiting: line search could not find a point satisfying the strong Wolfe conditions. Iterations: {iterations}, FuncCount: {func_count}."
1003 ),
1004 _ => format!(
1005 "Exiting: Maximum number of function evaluations or iterations has been exceeded. Iterations: {iterations}, FuncCount: {func_count}."
1006 ),
1007 }
1008}
1009
1010fn emit_summary(
1011 state: &ObjectiveState,
1012 exitflag: i32,
1013 message: &str,
1014 options: &FminuncOptions,
1015 iterations: usize,
1016 func_count: usize,
1017) {
1018 let should_emit = match options.display {
1019 DisplayMode::Off => false,
1020 DisplayMode::Final | DisplayMode::Iter => true,
1021 DisplayMode::Notify => exitflag <= 0,
1022 };
1023 if !should_emit {
1024 return;
1025 }
1026 crate::console::record_console_line(
1027 crate::console::ConsoleStream::Stdout,
1028 format!(
1029 "fminunc: fval = {fval:.6e}, firstorderopt = {opt:.6e}, exitflag = {exitflag}. {message}",
1030 fval = state.f,
1031 opt = norm_inf(&state.grad),
1032 ),
1033 );
1034 if matches!(options.display, DisplayMode::Iter) {
1035 crate::console::record_console_line(
1036 crate::console::ConsoleStream::Stdout,
1037 format!("fminunc: iterations = {iterations}, funcCount = {func_count}"),
1038 );
1039 }
1040}
1041
1042fn dense_len(n: usize) -> BuiltinResult<usize> {
1043 n.checked_mul(n).ok_or_else(|| {
1044 fminunc_error_with_detail(
1045 &FMINUNC_ERROR_INVALID_INPUT,
1046 "dense BFGS matrix size overflows usize",
1047 )
1048 })
1049}
1050
1051fn identity(n: usize) -> BuiltinResult<Vec<f64>> {
1052 let len = dense_len(n)?;
1053 let mut out = vec![0.0; len];
1054 for i in 0..n {
1055 out[i * n + i] = 1.0;
1056 }
1057 Ok(out)
1058}
1059
1060fn invert_matrix(matrix: &[f64], n: usize) -> Option<Vec<f64>> {
1061 let mut a = matrix.to_vec();
1062 let mut inv = identity(n).ok()?;
1063 for col in 0..n {
1064 let mut pivot = col;
1065 let mut pivot_abs = a[col * n + col].abs();
1066 for row in (col + 1)..n {
1067 let candidate = a[row * n + col].abs();
1068 if candidate > pivot_abs {
1069 pivot = row;
1070 pivot_abs = candidate;
1071 }
1072 }
1073 if pivot_abs <= 1.0e-14 || !pivot_abs.is_finite() {
1074 return None;
1075 }
1076 if pivot != col {
1077 for j in 0..n {
1078 a.swap(col * n + j, pivot * n + j);
1079 inv.swap(col * n + j, pivot * n + j);
1080 }
1081 }
1082 let diag = a[col * n + col];
1083 for j in 0..n {
1084 a[col * n + j] /= diag;
1085 inv[col * n + j] /= diag;
1086 }
1087 for row in 0..n {
1088 if row == col {
1089 continue;
1090 }
1091 let factor = a[row * n + col];
1092 if factor == 0.0 {
1093 continue;
1094 }
1095 for j in 0..n {
1096 a[row * n + j] -= factor * a[col * n + j];
1097 inv[row * n + j] -= factor * inv[col * n + j];
1098 }
1099 }
1100 }
1101 Some(inv)
1102}
1103
1104fn mat_vec(matrix: &[f64], vector: &[f64], n: usize) -> Vec<f64> {
1105 let mut out = vec![0.0; n];
1106 for row in 0..n {
1107 for col in 0..n {
1108 out[row] += matrix[row * n + col] * vector[col];
1109 }
1110 }
1111 out
1112}
1113
1114fn dot(a: &[f64], b: &[f64]) -> f64 {
1115 a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
1116}
1117
1118fn norm_inf(values: &[f64]) -> f64 {
1119 values.iter().fold(0.0, |acc, value| acc.max(value.abs()))
1120}
1121
1122fn all_finite(values: &[f64]) -> bool {
1123 values.iter().all(|value| value.is_finite())
1124}
1125
1126fn subtract(a: &[f64], b: &[f64]) -> Vec<f64> {
1127 a.iter().zip(b.iter()).map(|(x, y)| x - y).collect()
1128}
1129
1130fn add_scaled(x: &[f64], direction: &[f64], alpha: f64) -> Vec<f64> {
1131 x.iter()
1132 .zip(direction.iter())
1133 .map(|(xi, di)| xi + alpha * di)
1134 .collect()
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use futures::executor::block_on;
1141 use runmat_builtins::IntValue;
1142 use std::sync::Arc;
1143
1144 fn bound(id: usize) -> Value {
1145 Value::BoundFunctionHandle {
1146 name: format!("objective_{id}"),
1147 function: id,
1148 }
1149 }
1150
1151 fn tensor(data: Vec<f64>) -> Value {
1152 Value::Tensor(Tensor::new(data, vec![3, 1]).unwrap())
1153 }
1154
1155 fn vector_from_value(value: &Value) -> Vec<f64> {
1156 match value {
1157 Value::Tensor(tensor) => tensor.data.clone(),
1158 Value::Num(n) => vec![*n],
1159 other => panic!("expected numeric value, got {other:?}"),
1160 }
1161 }
1162
1163 fn assert_close_vec(actual: &[f64], expected: &[f64], tol: f64) {
1164 assert_eq!(actual.len(), expected.len());
1165 for (idx, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
1166 assert!((a - e).abs() <= tol, "at {idx}: expected {e}, got {a}");
1167 }
1168 }
1169
1170 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1171 #[test]
1172 fn fminunc_quadratic_reaches_vector_minimum() {
1173 let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
1174 |_function, args, requested_outputs| {
1175 assert_eq!(requested_outputs, 1);
1176 let x = vector_from_value(&args[0]);
1177 Box::pin(async move {
1178 let target = [1.0, 2.0, 3.0];
1179 let f = x
1180 .iter()
1181 .zip(target.iter())
1182 .map(|(xi, ti)| (xi - ti).powi(2))
1183 .sum::<f64>();
1184 Ok(Value::Num(f))
1185 })
1186 },
1187 )));
1188 let result = block_on(fminunc_builtin(
1189 bound(101),
1190 tensor(vec![0.0, 0.0, 0.0]),
1191 Vec::new(),
1192 ))
1193 .expect("fminunc");
1194 assert_close_vec(&vector_from_value(&result), &[1.0, 2.0, 3.0], 1.0e-4);
1195 }
1196
1197 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1198 #[test]
1199 fn fminunc_rosenbrock_converges_from_standard_start() {
1200 let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
1201 |_function, args, requested_outputs| {
1202 assert_eq!(requested_outputs, 1);
1203 let x = vector_from_value(&args[0]);
1204 Box::pin(async move {
1205 let a = x[0];
1206 let b = x[1];
1207 Ok(Value::Num(100.0 * (b - a * a).powi(2) + (1.0 - a).powi(2)))
1208 })
1209 },
1210 )));
1211 let x0 = Value::Tensor(Tensor::new(vec![-1.2, 1.0], vec![2, 1]).unwrap());
1212 let result = block_on(fminunc_builtin(bound(102), x0, Vec::new())).expect("fminunc");
1213 assert_close_vec(&vector_from_value(&result), &[1.0, 1.0], 2.0e-3);
1214 }
1215
1216 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1217 #[test]
1218 fn fminunc_handles_high_dimensional_smooth_objective() {
1219 let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
1220 |_function, args, requested_outputs| {
1221 assert_eq!(requested_outputs, 1);
1222 let x = vector_from_value(&args[0]);
1223 Box::pin(async move {
1224 let f = x
1225 .iter()
1226 .enumerate()
1227 .map(|(idx, xi)| {
1228 let target = (idx + 1) as f64;
1229 (xi - target).powi(2)
1230 })
1231 .sum::<f64>();
1232 Ok(Value::Num(f))
1233 })
1234 },
1235 )));
1236 let x0 = Value::Tensor(Tensor::new(vec![0.0; 8], vec![8, 1]).unwrap());
1237 let result = block_on(fminunc_builtin(bound(103), x0, Vec::new())).expect("fminunc");
1238 assert_close_vec(
1239 &vector_from_value(&result),
1240 &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
1241 1.0e-4,
1242 );
1243 }
1244
1245 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1246 #[test]
1247 fn fminunc_uses_objective_gradient_and_returns_six_outputs() {
1248 let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
1249 |_function, args, requested_outputs| {
1250 assert_eq!(requested_outputs, 2);
1251 let x = vector_from_value(&args[0]);
1252 Box::pin(async move {
1253 let target = [1.0, 2.0, 3.0];
1254 let grad = x
1255 .iter()
1256 .zip(target.iter())
1257 .map(|(xi, ti)| 2.0 * (xi - ti))
1258 .collect::<Vec<_>>();
1259 let f = x
1260 .iter()
1261 .zip(target.iter())
1262 .map(|(xi, ti)| (xi - ti).powi(2))
1263 .sum::<f64>();
1264 Ok(Value::OutputList(vec![
1265 Value::Num(f),
1266 Value::Tensor(Tensor::new(grad, vec![3, 1]).unwrap()),
1267 ]))
1268 })
1269 },
1270 )));
1271 let mut opts = StructValue::new();
1272 opts.insert("SpecifyObjectiveGradient", Value::Bool(true));
1273 opts.insert("TolX", Value::Num(1.0e-10));
1274 opts.insert("TolFun", Value::Num(1.0e-10));
1275 let _outputs = crate::output_count::push_output_count(Some(6));
1276 let result = block_on(fminunc_builtin(
1277 bound(104),
1278 tensor(vec![0.0, 0.0, 0.0]),
1279 vec![Value::Struct(opts)],
1280 ))
1281 .expect("fminunc");
1282 let Value::OutputList(outputs) = result else {
1283 panic!("expected output list");
1284 };
1285 assert_eq!(outputs.len(), 6);
1286 assert_close_vec(&vector_from_value(&outputs[0]), &[1.0, 2.0, 3.0], 1.0e-7);
1287 assert!(matches!(outputs[1], Value::Num(f) if f.abs() < 1.0e-14));
1288 assert!(matches!(outputs[2], Value::Num(flag) if flag > 0.0));
1289 match &outputs[3] {
1290 Value::Struct(output) => {
1291 assert!(matches!(
1292 output.fields.get("iterations"),
1293 Some(Value::Num(_))
1294 ));
1295 assert!(matches!(
1296 output.fields.get("funcCount"),
1297 Some(Value::Num(_))
1298 ));
1299 assert!(
1300 matches!(output.fields.get("firstorderopt"), Some(Value::Num(v)) if *v < 1.0e-8)
1301 );
1302 assert!(
1303 matches!(output.fields.get("algorithm"), Some(Value::String(text)) if text.contains("bfgs"))
1304 );
1305 }
1306 other => panic!("unexpected output struct {other:?}"),
1307 }
1308 assert_close_vec(&vector_from_value(&outputs[4]), &[0.0, 0.0, 0.0], 1.0e-7);
1309 match &outputs[5] {
1310 Value::Tensor(hessian) => {
1311 assert_eq!(hessian.shape, vec![3, 3]);
1312 assert!(hessian.data.iter().all(|value| value.is_finite()));
1313 assert!(hessian.data[0] > 0.0);
1314 assert!(hessian.data[4] > 0.0);
1315 assert!(hessian.data[8] > 0.0);
1316 }
1317 other => panic!("unexpected hessian {other:?}"),
1318 }
1319 }
1320
1321 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1322 #[test]
1323 fn fminunc_rejects_invalid_gradient_length() {
1324 let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
1325 |_function, _args, requested_outputs| {
1326 assert_eq!(requested_outputs, 2);
1327 Box::pin(async {
1328 Ok(Value::OutputList(vec![
1329 Value::Num(1.0),
1330 Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap()),
1331 ]))
1332 })
1333 },
1334 )));
1335 let mut opts = StructValue::new();
1336 opts.insert("SpecifyObjectiveGradient", Value::Bool(true));
1337 let err = block_on(fminunc_builtin(
1338 bound(105),
1339 tensor(vec![0.0, 0.0, 0.0]),
1340 vec![Value::Struct(opts)],
1341 ))
1342 .unwrap_err();
1343 assert!(err.message().contains("gradient length"));
1344 }
1345
1346 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1347 #[test]
1348 fn fminunc_scalar_input_returns_scalar() {
1349 let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
1350 |_function, args, requested_outputs| {
1351 assert_eq!(requested_outputs, 1);
1352 let x = match &args[0] {
1353 Value::Num(value) => *value,
1354 other => panic!("expected scalar, got {other:?}"),
1355 };
1356 Box::pin(async move { Ok(Value::Num((x - 4.0).powi(2))) })
1357 },
1358 )));
1359 let result =
1360 block_on(fminunc_builtin(bound(106), Value::Num(0.0), Vec::new())).expect("fminunc");
1361 assert!(matches!(result, Value::Num(x) if (x - 4.0).abs() < 1.0e-4));
1362 }
1363
1364 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1365 #[test]
1366 fn fminunc_rejects_non_struct_options() {
1367 let err = block_on(fminunc_builtin(
1368 bound(107),
1369 Value::Num(0.0),
1370 vec![Value::Int(IntValue::I32(1))],
1371 ))
1372 .unwrap_err();
1373 assert_eq!(err.identifier(), Some("RunMat:fminunc:InvalidArgument"));
1374 }
1375
1376 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1377 #[test]
1378 fn fminunc_rejects_budget_too_small_for_finite_difference_gradient() {
1379 let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
1380 |_function, _args, _requested_outputs| {
1381 Box::pin(async { panic!("budget check should happen before invoking objective") })
1382 },
1383 )));
1384 let mut opts = StructValue::new();
1385 opts.insert("MaxFunEvals", Value::Num(3.0));
1386 let err = block_on(fminunc_builtin(
1387 bound(108),
1388 tensor(vec![0.0, 0.0, 0.0]),
1389 vec![Value::Struct(opts)],
1390 ))
1391 .unwrap_err();
1392 assert!(err.message().contains("MaxFunEvals"));
1393 }
1394
1395 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1396 #[test]
1397 fn fminunc_rejects_problem_too_large_for_dense_bfgs() {
1398 let variables = MAX_DENSE_BFGS_VARIABLES + 1;
1399 let x0 = Value::Tensor(Tensor::new(vec![0.0; variables], vec![variables, 1]).unwrap());
1400 let err = block_on(fminunc_builtin(bound(109), x0, Vec::new())).unwrap_err();
1401 assert!(err.message().contains("dense BFGS"));
1402 }
1403
1404 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1405 #[test]
1406 fn fminunc_rejects_huge_direct_iteration_limits() {
1407 let mut opts = StructValue::new();
1408 opts.insert("MaxFunEvals", Value::Num((MAX_FUN_EVAL_LIMIT as f64) + 1.0));
1409 let err = block_on(fminunc_builtin(
1410 bound(110),
1411 Value::Num(0.0),
1412 vec![Value::Struct(opts)],
1413 ))
1414 .unwrap_err();
1415 assert!(err.message().contains("MaxFunEvals"));
1416 }
1417}