Skip to main content

runmat_runtime/builtins/math/optim/
fzero.rs

1//! MATLAB-compatible `fzero` builtin for scalar nonlinear root finding.
2//!
3//! `fzero` searches for a scalar zero from either a two-point bracket or a
4//! scalar initial guess.  It supports MATLAB's four output arities:
5//!
6//! * `x = fzero(fun, x0)`
7//! * `x = fzero(fun, x0, options)`
8//! * `[x, fval] = fzero(...)`
9//! * `[x, fval, exitflag] = fzero(...)`
10//! * `[x, fval, exitflag, output] = fzero(...)`
11
12use runmat_builtins::{
13    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
14    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
15    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
16    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
17    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
18    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
19};
20use runmat_macros::runtime_builtin;
21use runmat_value::{StructValue, Tensor, Value};
22
23use crate::builtins::common::spec::{
24    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
25    ReductionNaN, ResidencyPolicy, ShapeRequirements,
26};
27use crate::builtins::common::tensor;
28use crate::builtins::math::optim::brent::{
29    brent_zero, BrentParams, BrentZeroBracket, BrentZeroObserver, BrentZeroResult,
30    BrentZeroStepKind,
31};
32use crate::builtins::math::optim::common::{
33    call_scalar_function_with_precision, call_scalar_function_with_precision_info, option_f64,
34    option_string, option_usize,
35};
36use crate::builtins::math::optim::type_resolvers::scalar_root_type;
37use crate::{build_runtime_error, BuiltinResult, RuntimeError};
38
39const NAME: &str = "fzero";
40const ALGORITHM: &str = "bisection, interpolation";
41const DEFAULT_TOL_X: f64 = f64::EPSILON;
42const DEFAULT_MAX_ITER: usize = 400;
43const DEFAULT_MAX_FUN_EVALS: usize = 500;
44
45const FZERO_OUTPUT_X: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
46    name: "x",
47    ty: BuiltinParamType::NumericScalar,
48    arity: BuiltinParamArity::Required,
49    default: None,
50    description: "Estimated root location.",
51}];
52
53const FZERO_OUTPUT_X_FVAL: [BuiltinParamDescriptor; 2] = [
54    BuiltinParamDescriptor {
55        name: "x",
56        ty: BuiltinParamType::NumericScalar,
57        arity: BuiltinParamArity::Required,
58        default: None,
59        description: "Estimated root location.",
60    },
61    BuiltinParamDescriptor {
62        name: "fval",
63        ty: BuiltinParamType::NumericScalar,
64        arity: BuiltinParamArity::Required,
65        default: None,
66        description: "Function value at x.",
67    },
68];
69
70const FZERO_OUTPUT_X_FVAL_EXITFLAG: [BuiltinParamDescriptor; 3] = [
71    BuiltinParamDescriptor {
72        name: "x",
73        ty: BuiltinParamType::NumericScalar,
74        arity: BuiltinParamArity::Required,
75        default: None,
76        description: "Estimated root location.",
77    },
78    BuiltinParamDescriptor {
79        name: "fval",
80        ty: BuiltinParamType::NumericScalar,
81        arity: BuiltinParamArity::Required,
82        default: None,
83        description: "Function value at x.",
84    },
85    BuiltinParamDescriptor {
86        name: "exitflag",
87        ty: BuiltinParamType::NumericScalar,
88        arity: BuiltinParamArity::Required,
89        default: None,
90        description: "Convergence status code.",
91    },
92];
93
94const FZERO_OUTPUT_ALL: [BuiltinParamDescriptor; 4] = [
95    BuiltinParamDescriptor {
96        name: "x",
97        ty: BuiltinParamType::NumericScalar,
98        arity: BuiltinParamArity::Required,
99        default: None,
100        description: "Estimated root location.",
101    },
102    BuiltinParamDescriptor {
103        name: "fval",
104        ty: BuiltinParamType::NumericScalar,
105        arity: BuiltinParamArity::Required,
106        default: None,
107        description: "Function value at x.",
108    },
109    BuiltinParamDescriptor {
110        name: "exitflag",
111        ty: BuiltinParamType::NumericScalar,
112        arity: BuiltinParamArity::Required,
113        default: None,
114        description: "Convergence status code.",
115    },
116    BuiltinParamDescriptor {
117        name: "output",
118        ty: BuiltinParamType::Any,
119        arity: BuiltinParamArity::Required,
120        default: None,
121        description: "Iteration/function-count metadata struct.",
122    },
123];
124
125const FZERO_INPUTS_CORE: [BuiltinParamDescriptor; 2] = [
126    BuiltinParamDescriptor {
127        name: "fun",
128        ty: BuiltinParamType::Any,
129        arity: BuiltinParamArity::Required,
130        default: None,
131        description: "Scalar-valued callback.",
132    },
133    BuiltinParamDescriptor {
134        name: "x0",
135        ty: BuiltinParamType::Any,
136        arity: BuiltinParamArity::Required,
137        default: None,
138        description: "Initial point or two-element bracket.",
139    },
140];
141
142const FZERO_INPUTS_WITH_OPTIONS: [BuiltinParamDescriptor; 3] = [
143    BuiltinParamDescriptor {
144        name: "fun",
145        ty: BuiltinParamType::Any,
146        arity: BuiltinParamArity::Required,
147        default: None,
148        description: "Scalar-valued callback.",
149    },
150    BuiltinParamDescriptor {
151        name: "x0",
152        ty: BuiltinParamType::Any,
153        arity: BuiltinParamArity::Required,
154        default: None,
155        description: "Initial point or two-element bracket.",
156    },
157    BuiltinParamDescriptor {
158        name: "options",
159        ty: BuiltinParamType::Any,
160        arity: BuiltinParamArity::Optional,
161        default: None,
162        description: "Options struct from optimset.",
163    },
164];
165
166const FZERO_SIGNATURES: [BuiltinSignatureDescriptor; 8] = [
167    BuiltinSignatureDescriptor {
168        label: "x = fzero(fun, x0)",
169        inputs: &FZERO_INPUTS_CORE,
170        outputs: &FZERO_OUTPUT_X,
171    },
172    BuiltinSignatureDescriptor {
173        label: "x = fzero(fun, x0, options)",
174        inputs: &FZERO_INPUTS_WITH_OPTIONS,
175        outputs: &FZERO_OUTPUT_X,
176    },
177    BuiltinSignatureDescriptor {
178        label: "[x, fval] = fzero(fun, x0)",
179        inputs: &FZERO_INPUTS_CORE,
180        outputs: &FZERO_OUTPUT_X_FVAL,
181    },
182    BuiltinSignatureDescriptor {
183        label: "[x, fval] = fzero(fun, x0, options)",
184        inputs: &FZERO_INPUTS_WITH_OPTIONS,
185        outputs: &FZERO_OUTPUT_X_FVAL,
186    },
187    BuiltinSignatureDescriptor {
188        label: "[x, fval, exitflag] = fzero(fun, x0)",
189        inputs: &FZERO_INPUTS_CORE,
190        outputs: &FZERO_OUTPUT_X_FVAL_EXITFLAG,
191    },
192    BuiltinSignatureDescriptor {
193        label: "[x, fval, exitflag] = fzero(fun, x0, options)",
194        inputs: &FZERO_INPUTS_WITH_OPTIONS,
195        outputs: &FZERO_OUTPUT_X_FVAL_EXITFLAG,
196    },
197    BuiltinSignatureDescriptor {
198        label: "[x, fval, exitflag, output] = fzero(fun, x0)",
199        inputs: &FZERO_INPUTS_CORE,
200        outputs: &FZERO_OUTPUT_ALL,
201    },
202    BuiltinSignatureDescriptor {
203        label: "[x, fval, exitflag, output] = fzero(fun, x0, options)",
204        inputs: &FZERO_INPUTS_WITH_OPTIONS,
205        outputs: &FZERO_OUTPUT_ALL,
206    },
207];
208
209const FZERO_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
210    code: "RM.FZERO.INVALID_ARGUMENT",
211    identifier: Some("RunMat:fzero:InvalidArgument"),
212    when: "Argument grammar/options struct are invalid.",
213    message: "fzero: invalid argument",
214};
215
216const FZERO_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
217    code: "RM.FZERO.INVALID_INPUT",
218    identifier: Some("RunMat:fzero:InvalidInput"),
219    when: "Callback/bracket/initial-point semantics are invalid.",
220    message: "fzero: invalid input",
221};
222
223const FZERO_ERROR_TOO_MANY_OUTPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
224    code: "RM.FZERO.TOO_MANY_OUTPUTS",
225    identifier: Some("RunMat:fzero:TooManyOutputs"),
226    when: "`fzero` is called with more than four requested output arguments.",
227    message: "fzero: too many output arguments",
228};
229
230const FZERO_ERRORS: [BuiltinErrorDescriptor; 3] = [
231    FZERO_ERROR_INVALID_ARGUMENT,
232    FZERO_ERROR_INVALID_INPUT,
233    FZERO_ERROR_TOO_MANY_OUTPUTS,
234];
235
236const FZERO_INTEGER_INITIAL_INPUTS: [BuiltinIntegerInputCapability; 1] =
237    [BuiltinIntegerInputCapability {
238        name: "x0 or [a,b]",
239        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
240        availability: BuiltinIntegerInputAvailability::RunMatOnly,
241        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
242        notes: "Typed-integer initial points and intervals are independently gated and every value must convert exactly to binary64.",
243    }];
244const FZERO_INTEGER_CALLBACK_INPUTS: [BuiltinIntegerInputCapability; 1] =
245    [BuiltinIntegerInputCapability {
246        name: "fun(x) result",
247        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
248        availability: BuiltinIntegerInputAvailability::RunMatOnly,
249        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
250        notes: "Typed-integer function values are gated before resident gather and must convert exactly to binary64.",
251    }];
252const FZERO_INTEGER_TOLERANCE_INPUTS: [BuiltinIntegerInputCapability; 1] =
253    [BuiltinIntegerInputCapability {
254        name: "TolX",
255        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
256        availability: BuiltinIntegerInputAvailability::RunMatOnly,
257        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
258        notes: "Typed-integer TolX is independently gated and must convert exactly to a positive binary64 scalar.",
259    }];
260const FZERO_INTEGER_COUNT_INPUTS: [BuiltinIntegerInputCapability; 2] = [
261    BuiltinIntegerInputCapability {
262        name: "MaxIter",
263        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
264        availability: BuiltinIntegerInputAvailability::RunMatOnly,
265        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
266        notes: "Typed-integer iteration counts are independently gated and decoded exactly through platform bounds.",
267    },
268    BuiltinIntegerInputCapability {
269        name: "MaxFunEvals",
270        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
271        availability: BuiltinIntegerInputAvailability::RunMatOnly,
272        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
273        notes: "Typed-integer evaluation counts are independently gated and decoded exactly through platform bounds.",
274    },
275];
276pub const FZERO_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 4] = [
277    BuiltinIntegerCapabilityDescriptor {
278        form: "x = fzero(fun, integer_x0_or_interval, options)",
279        inputs: &FZERO_INTEGER_INITIAL_INPUTS,
280        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
281        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
282        overflow: BuiltinIntegerOverflowRule::Error,
283        backend: BuiltinIntegerBackendRule::GatherFallback,
284        overload: BuiltinIntegerOverloadKind::Multiple,
285        notes: "Documented x0 supports single or double, not typed-integer classes. Strict compatibility rejects typed integers; RunMat mode admits exact binary64 values and returns double x, fval, and exitflag. A documented single x0 separately yields single numeric outputs.",
286    },
287    BuiltinIntegerCapabilityDescriptor {
288        form: "fzero callback returns an integer function value",
289        inputs: &FZERO_INTEGER_CALLBACK_INPUTS,
290        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
291        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
292        overflow: BuiltinIntegerOverflowRule::Error,
293        backend: BuiltinIntegerBackendRule::GatherFallback,
294        overload: BuiltinIntegerOverloadKind::ScalarOnly,
295        notes: "Strict compatibility rejects typed function values before provider access. RunMat mode converts exact values at the binary64 solver boundary; numeric output precision remains determined by x0.",
296    },
297    BuiltinIntegerCapabilityDescriptor {
298        form: "fzero(..., options.TolX=integer)",
299        inputs: &FZERO_INTEGER_TOLERANCE_INPUTS,
300        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
301        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
302        overflow: BuiltinIntegerOverflowRule::Error,
303        backend: BuiltinIntegerBackendRule::HostOnly,
304        overload: BuiltinIntegerOverloadKind::StructuralParameter,
305        notes: "Typed-integer TolX is RunMat-only. It overrides the precision-specific default without changing output precision.",
306    },
307    BuiltinIntegerCapabilityDescriptor {
308        form: "fzero(..., options.MaxIter=integer, options.MaxFunEvals=integer)",
309        inputs: &FZERO_INTEGER_COUNT_INPUTS,
310        computation_domain: BuiltinIntegerComputationDomain::Structural,
311        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
312        overflow: BuiltinIntegerOverflowRule::Error,
313        backend: BuiltinIntegerBackendRule::HostOnly,
314        overload: BuiltinIntegerOverloadKind::StructuralParameter,
315        notes: "The documented controls are integer-valued numeric scalars, not documented typed-integer classes. RunMat's typed forms preserve exact counts without a binary64 round trip.",
316    },
317];
318
319pub(crate) const FZERO_INPUT_NUMERIC_EXTENSION: BuiltinExtensionDescriptor =
320    BuiltinExtensionDescriptor {
321        id: "fzero-nonfloating-initial-point",
322        mode: BuiltinExtensionMode::RunMatOnly,
323        description:
324            "fzero with a typed-integer or logical initial point or interval is a RunMat extension",
325        error_identifier: Some("RunMat:compatibility:FzeroNumericInputExtension"),
326    };
327pub(crate) const FZERO_CALLBACK_NUMERIC_EXTENSION: BuiltinExtensionDescriptor =
328    BuiltinExtensionDescriptor {
329        id: "fzero-nonfloating-callback-output",
330        mode: BuiltinExtensionMode::RunMatOnly,
331        description: "fzero with typed-integer or logical function values is a RunMat extension",
332        error_identifier: Some("RunMat:compatibility:FzeroCallbackExtension"),
333    };
334pub(crate) const FZERO_OPTION_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
335    id: "fzero-typed-option-controls",
336    mode: BuiltinExtensionMode::RunMatOnly,
337    description: "fzero with typed-integer option controls is a RunMat extension",
338    error_identifier: Some("RunMat:compatibility:FzeroOptionExtension"),
339};
340pub(crate) const FZERO_RESIDENT_EXTENSION: BuiltinExtensionDescriptor =
341    BuiltinExtensionDescriptor {
342        id: "fzero-resident-fallback",
343        mode: BuiltinExtensionMode::RunMatOnly,
344        description:
345            "fzero with provider-resident numeric input or callback output is a RunMat extension",
346        error_identifier: Some("RunMat:compatibility:FzeroResidentExtension"),
347    };
348pub const FZERO_EXTENSIONS: [BuiltinExtensionDescriptor; 4] = [
349    FZERO_INPUT_NUMERIC_EXTENSION,
350    FZERO_CALLBACK_NUMERIC_EXTENSION,
351    FZERO_OPTION_EXTENSION,
352    FZERO_RESIDENT_EXTENSION,
353];
354
355pub const FZERO_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
356    signatures: &FZERO_SIGNATURES,
357    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
358    completion_policy: BuiltinCompletionPolicy::Public,
359    errors: &FZERO_ERRORS,
360};
361
362fn fzero_error_with_detail(
363    error: &'static BuiltinErrorDescriptor,
364    detail: impl AsRef<str>,
365) -> RuntimeError {
366    let detail = detail.as_ref();
367    let message = if detail.starts_with("fzero:") {
368        detail.to_string()
369    } else {
370        format!("{}: {detail}", error.message)
371    };
372    let mut builder = build_runtime_error(message).with_builtin(NAME);
373    if let Some(identifier) = error.identifier {
374        builder = builder.with_identifier(identifier);
375    }
376    builder.build()
377}
378
379fn fzero_map_error(err: RuntimeError, fallback: &'static BuiltinErrorDescriptor) -> RuntimeError {
380    if err.identifier().is_some() {
381        err
382    } else {
383        fzero_error_with_detail(fallback, err.message())
384    }
385}
386
387fn validate_requested_outputs() -> BuiltinResult<()> {
388    if matches!(crate::output_count::current_output_count(), Some(n) if n > 4) {
389        return Err(fzero_too_many_outputs_error());
390    }
391    Ok(())
392}
393
394fn fzero_too_many_outputs_error() -> RuntimeError {
395    fzero_error_with_detail(
396        &FZERO_ERROR_TOO_MANY_OUTPUTS,
397        "fzero: too many output arguments; maximum is 4",
398    )
399}
400
401#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::optim::fzero")]
402pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
403    name: "fzero",
404    op_kind: GpuOpKind::Custom("scalar-root-find"),
405    supported_precisions: &[],
406    broadcast: BroadcastSemantics::None,
407    provider_hooks: &[],
408    constant_strategy: ConstantStrategy::InlineLiteral,
409    residency: ResidencyPolicy::GatherImmediately,
410    nan_mode: ReductionNaN::Include,
411    two_pass_threshold: None,
412    workgroup_size: None,
413    accepts_nan_mode: false,
414    notes: "Host iterative solver. Callback values may use GPU-aware builtins, but the root search runs on the CPU.",
415};
416
417#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::optim::fzero")]
418pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
419    name: "fzero",
420    shape: ShapeRequirements::Any,
421    constant_strategy: ConstantStrategy::InlineLiteral,
422    elementwise: None,
423    reduction: None,
424    emits_nan: false,
425    notes: "Root finding repeatedly invokes user code and terminates fusion planning.",
426};
427
428#[runtime_builtin(
429    name = "fzero",
430    category = "math/optim",
431    summary = "Find scalar function zeros with bracketed root-finding.",
432    keywords = "fzero,root finding,zero,brent,optimization",
433    accel = "sink",
434    type_resolver(scalar_root_type),
435    descriptor(crate::builtins::math::optim::fzero::FZERO_DESCRIPTOR),
436    extensions(crate::builtins::math::optim::fzero::FZERO_EXTENSIONS),
437    integer_capabilities(crate::builtins::math::optim::fzero::FZERO_INTEGER_CAPABILITIES),
438    builtin_path = "crate::builtins::math::optim::fzero"
439)]
440async fn fzero_builtin(function: Value, x: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
441    if rest.len() > 1 {
442        return Err(fzero_error_with_detail(
443            &FZERO_ERROR_INVALID_ARGUMENT,
444            "too many input arguments",
445        ));
446    }
447    validate_requested_outputs()?;
448    let options = parse_options(rest.first())
449        .map_err(|err| fzero_map_error(err, &FZERO_ERROR_INVALID_ARGUMENT))?;
450    let mut opts = FzeroOptions::from_struct(options.as_ref())
451        .map_err(|err| fzero_map_error(err, &FZERO_ERROR_INVALID_ARGUMENT))?;
452    let x = crate::builtins::math::optim::common::prepare_floating_value(
453        NAME,
454        x,
455        &FZERO_INPUT_NUMERIC_EXTENSION,
456        &FZERO_RESIDENT_EXTENSION,
457        "initial point or interval",
458    )
459    .await
460    .map_err(|err| fzero_map_error(err, &FZERO_ERROR_INVALID_INPUT))?;
461    let initial_single = matches!(&x, Value::Tensor(tensor) if tensor.numeric_dtype() == runmat_value::NumericDType::F32);
462    let bracket = initial_bracket(&function, x, &opts, initial_single)
463        .await
464        .map_err(|err| fzero_map_error(err, &FZERO_ERROR_INVALID_INPUT))?;
465    if !opts.tol_x_explicit {
466        opts.tol_x = default_tol_x(initial_single || bracket.function_single);
467    }
468    let mut iter_log = IterDisplay::new(opts.display);
469    let observer: Option<&mut dyn BrentZeroObserver> = if matches!(opts.display, DisplayMode::Iter)
470    {
471        Some(&mut iter_log)
472    } else {
473        None
474    };
475    let result = brent_zero(
476        NAME,
477        &function,
478        BrentZeroBracket {
479            a: bracket.a,
480            b: bracket.b,
481            fa: bracket.fa,
482            fb: bracket.fb,
483            evals: bracket.evals,
484        },
485        BrentParams {
486            tol_x: opts.tol_x,
487            max_iter: opts.max_iter,
488            max_fun_evals: opts.max_fun_evals,
489        },
490        initial_single,
491        observer,
492    )
493    .await
494    .map_err(|err| fzero_map_error(err, &FZERO_ERROR_INVALID_INPUT))?;
495    finalize(result, &opts, initial_single)
496}
497
498fn default_tol_x(single: bool) -> f64 {
499    if single {
500        f32::EPSILON as f64
501    } else {
502        f64::EPSILON
503    }
504}
505
506fn parse_options(value: Option<&Value>) -> BuiltinResult<Option<StructValue>> {
507    match value {
508        None => Ok(None),
509        Some(Value::Struct(options)) => Ok(Some(options.clone())),
510        Some(other) => Err(fzero_error_with_detail(
511            &FZERO_ERROR_INVALID_ARGUMENT,
512            format!("options must be a struct, got {other:?}"),
513        )),
514    }
515}
516
517#[derive(Clone, Copy)]
518struct FzeroOptions {
519    tol_x: f64,
520    tol_x_explicit: bool,
521    max_iter: usize,
522    max_fun_evals: usize,
523    display: DisplayMode,
524}
525
526impl FzeroOptions {
527    fn from_struct(options: Option<&StructValue>) -> BuiltinResult<Self> {
528        crate::builtins::math::optim::common::ensure_option_extensions(
529            NAME,
530            options,
531            &FZERO_OPTION_EXTENSION,
532            &FZERO_RESIDENT_EXTENSION,
533        )?;
534        let display = DisplayMode::parse(&option_string(options, "Display", "off")?)?;
535        let tol_x = option_f64(NAME, options, "TolX", DEFAULT_TOL_X)?;
536        if tol_x <= 0.0 {
537            return Err(fzero_error_with_detail(
538                &FZERO_ERROR_INVALID_ARGUMENT,
539                "option TolX must be positive",
540            ));
541        }
542        let max_iter = option_usize(NAME, options, "MaxIter", DEFAULT_MAX_ITER)?;
543        let max_fun_evals = option_usize(NAME, options, "MaxFunEvals", DEFAULT_MAX_FUN_EVALS)?;
544        Ok(Self {
545            tol_x,
546            tol_x_explicit: options.is_some_and(|options| {
547                options
548                    .fields
549                    .keys()
550                    .any(|key| key.eq_ignore_ascii_case("TolX"))
551            }),
552            max_iter: max_iter.max(1),
553            max_fun_evals: max_fun_evals.max(1),
554            display,
555        })
556    }
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560enum DisplayMode {
561    Off,
562    Iter,
563    Final,
564}
565
566impl DisplayMode {
567    fn parse(text: &str) -> BuiltinResult<Self> {
568        match text.to_ascii_lowercase().as_str() {
569            "off" | "none" => Ok(Self::Off),
570            "iter" => Ok(Self::Iter),
571            "final" => Ok(Self::Final),
572            other => Err(fzero_error_with_detail(
573                &FZERO_ERROR_INVALID_ARGUMENT,
574                format!("option Display must be 'off', 'none', 'final', or 'iter', got '{other}'"),
575            )),
576        }
577    }
578}
579
580#[derive(Clone, Copy)]
581struct Bracket {
582    a: f64,
583    b: f64,
584    fa: f64,
585    fb: f64,
586    evals: usize,
587    function_single: bool,
588}
589
590async fn initial_bracket(
591    function: &Value,
592    x: Value,
593    options: &FzeroOptions,
594    single: bool,
595) -> BuiltinResult<Bracket> {
596    let x = crate::dispatcher::gather_if_needed_async(&x).await?;
597    match x {
598        Value::Tensor(tensor) => {
599            let values = tensor::tensor_values_f64(&tensor);
600            match values.as_slice() {
601                [a, b] => bracket_from_endpoints(function, *a, *b, single).await,
602                [guess] => expand_bracket(function, *guess, options, single).await,
603                _ => Err(fzero_error_with_detail(
604                    &FZERO_ERROR_INVALID_INPUT,
605                    "initial point must be a scalar or two-element bracket",
606                )),
607            }
608        }
609        Value::Num(n) => expand_bracket(function, n, options, single).await,
610        Value::Int(i) => expand_bracket(function, i.to_f64(), options, single).await,
611        Value::Bool(b) => {
612            expand_bracket(function, if b { 1.0 } else { 0.0 }, options, single).await
613        }
614        other => Err(fzero_error_with_detail(
615            &FZERO_ERROR_INVALID_INPUT,
616            format!("initial point must be a scalar or two-element bracket, got {other:?}"),
617        )),
618    }
619}
620
621async fn bracket_from_endpoints(
622    function: &Value,
623    a: f64,
624    b: f64,
625    single: bool,
626) -> BuiltinResult<Bracket> {
627    if !a.is_finite() || !b.is_finite() || a == b {
628        return Err(fzero_error_with_detail(
629            &FZERO_ERROR_INVALID_INPUT,
630            "bracket endpoints must be finite and distinct",
631        ));
632    }
633    let (fa, fa_single) =
634        call_scalar_function_with_precision_info(NAME, function, a, single).await?;
635    if fa == 0.0 {
636        return Ok(Bracket {
637            a,
638            b: a,
639            fa,
640            fb: fa,
641            evals: 1,
642            function_single: fa_single,
643        });
644    }
645    let (fb, fb_single) =
646        call_scalar_function_with_precision_info(NAME, function, b, single).await?;
647    if fb == 0.0 || fa.signum() != fb.signum() {
648        Ok(Bracket {
649            a,
650            b,
651            fa,
652            fb,
653            evals: 2,
654            function_single: fa_single || fb_single,
655        })
656    } else {
657        Err(fzero_error_with_detail(
658            &FZERO_ERROR_INVALID_INPUT,
659            "function values at bracket endpoints must differ in sign",
660        ))
661    }
662}
663
664async fn expand_bracket(
665    function: &Value,
666    x0: f64,
667    options: &FzeroOptions,
668    single: bool,
669) -> BuiltinResult<Bracket> {
670    if !x0.is_finite() {
671        return Err(fzero_error_with_detail(
672            &FZERO_ERROR_INVALID_INPUT,
673            "initial point must be finite",
674        ));
675    }
676    let (f0, function_single) =
677        call_scalar_function_with_precision_info(NAME, function, x0, single).await?;
678    if f0 == 0.0 {
679        return Ok(Bracket {
680            a: x0,
681            b: x0,
682            fa: f0,
683            fb: f0,
684            evals: 1,
685            function_single,
686        });
687    }
688
689    let mut evals = 1usize;
690    let mut step = (x0.abs() * 0.01).max(0.01);
691    while evals + 2 <= options.max_fun_evals {
692        let a = x0 - step;
693        let b = x0 + step;
694        let fa = call_scalar_function_with_precision(NAME, function, a, single).await?;
695        let fb = call_scalar_function_with_precision(NAME, function, b, single).await?;
696        evals += 2;
697        if fa == 0.0 {
698            return Ok(Bracket {
699                a,
700                b: a,
701                fa,
702                fb: fa,
703                evals,
704                function_single,
705            });
706        }
707        if fa.signum() != f0.signum() {
708            return Ok(Bracket {
709                a,
710                b: x0,
711                fa,
712                fb: f0,
713                evals,
714                function_single,
715            });
716        }
717        if fb.signum() != f0.signum() {
718            return Ok(Bracket {
719                a: x0,
720                b,
721                fa: f0,
722                fb,
723                evals,
724                function_single,
725            });
726        }
727        if fb == 0.0 || fa.signum() != fb.signum() {
728            return Ok(Bracket {
729                a,
730                b,
731                fa,
732                fb,
733                evals,
734                function_single,
735            });
736        }
737        step *= 1.6;
738    }
739
740    Err(fzero_error_with_detail(
741        &FZERO_ERROR_INVALID_INPUT,
742        "could not find a sign-changing bracket around the initial point",
743    ))
744}
745
746fn finalize(result: BrentZeroResult, options: &FzeroOptions, single: bool) -> BuiltinResult<Value> {
747    let exit_flag = if result.converged { 1 } else { 0 };
748    let message = build_message(&result);
749    emit_summary(&result, exit_flag, &message, options);
750
751    let scalar = |value: f64| -> BuiltinResult<Value> {
752        if single {
753            Tensor::new_with_dtype(vec![value], vec![1, 1], runmat_value::NumericDType::F32)
754                .map(Value::Tensor)
755                .map_err(|error| fzero_error_with_detail(&FZERO_ERROR_INVALID_INPUT, error))
756        } else {
757            Ok(Value::Num(value))
758        }
759    };
760    let x = scalar(result.x)?;
761    let fval = scalar(result.fval)?;
762    let exitflag = scalar(exit_flag as f64)?;
763    let output_struct = Value::Struct(build_output_struct(&result, &message));
764
765    match crate::output_count::current_output_count() {
766        None => Ok(x),
767        Some(0) => Ok(Value::OutputList(Vec::new())),
768        Some(1) => Ok(crate::output_count::output_list_with_padding(1, vec![x])),
769        Some(2) => Ok(crate::output_count::output_list_with_padding(
770            2,
771            vec![x, fval],
772        )),
773        Some(3) => Ok(crate::output_count::output_list_with_padding(
774            3,
775            vec![x, fval, exitflag],
776        )),
777        Some(4) => Ok(crate::output_count::output_list_with_padding(
778            4,
779            vec![x, fval, exitflag, output_struct],
780        )),
781        Some(_) => Err(fzero_too_many_outputs_error()),
782    }
783}
784
785fn build_output_struct(result: &BrentZeroResult, message: &str) -> StructValue {
786    let mut fields = StructValue::new();
787    fields.insert("iterations", Value::Num(result.iterations as f64));
788    fields.insert("funcCount", Value::Num(result.func_count as f64));
789    fields.insert("algorithm", Value::from(ALGORITHM));
790    fields.insert("message", Value::from(message.to_string()));
791    fields
792}
793
794fn build_message(result: &BrentZeroResult) -> String {
795    if result.converged {
796        format!(
797            "Zero found within OPTIONS.TolX. Iterations: {}, FuncCount: {}.",
798            result.iterations, result.func_count
799        )
800    } else {
801        format!(
802            "Exiting: Maximum number of function evaluations or iterations has been exceeded - increase MaxFunEvals or MaxIter. Iterations: {}, FuncCount: {}.",
803            result.iterations, result.func_count
804        )
805    }
806}
807
808fn emit_summary(result: &BrentZeroResult, exit_flag: i32, message: &str, options: &FzeroOptions) {
809    if !matches!(options.display, DisplayMode::Final | DisplayMode::Iter) {
810        return;
811    }
812    crate::console::record_console_line(
813        crate::console::ConsoleStream::Stdout,
814        format!(
815            "fzero: x = {x:.6}, fval = {fval:.6}, exitflag = {exit_flag}. {message}",
816            x = result.x,
817            fval = result.fval,
818        ),
819    );
820}
821
822struct IterDisplay {
823    mode: DisplayMode,
824    printed_header: bool,
825}
826
827impl IterDisplay {
828    fn new(mode: DisplayMode) -> Self {
829        Self {
830            mode,
831            printed_header: false,
832        }
833    }
834}
835
836impl BrentZeroObserver for IterDisplay {
837    fn on_iteration(
838        &mut self,
839        iter: usize,
840        func_count: usize,
841        x: f64,
842        fx: f64,
843        step_kind: BrentZeroStepKind,
844    ) {
845        if !matches!(self.mode, DisplayMode::Iter) {
846            return;
847        }
848        if !self.printed_header {
849            crate::console::record_console_line(
850                crate::console::ConsoleStream::Stdout,
851                " Func-count        x          f(x)          Procedure",
852            );
853            self.printed_header = true;
854        }
855        let procedure = match step_kind {
856            BrentZeroStepKind::Initial => "initial",
857            BrentZeroStepKind::Bisection => "bisection",
858            BrentZeroStepKind::Interpolation => "interpolation",
859        };
860        let line =
861            format!("    {func_count:>5}    {x:13.6e} {fx:13.6e}    {procedure}    (iter {iter})");
862        crate::console::record_console_line(crate::console::ConsoleStream::Stdout, line);
863    }
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use crate::builtins::math::optim::brent::interpolation_step_accepted;
870    use futures::executor::block_on;
871    use runmat_value::{IntegerStorage, Tensor};
872    use std::sync::Arc;
873
874    #[test]
875    fn fzero_bracketed_builtin_handle() {
876        let bracket = Tensor::new(vec![3.0, 4.0], vec![1, 2]).unwrap();
877        let root = block_on(fzero_builtin(
878            Value::FunctionHandle("sin".into()),
879            Value::Tensor(bracket),
880            Vec::new(),
881        ))
882        .unwrap();
883        match root {
884            Value::Num(n) => assert!((n - std::f64::consts::PI).abs() < 1.0e-6),
885            other => panic!("unexpected value {other:?}"),
886        }
887    }
888
889    #[test]
890    fn fzero_single_interval_returns_single_numerical_outputs() {
891        let bracket =
892            Tensor::new_with_dtype(vec![3.0, 4.0], vec![1, 2], runmat_value::NumericDType::F32)
893                .unwrap();
894        let _outputs = crate::output_count::push_output_count(Some(3));
895        let result = block_on(fzero_builtin(
896            Value::FunctionHandle("sin".into()),
897            Value::Tensor(bracket),
898            Vec::new(),
899        ))
900        .unwrap();
901        let Value::OutputList(outputs) = result else {
902            panic!("expected three outputs");
903        };
904        assert_eq!(outputs.len(), 3);
905        for output in outputs {
906            assert!(
907                matches!(output, Value::Tensor(tensor) if tensor.numeric_dtype() == runmat_value::NumericDType::F32)
908            );
909        }
910    }
911
912    #[test]
913    fn fzero_single_precision_is_explicitly_threaded_through_all_callback_calls() {
914        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
915        let observed_calls = calls.clone();
916        let _invoker = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
917            move |_function, args, _requested_outputs| {
918                observed_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
919                let Value::Tensor(argument) = &args[0] else {
920                    panic!("expected a single-precision scalar tensor")
921                };
922                assert_eq!(argument.numeric_dtype(), runmat_value::NumericDType::F32);
923                let residual = argument.materialize_f64()[0] - std::f64::consts::PI;
924                Box::pin(async move {
925                    Ok(Value::Tensor(
926                        Tensor::new_with_dtype(
927                            vec![residual],
928                            vec![1, 1],
929                            runmat_value::NumericDType::F32,
930                        )
931                        .unwrap(),
932                    ))
933                })
934            },
935        )));
936        let bracket =
937            Tensor::new_with_dtype(vec![3.0, 4.0], vec![1, 2], runmat_value::NumericDType::F32)
938                .unwrap();
939        let root = block_on(fzero_builtin(
940            Value::BoundFunctionHandle {
941                name: "single_root".to_string(),
942                function: 7_709,
943            },
944            Value::Tensor(bracket),
945            Vec::new(),
946        ))
947        .unwrap();
948        assert!(
949            matches!(root, Value::Tensor(tensor) if tensor.numeric_dtype() == runmat_value::NumericDType::F32)
950        );
951        assert!(calls.load(std::sync::atomic::Ordering::Relaxed) > 2);
952    }
953
954    #[test]
955    fn fzero_single_callback_result_selects_single_default_tolerance() {
956        let _invoker = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
957            |_function, args, _requested_outputs| {
958                assert!(matches!(args[0], Value::Num(_)));
959                let x = match &args[0] {
960                    Value::Num(value) => *value,
961                    _ => unreachable!(),
962                };
963                Box::pin(async move {
964                    Ok(Value::Tensor(
965                        Tensor::new_with_dtype(
966                            vec![x - 1.0],
967                            vec![1, 1],
968                            runmat_value::NumericDType::F32,
969                        )
970                        .unwrap(),
971                    ))
972                })
973            },
974        )));
975        let options = FzeroOptions::from_struct(None).unwrap();
976        let bracket = block_on(bracket_from_endpoints(
977            &Value::BoundFunctionHandle {
978                name: "single_result".to_string(),
979                function: 7_710,
980            },
981            0.0,
982            2.0,
983            false,
984        ))
985        .unwrap();
986        assert!(bracket.function_single);
987        assert_eq!(default_tol_x(bracket.function_single), f32::EPSILON as f64);
988        assert_eq!(default_tol_x(false), f64::EPSILON);
989        assert!(!options.tol_x_explicit);
990    }
991
992    #[test]
993    fn fzero_bracket_reads_typed_integer_storage_exactly() {
994        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
995        let bracket =
996            Tensor::new_integer(IntegerStorage::U16(vec![3, 4]), vec![1, 2]).expect("bracket");
997
998        let root = block_on(fzero_builtin(
999            Value::FunctionHandle("sin".into()),
1000            Value::Tensor(bracket),
1001            Vec::new(),
1002        ))
1003        .unwrap();
1004        match root {
1005            Value::Num(n) => assert!((n - std::f64::consts::PI).abs() < 1.0e-6),
1006            other => panic!("unexpected value {other:?}"),
1007        }
1008    }
1009
1010    #[test]
1011    fn fzero_scalar_initial_guess_expands_bracket() {
1012        let root = block_on(fzero_builtin(
1013            Value::FunctionHandle("cos".into()),
1014            Value::Num(1.0),
1015            Vec::new(),
1016        ))
1017        .unwrap();
1018        match root {
1019            Value::Num(n) => assert!((n - std::f64::consts::FRAC_PI_2).abs() < 1.0e-6),
1020            other => panic!("unexpected value {other:?}"),
1021        }
1022    }
1023
1024    #[test]
1025    fn fzero_initial_guess_reads_typed_integer_storage_exactly() {
1026        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
1027        let guess = Tensor::new_integer(IntegerStorage::U16(vec![1]), vec![1, 1]).expect("guess");
1028
1029        let root = block_on(fzero_builtin(
1030            Value::FunctionHandle("cos".into()),
1031            Value::Tensor(guess),
1032            Vec::new(),
1033        ))
1034        .unwrap();
1035        match root {
1036            Value::Num(n) => assert!((n - std::f64::consts::FRAC_PI_2).abs() < 1.0e-6),
1037            other => panic!("unexpected value {other:?}"),
1038        }
1039    }
1040
1041    #[test]
1042    fn fzero_scalar_initial_guess_uses_center_sign_for_bracket() {
1043        let root = block_on(fzero_builtin(
1044            Value::FunctionHandle("sin".into()),
1045            Value::Num(std::f64::consts::FRAC_PI_2),
1046            Vec::new(),
1047        ))
1048        .unwrap();
1049        match root {
1050            Value::Num(n) => assert!(n.abs() < 1.0e-6),
1051            other => panic!("unexpected value {other:?}"),
1052        }
1053    }
1054    #[test]
1055    fn fzero_accepts_semantic_function_handle_callback() {
1056        let _invoker = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
1057            |function, args, requested_outputs| {
1058                assert_eq!(function, 42);
1059                assert_eq!(requested_outputs, 1);
1060                let x = match &args[0] {
1061                    Value::Num(value) => *value,
1062                    other => panic!("expected scalar numeric argument, got {other:?}"),
1063                };
1064                Box::pin(async move { Ok(Value::Num(x - 2.0)) })
1065            },
1066        )));
1067
1068        let root = block_on(fzero_builtin(
1069            Value::BoundFunctionHandle {
1070                name: "root_function".to_string(),
1071                function: 42,
1072            },
1073            Value::Num(0.0),
1074            Vec::new(),
1075        ))
1076        .unwrap();
1077        match root {
1078            Value::Num(n) => assert!((n - 2.0).abs() < 1.0e-6),
1079            other => panic!("unexpected value {other:?}"),
1080        }
1081    }
1082
1083    #[test]
1084    fn fzero_multi_output_two_returns_root_and_fval() {
1085        let _guard = crate::output_count::push_output_count(Some(2));
1086        let bracket = Tensor::new(vec![3.0, 4.0], vec![1, 2]).unwrap();
1087        let result = block_on(fzero_builtin(
1088            Value::FunctionHandle("sin".into()),
1089            Value::Tensor(bracket),
1090            Vec::new(),
1091        ))
1092        .expect("fzero");
1093        match result {
1094            Value::OutputList(outputs) => {
1095                assert_eq!(outputs.len(), 2);
1096                match (&outputs[0], &outputs[1]) {
1097                    (Value::Num(x), Value::Num(fval)) => {
1098                        assert!((x - std::f64::consts::PI).abs() < 1.0e-6);
1099                        assert!(fval.abs() < 1.0e-6);
1100                    }
1101                    other => panic!("unexpected outputs {other:?}"),
1102                }
1103            }
1104            other => panic!("unexpected value {other:?}"),
1105        }
1106    }
1107
1108    #[test]
1109    fn fzero_multi_output_four_includes_output_struct() {
1110        let _guard = crate::output_count::push_output_count(Some(4));
1111        let bracket = Tensor::new(vec![3.0, 4.0], vec![1, 2]).unwrap();
1112        let result = block_on(fzero_builtin(
1113            Value::FunctionHandle("sin".into()),
1114            Value::Tensor(bracket),
1115            Vec::new(),
1116        ))
1117        .expect("fzero");
1118        match result {
1119            Value::OutputList(outputs) => {
1120                assert_eq!(outputs.len(), 4);
1121                assert!(matches!(&outputs[2], Value::Num(flag) if *flag == 1.0));
1122                match &outputs[3] {
1123                    Value::Struct(output) => {
1124                        assert!(matches!(
1125                            output.fields.get("iterations"),
1126                            Some(Value::Num(_))
1127                        ));
1128                        assert!(matches!(
1129                            output.fields.get("funcCount"),
1130                            Some(Value::Num(_))
1131                        ));
1132                        match output.fields.get("algorithm") {
1133                            Some(Value::String(text)) => assert!(text.contains("bisection")),
1134                            other => panic!("unexpected algorithm field {other:?}"),
1135                        }
1136                        match output.fields.get("message") {
1137                            Some(Value::String(text)) => assert!(text.contains("Zero found")),
1138                            other => panic!("unexpected message field {other:?}"),
1139                        }
1140                    }
1141                    other => panic!("unexpected output struct {other:?}"),
1142                }
1143            }
1144            other => panic!("unexpected value {other:?}"),
1145        }
1146    }
1147
1148    #[test]
1149    fn fzero_reports_zero_exitflag_when_iteration_budget_exhausted() {
1150        let mut opts = StructValue::new();
1151        opts.insert("MaxIter", Value::Num(1.0));
1152        opts.insert("MaxFunEvals", Value::Num(2.0));
1153        opts.insert("Display", Value::from("off"));
1154        let _guard = crate::output_count::push_output_count(Some(3));
1155        let bracket = Tensor::new(vec![3.0, 4.0], vec![1, 2]).unwrap();
1156        let result = block_on(fzero_builtin(
1157            Value::FunctionHandle("sin".into()),
1158            Value::Tensor(bracket),
1159            vec![Value::Struct(opts)],
1160        ))
1161        .expect("fzero");
1162        match result {
1163            Value::OutputList(outputs) => match &outputs[2] {
1164                Value::Num(flag) => assert_eq!(*flag, 0.0),
1165                other => panic!("unexpected exitflag {other:?}"),
1166            },
1167            other => panic!("unexpected value {other:?}"),
1168        }
1169    }
1170
1171    #[test]
1172    fn fzero_reports_convergence_when_final_step_hits_root() {
1173        let mut opts = StructValue::new();
1174        opts.insert("MaxIter", Value::Num(1.0));
1175        opts.insert("Display", Value::from("off"));
1176        let _guard = crate::output_count::push_output_count(Some(3));
1177        let bracket = Tensor::new(vec![-1.0, 1.0], vec![1, 2]).unwrap();
1178        let result = block_on(fzero_builtin(
1179            Value::FunctionHandle("sin".into()),
1180            Value::Tensor(bracket),
1181            vec![Value::Struct(opts)],
1182        ))
1183        .expect("fzero");
1184        match result {
1185            Value::OutputList(outputs) => {
1186                assert!(matches!(&outputs[0], Value::Num(x) if x.abs() < 1.0e-12));
1187                assert!(matches!(&outputs[1], Value::Num(fval) if fval.abs() < 1.0e-12));
1188                assert!(matches!(&outputs[2], Value::Num(flag) if *flag == 1.0));
1189            }
1190            other => panic!("unexpected value {other:?}"),
1191        }
1192    }
1193
1194    #[test]
1195    fn fzero_rejects_more_than_four_outputs() {
1196        let _guard = crate::output_count::push_output_count(Some(5));
1197        let bracket = Tensor::new(vec![3.0, 4.0], vec![1, 2]).unwrap();
1198        let err = block_on(fzero_builtin(
1199            Value::FunctionHandle("sin".into()),
1200            Value::Tensor(bracket),
1201            Vec::new(),
1202        ))
1203        .expect_err("too many outputs should fail");
1204        assert_eq!(err.identifier(), Some("RunMat:fzero:TooManyOutputs"));
1205        assert!(err.message().contains("maximum is 4"));
1206    }
1207
1208    #[test]
1209    fn fzero_iter_display_records_iteration_rows() {
1210        crate::console::reset_thread_buffer();
1211        let mut opts = StructValue::new();
1212        opts.insert("Display", Value::from("iter"));
1213        let bracket = Tensor::new(vec![3.0, 4.0], vec![1, 2]).unwrap();
1214        let result = block_on(fzero_builtin(
1215            Value::FunctionHandle("sin".into()),
1216            Value::Tensor(bracket),
1217            vec![Value::Struct(opts)],
1218        ))
1219        .expect("fzero");
1220        assert!(matches!(result, Value::Num(_)));
1221
1222        let joined = crate::console::take_thread_buffer()
1223            .into_iter()
1224            .map(|entry| entry.text)
1225            .collect::<String>();
1226        assert!(joined.contains("Func-count"), "{joined}");
1227        assert!(joined.contains("initial"), "{joined}");
1228        assert!(
1229            joined.contains("interpolation") || joined.contains("bisection"),
1230            "{joined}"
1231        );
1232        assert!(joined.contains("exitflag = 1"), "{joined}");
1233    }
1234
1235    #[test]
1236    fn brent_interpolation_acceptance_uses_signed_q() {
1237        assert!(!interpolation_step_accepted(1.0, -2.0, 1.0, 0.1, 10.0));
1238        assert!(interpolation_step_accepted(1.0, -2.0, -1.0, 0.1, 10.0));
1239    }
1240
1241    #[test]
1242    fn fzero_descriptor_signatures_cover_core_forms() {
1243        let labels: Vec<&str> = FZERO_DESCRIPTOR
1244            .signatures
1245            .iter()
1246            .map(|signature| signature.label)
1247            .collect();
1248        assert_eq!(
1249            labels,
1250            vec![
1251                "x = fzero(fun, x0)",
1252                "x = fzero(fun, x0, options)",
1253                "[x, fval] = fzero(fun, x0)",
1254                "[x, fval] = fzero(fun, x0, options)",
1255                "[x, fval, exitflag] = fzero(fun, x0)",
1256                "[x, fval, exitflag] = fzero(fun, x0, options)",
1257                "[x, fval, exitflag, output] = fzero(fun, x0)",
1258                "[x, fval, exitflag, output] = fzero(fun, x0, options)",
1259            ]
1260        );
1261
1262        let codes: Vec<&str> = FZERO_DESCRIPTOR
1263            .errors
1264            .iter()
1265            .map(|error| error.code)
1266            .collect();
1267        assert_eq!(
1268            codes,
1269            vec![
1270                "RM.FZERO.INVALID_ARGUMENT",
1271                "RM.FZERO.INVALID_INPUT",
1272                "RM.FZERO.TOO_MANY_OUTPUTS",
1273            ]
1274        );
1275    }
1276
1277    #[test]
1278    fn fzero_too_many_args_uses_stable_identifier() {
1279        let err = block_on(fzero_builtin(
1280            Value::FunctionHandle("sin".into()),
1281            Value::Num(0.0),
1282            vec![
1283                Value::Struct(StructValue::new()),
1284                Value::Struct(StructValue::new()),
1285            ],
1286        ))
1287        .unwrap_err();
1288        assert_eq!(err.identifier(), Some("RunMat:fzero:InvalidArgument"));
1289    }
1290}