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::{
17    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
18    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
19    LogicalArray, StructValue, Tensor, Value,
20};
21use runmat_macros::runtime_builtin;
22
23use crate::builtins::common::spec::{
24    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
25    ReductionNaN, ResidencyPolicy, ShapeRequirements,
26};
27use crate::builtins::math::optim::brent::{
28    brent_min, BrentMinObserver, BrentMinResult, BrentParams, BrentStepKind,
29};
30use crate::builtins::math::optim::type_resolvers::scalar_root_type;
31use crate::{build_runtime_error, BuiltinResult, RuntimeError};
32
33const NAME: &str = "fminbnd";
34const ALGORITHM: &str = "golden section search, parabolic interpolation";
35const DEFAULT_TOL_X: f64 = 1.0e-4;
36const DEFAULT_MAX_ITER: usize = 500;
37const DEFAULT_MAX_FUN_EVALS: usize = 500;
38const DEFAULT_DISPLAY: DisplayMode = DisplayMode::Notify;
39
40const FMINBND_OUTPUT_X: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
41    name: "x",
42    ty: BuiltinParamType::NumericScalar,
43    arity: BuiltinParamArity::Required,
44    default: None,
45    description: "Estimated minimizer location.",
46}];
47
48const FMINBND_OUTPUT_X_FVAL: [BuiltinParamDescriptor; 2] = [
49    BuiltinParamDescriptor {
50        name: "x",
51        ty: BuiltinParamType::NumericScalar,
52        arity: BuiltinParamArity::Required,
53        default: None,
54        description: "Estimated minimizer location.",
55    },
56    BuiltinParamDescriptor {
57        name: "fval",
58        ty: BuiltinParamType::NumericScalar,
59        arity: BuiltinParamArity::Required,
60        default: None,
61        description: "Objective value at x.",
62    },
63];
64
65const FMINBND_OUTPUT_X_FVAL_EXITFLAG: [BuiltinParamDescriptor; 3] = [
66    BuiltinParamDescriptor {
67        name: "x",
68        ty: BuiltinParamType::NumericScalar,
69        arity: BuiltinParamArity::Required,
70        default: None,
71        description: "Estimated minimizer location.",
72    },
73    BuiltinParamDescriptor {
74        name: "fval",
75        ty: BuiltinParamType::NumericScalar,
76        arity: BuiltinParamArity::Required,
77        default: None,
78        description: "Objective value at x.",
79    },
80    BuiltinParamDescriptor {
81        name: "exitflag",
82        ty: BuiltinParamType::NumericScalar,
83        arity: BuiltinParamArity::Required,
84        default: None,
85        description: "Convergence status code.",
86    },
87];
88
89const FMINBND_OUTPUT_ALL: [BuiltinParamDescriptor; 4] = [
90    BuiltinParamDescriptor {
91        name: "x",
92        ty: BuiltinParamType::NumericScalar,
93        arity: BuiltinParamArity::Required,
94        default: None,
95        description: "Estimated minimizer location.",
96    },
97    BuiltinParamDescriptor {
98        name: "fval",
99        ty: BuiltinParamType::NumericScalar,
100        arity: BuiltinParamArity::Required,
101        default: None,
102        description: "Objective value at x.",
103    },
104    BuiltinParamDescriptor {
105        name: "exitflag",
106        ty: BuiltinParamType::NumericScalar,
107        arity: BuiltinParamArity::Required,
108        default: None,
109        description: "Convergence status code.",
110    },
111    BuiltinParamDescriptor {
112        name: "output",
113        ty: BuiltinParamType::Any,
114        arity: BuiltinParamArity::Required,
115        default: None,
116        description: "Iteration/function-count metadata struct.",
117    },
118];
119
120const FMINBND_INPUTS_CORE: [BuiltinParamDescriptor; 3] = [
121    BuiltinParamDescriptor {
122        name: "fun",
123        ty: BuiltinParamType::Any,
124        arity: BuiltinParamArity::Required,
125        default: None,
126        description: "Scalar objective callback.",
127    },
128    BuiltinParamDescriptor {
129        name: "x1",
130        ty: BuiltinParamType::Any,
131        arity: BuiltinParamArity::Required,
132        default: None,
133        description: "Lower bound.",
134    },
135    BuiltinParamDescriptor {
136        name: "x2",
137        ty: BuiltinParamType::Any,
138        arity: BuiltinParamArity::Required,
139        default: None,
140        description: "Upper bound.",
141    },
142];
143
144const FMINBND_INPUTS_WITH_OPTIONS: [BuiltinParamDescriptor; 4] = [
145    BuiltinParamDescriptor {
146        name: "fun",
147        ty: BuiltinParamType::Any,
148        arity: BuiltinParamArity::Required,
149        default: None,
150        description: "Scalar objective callback.",
151    },
152    BuiltinParamDescriptor {
153        name: "x1",
154        ty: BuiltinParamType::Any,
155        arity: BuiltinParamArity::Required,
156        default: None,
157        description: "Lower bound.",
158    },
159    BuiltinParamDescriptor {
160        name: "x2",
161        ty: BuiltinParamType::Any,
162        arity: BuiltinParamArity::Required,
163        default: None,
164        description: "Upper bound.",
165    },
166    BuiltinParamDescriptor {
167        name: "options",
168        ty: BuiltinParamType::Any,
169        arity: BuiltinParamArity::Optional,
170        default: None,
171        description: "Options struct from optimset.",
172    },
173];
174
175const FMINBND_SIGNATURES: [BuiltinSignatureDescriptor; 8] = [
176    BuiltinSignatureDescriptor {
177        label: "x = fminbnd(fun, x1, x2)",
178        inputs: &FMINBND_INPUTS_CORE,
179        outputs: &FMINBND_OUTPUT_X,
180    },
181    BuiltinSignatureDescriptor {
182        label: "x = fminbnd(fun, x1, x2, options)",
183        inputs: &FMINBND_INPUTS_WITH_OPTIONS,
184        outputs: &FMINBND_OUTPUT_X,
185    },
186    BuiltinSignatureDescriptor {
187        label: "[x, fval] = fminbnd(fun, x1, x2)",
188        inputs: &FMINBND_INPUTS_CORE,
189        outputs: &FMINBND_OUTPUT_X_FVAL,
190    },
191    BuiltinSignatureDescriptor {
192        label: "[x, fval] = fminbnd(fun, x1, x2, options)",
193        inputs: &FMINBND_INPUTS_WITH_OPTIONS,
194        outputs: &FMINBND_OUTPUT_X_FVAL,
195    },
196    BuiltinSignatureDescriptor {
197        label: "[x, fval, exitflag] = fminbnd(fun, x1, x2)",
198        inputs: &FMINBND_INPUTS_CORE,
199        outputs: &FMINBND_OUTPUT_X_FVAL_EXITFLAG,
200    },
201    BuiltinSignatureDescriptor {
202        label: "[x, fval, exitflag] = fminbnd(fun, x1, x2, options)",
203        inputs: &FMINBND_INPUTS_WITH_OPTIONS,
204        outputs: &FMINBND_OUTPUT_X_FVAL_EXITFLAG,
205    },
206    BuiltinSignatureDescriptor {
207        label: "[x, fval, exitflag, output] = fminbnd(fun, x1, x2)",
208        inputs: &FMINBND_INPUTS_CORE,
209        outputs: &FMINBND_OUTPUT_ALL,
210    },
211    BuiltinSignatureDescriptor {
212        label: "[x, fval, exitflag, output] = fminbnd(fun, x1, x2, options)",
213        inputs: &FMINBND_INPUTS_WITH_OPTIONS,
214        outputs: &FMINBND_OUTPUT_ALL,
215    },
216];
217
218const FMINBND_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
219    code: "RM.FMINBND.INVALID_ARGUMENT",
220    identifier: Some("RunMat:fminbnd:InvalidArgument"),
221    when: "Argument grammar/options parsing is invalid.",
222    message: "fminbnd: invalid argument",
223};
224
225const FMINBND_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
226    code: "RM.FMINBND.INVALID_INPUT",
227    identifier: Some("RunMat:fminbnd:InvalidInput"),
228    when: "Bounds/callback/input scalar semantics are invalid.",
229    message: "fminbnd: invalid input",
230};
231
232const FMINBND_ERRORS: [BuiltinErrorDescriptor; 2] =
233    [FMINBND_ERROR_INVALID_ARGUMENT, FMINBND_ERROR_INVALID_INPUT];
234
235pub const FMINBND_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
236    signatures: &FMINBND_SIGNATURES,
237    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
238    completion_policy: BuiltinCompletionPolicy::Public,
239    errors: &FMINBND_ERRORS,
240};
241
242fn fminbnd_error_with_detail(
243    error: &'static BuiltinErrorDescriptor,
244    detail: impl AsRef<str>,
245) -> RuntimeError {
246    let detail = detail.as_ref();
247    let message = if detail.starts_with("fminbnd:") {
248        detail.to_string()
249    } else {
250        format!("{}: {detail}", error.message)
251    };
252    let mut builder = build_runtime_error(message).with_builtin(NAME);
253    if let Some(identifier) = error.identifier {
254        builder = builder.with_identifier(identifier);
255    }
256    builder.build()
257}
258
259fn fminbnd_map_error(err: RuntimeError, fallback: &'static BuiltinErrorDescriptor) -> RuntimeError {
260    if err.identifier().is_some() {
261        err
262    } else {
263        fminbnd_error_with_detail(fallback, err.message())
264    }
265}
266
267#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::optim::fminbnd")]
268pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
269    name: "fminbnd",
270    op_kind: GpuOpKind::Custom("bounded-scalar-min"),
271    supported_precisions: &[],
272    broadcast: BroadcastSemantics::None,
273    provider_hooks: &[],
274    constant_strategy: ConstantStrategy::InlineLiteral,
275    residency: ResidencyPolicy::GatherImmediately,
276    nan_mode: ReductionNaN::Include,
277    two_pass_threshold: None,
278    workgroup_size: None,
279    accepts_nan_mode: false,
280    notes: "Host iterative solver. Callback computations may use GPU-aware builtins, but the minimization loop runs on the CPU.",
281};
282
283#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::optim::fminbnd")]
284pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
285    name: "fminbnd",
286    shape: ShapeRequirements::Any,
287    constant_strategy: ConstantStrategy::InlineLiteral,
288    elementwise: None,
289    reduction: None,
290    emits_nan: false,
291    notes:
292        "Bounded scalar minimization repeatedly invokes user code and terminates fusion planning.",
293};
294
295#[runtime_builtin(
296    name = "fminbnd",
297    category = "math/optim",
298    summary = "Find bounded scalar minima with Brent's method.",
299    keywords = "fminbnd,bounded minimization,brent,golden section,parabolic interpolation,optimization",
300    accel = "sink",
301    type_resolver(scalar_root_type),
302    descriptor(crate::builtins::math::optim::fminbnd::FMINBND_DESCRIPTOR),
303    builtin_path = "crate::builtins::math::optim::fminbnd"
304)]
305async fn fminbnd_builtin(
306    function: Value,
307    x1: Value,
308    x2: Value,
309    rest: Vec<Value>,
310) -> BuiltinResult<Value> {
311    if rest.len() > 1 {
312        return Err(fminbnd_error_with_detail(
313            &FMINBND_ERROR_INVALID_ARGUMENT,
314            "too many input arguments",
315        ));
316    }
317    let options_struct = parse_options(rest.first())
318        .map_err(|err| fminbnd_map_error(err, &FMINBND_ERROR_INVALID_ARGUMENT))?;
319    let options = FminbndOptions::from_struct(options_struct.as_ref())
320        .map_err(|err| fminbnd_map_error(err, &FMINBND_ERROR_INVALID_ARGUMENT))?;
321    let x1 = scalar_bound("lower bound", x1)
322        .await
323        .map_err(|err| fminbnd_map_error(err, &FMINBND_ERROR_INVALID_INPUT))?;
324    let x2 = scalar_bound("upper bound", x2)
325        .await
326        .map_err(|err| fminbnd_map_error(err, &FMINBND_ERROR_INVALID_INPUT))?;
327
328    if !x1.is_finite() || !x2.is_finite() {
329        return Err(fminbnd_error_with_detail(
330            &FMINBND_ERROR_INVALID_INPUT,
331            "bounds must be finite",
332        ));
333    }
334    if x1 > x2 {
335        return finalize_inconsistent_bounds(&options);
336    }
337
338    let outcome = run_solver(&function, x1, x2, &options)
339        .await
340        .map_err(|err| fminbnd_map_error(err, &FMINBND_ERROR_INVALID_INPUT))?;
341    finalize(outcome, &options)
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345enum DisplayMode {
346    Off,
347    Iter,
348    Notify,
349    Final,
350}
351
352impl DisplayMode {
353    fn parse(text: &str) -> BuiltinResult<Self> {
354        match text.to_ascii_lowercase().as_str() {
355            "off" | "none" => Ok(Self::Off),
356            "iter" => Ok(Self::Iter),
357            "notify" => Ok(Self::Notify),
358            "final" => Ok(Self::Final),
359            other => Err(fminbnd_error_with_detail(
360                &FMINBND_ERROR_INVALID_ARGUMENT,
361                format!(
362                    "option Display must be 'off', 'iter', 'notify', or 'final', got '{other}'"
363                ),
364            )),
365        }
366    }
367}
368
369#[derive(Debug, Clone, Copy)]
370struct FminbndOptions {
371    tol_x: f64,
372    max_iter: usize,
373    max_fun_evals: usize,
374    display: DisplayMode,
375}
376
377impl FminbndOptions {
378    fn from_struct(options: Option<&StructValue>) -> BuiltinResult<Self> {
379        let display = match options {
380            Some(opts) => match lookup(opts, "Display") {
381                Some(value) => DisplayMode::parse(&option_string("Display", value)?)?,
382                None => DEFAULT_DISPLAY,
383            },
384            None => DEFAULT_DISPLAY,
385        };
386        let tol_x = match options.and_then(|o| lookup(o, "TolX")) {
387            Some(value) => option_f64("TolX", value)?,
388            None => DEFAULT_TOL_X,
389        };
390        if tol_x <= 0.0 {
391            return Err(fminbnd_error_with_detail(
392                &FMINBND_ERROR_INVALID_ARGUMENT,
393                "option TolX must be positive",
394            ));
395        }
396        let max_iter = match options.and_then(|o| lookup(o, "MaxIter")) {
397            Some(value) => option_positive_usize("MaxIter", value)?,
398            None => DEFAULT_MAX_ITER,
399        };
400        let max_fun_evals = match options.and_then(|o| lookup(o, "MaxFunEvals")) {
401            Some(value) => option_positive_usize("MaxFunEvals", value)?,
402            None => DEFAULT_MAX_FUN_EVALS,
403        };
404        Ok(Self {
405            tol_x,
406            max_iter,
407            max_fun_evals,
408            display,
409        })
410    }
411}
412
413fn parse_options(value: Option<&Value>) -> BuiltinResult<Option<StructValue>> {
414    match value {
415        None => Ok(None),
416        Some(Value::Struct(options)) => Ok(Some(options.clone())),
417        Some(other) => Err(fminbnd_error_with_detail(
418            &FMINBND_ERROR_INVALID_ARGUMENT,
419            format!("options must be a struct, got {other:?}"),
420        )),
421    }
422}
423
424fn lookup<'a>(options: &'a StructValue, name: &str) -> Option<&'a Value> {
425    options
426        .fields
427        .iter()
428        .find(|(key, _)| key.eq_ignore_ascii_case(name))
429        .map(|(_, v)| v)
430}
431
432fn option_string(field: &str, value: &Value) -> BuiltinResult<String> {
433    match value {
434        Value::String(s) => Ok(s.clone()),
435        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
436        Value::CharArray(chars) if chars.rows == 1 => Ok(chars.data.iter().collect()),
437        other => Err(fminbnd_error_with_detail(
438            &FMINBND_ERROR_INVALID_ARGUMENT,
439            format!("option {field} must be a string, got {other:?}"),
440        )),
441    }
442}
443
444fn option_f64(field: &str, value: &Value) -> BuiltinResult<f64> {
445    let parsed = match value {
446        Value::Num(n) => *n,
447        Value::Int(i) => i.to_f64(),
448        Value::Bool(b) => {
449            if *b {
450                1.0
451            } else {
452                0.0
453            }
454        }
455        Value::Tensor(Tensor { data, .. }) if data.len() == 1 => data[0],
456        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => {
457            if data[0] != 0 {
458                1.0
459            } else {
460                0.0
461            }
462        }
463        other => {
464            return Err(fminbnd_error_with_detail(
465                &FMINBND_ERROR_INVALID_ARGUMENT,
466                format!("option {field} must be a real scalar, got {other:?}"),
467            ))
468        }
469    };
470    if parsed.is_finite() {
471        Ok(parsed)
472    } else {
473        Err(fminbnd_error_with_detail(
474            &FMINBND_ERROR_INVALID_ARGUMENT,
475            format!("option {field} must be finite"),
476        ))
477    }
478}
479
480fn option_positive_usize(field: &str, value: &Value) -> BuiltinResult<usize> {
481    let parsed = option_f64(field, value)?;
482    if parsed < 1.0 {
483        return Err(fminbnd_error_with_detail(
484            &FMINBND_ERROR_INVALID_ARGUMENT,
485            format!("option {field} must be a positive integer"),
486        ));
487    }
488    if parsed.fract() != 0.0 {
489        return Err(fminbnd_error_with_detail(
490            &FMINBND_ERROR_INVALID_ARGUMENT,
491            format!("option {field} must be an integer scalar"),
492        ));
493    }
494    Ok(parsed as usize)
495}
496
497async fn scalar_bound(label: &str, value: Value) -> BuiltinResult<f64> {
498    let value = crate::dispatcher::gather_if_needed_async(&value).await?;
499    let parsed = match value {
500        Value::Num(n) => n,
501        Value::Int(i) => i.to_f64(),
502        Value::Bool(b) => {
503            if b {
504                1.0
505            } else {
506                0.0
507            }
508        }
509        Value::Tensor(t) if t.data.len() == 1 => t.data[0],
510        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => {
511            if data[0] != 0 {
512                1.0
513            } else {
514                0.0
515            }
516        }
517        other => {
518            return Err(fminbnd_error_with_detail(
519                &FMINBND_ERROR_INVALID_INPUT,
520                format!("{label} must be a finite real scalar, got {other:?}"),
521            ))
522        }
523    };
524    if parsed.is_finite() {
525        Ok(parsed)
526    } else {
527        Err(fminbnd_error_with_detail(
528            &FMINBND_ERROR_INVALID_INPUT,
529            format!("{label} must be finite"),
530        ))
531    }
532}
533
534#[derive(Debug, Clone)]
535struct Outcome {
536    inner: BrentMinResult,
537}
538
539async fn run_solver(
540    function: &Value,
541    lo: f64,
542    hi: f64,
543    options: &FminbndOptions,
544) -> BuiltinResult<Outcome> {
545    let mut iter_log = IterDisplay::new(options.display);
546    let observer: Option<&mut dyn BrentMinObserver> =
547        if matches!(options.display, DisplayMode::Iter) {
548            Some(&mut iter_log)
549        } else {
550            None
551        };
552    let inner = brent_min(
553        NAME,
554        function,
555        lo,
556        hi,
557        BrentParams {
558            tol_x: options.tol_x,
559            max_iter: options.max_iter,
560            max_fun_evals: options.max_fun_evals,
561        },
562        observer,
563    )
564    .await?;
565    Ok(Outcome { inner })
566}
567
568fn finalize(outcome: Outcome, options: &FminbndOptions) -> BuiltinResult<Value> {
569    let exit_flag = if outcome.inner.converged { 1 } else { 0 };
570    let message = build_message(&outcome.inner);
571
572    emit_summary(&outcome.inner, exit_flag, &message, options);
573
574    let x = Value::Num(outcome.inner.x);
575    let fval = Value::Num(outcome.inner.fval);
576    let exitflag = Value::Num(exit_flag as f64);
577    let output_struct = Value::Struct(build_output_struct(&outcome.inner, &message));
578
579    match crate::output_count::current_output_count() {
580        None => Ok(x),
581        Some(0) => Ok(Value::OutputList(Vec::new())),
582        Some(1) => Ok(crate::output_count::output_list_with_padding(1, vec![x])),
583        Some(2) => Ok(crate::output_count::output_list_with_padding(
584            2,
585            vec![x, fval],
586        )),
587        Some(3) => Ok(crate::output_count::output_list_with_padding(
588            3,
589            vec![x, fval, exitflag],
590        )),
591        Some(n) if n >= 4 => Ok(crate::output_count::output_list_with_padding(
592            n,
593            vec![x, fval, exitflag, output_struct],
594        )),
595        Some(_) => Ok(x),
596    }
597}
598
599fn finalize_inconsistent_bounds(options: &FminbndOptions) -> BuiltinResult<Value> {
600    let message = "Exiting: The bounds are inconsistent because x1 > x2.".to_string();
601    emit_invalid_summary(-2, &message, options);
602
603    let x = empty_double();
604    let fval = empty_double();
605    let exitflag = Value::Num(-2.0);
606    let output_struct = Value::Struct(build_invalid_output_struct(&message));
607
608    match crate::output_count::current_output_count() {
609        None => Ok(x),
610        Some(0) => Ok(Value::OutputList(Vec::new())),
611        Some(1) => Ok(crate::output_count::output_list_with_padding(1, vec![x])),
612        Some(2) => Ok(crate::output_count::output_list_with_padding(
613            2,
614            vec![x, fval],
615        )),
616        Some(3) => Ok(crate::output_count::output_list_with_padding(
617            3,
618            vec![x, fval, exitflag],
619        )),
620        Some(n) if n >= 4 => Ok(crate::output_count::output_list_with_padding(
621            n,
622            vec![x, fval, exitflag, output_struct],
623        )),
624        Some(_) => Ok(empty_double()),
625    }
626}
627
628fn empty_double() -> Value {
629    Value::Tensor(Tensor::zeros(vec![0, 0]))
630}
631
632fn build_output_struct(result: &BrentMinResult, message: &str) -> StructValue {
633    let mut fields = StructValue::new();
634    fields.insert("iterations", Value::Num(result.iterations as f64));
635    fields.insert("funcCount", Value::Num(result.func_count as f64));
636    fields.insert("algorithm", Value::from(ALGORITHM));
637    fields.insert("message", Value::from(message.to_string()));
638    fields
639}
640
641fn build_invalid_output_struct(message: &str) -> StructValue {
642    let mut fields = StructValue::new();
643    fields.insert("iterations", Value::Num(0.0));
644    fields.insert("funcCount", Value::Num(0.0));
645    fields.insert("algorithm", Value::from(ALGORITHM));
646    fields.insert("message", Value::from(message.to_string()));
647    fields
648}
649
650fn build_message(result: &BrentMinResult) -> String {
651    if result.converged {
652        format!(
653            "Optimization terminated: the current x satisfies the termination criteria using OPTIONS.TolX. Iterations: {}, FuncCount: {}.",
654            result.iterations, result.func_count
655        )
656    } else {
657        format!(
658            "Exiting: Maximum number of function evaluations or iterations has been exceeded - increase MaxFunEvals or MaxIter. Iterations: {}, FuncCount: {}.",
659            result.iterations, result.func_count
660        )
661    }
662}
663
664fn emit_summary(result: &BrentMinResult, exit_flag: i32, message: &str, options: &FminbndOptions) {
665    let should_emit = match options.display {
666        DisplayMode::Off => false,
667        DisplayMode::Final | DisplayMode::Iter => true,
668        DisplayMode::Notify => exit_flag != 1,
669    };
670    if !should_emit {
671        return;
672    }
673    let line = format!(
674        "fminbnd: x = {x:.6}, fval = {fval:.6}, exitflag = {exit_flag}. {message}",
675        x = result.x,
676        fval = result.fval,
677    );
678    crate::console::record_console_line(crate::console::ConsoleStream::Stdout, line);
679}
680
681fn emit_invalid_summary(exit_flag: i32, message: &str, options: &FminbndOptions) {
682    let should_emit = match options.display {
683        DisplayMode::Off => false,
684        DisplayMode::Final | DisplayMode::Iter => true,
685        DisplayMode::Notify => exit_flag != 1,
686    };
687    if should_emit {
688        crate::console::record_console_line(
689            crate::console::ConsoleStream::Stdout,
690            format!("fminbnd: exitflag = {exit_flag}. {message}"),
691        );
692    }
693}
694
695struct IterDisplay {
696    mode: DisplayMode,
697    printed_header: bool,
698}
699
700impl IterDisplay {
701    fn new(mode: DisplayMode) -> Self {
702        Self {
703            mode,
704            printed_header: false,
705        }
706    }
707}
708
709impl BrentMinObserver for IterDisplay {
710    fn on_iteration(
711        &mut self,
712        iter: usize,
713        func_count: usize,
714        x: f64,
715        fx: f64,
716        step_kind: BrentStepKind,
717    ) {
718        if !matches!(self.mode, DisplayMode::Iter) {
719            return;
720        }
721        if !self.printed_header {
722            crate::console::record_console_line(
723                crate::console::ConsoleStream::Stdout,
724                " Func-count        x          f(x)          Procedure",
725            );
726            self.printed_header = true;
727        }
728        let procedure = match step_kind {
729            BrentStepKind::Initial => "initial",
730            BrentStepKind::GoldenSection => "golden",
731            BrentStepKind::Parabolic => "parabolic",
732        };
733        let line =
734            format!("    {func_count:>5}    {x:13.6e} {fx:13.6e}    {procedure}    (iter {iter})");
735        crate::console::record_console_line(crate::console::ConsoleStream::Stdout, line);
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use crate::builtins::math::optim::brent::brent_min_tolerance;
743    use futures::executor::block_on;
744    use runmat_builtins::Value as V;
745
746    const FMINBND_HELPER_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
747        name: "fx",
748        ty: BuiltinParamType::NumericScalar,
749        arity: BuiltinParamArity::Required,
750        default: None,
751        description: "Objective scalar value.",
752    }];
753
754    const FMINBND_HELPER_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
755        name: "x",
756        ty: BuiltinParamType::NumericScalar,
757        arity: BuiltinParamArity::Required,
758        default: None,
759        description: "Scalar objective input.",
760    }];
761
762    const FMINBND_HELPER_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
763        [BuiltinSignatureDescriptor {
764            label: "fx = __fminbnd_helper(x)",
765            inputs: &FMINBND_HELPER_INPUTS,
766            outputs: &FMINBND_HELPER_OUTPUT,
767        }];
768
769    const FMINBND_HELPER_ERRORS: [BuiltinErrorDescriptor; 0] = [];
770
771    pub const FMINBND_TEST_HELPER_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
772        signatures: &FMINBND_HELPER_SIGNATURES,
773        output_mode: BuiltinOutputMode::Fixed,
774        completion_policy: BuiltinCompletionPolicy::HiddenInternal,
775        errors: &FMINBND_HELPER_ERRORS,
776    };
777
778    fn run_default(handle: &str, lo: f64, hi: f64) -> Value {
779        block_on(fminbnd_builtin(
780            V::FunctionHandle(handle.into()),
781            V::Num(lo),
782            V::Num(hi),
783            Vec::new(),
784        ))
785        .expect("fminbnd")
786    }
787
788    fn run_with(handle: &str, lo: f64, hi: f64, extra: Vec<Value>) -> Value {
789        block_on(fminbnd_builtin(
790            V::FunctionHandle(handle.into()),
791            V::Num(lo),
792            V::Num(hi),
793            extra,
794        ))
795        .expect("fminbnd")
796    }
797
798    #[test]
799    fn fminbnd_test_helper_descriptor_is_attached_shape() {
800        assert_eq!(
801            FMINBND_TEST_HELPER_DESCRIPTOR.signatures[0].label,
802            "fx = __fminbnd_helper(x)"
803        );
804    }
805
806    #[runtime_builtin(
807        name = "__fminbnd_quad_minus_two",
808        type_resolver(crate::builtins::math::optim::type_resolvers::scalar_root_type),
809        descriptor(crate::builtins::math::optim::fminbnd::tests::FMINBND_TEST_HELPER_DESCRIPTOR),
810        builtin_path = "crate::builtins::math::optim::fminbnd::tests"
811    )]
812    async fn quad_minus_two(x: Value) -> crate::BuiltinResult<Value> {
813        let x = scalar_bound("x", x).await?;
814        let diff = x - 2.0;
815        Ok(Value::Num(diff * diff))
816    }
817
818    #[runtime_builtin(
819        name = "__fminbnd_quad_minus_three",
820        type_resolver(crate::builtins::math::optim::type_resolvers::scalar_root_type),
821        descriptor(crate::builtins::math::optim::fminbnd::tests::FMINBND_TEST_HELPER_DESCRIPTOR),
822        builtin_path = "crate::builtins::math::optim::fminbnd::tests"
823    )]
824    async fn quad_minus_three(x: Value) -> crate::BuiltinResult<Value> {
825        let x = scalar_bound("x", x).await?;
826        let diff = x - 3.0;
827        Ok(Value::Num(diff * diff))
828    }
829
830    #[runtime_builtin(
831        name = "__fminbnd_multi_modal",
832        type_resolver(crate::builtins::math::optim::type_resolvers::scalar_root_type),
833        descriptor(crate::builtins::math::optim::fminbnd::tests::FMINBND_TEST_HELPER_DESCRIPTOR),
834        builtin_path = "crate::builtins::math::optim::fminbnd::tests"
835    )]
836    async fn multi_modal(x: Value) -> crate::BuiltinResult<Value> {
837        // 1 + sin(3x) on [0, 2π] — local minima near x ≈ π/2 + 2π/3 (etc.).
838        let x = scalar_bound("x", x).await?;
839        Ok(Value::Num(1.0 + (3.0 * x).sin()))
840    }
841
842    #[test]
843    fn locates_smooth_quadratic_minimum() {
844        let result = run_default("__fminbnd_quad_minus_two", 0.0, 5.0);
845        match result {
846            V::Num(x) => assert!((x - 2.0).abs() < 1.0e-3, "x = {x}"),
847            other => panic!("unexpected value {other:?}"),
848        }
849    }
850
851    #[test]
852    fn locates_quadratic_minimum_offset_three() {
853        let result = run_default("__fminbnd_quad_minus_three", 0.0, 5.0);
854        match result {
855            V::Num(x) => assert!((x - 3.0).abs() < 1.0e-3, "x = {x}"),
856            other => panic!("unexpected value {other:?}"),
857        }
858    }
859
860    #[test]
861    fn locates_cosine_minimum_at_right_endpoint() {
862        // cos(x) is monotonically decreasing on [0, π]; minimum is at x = π.
863        let result = run_default("cos", 0.0, std::f64::consts::PI);
864        match result {
865            V::Num(x) => assert!((x - std::f64::consts::PI).abs() < 1.0e-3, "x = {x}"),
866            other => panic!("unexpected value {other:?}"),
867        }
868    }
869
870    #[test]
871    fn returns_lone_endpoint_when_bounds_collapse() {
872        let result = run_default("__fminbnd_quad_minus_two", 1.5, 1.5);
873        match result {
874            V::Num(x) => assert!((x - 1.5).abs() < 1.0e-12, "x = {x}"),
875            other => panic!("unexpected value {other:?}"),
876        }
877    }
878
879    #[test]
880    fn reports_inconsistent_reversed_bounds() {
881        let _guard = crate::output_count::push_output_count(Some(4));
882        let result = block_on(fminbnd_builtin(
883            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
884            V::Num(5.0),
885            V::Num(0.0),
886            Vec::new(),
887        ))
888        .expect("fminbnd");
889        match result {
890            V::OutputList(outputs) => {
891                assert_eq!(outputs.len(), 4);
892                assert!(matches!(&outputs[0], V::Tensor(t) if t.data.is_empty()));
893                assert!(matches!(&outputs[1], V::Tensor(t) if t.data.is_empty()));
894                assert!(matches!(&outputs[2], V::Num(flag) if *flag == -2.0));
895                match &outputs[3] {
896                    V::Struct(s) => {
897                        assert!(matches!(s.fields.get("iterations"), Some(V::Num(0.0))));
898                        assert!(matches!(s.fields.get("funcCount"), Some(V::Num(0.0))));
899                        match s.fields.get("message") {
900                            Some(V::String(text)) => assert!(text.contains("bounds")),
901                            other => panic!("unexpected message field {other:?}"),
902                        }
903                    }
904                    other => panic!("unexpected output struct {other:?}"),
905                }
906            }
907            other => panic!("unexpected value {other:?}"),
908        }
909    }
910
911    #[test]
912    fn tolerance_is_additive_not_scaled_by_x() {
913        let params = BrentParams {
914            tol_x: 1.0e-4,
915            max_iter: 500,
916            max_fun_evals: 500,
917        };
918        let small = brent_min_tolerance(2.0, params);
919        let large = brent_min_tolerance(1.0e9, params);
920        assert!(small > params.tol_x);
921        assert!(
922            large < params.tol_x * 1.0e9,
923            "large-scale tolerance was {large}"
924        );
925    }
926
927    #[test]
928    fn finds_local_minimum_in_multi_modal_function() {
929        // 1 + sin(3x) on [1.5, 3.5] has its local minimum near x = π/2 ≈ 1.571 (sin(3π/2) = -1).
930        let result = run_default("__fminbnd_multi_modal", 1.5, 3.5);
931        match result {
932            V::Num(x) => {
933                let target = std::f64::consts::PI / 2.0;
934                assert!((x - target).abs() < 5.0e-3, "x = {x}, target = {target}");
935            }
936            other => panic!("unexpected value {other:?}"),
937        }
938    }
939
940    #[test]
941    fn options_struct_overrides_default_tolerance() {
942        let mut opts = StructValue::new();
943        opts.insert("TolX", Value::Num(1.0e-12));
944        let result = run_with(
945            "__fminbnd_quad_minus_two",
946            0.0,
947            5.0,
948            vec![Value::Struct(opts)],
949        );
950        match result {
951            V::Num(x) => assert!((x - 2.0).abs() < 1.0e-6, "x = {x}"),
952            other => panic!("unexpected value {other:?}"),
953        }
954    }
955
956    #[test]
957    fn max_fun_evals_default_is_independent_of_max_iter() {
958        let mut opts = StructValue::new();
959        opts.insert("MaxIter", Value::Num(1000.0));
960        let parsed = FminbndOptions::from_struct(Some(&opts)).unwrap();
961        assert_eq!(parsed.max_iter, 1000);
962        assert_eq!(parsed.max_fun_evals, DEFAULT_MAX_FUN_EVALS);
963    }
964
965    #[test]
966    fn rejects_nonfinite_bounds() {
967        let err = block_on(fminbnd_builtin(
968            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
969            V::Num(f64::NAN),
970            V::Num(5.0),
971            Vec::new(),
972        ))
973        .unwrap_err();
974        assert!(err.message().to_ascii_lowercase().contains("finite"));
975    }
976
977    #[test]
978    fn rejects_invalid_options_type() {
979        let err = block_on(fminbnd_builtin(
980            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
981            V::Num(0.0),
982            V::Num(5.0),
983            vec![Value::Num(1.0)],
984        ))
985        .unwrap_err();
986        assert!(err.message().to_ascii_lowercase().contains("options"));
987    }
988
989    #[test]
990    fn rejects_nonpositive_tol_x() {
991        let mut opts = StructValue::new();
992        opts.insert("TolX", Value::Num(0.0));
993        let err = block_on(fminbnd_builtin(
994            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
995            V::Num(0.0),
996            V::Num(5.0),
997            vec![Value::Struct(opts)],
998        ))
999        .unwrap_err();
1000        assert!(err.message().to_lowercase().contains("tolx"));
1001    }
1002
1003    #[test]
1004    fn rejects_unknown_display_value() {
1005        let mut opts = StructValue::new();
1006        opts.insert("Display", Value::from("loud"));
1007        let err = block_on(fminbnd_builtin(
1008            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
1009            V::Num(0.0),
1010            V::Num(5.0),
1011            vec![Value::Struct(opts)],
1012        ))
1013        .unwrap_err();
1014        assert!(err.message().to_lowercase().contains("display"));
1015    }
1016
1017    #[test]
1018    fn multi_output_two_returns_x_and_fval() {
1019        let _guard = crate::output_count::push_output_count(Some(2));
1020        let result = block_on(fminbnd_builtin(
1021            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
1022            V::Num(0.0),
1023            V::Num(5.0),
1024            Vec::new(),
1025        ))
1026        .expect("fminbnd");
1027        match result {
1028            V::OutputList(outputs) => {
1029                assert_eq!(outputs.len(), 2);
1030                match (&outputs[0], &outputs[1]) {
1031                    (V::Num(x), V::Num(fval)) => {
1032                        assert!((x - 2.0).abs() < 1.0e-3);
1033                        assert!(fval.abs() < 1.0e-5);
1034                    }
1035                    other => panic!("unexpected outputs {other:?}"),
1036                }
1037            }
1038            other => panic!("unexpected value {other:?}"),
1039        }
1040    }
1041
1042    #[test]
1043    fn multi_output_three_includes_exitflag() {
1044        let _guard = crate::output_count::push_output_count(Some(3));
1045        let result = block_on(fminbnd_builtin(
1046            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
1047            V::Num(0.0),
1048            V::Num(5.0),
1049            Vec::new(),
1050        ))
1051        .expect("fminbnd");
1052        match result {
1053            V::OutputList(outputs) => {
1054                assert_eq!(outputs.len(), 3);
1055                match &outputs[2] {
1056                    V::Num(flag) => assert!((*flag - 1.0).abs() < 1.0e-12),
1057                    other => panic!("unexpected exitflag {other:?}"),
1058                }
1059            }
1060            other => panic!("unexpected value {other:?}"),
1061        }
1062    }
1063
1064    #[test]
1065    fn multi_output_four_includes_output_struct() {
1066        let _guard = crate::output_count::push_output_count(Some(4));
1067        let result = block_on(fminbnd_builtin(
1068            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
1069            V::Num(0.0),
1070            V::Num(5.0),
1071            Vec::new(),
1072        ))
1073        .expect("fminbnd");
1074        match result {
1075            V::OutputList(outputs) => {
1076                assert_eq!(outputs.len(), 4);
1077                match &outputs[3] {
1078                    V::Struct(s) => {
1079                        assert!(matches!(s.fields.get("iterations"), Some(V::Num(_))));
1080                        assert!(matches!(s.fields.get("funcCount"), Some(V::Num(_))));
1081                        match s.fields.get("algorithm") {
1082                            Some(V::String(text)) => assert!(text.contains("golden")),
1083                            other => panic!("unexpected algorithm field {other:?}"),
1084                        }
1085                        assert!(s.fields.get("message").is_some());
1086                    }
1087                    other => panic!("unexpected output struct {other:?}"),
1088                }
1089            }
1090            other => panic!("unexpected value {other:?}"),
1091        }
1092    }
1093
1094    #[test]
1095    fn reports_zero_exitflag_when_max_iter_exhausted() {
1096        let mut opts = StructValue::new();
1097        opts.insert("MaxIter", Value::Num(1.0));
1098        opts.insert("MaxFunEvals", Value::Num(2.0));
1099        opts.insert("Display", Value::from("off"));
1100        let _guard = crate::output_count::push_output_count(Some(3));
1101        let result = block_on(fminbnd_builtin(
1102            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
1103            V::Num(0.0),
1104            V::Num(5.0),
1105            vec![Value::Struct(opts)],
1106        ))
1107        .expect("fminbnd");
1108        match result {
1109            V::OutputList(outputs) => match &outputs[2] {
1110                V::Num(flag) => assert_eq!(*flag, 0.0),
1111                other => panic!("unexpected exitflag {other:?}"),
1112            },
1113            other => panic!("unexpected value {other:?}"),
1114        }
1115    }
1116
1117    #[test]
1118    fn fminbnd_descriptor_signatures_cover_core_forms() {
1119        let labels: Vec<&str> = FMINBND_DESCRIPTOR
1120            .signatures
1121            .iter()
1122            .map(|signature| signature.label)
1123            .collect();
1124        assert_eq!(
1125            labels,
1126            vec![
1127                "x = fminbnd(fun, x1, x2)",
1128                "x = fminbnd(fun, x1, x2, options)",
1129                "[x, fval] = fminbnd(fun, x1, x2)",
1130                "[x, fval] = fminbnd(fun, x1, x2, options)",
1131                "[x, fval, exitflag] = fminbnd(fun, x1, x2)",
1132                "[x, fval, exitflag] = fminbnd(fun, x1, x2, options)",
1133                "[x, fval, exitflag, output] = fminbnd(fun, x1, x2)",
1134                "[x, fval, exitflag, output] = fminbnd(fun, x1, x2, options)",
1135            ]
1136        );
1137
1138        let codes: Vec<&str> = FMINBND_DESCRIPTOR
1139            .errors
1140            .iter()
1141            .map(|error| error.code)
1142            .collect();
1143        assert_eq!(
1144            codes,
1145            vec!["RM.FMINBND.INVALID_ARGUMENT", "RM.FMINBND.INVALID_INPUT"]
1146        );
1147    }
1148
1149    #[test]
1150    fn fminbnd_too_many_args_uses_stable_identifier() {
1151        let err = block_on(fminbnd_builtin(
1152            V::FunctionHandle("__fminbnd_quad_minus_two".into()),
1153            V::Num(0.0),
1154            V::Num(5.0),
1155            vec![
1156                Value::Struct(StructValue::new()),
1157                Value::Struct(StructValue::new()),
1158            ],
1159        ))
1160        .unwrap_err();
1161        assert_eq!(err.identifier(), Some("RunMat:fminbnd:InvalidArgument"));
1162    }
1163}