Skip to main content

runmat_runtime/builtins/math/optim/
fminbnd.rs

1//! MATLAB-compatible `fminbnd` builtin for bounded scalar minimization.
2//!
3//! `fminbnd` finds a local minimum of a scalar function on a finite interval
4//! using Brent's method (golden-section search combined with parabolic
5//! interpolation).  The implementation supports MATLAB's four output arities:
6//!
7//! * `x = fminbnd(fun, x1, x2)`
8//! * `x = fminbnd(fun, x1, x2, options)`
9//! * `[x, fval] = fminbnd(...)`
10//! * `[x, fval, exitflag] = fminbnd(...)`
11//! * `[x, fval, exitflag, output] = fminbnd(...)`
12//!
13//! The optional options struct (typically created by `optimset`) honours
14//! `TolX`, `MaxIter`, `MaxFunEvals`, and `Display`.
15
16use runmat_builtins::{LogicalArray, StructValue, Tensor, Value};
17use runmat_macros::runtime_builtin;
18
19use crate::builtins::common::spec::{
20    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
21    ReductionNaN, ResidencyPolicy, ShapeRequirements,
22};
23use crate::builtins::math::optim::brent::{
24    brent_min, BrentMinObserver, BrentMinResult, BrentParams, BrentStepKind,
25};
26use crate::builtins::math::optim::common::optim_error;
27use crate::builtins::math::optim::type_resolvers::scalar_root_type;
28use crate::BuiltinResult;
29
30const NAME: &str = "fminbnd";
31const ALGORITHM: &str = "golden section search, parabolic interpolation";
32const DEFAULT_TOL_X: f64 = 1.0e-4;
33const DEFAULT_MAX_ITER: usize = 500;
34const DEFAULT_MAX_FUN_EVALS: usize = 500;
35const DEFAULT_DISPLAY: DisplayMode = DisplayMode::Notify;
36
37#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::optim::fminbnd")]
38pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
39    name: "fminbnd",
40    op_kind: GpuOpKind::Custom("bounded-scalar-min"),
41    supported_precisions: &[],
42    broadcast: BroadcastSemantics::None,
43    provider_hooks: &[],
44    constant_strategy: ConstantStrategy::InlineLiteral,
45    residency: ResidencyPolicy::GatherImmediately,
46    nan_mode: ReductionNaN::Include,
47    two_pass_threshold: None,
48    workgroup_size: None,
49    accepts_nan_mode: false,
50    notes: "Host iterative solver. Callback computations may use GPU-aware builtins, but the minimization loop runs on the CPU.",
51};
52
53#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::optim::fminbnd")]
54pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
55    name: "fminbnd",
56    shape: ShapeRequirements::Any,
57    constant_strategy: ConstantStrategy::InlineLiteral,
58    elementwise: None,
59    reduction: None,
60    emits_nan: false,
61    notes:
62        "Bounded scalar minimization repeatedly invokes user code and terminates fusion planning.",
63};
64
65#[runtime_builtin(
66    name = "fminbnd",
67    category = "math/optim",
68    summary = "Find a local minimum of a scalar function on a bounded interval using Brent's method.",
69    keywords = "fminbnd,bounded minimization,brent,golden section,parabolic interpolation,optimization",
70    accel = "sink",
71    type_resolver(scalar_root_type),
72    builtin_path = "crate::builtins::math::optim::fminbnd"
73)]
74async fn fminbnd_builtin(
75    function: Value,
76    x1: Value,
77    x2: Value,
78    rest: Vec<Value>,
79) -> BuiltinResult<Value> {
80    if rest.len() > 1 {
81        return Err(optim_error(NAME, "fminbnd: too many input arguments"));
82    }
83    let options_struct = parse_options(rest.first())?;
84    let options = FminbndOptions::from_struct(options_struct.as_ref())?;
85    let x1 = scalar_bound("lower bound", x1).await?;
86    let x2 = scalar_bound("upper bound", x2).await?;
87
88    if !x1.is_finite() || !x2.is_finite() {
89        return Err(optim_error(NAME, "fminbnd: bounds must be finite"));
90    }
91    if x1 > x2 {
92        return finalize_inconsistent_bounds(&options);
93    }
94
95    let outcome = run_solver(&function, x1, x2, &options).await?;
96    finalize(outcome, &options)
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100enum DisplayMode {
101    Off,
102    Iter,
103    Notify,
104    Final,
105}
106
107impl DisplayMode {
108    fn parse(text: &str) -> BuiltinResult<Self> {
109        match text.to_ascii_lowercase().as_str() {
110            "off" | "none" => Ok(Self::Off),
111            "iter" => Ok(Self::Iter),
112            "notify" => Ok(Self::Notify),
113            "final" => Ok(Self::Final),
114            other => Err(optim_error(
115                NAME,
116                format!(
117                    "fminbnd: option Display must be 'off', 'iter', 'notify', or 'final', got '{other}'"
118                ),
119            )),
120        }
121    }
122}
123
124#[derive(Debug, Clone, Copy)]
125struct FminbndOptions {
126    tol_x: f64,
127    max_iter: usize,
128    max_fun_evals: usize,
129    display: DisplayMode,
130}
131
132impl FminbndOptions {
133    fn from_struct(options: Option<&StructValue>) -> BuiltinResult<Self> {
134        let display = match options {
135            Some(opts) => match lookup(opts, "Display") {
136                Some(value) => DisplayMode::parse(&option_string("Display", value)?)?,
137                None => DEFAULT_DISPLAY,
138            },
139            None => DEFAULT_DISPLAY,
140        };
141        let tol_x = match options.and_then(|o| lookup(o, "TolX")) {
142            Some(value) => option_f64("TolX", value)?,
143            None => DEFAULT_TOL_X,
144        };
145        if tol_x <= 0.0 {
146            return Err(optim_error(NAME, "fminbnd: option TolX must be positive"));
147        }
148        let max_iter = match options.and_then(|o| lookup(o, "MaxIter")) {
149            Some(value) => option_positive_usize("MaxIter", value)?,
150            None => DEFAULT_MAX_ITER,
151        };
152        let max_fun_evals = match options.and_then(|o| lookup(o, "MaxFunEvals")) {
153            Some(value) => option_positive_usize("MaxFunEvals", value)?,
154            None => DEFAULT_MAX_FUN_EVALS,
155        };
156        Ok(Self {
157            tol_x,
158            max_iter,
159            max_fun_evals,
160            display,
161        })
162    }
163}
164
165fn parse_options(value: Option<&Value>) -> BuiltinResult<Option<StructValue>> {
166    match value {
167        None => Ok(None),
168        Some(Value::Struct(options)) => Ok(Some(options.clone())),
169        Some(other) => Err(optim_error(
170            NAME,
171            format!("fminbnd: options must be a struct, got {other:?}"),
172        )),
173    }
174}
175
176fn lookup<'a>(options: &'a StructValue, name: &str) -> Option<&'a Value> {
177    options
178        .fields
179        .iter()
180        .find(|(key, _)| key.eq_ignore_ascii_case(name))
181        .map(|(_, v)| v)
182}
183
184fn option_string(field: &str, value: &Value) -> BuiltinResult<String> {
185    match value {
186        Value::String(s) => Ok(s.clone()),
187        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
188        Value::CharArray(chars) if chars.rows == 1 => Ok(chars.data.iter().collect()),
189        other => Err(optim_error(
190            NAME,
191            format!("fminbnd: option {field} must be a string, got {other:?}"),
192        )),
193    }
194}
195
196fn option_f64(field: &str, value: &Value) -> BuiltinResult<f64> {
197    let parsed = match value {
198        Value::Num(n) => *n,
199        Value::Int(i) => i.to_f64(),
200        Value::Bool(b) => {
201            if *b {
202                1.0
203            } else {
204                0.0
205            }
206        }
207        Value::Tensor(Tensor { data, .. }) if data.len() == 1 => data[0],
208        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => {
209            if data[0] != 0 {
210                1.0
211            } else {
212                0.0
213            }
214        }
215        other => {
216            return Err(optim_error(
217                NAME,
218                format!("fminbnd: option {field} must be a real scalar, got {other:?}"),
219            ))
220        }
221    };
222    if parsed.is_finite() {
223        Ok(parsed)
224    } else {
225        Err(optim_error(
226            NAME,
227            format!("fminbnd: option {field} must be finite"),
228        ))
229    }
230}
231
232fn option_positive_usize(field: &str, value: &Value) -> BuiltinResult<usize> {
233    let parsed = option_f64(field, value)?;
234    if parsed < 1.0 {
235        return Err(optim_error(
236            NAME,
237            format!("fminbnd: option {field} must be a positive integer"),
238        ));
239    }
240    if parsed.fract() != 0.0 {
241        return Err(optim_error(
242            NAME,
243            format!("fminbnd: option {field} must be an integer scalar"),
244        ));
245    }
246    Ok(parsed as usize)
247}
248
249async fn scalar_bound(label: &str, value: Value) -> BuiltinResult<f64> {
250    let value = crate::dispatcher::gather_if_needed_async(&value).await?;
251    let parsed = match value {
252        Value::Num(n) => n,
253        Value::Int(i) => i.to_f64(),
254        Value::Bool(b) => {
255            if b {
256                1.0
257            } else {
258                0.0
259            }
260        }
261        Value::Tensor(t) if t.data.len() == 1 => t.data[0],
262        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => {
263            if data[0] != 0 {
264                1.0
265            } else {
266                0.0
267            }
268        }
269        other => {
270            return Err(optim_error(
271                NAME,
272                format!("fminbnd: {label} must be a finite real scalar, got {other:?}"),
273            ))
274        }
275    };
276    if parsed.is_finite() {
277        Ok(parsed)
278    } else {
279        Err(optim_error(
280            NAME,
281            format!("fminbnd: {label} must be finite"),
282        ))
283    }
284}
285
286#[derive(Debug, Clone)]
287struct Outcome {
288    inner: BrentMinResult,
289}
290
291async fn run_solver(
292    function: &Value,
293    lo: f64,
294    hi: f64,
295    options: &FminbndOptions,
296) -> BuiltinResult<Outcome> {
297    let mut iter_log = IterDisplay::new(options.display);
298    let observer: Option<&mut dyn BrentMinObserver> =
299        if matches!(options.display, DisplayMode::Iter) {
300            Some(&mut iter_log)
301        } else {
302            None
303        };
304    let inner = brent_min(
305        NAME,
306        function,
307        lo,
308        hi,
309        BrentParams {
310            tol_x: options.tol_x,
311            max_iter: options.max_iter,
312            max_fun_evals: options.max_fun_evals,
313        },
314        observer,
315    )
316    .await?;
317    Ok(Outcome { inner })
318}
319
320fn finalize(outcome: Outcome, options: &FminbndOptions) -> BuiltinResult<Value> {
321    let exit_flag = if outcome.inner.converged { 1 } else { 0 };
322    let message = build_message(&outcome.inner);
323
324    emit_summary(&outcome.inner, exit_flag, &message, options);
325
326    let x = Value::Num(outcome.inner.x);
327    let fval = Value::Num(outcome.inner.fval);
328    let exitflag = Value::Num(exit_flag as f64);
329    let output_struct = Value::Struct(build_output_struct(&outcome.inner, &message));
330
331    match crate::output_count::current_output_count() {
332        None => Ok(x),
333        Some(0) => Ok(Value::OutputList(Vec::new())),
334        Some(1) => Ok(crate::output_count::output_list_with_padding(1, vec![x])),
335        Some(2) => Ok(crate::output_count::output_list_with_padding(
336            2,
337            vec![x, fval],
338        )),
339        Some(3) => Ok(crate::output_count::output_list_with_padding(
340            3,
341            vec![x, fval, exitflag],
342        )),
343        Some(n) if n >= 4 => Ok(crate::output_count::output_list_with_padding(
344            n,
345            vec![x, fval, exitflag, output_struct],
346        )),
347        Some(_) => Ok(x),
348    }
349}
350
351fn finalize_inconsistent_bounds(options: &FminbndOptions) -> BuiltinResult<Value> {
352    let message = "Exiting: The bounds are inconsistent because x1 > x2.".to_string();
353    emit_invalid_summary(-2, &message, options);
354
355    let x = empty_double();
356    let fval = empty_double();
357    let exitflag = Value::Num(-2.0);
358    let output_struct = Value::Struct(build_invalid_output_struct(&message));
359
360    match crate::output_count::current_output_count() {
361        None => Ok(x),
362        Some(0) => Ok(Value::OutputList(Vec::new())),
363        Some(1) => Ok(crate::output_count::output_list_with_padding(1, vec![x])),
364        Some(2) => Ok(crate::output_count::output_list_with_padding(
365            2,
366            vec![x, fval],
367        )),
368        Some(3) => Ok(crate::output_count::output_list_with_padding(
369            3,
370            vec![x, fval, exitflag],
371        )),
372        Some(n) if n >= 4 => Ok(crate::output_count::output_list_with_padding(
373            n,
374            vec![x, fval, exitflag, output_struct],
375        )),
376        Some(_) => Ok(empty_double()),
377    }
378}
379
380fn empty_double() -> Value {
381    Value::Tensor(Tensor::zeros(vec![0, 0]))
382}
383
384fn build_output_struct(result: &BrentMinResult, message: &str) -> StructValue {
385    let mut fields = StructValue::new();
386    fields.insert("iterations", Value::Num(result.iterations as f64));
387    fields.insert("funcCount", Value::Num(result.func_count as f64));
388    fields.insert("algorithm", Value::from(ALGORITHM));
389    fields.insert("message", Value::from(message.to_string()));
390    fields
391}
392
393fn build_invalid_output_struct(message: &str) -> StructValue {
394    let mut fields = StructValue::new();
395    fields.insert("iterations", Value::Num(0.0));
396    fields.insert("funcCount", Value::Num(0.0));
397    fields.insert("algorithm", Value::from(ALGORITHM));
398    fields.insert("message", Value::from(message.to_string()));
399    fields
400}
401
402fn build_message(result: &BrentMinResult) -> String {
403    if result.converged {
404        format!(
405            "Optimization terminated: the current x satisfies the termination criteria using OPTIONS.TolX. Iterations: {}, FuncCount: {}.",
406            result.iterations, result.func_count
407        )
408    } else {
409        format!(
410            "Exiting: Maximum number of function evaluations or iterations has been exceeded - increase MaxFunEvals or MaxIter. Iterations: {}, FuncCount: {}.",
411            result.iterations, result.func_count
412        )
413    }
414}
415
416fn emit_summary(result: &BrentMinResult, exit_flag: i32, message: &str, options: &FminbndOptions) {
417    let should_emit = match options.display {
418        DisplayMode::Off => false,
419        DisplayMode::Final | DisplayMode::Iter => true,
420        DisplayMode::Notify => exit_flag != 1,
421    };
422    if !should_emit {
423        return;
424    }
425    let line = format!(
426        "fminbnd: x = {x:.6}, fval = {fval:.6}, exitflag = {exit_flag}. {message}",
427        x = result.x,
428        fval = result.fval,
429    );
430    crate::console::record_console_line(crate::console::ConsoleStream::Stdout, line);
431}
432
433fn emit_invalid_summary(exit_flag: i32, message: &str, options: &FminbndOptions) {
434    let should_emit = match options.display {
435        DisplayMode::Off => false,
436        DisplayMode::Final | DisplayMode::Iter => true,
437        DisplayMode::Notify => exit_flag != 1,
438    };
439    if should_emit {
440        crate::console::record_console_line(
441            crate::console::ConsoleStream::Stdout,
442            format!("fminbnd: exitflag = {exit_flag}. {message}"),
443        );
444    }
445}
446
447struct IterDisplay {
448    mode: DisplayMode,
449    printed_header: bool,
450}
451
452impl IterDisplay {
453    fn new(mode: DisplayMode) -> Self {
454        Self {
455            mode,
456            printed_header: false,
457        }
458    }
459}
460
461impl BrentMinObserver for IterDisplay {
462    fn on_iteration(
463        &mut self,
464        iter: usize,
465        func_count: usize,
466        x: f64,
467        fx: f64,
468        step_kind: BrentStepKind,
469    ) {
470        if !matches!(self.mode, DisplayMode::Iter) {
471            return;
472        }
473        if !self.printed_header {
474            crate::console::record_console_line(
475                crate::console::ConsoleStream::Stdout,
476                " Func-count        x          f(x)          Procedure",
477            );
478            self.printed_header = true;
479        }
480        let procedure = match step_kind {
481            BrentStepKind::Initial => "initial",
482            BrentStepKind::GoldenSection => "golden",
483            BrentStepKind::Parabolic => "parabolic",
484        };
485        let line =
486            format!("    {func_count:>5}    {x:13.6e} {fx:13.6e}    {procedure}    (iter {iter})");
487        crate::console::record_console_line(crate::console::ConsoleStream::Stdout, line);
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::builtins::math::optim::brent::brent_min_tolerance;
495    use futures::executor::block_on;
496    use runmat_builtins::Value as V;
497
498    fn run_default(handle: &str, lo: f64, hi: f64) -> Value {
499        block_on(fminbnd_builtin(
500            V::FunctionHandle(handle.into()),
501            V::Num(lo),
502            V::Num(hi),
503            Vec::new(),
504        ))
505        .expect("fminbnd")
506    }
507
508    fn run_with(handle: &str, lo: f64, hi: f64, extra: Vec<Value>) -> Value {
509        block_on(fminbnd_builtin(
510            V::FunctionHandle(handle.into()),
511            V::Num(lo),
512            V::Num(hi),
513            extra,
514        ))
515        .expect("fminbnd")
516    }
517
518    #[runtime_builtin(
519        name = "__fminbnd_quad_minus_two",
520        type_resolver(crate::builtins::math::optim::type_resolvers::scalar_root_type),
521        builtin_path = "crate::builtins::math::optim::fminbnd::tests"
522    )]
523    async fn quad_minus_two(x: Value) -> crate::BuiltinResult<Value> {
524        let x = scalar_bound("x", x).await?;
525        let diff = x - 2.0;
526        Ok(Value::Num(diff * diff))
527    }
528
529    #[runtime_builtin(
530        name = "__fminbnd_quad_minus_three",
531        type_resolver(crate::builtins::math::optim::type_resolvers::scalar_root_type),
532        builtin_path = "crate::builtins::math::optim::fminbnd::tests"
533    )]
534    async fn quad_minus_three(x: Value) -> crate::BuiltinResult<Value> {
535        let x = scalar_bound("x", x).await?;
536        let diff = x - 3.0;
537        Ok(Value::Num(diff * diff))
538    }
539
540    #[runtime_builtin(
541        name = "__fminbnd_multi_modal",
542        type_resolver(crate::builtins::math::optim::type_resolvers::scalar_root_type),
543        builtin_path = "crate::builtins::math::optim::fminbnd::tests"
544    )]
545    async fn multi_modal(x: Value) -> crate::BuiltinResult<Value> {
546        // 1 + sin(3x) on [0, 2π] — local minima near x ≈ π/2 + 2π/3 (etc.).
547        let x = scalar_bound("x", x).await?;
548        Ok(Value::Num(1.0 + (3.0 * x).sin()))
549    }
550
551    #[test]
552    fn locates_smooth_quadratic_minimum() {
553        let result = run_default("__fminbnd_quad_minus_two", 0.0, 5.0);
554        match result {
555            V::Num(x) => assert!((x - 2.0).abs() < 1.0e-3, "x = {x}"),
556            other => panic!("unexpected value {other:?}"),
557        }
558    }
559
560    #[test]
561    fn locates_quadratic_minimum_offset_three() {
562        let result = run_default("__fminbnd_quad_minus_three", 0.0, 5.0);
563        match result {
564            V::Num(x) => assert!((x - 3.0).abs() < 1.0e-3, "x = {x}"),
565            other => panic!("unexpected value {other:?}"),
566        }
567    }
568
569    #[test]
570    fn locates_cosine_minimum_at_right_endpoint() {
571        // cos(x) is monotonically decreasing on [0, π]; minimum is at x = π.
572        let result = run_default("cos", 0.0, std::f64::consts::PI);
573        match result {
574            V::Num(x) => assert!((x - std::f64::consts::PI).abs() < 1.0e-3, "x = {x}"),
575            other => panic!("unexpected value {other:?}"),
576        }
577    }
578
579    #[test]
580    fn returns_lone_endpoint_when_bounds_collapse() {
581        let result = run_default("__fminbnd_quad_minus_two", 1.5, 1.5);
582        match result {
583            V::Num(x) => assert!((x - 1.5).abs() < 1.0e-12, "x = {x}"),
584            other => panic!("unexpected value {other:?}"),
585        }
586    }
587
588    #[test]
589    fn reports_inconsistent_reversed_bounds() {
590        let _guard = crate::output_count::push_output_count(Some(4));
591        let result = block_on(fminbnd_builtin(
592            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
593            V::Num(5.0),
594            V::Num(0.0),
595            Vec::new(),
596        ))
597        .expect("fminbnd");
598        match result {
599            V::OutputList(outputs) => {
600                assert_eq!(outputs.len(), 4);
601                assert!(matches!(&outputs[0], V::Tensor(t) if t.data.is_empty()));
602                assert!(matches!(&outputs[1], V::Tensor(t) if t.data.is_empty()));
603                assert!(matches!(&outputs[2], V::Num(flag) if *flag == -2.0));
604                match &outputs[3] {
605                    V::Struct(s) => {
606                        assert!(matches!(s.fields.get("iterations"), Some(V::Num(0.0))));
607                        assert!(matches!(s.fields.get("funcCount"), Some(V::Num(0.0))));
608                        match s.fields.get("message") {
609                            Some(V::String(text)) => assert!(text.contains("bounds")),
610                            other => panic!("unexpected message field {other:?}"),
611                        }
612                    }
613                    other => panic!("unexpected output struct {other:?}"),
614                }
615            }
616            other => panic!("unexpected value {other:?}"),
617        }
618    }
619
620    #[test]
621    fn tolerance_is_additive_not_scaled_by_x() {
622        let params = BrentParams {
623            tol_x: 1.0e-4,
624            max_iter: 500,
625            max_fun_evals: 500,
626        };
627        let small = brent_min_tolerance(2.0, params);
628        let large = brent_min_tolerance(1.0e9, params);
629        assert!(small > params.tol_x);
630        assert!(
631            large < params.tol_x * 1.0e9,
632            "large-scale tolerance was {large}"
633        );
634    }
635
636    #[test]
637    fn finds_local_minimum_in_multi_modal_function() {
638        // 1 + sin(3x) on [1.5, 3.5] has its local minimum near x = π/2 ≈ 1.571 (sin(3π/2) = -1).
639        let result = run_default("__fminbnd_multi_modal", 1.5, 3.5);
640        match result {
641            V::Num(x) => {
642                let target = std::f64::consts::PI / 2.0;
643                assert!((x - target).abs() < 5.0e-3, "x = {x}, target = {target}");
644            }
645            other => panic!("unexpected value {other:?}"),
646        }
647    }
648
649    #[test]
650    fn options_struct_overrides_default_tolerance() {
651        let mut opts = StructValue::new();
652        opts.insert("TolX", Value::Num(1.0e-12));
653        let result = run_with(
654            "__fminbnd_quad_minus_two",
655            0.0,
656            5.0,
657            vec![Value::Struct(opts)],
658        );
659        match result {
660            V::Num(x) => assert!((x - 2.0).abs() < 1.0e-6, "x = {x}"),
661            other => panic!("unexpected value {other:?}"),
662        }
663    }
664
665    #[test]
666    fn max_fun_evals_default_is_independent_of_max_iter() {
667        let mut opts = StructValue::new();
668        opts.insert("MaxIter", Value::Num(1000.0));
669        let parsed = FminbndOptions::from_struct(Some(&opts)).unwrap();
670        assert_eq!(parsed.max_iter, 1000);
671        assert_eq!(parsed.max_fun_evals, DEFAULT_MAX_FUN_EVALS);
672    }
673
674    #[test]
675    fn rejects_nonfinite_bounds() {
676        let err = block_on(fminbnd_builtin(
677            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
678            V::Num(f64::NAN),
679            V::Num(5.0),
680            Vec::new(),
681        ))
682        .unwrap_err();
683        assert!(err.message().to_ascii_lowercase().contains("finite"));
684    }
685
686    #[test]
687    fn rejects_invalid_options_type() {
688        let err = block_on(fminbnd_builtin(
689            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
690            V::Num(0.0),
691            V::Num(5.0),
692            vec![Value::Num(1.0)],
693        ))
694        .unwrap_err();
695        assert!(err.message().to_ascii_lowercase().contains("options"));
696    }
697
698    #[test]
699    fn rejects_nonpositive_tol_x() {
700        let mut opts = StructValue::new();
701        opts.insert("TolX", Value::Num(0.0));
702        let err = block_on(fminbnd_builtin(
703            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
704            V::Num(0.0),
705            V::Num(5.0),
706            vec![Value::Struct(opts)],
707        ))
708        .unwrap_err();
709        assert!(err.message().to_lowercase().contains("tolx"));
710    }
711
712    #[test]
713    fn rejects_unknown_display_value() {
714        let mut opts = StructValue::new();
715        opts.insert("Display", Value::from("loud"));
716        let err = block_on(fminbnd_builtin(
717            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
718            V::Num(0.0),
719            V::Num(5.0),
720            vec![Value::Struct(opts)],
721        ))
722        .unwrap_err();
723        assert!(err.message().to_lowercase().contains("display"));
724    }
725
726    #[test]
727    fn multi_output_two_returns_x_and_fval() {
728        let _guard = crate::output_count::push_output_count(Some(2));
729        let result = block_on(fminbnd_builtin(
730            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
731            V::Num(0.0),
732            V::Num(5.0),
733            Vec::new(),
734        ))
735        .expect("fminbnd");
736        match result {
737            V::OutputList(outputs) => {
738                assert_eq!(outputs.len(), 2);
739                match (&outputs[0], &outputs[1]) {
740                    (V::Num(x), V::Num(fval)) => {
741                        assert!((x - 2.0).abs() < 1.0e-3);
742                        assert!(fval.abs() < 1.0e-5);
743                    }
744                    other => panic!("unexpected outputs {other:?}"),
745                }
746            }
747            other => panic!("unexpected value {other:?}"),
748        }
749    }
750
751    #[test]
752    fn multi_output_three_includes_exitflag() {
753        let _guard = crate::output_count::push_output_count(Some(3));
754        let result = block_on(fminbnd_builtin(
755            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
756            V::Num(0.0),
757            V::Num(5.0),
758            Vec::new(),
759        ))
760        .expect("fminbnd");
761        match result {
762            V::OutputList(outputs) => {
763                assert_eq!(outputs.len(), 3);
764                match &outputs[2] {
765                    V::Num(flag) => assert!((*flag - 1.0).abs() < 1.0e-12),
766                    other => panic!("unexpected exitflag {other:?}"),
767                }
768            }
769            other => panic!("unexpected value {other:?}"),
770        }
771    }
772
773    #[test]
774    fn multi_output_four_includes_output_struct() {
775        let _guard = crate::output_count::push_output_count(Some(4));
776        let result = block_on(fminbnd_builtin(
777            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
778            V::Num(0.0),
779            V::Num(5.0),
780            Vec::new(),
781        ))
782        .expect("fminbnd");
783        match result {
784            V::OutputList(outputs) => {
785                assert_eq!(outputs.len(), 4);
786                match &outputs[3] {
787                    V::Struct(s) => {
788                        assert!(matches!(s.fields.get("iterations"), Some(V::Num(_))));
789                        assert!(matches!(s.fields.get("funcCount"), Some(V::Num(_))));
790                        match s.fields.get("algorithm") {
791                            Some(V::String(text)) => assert!(text.contains("golden")),
792                            other => panic!("unexpected algorithm field {other:?}"),
793                        }
794                        assert!(s.fields.get("message").is_some());
795                    }
796                    other => panic!("unexpected output struct {other:?}"),
797                }
798            }
799            other => panic!("unexpected value {other:?}"),
800        }
801    }
802
803    #[test]
804    fn reports_zero_exitflag_when_max_iter_exhausted() {
805        let mut opts = StructValue::new();
806        opts.insert("MaxIter", Value::Num(1.0));
807        opts.insert("MaxFunEvals", Value::Num(2.0));
808        opts.insert("Display", Value::from("off"));
809        let _guard = crate::output_count::push_output_count(Some(3));
810        let result = block_on(fminbnd_builtin(
811            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
812            V::Num(0.0),
813            V::Num(5.0),
814            vec![Value::Struct(opts)],
815        ))
816        .expect("fminbnd");
817        match result {
818            V::OutputList(outputs) => match &outputs[2] {
819                V::Num(flag) => assert_eq!(*flag, 0.0),
820                other => panic!("unexpected exitflag {other:?}"),
821            },
822            other => panic!("unexpected value {other:?}"),
823        }
824    }
825}