Skip to main content

use_numerical/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Small approximate numerical helpers for `RustUse`.
5
6#[doc(inline)]
7pub use approx::{approx_eq, clamp_epsilon, relative_eq};
8#[doc(inline)]
9pub use difference::{backward_difference, central_difference, forward_difference};
10#[doc(inline)]
11pub use integration::{midpoint_rule, rectangle_rule, simpsons_rule, trapezoidal_rule};
12#[cfg(feature = "interval")]
13#[doc(inline)]
14pub use root::bisection_interval;
15#[doc(inline)]
16pub use root::{RootError, RootOptions, bisection, newton_raphson};
17
18/// Floating-point comparison helpers for approximate numerical work.
19pub mod approx {
20    /// Normalizes an epsilon to a non-negative finite value.
21    ///
22    /// Negative finite epsilons are converted to their absolute value. `NaN`
23    /// becomes `0.0`, and infinite values clamp to [`f64::MAX`].
24    #[must_use]
25    pub const fn clamp_epsilon(epsilon: f64) -> f64 {
26        if epsilon.is_nan() {
27            0.0
28        } else if epsilon.is_infinite() {
29            f64::MAX
30        } else {
31            epsilon.abs()
32        }
33    }
34
35    /// Returns `true` when `a` and `b` are within an absolute epsilon.
36    ///
37    /// `NaN` never compares equal. Equal infinities compare equal. Mixed
38    /// finite and non-finite values compare unequal.
39    #[must_use]
40    pub fn approx_eq(a: f64, b: f64, epsilon: f64) -> bool {
41        if a.is_nan() || b.is_nan() {
42            return false;
43        }
44
45        if a == b {
46            return true;
47        }
48
49        if !a.is_finite() || !b.is_finite() {
50            return false;
51        }
52
53        (a - b).abs() <= clamp_epsilon(epsilon)
54    }
55
56    /// Returns `true` when `a` and `b` are within a relative epsilon.
57    ///
58    /// Near zero, this falls back to an absolute comparison by using a scale
59    /// factor of at least `1.0`. `NaN` never compares equal. Equal infinities
60    /// compare equal.
61    #[must_use]
62    pub fn relative_eq(a: f64, b: f64, epsilon: f64) -> bool {
63        if a.is_nan() || b.is_nan() {
64            return false;
65        }
66
67        if a == b {
68            return true;
69        }
70
71        if !a.is_finite() || !b.is_finite() {
72            return false;
73        }
74
75        let epsilon = clamp_epsilon(epsilon);
76        let scale = a.abs().max(b.abs()).max(1.0);
77
78        (a - b).abs() <= epsilon * scale
79    }
80}
81
82/// Finite-difference helpers for first-derivative approximation.
83pub mod difference {
84    /// Approximates the first derivative with a forward difference.
85    ///
86    /// This computes `(f(x + h) - f(x)) / h`. When `h == 0.0`, the result may
87    /// be infinite or `NaN` according to normal floating-point behavior.
88    #[must_use]
89    pub fn forward_difference<F>(f: F, x: f64, h: f64) -> f64
90    where
91        F: Fn(f64) -> f64,
92    {
93        (f(x + h) - f(x)) / h
94    }
95
96    /// Approximates the first derivative with a backward difference.
97    ///
98    /// This computes `(f(x) - f(x - h)) / h`. When `h == 0.0`, the result may
99    /// be infinite or `NaN` according to normal floating-point behavior.
100    #[must_use]
101    pub fn backward_difference<F>(f: F, x: f64, h: f64) -> f64
102    where
103        F: Fn(f64) -> f64,
104    {
105        (f(x) - f(x - h)) / h
106    }
107
108    /// Approximates the first derivative with a central difference.
109    ///
110    /// This computes `(f(x + h) - f(x - h)) / (2h)`. When `h == 0.0`, the
111    /// result may be infinite or `NaN` according to normal floating-point
112    /// behavior.
113    #[must_use]
114    pub fn central_difference<F>(f: F, x: f64, h: f64) -> f64
115    where
116        F: Fn(f64) -> f64,
117    {
118        (f(x + h) - f(x - h)) / (2.0 * h)
119    }
120}
121
122/// Deterministic numerical integration rules over `f64` intervals.
123pub mod integration {
124    #[allow(clippy::cast_precision_loss)]
125    const fn usize_to_f64(value: usize) -> f64 {
126        value as f64
127    }
128
129    fn normalized_bounds(a: f64, b: f64) -> Option<(f64, f64, f64)> {
130        if !a.is_finite() || !b.is_finite() {
131            return None;
132        }
133
134        if a <= b {
135            Some((a, b, 1.0))
136        } else {
137            Some((b, a, -1.0))
138        }
139    }
140
141    /// Approximates an integral with the left-rectangle rule.
142    ///
143    /// Returns `None` when `n == 0`, when the bounds are not finite, or when a
144    /// sampled function value is not finite. Reversed bounds return the
145    /// negative integral.
146    #[must_use]
147    pub fn rectangle_rule<F>(
148        integrand: F,
149        lower_bound: f64,
150        upper_bound: f64,
151        subinterval_count: usize,
152    ) -> Option<f64>
153    where
154        F: Fn(f64) -> f64,
155    {
156        if subinterval_count == 0 {
157            return None;
158        }
159
160        let (start, end, sign) = normalized_bounds(lower_bound, upper_bound)?;
161        let step = (end - start) / usize_to_f64(subinterval_count);
162        let mut sum = 0.0;
163
164        for sample_index in 0..subinterval_count {
165            let sample_point = usize_to_f64(sample_index).mul_add(step, start);
166            let value = integrand(sample_point);
167            if !value.is_finite() {
168                return None;
169            }
170
171            sum += value;
172        }
173
174        Some(sign * sum * step)
175    }
176
177    /// Approximates an integral with the midpoint rule.
178    ///
179    /// Returns `None` when `n == 0`, when the bounds are not finite, or when a
180    /// sampled function value is not finite. Reversed bounds return the
181    /// negative integral.
182    #[must_use]
183    pub fn midpoint_rule<F>(
184        integrand: F,
185        lower_bound: f64,
186        upper_bound: f64,
187        subinterval_count: usize,
188    ) -> Option<f64>
189    where
190        F: Fn(f64) -> f64,
191    {
192        if subinterval_count == 0 {
193            return None;
194        }
195
196        let (start, end, sign) = normalized_bounds(lower_bound, upper_bound)?;
197        let step = (end - start) / usize_to_f64(subinterval_count);
198        let mut sum = 0.0;
199
200        for sample_index in 0..subinterval_count {
201            let sample_point = (usize_to_f64(sample_index) + 0.5).mul_add(step, start);
202            let value = integrand(sample_point);
203            if !value.is_finite() {
204                return None;
205            }
206
207            sum += value;
208        }
209
210        Some(sign * sum * step)
211    }
212
213    /// Approximates an integral with the trapezoidal rule.
214    ///
215    /// Returns `None` when `n == 0`, when the bounds are not finite, or when a
216    /// sampled function value is not finite. Reversed bounds return the
217    /// negative integral.
218    #[must_use]
219    pub fn trapezoidal_rule<F>(
220        integrand: F,
221        lower_bound: f64,
222        upper_bound: f64,
223        subinterval_count: usize,
224    ) -> Option<f64>
225    where
226        F: Fn(f64) -> f64,
227    {
228        if subinterval_count == 0 {
229            return None;
230        }
231
232        let (start, end, sign) = normalized_bounds(lower_bound, upper_bound)?;
233        let step = (end - start) / usize_to_f64(subinterval_count);
234        let start_value = integrand(start);
235        let end_value = integrand(end);
236
237        if !start_value.is_finite() || !end_value.is_finite() {
238            return None;
239        }
240
241        let mut sum = 0.5 * (start_value + end_value);
242
243        for sample_index in 1..subinterval_count {
244            let sample_point = usize_to_f64(sample_index).mul_add(step, start);
245            let value = integrand(sample_point);
246            if !value.is_finite() {
247                return None;
248            }
249
250            sum += value;
251        }
252
253        Some(sign * sum * step)
254    }
255
256    /// Approximates an integral with Simpson's rule.
257    ///
258    /// Returns `None` when `n == 0`, when `n` is odd, when the bounds are not
259    /// finite, or when a sampled function value is not finite. Reversed bounds
260    /// return the negative integral.
261    #[must_use]
262    pub fn simpsons_rule<F>(
263        integrand: F,
264        lower_bound: f64,
265        upper_bound: f64,
266        subinterval_count: usize,
267    ) -> Option<f64>
268    where
269        F: Fn(f64) -> f64,
270    {
271        if subinterval_count == 0 || !subinterval_count.is_multiple_of(2) {
272            return None;
273        }
274
275        let (start, end, sign) = normalized_bounds(lower_bound, upper_bound)?;
276        let step = (end - start) / usize_to_f64(subinterval_count);
277        let start_value = integrand(start);
278        let end_value = integrand(end);
279
280        if !start_value.is_finite() || !end_value.is_finite() {
281            return None;
282        }
283
284        let mut sum = start_value + end_value;
285
286        for sample_index in 1..subinterval_count {
287            let sample_point = usize_to_f64(sample_index).mul_add(step, start);
288            let value = integrand(sample_point);
289            if !value.is_finite() {
290                return None;
291            }
292
293            if sample_index.is_multiple_of(2) {
294                sum += 2.0 * value;
295            } else {
296                sum += 4.0 * value;
297            }
298        }
299
300        Some(sign * sum * step / 3.0)
301    }
302}
303
304/// Iterative approximate root-finding helpers.
305pub mod root {
306    use crate::approx::approx_eq;
307
308    #[cfg(feature = "interval")]
309    use use_interval::{Bound, Interval};
310
311    /// Configuration for iterative approximate root finders.
312    #[derive(Debug, Clone, Copy, PartialEq)]
313    pub struct RootOptions {
314        /// Absolute tolerance used for convergence and zero checks.
315        pub tolerance: f64,
316        /// Maximum number of solver iterations before returning an error.
317        pub max_iterations: usize,
318    }
319
320    impl Default for RootOptions {
321        fn default() -> Self {
322            Self {
323                tolerance: 1e-10,
324                max_iterations: 100,
325            }
326        }
327    }
328
329    /// Failure modes for approximate iterative root finders.
330    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
331    pub enum RootError {
332        /// The initial interval does not bracket a root.
333        InvalidInterval,
334        /// The configured tolerance is not finite and positive.
335        InvalidTolerance,
336        /// The solver did not converge within the iteration budget.
337        MaxIterationsReached,
338        /// A function evaluation, derivative evaluation, or iterate became non-finite.
339        NonFiniteValue,
340        /// A Newton-Raphson step encountered an approximately zero derivative.
341        ZeroDerivative,
342    }
343
344    fn validate_options(options: RootOptions) -> Result<(), RootError> {
345        if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
346            Err(RootError::InvalidTolerance)
347        } else {
348            Ok(())
349        }
350    }
351
352    fn same_sign(left: f64, right: f64) -> bool {
353        (left < 0.0 && right < 0.0) || (left > 0.0 && right > 0.0)
354    }
355
356    fn bisection_with_policy<F>(
357        f: &F,
358        lower: f64,
359        upper: f64,
360        options: RootOptions,
361        allow_lower_endpoint: bool,
362        allow_upper_endpoint: bool,
363    ) -> Result<f64, RootError>
364    where
365        F: Fn(f64) -> f64,
366    {
367        validate_options(options)?;
368
369        if !lower.is_finite() || !upper.is_finite() || lower > upper {
370            return Err(RootError::InvalidInterval);
371        }
372
373        let lower_value = f(lower);
374        let upper_value = f(upper);
375
376        if !lower_value.is_finite() || !upper_value.is_finite() {
377            return Err(RootError::NonFiniteValue);
378        }
379
380        if approx_eq(lower_value, 0.0, options.tolerance) {
381            return if allow_lower_endpoint {
382                Ok(lower)
383            } else {
384                Err(RootError::InvalidInterval)
385            };
386        }
387
388        if approx_eq(upper_value, 0.0, options.tolerance) {
389            return if allow_upper_endpoint {
390                Ok(upper)
391            } else {
392                Err(RootError::InvalidInterval)
393            };
394        }
395
396        if same_sign(lower_value, upper_value) {
397            return Err(RootError::InvalidInterval);
398        }
399
400        let mut left = lower;
401        let mut right = upper;
402        let mut left_value = lower_value;
403
404        for _ in 0..options.max_iterations {
405            let midpoint = (right - left).mul_add(0.5, left);
406            let midpoint_value = f(midpoint);
407
408            if !midpoint.is_finite() || !midpoint_value.is_finite() {
409                return Err(RootError::NonFiniteValue);
410            }
411
412            if approx_eq(midpoint_value, 0.0, options.tolerance)
413                || (right - left).abs() * 0.5 <= options.tolerance
414            {
415                return Ok(midpoint);
416            }
417
418            if same_sign(left_value, midpoint_value) {
419                left = midpoint;
420                left_value = midpoint_value;
421            } else {
422                right = midpoint;
423            }
424        }
425
426        Err(RootError::MaxIterationsReached)
427    }
428
429    /// Finds a root with the bisection method on a bracketing interval.
430    ///
431    /// The endpoint values must have opposite signs, unless an endpoint is
432    /// already approximately zero. The interval bounds and function values must
433    /// be finite. Convergence uses [`RootOptions::tolerance`] as an absolute
434    /// tolerance.
435    ///
436    /// # Errors
437    ///
438    /// Returns [`RootError::InvalidInterval`] when the bounds are non-finite,
439    /// reversed, or do not bracket a root; [`RootError::InvalidTolerance`]
440    /// when the tolerance is not finite and positive;
441    /// [`RootError::NonFiniteValue`] when a function evaluation is non-finite;
442    /// and [`RootError::MaxIterationsReached`] when the iteration budget is
443    /// exhausted without convergence.
444    pub fn bisection<F>(
445        f: F,
446        lower: f64,
447        upper: f64,
448        options: RootOptions,
449    ) -> Result<f64, RootError>
450    where
451        F: Fn(f64) -> f64,
452    {
453        bisection_with_policy(&f, lower, upper, options, true, true)
454    }
455
456    /// Finds a root with Newton-Raphson iteration.
457    ///
458    /// This is an approximate iterative solver, not an exact equation helper.
459    /// The tolerance must be finite and positive. Non-finite values and
460    /// approximately zero derivatives return explicit errors.
461    ///
462    /// # Errors
463    ///
464    /// Returns [`RootError::InvalidTolerance`] when the tolerance is not
465    /// finite and positive; [`RootError::NonFiniteValue`] when the initial
466    /// value, a function evaluation, a derivative evaluation, or the next
467    /// iterate is non-finite; [`RootError::ZeroDerivative`] when the
468    /// derivative is approximately zero; and
469    /// [`RootError::MaxIterationsReached`] when the iteration budget is
470    /// exhausted without convergence.
471    pub fn newton_raphson<F, D>(
472        f: F,
473        derivative: D,
474        initial: f64,
475        options: RootOptions,
476    ) -> Result<f64, RootError>
477    where
478        F: Fn(f64) -> f64,
479        D: Fn(f64) -> f64,
480    {
481        validate_options(options)?;
482
483        if !initial.is_finite() {
484            return Err(RootError::NonFiniteValue);
485        }
486
487        let mut current = initial;
488
489        for _ in 0..options.max_iterations {
490            let value = f(current);
491            if !value.is_finite() {
492                return Err(RootError::NonFiniteValue);
493            }
494
495            if approx_eq(value, 0.0, options.tolerance) {
496                return Ok(current);
497            }
498
499            let slope = derivative(current);
500            if !slope.is_finite() {
501                return Err(RootError::NonFiniteValue);
502            }
503
504            if approx_eq(slope, 0.0, options.tolerance) {
505                return Err(RootError::ZeroDerivative);
506            }
507
508            let next = current - value / slope;
509            if !next.is_finite() {
510                return Err(RootError::NonFiniteValue);
511            }
512
513            if (next - current).abs() <= options.tolerance {
514                return Ok(next);
515            }
516
517            current = next;
518        }
519
520        Err(RootError::MaxIterationsReached)
521    }
522
523    /// Finds a root with the bisection method over a bounded `use-interval` interval.
524    ///
525    /// Unbounded and empty intervals return [`RootError::InvalidInterval`].
526    /// Open endpoints participate in bracketing, but only closed endpoints are
527    /// eligible for the immediate endpoint-root fast path.
528    ///
529    /// # Errors
530    ///
531    /// Returns [`RootError::InvalidInterval`] when the interval is empty,
532    /// unbounded, or does not bracket a root; [`RootError::InvalidTolerance`]
533    /// when the tolerance is not finite and positive;
534    /// [`RootError::NonFiniteValue`] when a function evaluation is non-finite;
535    /// and [`RootError::MaxIterationsReached`] when the iteration budget is
536    /// exhausted without convergence.
537    #[cfg(feature = "interval")]
538    pub fn bisection_interval<F>(
539        f: F,
540        interval: Interval<f64>,
541        options: RootOptions,
542    ) -> Result<f64, RootError>
543    where
544        F: Fn(f64) -> f64,
545    {
546        if interval.is_empty() {
547            return Err(RootError::InvalidInterval);
548        }
549
550        let (lower, allow_lower_endpoint) = match interval.lower {
551            Bound::Open(value) => (value, false),
552            Bound::Closed(value) => (value, true),
553            Bound::Unbounded => return Err(RootError::InvalidInterval),
554        };
555
556        let (upper, allow_upper_endpoint) = match interval.upper {
557            Bound::Open(value) => (value, false),
558            Bound::Closed(value) => (value, true),
559            Bound::Unbounded => return Err(RootError::InvalidInterval),
560        };
561
562        bisection_with_policy(
563            &f,
564            lower,
565            upper,
566            options,
567            allow_lower_endpoint,
568            allow_upper_endpoint,
569        )
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::{
576        RootError, RootOptions, approx_eq, backward_difference, bisection, central_difference,
577        clamp_epsilon, forward_difference, midpoint_rule, newton_raphson, rectangle_rule,
578        relative_eq, simpsons_rule, trapezoidal_rule,
579    };
580
581    #[cfg(feature = "interval")]
582    use super::bisection_interval;
583
584    #[cfg(feature = "interval")]
585    use use_interval::Interval;
586
587    #[test]
588    fn absolute_approximation_equality_uses_absolute_difference() {
589        assert!(approx_eq(1.0, 1.0 + 5.0e-7, 1.0e-6));
590        assert!(!approx_eq(1.0, 1.0 + 2.0e-6, 1.0e-6));
591    }
592
593    #[test]
594    fn relative_approximation_equality_uses_relative_difference() {
595        assert!(relative_eq(10_000.0, 10_000.01, 1.0e-6));
596        assert!(!relative_eq(10_000.0, 10_000.5, 1.0e-6));
597    }
598
599    #[test]
600    fn zero_and_near_zero_comparisons_use_safe_fallback() {
601        assert!(relative_eq(1.0e-12, 0.0, 1.0e-9));
602        assert!(!relative_eq(1.0e-3, 0.0, 1.0e-6));
603    }
604
605    #[test]
606    fn negative_epsilon_behavior_is_clamped_to_positive() {
607        assert!((clamp_epsilon(-1.0e-6) - 1.0e-6).abs() <= f64::EPSILON);
608        assert!(approx_eq(2.0, 2.0 + 5.0e-7, -1.0e-6));
609    }
610
611    #[test]
612    fn non_finite_comparison_behavior_is_explicit() {
613        assert!(!approx_eq(f64::NAN, f64::NAN, 1.0e-6));
614        assert!(!relative_eq(f64::NAN, 1.0, 1.0e-6));
615        assert!(approx_eq(f64::INFINITY, f64::INFINITY, 1.0e-6));
616        assert!(!approx_eq(f64::INFINITY, f64::NEG_INFINITY, 1.0e-6));
617        assert!(!relative_eq(f64::INFINITY, 1.0, 1.0e-6));
618    }
619
620    #[test]
621    fn forward_difference_approximates_first_derivative() {
622        let derivative = forward_difference(|x| x * x, 3.0, 1.0e-6);
623
624        assert!((derivative - 6.0).abs() < 1.0e-5);
625    }
626
627    #[test]
628    fn backward_difference_approximates_first_derivative() {
629        let derivative = backward_difference(|x| x * x, 3.0, 1.0e-6);
630
631        assert!((derivative - 6.0).abs() < 1.0e-5);
632    }
633
634    #[test]
635    fn central_difference_approximates_first_derivative() {
636        let derivative = central_difference(|x| x * x, 3.0, 1.0e-6);
637
638        assert!((derivative - 6.0).abs() < 1.0e-5);
639    }
640
641    #[test]
642    fn rectangle_rule_approximates_integrals() {
643        let area = rectangle_rule(|x| x * x, 0.0, 1.0, 10_000).unwrap();
644
645        assert!((area - 1.0 / 3.0).abs() < 1.0e-4);
646    }
647
648    #[test]
649    fn midpoint_rule_approximates_integrals() {
650        let area = midpoint_rule(|x| x * x, 0.0, 1.0, 1_000).unwrap();
651
652        assert!((area - 1.0 / 3.0).abs() < 1.0e-6);
653    }
654
655    #[test]
656    fn trapezoidal_rule_approximates_integrals() {
657        let area = trapezoidal_rule(|x| x * x, 0.0, 1.0, 1_000).unwrap();
658
659        assert!((area - 1.0 / 3.0).abs() < 1.0e-6);
660    }
661
662    #[test]
663    fn simpsons_rule_approximates_integrals() {
664        let area = simpsons_rule(|x| x * x, 0.0, 1.0, 10).unwrap();
665
666        assert!((area - 1.0 / 3.0).abs() < 1.0e-12);
667    }
668
669    #[test]
670    fn invalid_integration_subdivision_count_returns_none() {
671        assert_eq!(rectangle_rule(|x| x, 0.0, 1.0, 0), None);
672        assert_eq!(midpoint_rule(|x| x, 0.0, 1.0, 0), None);
673        assert_eq!(trapezoidal_rule(|x| x, 0.0, 1.0, 0), None);
674    }
675
676    #[test]
677    fn simpsons_rule_rejects_odd_subdivision_counts() {
678        assert_eq!(simpsons_rule(|x| x, 0.0, 1.0, 3), None);
679    }
680
681    #[test]
682    fn reversed_integration_bounds_return_negative_integral() {
683        let area = trapezoidal_rule(|x| x * x, 1.0, 0.0, 1_000).unwrap();
684
685        assert!((area + 1.0 / 3.0).abs() < 1.0e-6);
686    }
687
688    #[test]
689    fn bisection_succeeds_for_bracketed_root() {
690        let root = bisection(|x| x.mul_add(x, -2.0), 1.0, 2.0, RootOptions::default()).unwrap();
691
692        assert!((root - 2.0_f64.sqrt()).abs() < 1.0e-8);
693    }
694
695    #[test]
696    fn bisection_returns_endpoint_root_when_present() {
697        let root = bisection(|x| x - 1.0, 1.0, 3.0, RootOptions::default()).unwrap();
698
699        assert!((root - 1.0).abs() <= f64::EPSILON);
700    }
701
702    #[test]
703    fn bisection_rejects_invalid_intervals() {
704        assert_eq!(
705            bisection(|x| x.mul_add(x, 1.0), -1.0, 1.0, RootOptions::default()),
706            Err(RootError::InvalidInterval)
707        );
708    }
709
710    #[test]
711    fn bisection_rejects_invalid_tolerance() {
712        assert_eq!(
713            bisection(
714                |x| x.mul_add(x, -2.0),
715                1.0,
716                2.0,
717                RootOptions {
718                    tolerance: 0.0,
719                    max_iterations: 100,
720                },
721            ),
722            Err(RootError::InvalidTolerance)
723        );
724    }
725
726    #[test]
727    fn bisection_reports_max_iterations_reached() {
728        assert_eq!(
729            bisection(
730                |x| x.mul_add(x, -2.0),
731                1.0,
732                2.0,
733                RootOptions {
734                    tolerance: 1.0e-20,
735                    max_iterations: 1,
736                },
737            ),
738            Err(RootError::MaxIterationsReached)
739        );
740    }
741
742    #[test]
743    fn newton_raphson_succeeds_for_simple_root() {
744        let root = newton_raphson(
745            |x| x.mul_add(x, -2.0),
746            |x| 2.0 * x,
747            1.0,
748            RootOptions::default(),
749        )
750        .unwrap();
751
752        assert!((root - 2.0_f64.sqrt()).abs() < 1.0e-8);
753    }
754
755    #[test]
756    fn newton_raphson_reports_zero_derivative() {
757        assert_eq!(
758            newton_raphson(
759                |x| (x * x).mul_add(x, 1.0),
760                |x| 3.0 * x * x,
761                0.0,
762                RootOptions::default(),
763            ),
764            Err(RootError::ZeroDerivative)
765        );
766    }
767
768    #[test]
769    fn newton_raphson_rejects_invalid_tolerance() {
770        assert_eq!(
771            newton_raphson(
772                |x| x.mul_add(x, -2.0),
773                |x| 2.0 * x,
774                1.0,
775                RootOptions {
776                    tolerance: 0.0,
777                    max_iterations: 100,
778                },
779            ),
780            Err(RootError::InvalidTolerance)
781        );
782    }
783
784    #[test]
785    fn newton_raphson_reports_max_iterations_reached() {
786        assert_eq!(
787            newton_raphson(
788                |x| x.mul_add(x, -2.0),
789                |x| 2.0 * x,
790                1.0,
791                RootOptions {
792                    tolerance: 1.0e-20,
793                    max_iterations: 1,
794                },
795            ),
796            Err(RootError::MaxIterationsReached)
797        );
798    }
799
800    #[test]
801    fn non_finite_root_finding_behavior_is_reported() {
802        assert_eq!(
803            bisection(|_| f64::NAN, 0.0, 1.0, RootOptions::default()),
804            Err(RootError::NonFiniteValue)
805        );
806        assert_eq!(
807            newton_raphson(|_| f64::NAN, |_| 1.0, 0.0, RootOptions::default(),),
808            Err(RootError::NonFiniteValue)
809        );
810    }
811
812    #[cfg(feature = "interval")]
813    #[test]
814    fn bisection_interval_supports_bounded_intervals() {
815        let root = bisection_interval(
816            |x| x.mul_add(x, -2.0),
817            Interval::closed(1.0, 2.0),
818            RootOptions::default(),
819        )
820        .unwrap();
821
822        assert!((root - 2.0_f64.sqrt()).abs() < 1.0e-8);
823    }
824
825    #[cfg(feature = "interval")]
826    #[test]
827    fn bisection_interval_does_not_accept_open_endpoint_roots() {
828        assert_eq!(
829            bisection_interval(|x| x, Interval::open(0.0, 2.0), RootOptions::default()),
830            Err(RootError::InvalidInterval)
831        );
832    }
833}