Skip to main content

runmat_runtime/builtins/math/optim/
fminunc.rs

1//! MATLAB-compatible `fminunc` builtin for unconstrained smooth minimization.
2
3use 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}