Skip to main content

gammalooprs/utils/
newton_solver.rs

1use serde::Serialize;
2use std::{
3    collections::BTreeMap,
4    fmt::{Display, Formatter},
5};
6use symbolica::domains::{dual::HyperDual, float::Float as SymbolicaFloat};
7use tracing::debug;
8
9use crate::utils::{F, FloatLike};
10
11const MINIMUM_PRECISION_RESIDUAL_IMPROVEMENT: i64 = 8;
12
13#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub(crate) struct RadialRootIdentity(String);
15
16impl RadialRootIdentity {
17    pub(crate) fn new(description: String) -> Self {
18        Self(description)
19    }
20}
21
22impl Display for RadialRootIdentity {
23    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
24        self.0.fmt(formatter)
25    }
26}
27
28#[derive(Clone, Debug)]
29struct LocalRootConsistency {
30    near_radius_step: Option<SymbolicaFloat>,
31    near_lower_value: Option<SymbolicaFloat>,
32    near_upper_value: Option<SymbolicaFloat>,
33    near_secant_derivative_ratio: Option<SymbolicaFloat>,
34    far_secant_derivative_ratio: Option<SymbolicaFloat>,
35    secant_ratio_agreement: Option<SymbolicaFloat>,
36    is_valid: bool,
37}
38
39impl LocalRootConsistency {
40    fn invalid() -> Self {
41        Self {
42            near_radius_step: None,
43            near_lower_value: None,
44            near_upper_value: None,
45            near_secant_derivative_ratio: None,
46            far_secant_derivative_ratio: None,
47            secant_ratio_agreement: None,
48            is_valid: false,
49        }
50    }
51
52    fn check<T: FloatLike>(
53        result: &NewtonIterationResult<T>,
54        inside_radius: &F<T>,
55        f_x_and_df_x: &impl Fn(&F<T>) -> (F<T>, F<T>),
56        e_cm: &F<T>,
57    ) -> Self {
58        // The two symmetric probes below cost four function evaluations in total. Refuse rescue
59        // without probing when a caller attempted fewer iterations, so this diagnostic can never
60        // evaluate the E-surface more often than the failed Newton solve it is validating.
61        const PROBE_EVALUATIONS: usize = 4;
62        if result.num_iterations_used < PROBE_EVALUATIONS {
63            return Self::invalid();
64        }
65
66        let zero = result.solution.zero();
67        let two = result.solution.from_i64(2);
68        let four = result.solution.from_i64(4);
69        let eight = result.solution.from_i64(8);
70        let radial_scale = result
71            .solution
72            .abs()
73            .max(e_cm.abs())
74            .max(result.solution.epsilon());
75        let newton_correction =
76            result.error_of_function.abs() / result.derivative_at_solution.abs();
77        let mut near_radius_step =
78            (result.solution.epsilon().sqrt() * radial_scale).max(&four * newton_correction);
79        let available_radius = &result.solution - inside_radius;
80        if !is_finite(&available_radius) || available_radius <= zero {
81            return Self::invalid();
82        }
83        let maximum_far_radius_step = &available_radius / &two;
84        if &near_radius_step * &eight > maximum_far_radius_step {
85            near_radius_step = &maximum_far_radius_step / &eight;
86        }
87        let far_radius_step = &near_radius_step * &eight;
88        if !is_finite(&near_radius_step)
89            || near_radius_step <= zero
90            || !is_finite(&far_radius_step)
91            || far_radius_step <= near_radius_step
92        {
93            return Self::invalid();
94        }
95
96        let probe = |radius_step: &F<T>| {
97            let lower_radius = &result.solution - radius_step;
98            let upper_radius = &result.solution + radius_step;
99            let (lower_value, _) = f_x_and_df_x(&lower_radius);
100            let (upper_value, _) = f_x_and_df_x(&upper_radius);
101            let secant_derivative = (&upper_value - &lower_value) / (&two * radius_step);
102            let secant_derivative_ratio = &secant_derivative / &result.derivative_at_solution;
103            (
104                lower_value,
105                upper_value,
106                secant_derivative,
107                secant_derivative_ratio,
108            )
109        };
110        let (
111            near_lower_value,
112            near_upper_value,
113            near_secant_derivative,
114            near_secant_derivative_ratio,
115        ) = probe(&near_radius_step);
116        let (far_lower_value, far_upper_value, far_secant_derivative, far_secant_derivative_ratio) =
117            probe(&far_radius_step);
118        let secant_ratio_agreement = &far_secant_derivative_ratio / &near_secant_derivative_ratio;
119        let half = result.solution.from_i64(1) / &two;
120        let two_again = two.clone();
121        let three_quarters = result.solution.from_i64(3) / &four;
122        let five_quarters = result.solution.from_i64(5) / &four;
123        let is_valid = is_finite(&near_lower_value)
124            && is_finite(&near_upper_value)
125            && is_finite(&near_secant_derivative)
126            && is_finite(&near_secant_derivative_ratio)
127            && is_finite(&far_lower_value)
128            && is_finite(&far_upper_value)
129            && is_finite(&far_secant_derivative)
130            && is_finite(&far_secant_derivative_ratio)
131            && is_finite(&secant_ratio_agreement)
132            && near_lower_value < zero
133            && near_upper_value > zero
134            && far_lower_value < zero
135            && far_upper_value > zero
136            && near_secant_derivative > zero
137            && far_secant_derivative > zero
138            && near_secant_derivative_ratio >= half
139            && near_secant_derivative_ratio <= two_again
140            && far_secant_derivative_ratio >= half
141            && far_secant_derivative_ratio <= two_again
142            && secant_ratio_agreement >= three_quarters
143            && secant_ratio_agreement <= five_quarters;
144
145        Self {
146            near_radius_step: Some(near_radius_step.into()),
147            near_lower_value: Some(near_lower_value.into()),
148            near_upper_value: Some(near_upper_value.into()),
149            near_secant_derivative_ratio: Some(near_secant_derivative_ratio.into()),
150            far_secant_derivative_ratio: Some(far_secant_derivative_ratio.into()),
151            secant_ratio_agreement: Some(secant_ratio_agreement.into()),
152            is_valid,
153        }
154    }
155}
156
157#[derive(Clone, Debug)]
158struct RadialRootObservation {
159    precision_bits: u32,
160    precision_epsilon: SymbolicaFloat,
161    residual: SymbolicaFloat,
162    roundoff_residual_scale: SymbolicaFloat,
163    maximum_residual: SymbolicaFloat,
164    solution: SymbolicaFloat,
165    derivative: SymbolicaFloat,
166    lower_bound: Option<SymbolicaFloat>,
167    upper_bound: Option<SymbolicaFloat>,
168    bracket_is_valid: bool,
169    local_consistency: Option<LocalRootConsistency>,
170    relative_newton_correction: SymbolicaFloat,
171    relative_residual_limit: SymbolicaFloat,
172}
173
174impl RadialRootObservation {
175    fn new<T: FloatLike>(
176        result: &NewtonIterationResult<T>,
177        bracket: Option<(&F<T>, &F<T>)>,
178        local_consistency: Option<LocalRootConsistency>,
179        tolerance: &F<T>,
180        e_cm: &F<T>,
181    ) -> Self {
182        let precision_epsilon_t = result.solution.epsilon();
183        let residual_t = result.error_of_function.abs();
184        let derivative_abs_t = result.derivative_at_solution.abs();
185        let e_cm_t = e_cm.abs();
186        let tolerance_t = tolerance.abs();
187        let radial_scale_t = result
188            .solution
189            .abs()
190            .max(e_cm_t.clone())
191            .max(precision_epsilon_t.clone());
192        let roundoff_residual_scale_t = &precision_epsilon_t * &e_cm_t;
193        let maximum_residual_t = &roundoff_residual_scale_t * &tolerance_t;
194        let relative_newton_correction_t = &residual_t / &derivative_abs_t / &radial_scale_t;
195        let relative_residual_limit_t = &precision_epsilon_t * &tolerance_t;
196
197        // `SymbolicaFloat` retains the source precision. These observations cross the generic
198        // stability lanes, so keeping them in a precision-carrying type avoids feeding an f128 or
199        // arbitrary-precision residual through binary64 during the rescue decision.
200        let precision_epsilon: SymbolicaFloat = precision_epsilon_t.into();
201        let precision_bits = precision_epsilon.prec();
202        let residual = residual_t.into();
203        let solution = result.solution.clone().into();
204        let derivative = result.derivative_at_solution.clone().into();
205        let roundoff_residual_scale = roundoff_residual_scale_t.into();
206        let maximum_residual = maximum_residual_t.into();
207        let relative_newton_correction = relative_newton_correction_t.into();
208        let relative_residual_limit = relative_residual_limit_t.into();
209
210        let (lower_bound, upper_bound, bracket_is_valid) =
211            bracket.map_or((None, None, true), |(lower_bound, upper_bound)| {
212                let bracket_is_valid = lower_bound <= upper_bound
213                    && &result.solution >= lower_bound
214                    && &result.solution <= upper_bound;
215                (
216                    Some(lower_bound.clone().into()),
217                    Some(upper_bound.clone().into()),
218                    bracket_is_valid,
219                )
220            });
221
222        Self {
223            precision_bits,
224            precision_epsilon,
225            residual,
226            roundoff_residual_scale,
227            maximum_residual,
228            solution,
229            derivative,
230            // A safeguarded bracket can collapse to one representable radius after both
231            // endpoints have been updated from opposite signs. That is a localized root,
232            // not a loss of the bracket invariant.
233            lower_bound,
234            upper_bound,
235            bracket_is_valid,
236            local_consistency,
237            relative_newton_correction,
238            relative_residual_limit,
239        }
240    }
241
242    fn comparison_residual(&self) -> SymbolicaFloat {
243        if self.residual >= self.roundoff_residual_scale {
244            self.residual.clone()
245        } else {
246            self.roundoff_residual_scale.clone()
247        }
248    }
249
250    fn values_are_finite(&self) -> bool {
251        self.precision_epsilon.is_finite()
252            && self.residual.is_finite()
253            && self.roundoff_residual_scale.is_finite()
254            && self.maximum_residual.is_finite()
255            && self.solution.is_finite()
256            && self.derivative.is_finite()
257            && self.relative_newton_correction.is_finite()
258            && self.relative_residual_limit.is_finite()
259            && self
260                .lower_bound
261                .as_ref()
262                .is_none_or(SymbolicaFloat::is_finite)
263            && self
264                .upper_bound
265                .as_ref()
266                .is_none_or(SymbolicaFloat::is_finite)
267    }
268
269    fn precision_rescue_improvement(&self, previous: &Self) -> Option<SymbolicaFloat> {
270        let zero = SymbolicaFloat::with_val(self.precision_bits.max(previous.precision_bits), 0);
271        if !self.values_are_finite()
272            || !previous.values_are_finite()
273            || self.precision_bits <= previous.precision_bits
274            || self.solution <= zero
275            || self.derivative <= zero
276            || !self.bracket_is_valid
277            || !self
278                .local_consistency
279                .as_ref()
280                .is_some_and(|consistency| consistency.is_valid)
281            || previous.solution <= zero
282            || previous.derivative <= zero
283            || !previous.bracket_is_valid
284            || self.residual > previous.maximum_residual
285            || self.relative_newton_correction > previous.relative_residual_limit
286            || self.residual <= zero
287        {
288            return None;
289        }
290
291        // A lower-precision residual can be exactly zero by accidental cancellation. In that
292        // case its precision-scaled roundoff floor is the meaningful comparison baseline.
293        let improvement = previous.comparison_residual() / self.residual.clone();
294        let required_improvement =
295            SymbolicaFloat::with_val(improvement.prec(), MINIMUM_PRECISION_RESIDUAL_IMPROVEMENT);
296        (improvement >= required_improvement).then_some(improvement)
297    }
298}
299
300/// Transient diagnostics shared by the stability levels of one sample evaluation.
301///
302/// A higher-precision solve may recover a residual-limited root when it reaches the
303/// original lower-precision accuracy target and clearly improves the same root. All
304/// structural solver failures remain errors in every precision.
305///
306/// For lower and higher precisions `lo` and `hi`, rescue requires
307/// `|f_hi| <= tau * epsilon_lo * E_cm`,
308/// `|f_hi / f'_hi| / max(|r_hi|, E_cm) <= tau * epsilon_lo`, and
309/// `max(|f_lo|, epsilon_lo * E_cm) / |f_hi| >= 8`, in addition to the bracket and
310/// two-scale local-root consistency checks.
311#[derive(Clone, Debug, Default)]
312pub(crate) struct RadialRootDiagnostics {
313    observations: BTreeMap<(RadialRootIdentity, usize, u32), RadialRootObservation>,
314    current_precision_bits: Option<u32>,
315    current_occurrences: BTreeMap<RadialRootIdentity, usize>,
316}
317
318impl RadialRootDiagnostics {
319    fn next_call_key<T: FloatLike>(
320        &mut self,
321        identity: &RadialRootIdentity,
322        precision_source: &F<T>,
323    ) -> (RadialRootIdentity, usize, u32) {
324        let precision: SymbolicaFloat = precision_source.clone().into();
325        let precision_bits = precision.prec();
326        if self.current_precision_bits != Some(precision_bits) {
327            self.current_precision_bits = Some(precision_bits);
328            self.current_occurrences.clear();
329        }
330
331        let occurrence = self
332            .current_occurrences
333            .entry(identity.clone())
334            .or_default();
335        let key = (identity.clone(), *occurrence, precision_bits);
336        *occurrence += 1;
337        key
338    }
339
340    fn record_observation(
341        &mut self,
342        key: (RadialRootIdentity, usize, u32),
343        observation: RadialRootObservation,
344    ) {
345        match self.observations.get(&key) {
346            Some(previous) if observation.residual >= previous.residual => {}
347            _ => {
348                self.observations.insert(key, observation);
349            }
350        }
351    }
352
353    pub(crate) fn restart_precision_pass(&mut self) {
354        self.current_precision_bits = None;
355        self.current_occurrences.clear();
356    }
357
358    #[allow(clippy::too_many_arguments)]
359    pub(crate) fn solve<T: FloatLike>(
360        &mut self,
361        identity: &RadialRootIdentity,
362        inside_radius: &F<T>,
363        outside_radius_guess: &F<T>,
364        f_x_and_df_x: impl Fn(&F<T>) -> (F<T>, F<T>),
365        tolerance: &F<T>,
366        max_iterations: usize,
367        max_bracket_expansions: usize,
368        e_cm: &F<T>,
369    ) -> Result<NewtonIterationResult<T>, SafeguardedNewtonError<T>> {
370        let call_key = self.next_call_key(identity, inside_radius);
371        let solve_result = safeguarded_newton_iteration_and_derivative(
372            inside_radius,
373            outside_radius_guess,
374            &f_x_and_df_x,
375            tolerance,
376            max_iterations,
377            max_bracket_expansions,
378            e_cm,
379        );
380
381        let (result, lower_bound, upper_bound) = match &solve_result {
382            Ok(result) => {
383                let observation = RadialRootObservation::new(result, None, None, tolerance, e_cm);
384                self.record_observation(call_key, observation);
385                return solve_result;
386            }
387            Err(SafeguardedNewtonError::DidNotConverge {
388                result,
389                lower_bound,
390                upper_bound,
391            }) => (result, lower_bound, upper_bound),
392            Err(error) => {
393                debug!(
394                    radial_root = %identity,
395                    occurrence = call_key.1,
396                    error = %error,
397                    "radial root failed a structural safeguarded-solver check"
398                );
399                return solve_result;
400            }
401        };
402
403        let previous = self
404            .observations
405            .range(
406                (call_key.0.clone(), call_key.1, 0)..(call_key.0.clone(), call_key.1, call_key.2),
407            )
408            .next_back()
409            .map(|(_, observation)| observation.clone());
410        let local_consistency = previous
411            .is_some()
412            .then(|| LocalRootConsistency::check(result, inside_radius, &f_x_and_df_x, e_cm));
413        let current = RadialRootObservation::new(
414            result,
415            Some((lower_bound, upper_bound)),
416            local_consistency,
417            tolerance,
418            e_cm,
419        );
420        let precision_improvement = previous
421            .as_ref()
422            .and_then(|previous| current.precision_rescue_improvement(previous));
423
424        if let Some(improvement) = precision_improvement {
425            let previous = previous.expect("a precision improvement requires a prior observation");
426            debug!(
427                radial_root = %identity,
428                occurrence = call_key.1,
429                previous_epsilon = %previous.precision_epsilon,
430                current_epsilon = %current.precision_epsilon,
431                previous_residual = %previous.residual,
432                previous_comparison_residual = %previous.comparison_residual(),
433                current_residual = %current.residual,
434                residual_improvement = %improvement,
435                accepted_residual = %previous.maximum_residual,
436                relative_newton_correction = %current.relative_newton_correction,
437                local_near_radius_step = ?current
438                    .local_consistency
439                    .as_ref()
440                    .and_then(|consistency| consistency.near_radius_step.as_ref()),
441                local_near_lower_value = ?current
442                    .local_consistency
443                    .as_ref()
444                    .and_then(|consistency| consistency.near_lower_value.as_ref()),
445                local_near_upper_value = ?current
446                    .local_consistency
447                    .as_ref()
448                    .and_then(|consistency| consistency.near_upper_value.as_ref()),
449                local_near_secant_derivative_ratio = ?current
450                    .local_consistency
451                    .as_ref()
452                    .and_then(|consistency| consistency.near_secant_derivative_ratio.as_ref()),
453                local_far_secant_derivative_ratio = ?current
454                    .local_consistency
455                    .as_ref()
456                    .and_then(|consistency| consistency.far_secant_derivative_ratio.as_ref()),
457                local_secant_ratio_agreement = ?current
458                    .local_consistency
459                    .as_ref()
460                    .and_then(|consistency| consistency.secant_ratio_agreement.as_ref()),
461                "accepted a residual-limited radial root after precision escalation"
462            );
463            self.record_observation(call_key, current);
464            return Ok(result.clone());
465        }
466
467        if let Some(previous) = previous.as_ref() {
468            let residual_improvement = previous.comparison_residual() / current.residual.clone();
469            debug!(
470                radial_root = %identity,
471                occurrence = call_key.1,
472                previous_epsilon = %previous.precision_epsilon,
473                current_epsilon = %current.precision_epsilon,
474                previous_residual = %previous.residual,
475                previous_comparison_residual = %previous.comparison_residual(),
476                current_residual = %current.residual,
477                residual_improvement = %residual_improvement,
478                accepted_residual = %previous.maximum_residual,
479                relative_newton_correction = %current.relative_newton_correction,
480                accepted_relative_newton_correction = %previous.relative_residual_limit,
481                solution = %current.solution,
482                derivative = %current.derivative,
483                lower_bound = ?current.lower_bound,
484                upper_bound = ?current.upper_bound,
485                bracket_is_valid = current.bracket_is_valid,
486                local_consistency = ?current.local_consistency,
487                "radial root did not satisfy precision-rescue criteria"
488            );
489        } else {
490            debug!(
491                radial_root = %identity,
492                occurrence = call_key.1,
493                current_epsilon = %current.precision_epsilon,
494                current_residual = %current.residual,
495                "radial root has no matching lower-precision observation"
496            );
497        }
498
499        self.record_observation(call_key, current);
500        solve_result
501    }
502}
503
504#[derive(Clone, Debug)]
505pub(crate) enum SafeguardedNewtonError<T: FloatLike> {
506    InvalidInside {
507        radius: F<T>,
508        value: F<T>,
509    },
510    InvalidOutside {
511        radius: F<T>,
512        value: F<T>,
513        bracket_expansions: usize,
514    },
515    InvalidDerivative {
516        result: NewtonIterationResult<T>,
517    },
518    DidNotConverge {
519        result: NewtonIterationResult<T>,
520        lower_bound: F<T>,
521        upper_bound: F<T>,
522    },
523}
524
525impl<T: FloatLike> Display for SafeguardedNewtonError<T> {
526    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
527        match self {
528            Self::InvalidInside { radius, value } => write!(
529                formatter,
530                "inside radius {radius} has non-finite or non-negative value {value}"
531            ),
532            Self::InvalidOutside {
533                radius,
534                value,
535                bracket_expansions,
536            } => write!(
537                formatter,
538                "outside radius {radius} has invalid value {value} after {bracket_expansions} bracket expansions"
539            ),
540            Self::InvalidDerivative { result } => write!(
541                formatter,
542                "root candidate {} has invalid derivative {} with residual {} after {} iterations",
543                result.solution,
544                result.derivative_at_solution,
545                result.error_of_function,
546                result.num_iterations_used,
547            ),
548            Self::DidNotConverge {
549                result,
550                lower_bound,
551                upper_bound,
552            } => write!(
553                formatter,
554                "did not converge in bracket [{lower_bound}, {upper_bound}]: candidate {}, derivative {}, residual {} after {} iterations",
555                result.solution,
556                result.derivative_at_solution,
557                result.error_of_function,
558                result.num_iterations_used,
559            ),
560        }
561    }
562}
563
564fn is_finite<T: FloatLike>(value: &F<T>) -> bool {
565    !value.is_nan() && !value.is_infinite()
566}
567
568/// Find a radial root while preserving a finite negative/positive bracket.
569///
570/// Newton steps are used only when their derivative and proposed radius are
571/// finite and the proposed radius stays strictly inside the bracket. Otherwise
572/// the next iterate is the bracket midpoint.
573pub(crate) fn safeguarded_newton_iteration_and_derivative<T: FloatLike>(
574    inside_radius: &F<T>,
575    outside_radius_guess: &F<T>,
576    f_x_and_df_x: impl Fn(&F<T>) -> (F<T>, F<T>),
577    tolerance: &F<T>,
578    max_iterations: usize,
579    max_bracket_expansions: usize,
580    e_cm: &F<T>,
581) -> Result<NewtonIterationResult<T>, SafeguardedNewtonError<T>> {
582    let zero = inside_radius.zero();
583    let two = inside_radius.from_i64(2);
584    let maximum_residual = inside_radius.epsilon() * tolerance * e_cm;
585    let (inside_value, _) = f_x_and_df_x(inside_radius);
586    if !is_finite(&inside_value) || inside_value >= -maximum_residual.clone() {
587        return Err(SafeguardedNewtonError::InvalidInside {
588            radius: inside_radius.clone(),
589            value: inside_value,
590        });
591    }
592
593    let mut lower_bound = inside_radius.clone();
594    let mut upper_bound = outside_radius_guess.clone();
595    if !is_finite(&upper_bound) || upper_bound <= lower_bound {
596        let upper_value = if is_finite(&upper_bound) {
597            f_x_and_df_x(&upper_bound).0
598        } else {
599            upper_bound.clone()
600        };
601        return Err(SafeguardedNewtonError::InvalidOutside {
602            radius: upper_bound,
603            value: upper_value,
604            bracket_expansions: 0,
605        });
606    }
607
608    let (mut upper_value, _) = f_x_and_df_x(&upper_bound);
609    let mut bracket_expansions = 0;
610    while is_finite(&upper_value)
611        && upper_value <= zero
612        && bracket_expansions < max_bracket_expansions
613    {
614        upper_bound *= &two;
615        (upper_value, _) = f_x_and_df_x(&upper_bound);
616        bracket_expansions += 1;
617    }
618
619    if !is_finite(&upper_bound) || !is_finite(&upper_value) || upper_value <= zero {
620        return Err(SafeguardedNewtonError::InvalidOutside {
621            radius: upper_bound,
622            value: upper_value,
623            bracket_expansions,
624        });
625    }
626
627    let mut solution = upper_bound.clone();
628    let (mut value, mut derivative) = f_x_and_df_x(&solution);
629    for iteration in 0..=max_iterations {
630        let current_result = NewtonIterationResult {
631            solution: solution.clone(),
632            derivative_at_solution: derivative.clone(),
633            error_of_function: value.clone(),
634            num_iterations_used: iteration,
635        };
636
637        if is_finite(&value) && value.abs() <= maximum_residual {
638            if is_finite(&derivative) && derivative > zero {
639                return Ok(current_result);
640            }
641            return Err(SafeguardedNewtonError::InvalidDerivative {
642                result: current_result,
643            });
644        }
645
646        if iteration == max_iterations {
647            return Err(SafeguardedNewtonError::DidNotConverge {
648                result: current_result,
649                lower_bound,
650                upper_bound,
651            });
652        }
653
654        if is_finite(&value) {
655            if value < zero {
656                lower_bound = solution.clone();
657            } else {
658                upper_bound = solution.clone();
659            }
660        }
661
662        let midpoint = (&lower_bound + &upper_bound) / &two;
663        let newton_candidate = if is_finite(&value) && is_finite(&derivative) && derivative > zero {
664            Some(&solution - &value / &derivative)
665        } else {
666            None
667        };
668
669        solution = match newton_candidate {
670            Some(candidate)
671                if is_finite(&candidate) && candidate > lower_bound && candidate < upper_bound =>
672            {
673                candidate
674            }
675            _ => midpoint,
676        };
677        (value, derivative) = f_x_and_df_x(&solution);
678    }
679
680    unreachable!("safeguarded Newton loop always returns")
681}
682
683/// root finding, returns the derivative at the root, so that we don't have to recompute it.
684/// Also returns the value of the function whose root is being found and the number of iterations used for debug information
685pub(crate) fn newton_iteration_and_derivative<T: FloatLike>(
686    guess: &F<T>,
687    f_x_and_df_x: impl Fn(&F<T>) -> (F<T>, F<T>),
688    tolerance: &F<T>,
689    max_iterations: usize,
690    e_cm: &F<T>,
691) -> NewtonIterationResult<T> {
692    let mut x = guess.clone();
693    let (mut val_f_x, mut val_df_x) = f_x_and_df_x(&x);
694
695    let mut iteration = 0;
696
697    while iteration < max_iterations && val_f_x.abs() > guess.epsilon() * tolerance * e_cm {
698        x -= val_f_x / val_df_x;
699        (val_f_x, val_df_x) = f_x_and_df_x(&x);
700        iteration += 1;
701    }
702
703    NewtonIterationResult {
704        solution: x,
705        derivative_at_solution: val_df_x,
706        error_of_function: val_f_x,
707        num_iterations_used: iteration,
708    }
709}
710
711#[allow(dead_code)]
712pub(crate) fn newton_iteration_and_derivative_dual<T: FloatLike>(
713    guess: &HyperDual<F<T>>,
714    f_x_and_df_x: impl Fn(&HyperDual<F<T>>) -> (HyperDual<F<T>>, HyperDual<F<T>>),
715    tolerance: &F<T>,
716    max_iterations: usize,
717    e_cm: &F<T>,
718) -> NewtonIterationResultDual<T> {
719    let mut x = guess.clone();
720    let (mut val_f_x, mut val_df_x) = f_x_and_df_x(&x);
721
722    let mut iteration = 0;
723
724    while iteration < max_iterations
725        && val_f_x.values[0].abs() > guess.values[0].epsilon() * tolerance * e_cm
726    {
727        x -= val_f_x / val_df_x;
728        (val_f_x, val_df_x) = f_x_and_df_x(&x);
729        iteration += 1;
730    }
731
732    NewtonIterationResultDual {
733        solution: x,
734        derivative_at_solution: val_df_x,
735        error_of_function: val_f_x.values[0].clone(),
736        num_iterations_used: iteration,
737    }
738}
739
740#[derive(Serialize, Clone, Debug)]
741pub(crate) struct NewtonIterationResult<T: FloatLike> {
742    pub solution: F<T>,
743    pub derivative_at_solution: F<T>,
744    pub error_of_function: F<T>,
745    pub num_iterations_used: usize,
746}
747
748#[derive(Clone, Debug)]
749#[allow(dead_code)]
750pub(crate) struct NewtonIterationResultDual<T: FloatLike> {
751    pub solution: HyperDual<F<T>>,
752    pub derivative_at_solution: HyperDual<F<T>>,
753    pub error_of_function: F<T>,
754    pub num_iterations_used: usize,
755}
756
757#[cfg(test)]
758mod tests {
759    use std::cell::Cell;
760
761    use super::*;
762    use crate::utils::f128;
763
764    const ROOT_TOLERANCE: i64 = 64;
765
766    fn cancellation_limited_root<T: FloatLike>(
767        diagnostics: &mut RadialRootDiagnostics,
768        identity: &RadialRootIdentity,
769        large_momentum: f64,
770        target: f64,
771    ) -> Result<NewtonIterationResult<T>, SafeguardedNewtonError<T>> {
772        let zero = F::<T>::default();
773        let one = zero.one();
774        let target = F::<T>::from_f64(target);
775        let k3 = F::<T>::from_f64(large_momentum);
776        let k2 = F::<T>::from_f64(large_momentum + 1.0);
777        let derivative = &k2 - &k3;
778        let tolerance = zero.from_i64(ROOT_TOLERANCE);
779
780        diagnostics.solve(
781            identity,
782            &zero,
783            &one,
784            |radius| (radius * &k2 - radius * &k3 - &target, derivative.clone()),
785            &tolerance,
786            40,
787            64,
788            &one,
789        )
790    }
791
792    fn discontinuous_root<T: FloatLike>(
793        diagnostics: &mut RadialRootDiagnostics,
794        identity: &RadialRootIdentity,
795        positive_residual: f64,
796    ) -> Result<NewtonIterationResult<T>, SafeguardedNewtonError<T>> {
797        let zero = F::<T>::default();
798        let one = zero.one();
799        let half = &one / one.from_i64(2);
800        let positive_residual = F::<T>::from_f64(positive_residual);
801        let tolerance = zero.from_i64(ROOT_TOLERANCE);
802
803        diagnostics.solve(
804            identity,
805            &zero,
806            &one,
807            |radius| {
808                let value = if radius == &zero {
809                    -one.clone()
810                } else if radius < &half {
811                    -positive_residual.clone()
812                } else {
813                    positive_residual.clone()
814                };
815                (value, one.clone())
816            },
817            &tolerance,
818            40,
819            64,
820            &one,
821        )
822    }
823
824    #[test]
825    fn safeguarded_newton_expands_bracket_and_converges() {
826        let result = safeguarded_newton_iteration_and_derivative(
827            &F(0.0),
828            &F(0.5),
829            |x| (x * x - F(2.0), F(2.0) * x),
830            &F(8.0),
831            40,
832            64,
833            &F(1.0),
834        )
835        .unwrap();
836
837        let expected_solution = result.solution.from_i64(2).sqrt();
838        let solution_tolerance = result.solution.epsilon() * result.solution.from_i64(64);
839        assert!((result.solution - expected_solution).abs() <= solution_tolerance);
840
841        let residual_tolerance =
842            result.error_of_function.epsilon() * result.error_of_function.from_i64(8);
843        assert!(result.error_of_function.abs() <= residual_tolerance);
844        assert!(result.derivative_at_solution > result.derivative_at_solution.zero());
845    }
846
847    #[test]
848    fn safeguarded_newton_accepts_a_few_ulp_residual() {
849        let residual = 2.2737367544323206e-13;
850        let result = safeguarded_newton_iteration_and_derivative(
851            &F(0.0),
852            &F(1.0),
853            |x| {
854                let value = if x == &F(1.0) {
855                    F(residual)
856                } else {
857                    x - F(1.0)
858                };
859                (value, F(1.0))
860            },
861            &F(8.0),
862            40,
863            64,
864            &F(1000.0),
865        )
866        .unwrap();
867
868        assert_eq!(result.solution, F(1.0));
869        assert_eq!(result.error_of_function, F(residual));
870    }
871
872    #[test]
873    fn safeguarded_newton_rejects_a_large_discontinuous_residual() {
874        let error = safeguarded_newton_iteration_and_derivative(
875            &F(0.0),
876            &F(1.0),
877            |x| {
878                if x < &F(1.0) {
879                    (F(-1.0), F(1.0))
880                } else {
881                    (F(741.0), F(1.0))
882                }
883            },
884            &F(8.0),
885            20,
886            64,
887            &F(1000.0),
888        )
889        .unwrap_err();
890
891        assert!(matches!(
892            error,
893            SafeguardedNewtonError::DidNotConverge { .. }
894        ));
895    }
896
897    #[test]
898    fn safeguarded_newton_rejects_a_non_interior_center() {
899        let error = safeguarded_newton_iteration_and_derivative(
900            &F(0.0),
901            &F(1.0),
902            |x| (*x, F(1.0)),
903            &F(8.0),
904            40,
905            64,
906            &F(1.0),
907        )
908        .unwrap_err();
909
910        assert!(matches!(
911            error,
912            SafeguardedNewtonError::InvalidInside { .. }
913        ));
914    }
915
916    #[test]
917    fn safeguarded_newton_rejects_an_invalid_outside_bracket() {
918        let error = safeguarded_newton_iteration_and_derivative(
919            &F(0.0),
920            &F(1.0),
921            |_| (F(-1.0), F(1.0)),
922            &F(8.0),
923            40,
924            4,
925            &F(1.0),
926        )
927        .unwrap_err();
928
929        assert!(matches!(
930            error,
931            SafeguardedNewtonError::InvalidOutside {
932                bracket_expansions: 4,
933                ..
934            }
935        ));
936    }
937
938    #[test]
939    fn safeguarded_newton_reports_invalid_outside_endpoint_value() {
940        let error = safeguarded_newton_iteration_and_derivative(
941            &F(1.0),
942            &F(0.5),
943            |x| (x - F(2.0), F(1.0)),
944            &F(8.0),
945            40,
946            64,
947            &F(1.0),
948        )
949        .unwrap_err();
950
951        assert!(matches!(
952            error,
953            SafeguardedNewtonError::InvalidOutside {
954                radius: F(0.5),
955                value: F(-1.5),
956                bracket_expansions: 0,
957            }
958        ));
959    }
960
961    #[test]
962    fn safeguarded_newton_reports_non_finite_outside_radius() {
963        let nan = F(0.0) / F(0.0);
964        let error = safeguarded_newton_iteration_and_derivative(
965            &F(0.0),
966            &nan,
967            |x| (x - F(1.0), F(1.0)),
968            &F(8.0),
969            40,
970            64,
971            &F(1.0),
972        )
973        .unwrap_err();
974
975        match error {
976            SafeguardedNewtonError::InvalidOutside {
977                radius,
978                value,
979                bracket_expansions: 0,
980            } => {
981                assert!(radius.is_nan());
982                assert!(value.is_nan());
983            }
984            unexpected => panic!("unexpected safeguarded Newton error: {unexpected:?}"),
985        }
986    }
987
988    #[test]
989    fn safeguarded_newton_rejects_an_invalid_root_derivative() {
990        let error = safeguarded_newton_iteration_and_derivative(
991            &F(0.0),
992            &F(1.0),
993            |x| (x - F(1.0), F(0.0)),
994            &F(8.0),
995            40,
996            64,
997            &F(1.0),
998        )
999        .unwrap_err();
1000
1001        assert!(matches!(
1002            error,
1003            SafeguardedNewtonError::InvalidDerivative { .. }
1004        ));
1005    }
1006
1007    #[test]
1008    fn local_root_consistency_has_bounded_evaluation_cost() {
1009        let result = NewtonIterationResult {
1010            solution: F(0.5),
1011            derivative_at_solution: F(1.0),
1012            error_of_function: F(1.0e-20),
1013            num_iterations_used: 40,
1014        };
1015        let calls = Cell::new(0);
1016        let consistency = LocalRootConsistency::check(
1017            &result,
1018            &F(0.0),
1019            &|radius| {
1020                calls.set(calls.get() + 1);
1021                (radius - F(0.5), F(1.0))
1022            },
1023            &F(1.0),
1024        );
1025        assert!(consistency.is_valid);
1026        assert_eq!(calls.get(), 4);
1027
1028        let short_solve_result = NewtonIterationResult {
1029            num_iterations_used: 3,
1030            ..result
1031        };
1032        calls.set(0);
1033        let consistency = LocalRootConsistency::check(
1034            &short_solve_result,
1035            &F(0.0),
1036            &|radius| {
1037                calls.set(calls.get() + 1);
1038                (radius - F(0.5), F(1.0))
1039            },
1040            &F(1.0),
1041        );
1042        assert!(!consistency.is_valid);
1043        assert_eq!(calls.get(), 0);
1044    }
1045
1046    #[test]
1047    fn higher_precision_rescues_large_nearly_equal_momenta() {
1048        let mut diagnostics = RadialRootDiagnostics::default();
1049
1050        for exponent in [32, 40, 48, 52] {
1051            let identity = RadialRootIdentity::new(format!("cancellation at 2^{exponent}"));
1052            let large_momentum = 2.0_f64.powi(exponent);
1053            let f64_error = cancellation_limited_root::<f64>(
1054                &mut diagnostics,
1055                &identity,
1056                large_momentum,
1057                1.0 / 3.0,
1058            )
1059            .unwrap_err();
1060            assert!(matches!(
1061                f64_error,
1062                SafeguardedNewtonError::DidNotConverge { .. }
1063            ));
1064
1065            let rescued = cancellation_limited_root::<f128>(
1066                &mut diagnostics,
1067                &identity,
1068                large_momentum,
1069                1.0 / 3.0,
1070            )
1071            .unwrap();
1072            let active_precision_limit = rescued.solution.epsilon()
1073                * rescued.solution.from_i64(ROOT_TOLERANCE)
1074                * rescued.solution.one();
1075            assert!(
1076                rescued.error_of_function.abs() > active_precision_limit,
1077                "2^{exponent} case should exercise precision rescue rather than direct convergence"
1078            );
1079        }
1080    }
1081
1082    #[test]
1083    fn higher_precision_can_use_a_successful_lower_precision_baseline() {
1084        let mut diagnostics = RadialRootDiagnostics::default();
1085        let identity = RadialRootIdentity::new("successful f64 baseline".to_string());
1086        let target = 0.3;
1087
1088        let f64_result = diagnostics
1089            .solve(
1090                &identity,
1091                &F(0.0),
1092                &F(1.0),
1093                |radius| (radius - F(target), F(1.0)),
1094                &F(ROOT_TOLERANCE as f64),
1095                40,
1096                64,
1097                &F(1.0),
1098            )
1099            .unwrap();
1100        assert!(f64_result.error_of_function.abs() > F(0.0));
1101        assert!(
1102            f64_result.error_of_function.abs()
1103                <= f64_result.solution.epsilon()
1104                    * F(ROOT_TOLERANCE as f64)
1105                    * f64_result.solution.one()
1106        );
1107
1108        // This is algebraically the same root as `r - target`, but separately evaluating
1109        // `(K + 1) r - K r` leaves a precision-limited residual for large K.
1110        let rescued = cancellation_limited_root::<f128>(
1111            &mut diagnostics,
1112            &identity,
1113            2.0_f64.powi(52),
1114            target,
1115        )
1116        .unwrap();
1117        let active_precision_limit =
1118            rescued.solution.epsilon() * rescued.solution.from_i64(ROOT_TOLERANCE);
1119        assert!(
1120            rescued.error_of_function.abs() > active_precision_limit,
1121            "the f128 solve should require the successful f64 observation as its baseline"
1122        );
1123    }
1124
1125    #[test]
1126    fn higher_precision_uses_the_roundoff_floor_after_an_exact_lower_precision_zero() {
1127        let mut diagnostics = RadialRootDiagnostics::default();
1128        let identity = RadialRootIdentity::new("accidental exact f64 residual".to_string());
1129
1130        let f64_result = diagnostics
1131            .solve(
1132                &identity,
1133                &F(0.0),
1134                &F(1.0),
1135                |radius| (radius - F(0.3), F(1.0)),
1136                &F(0.1),
1137                40,
1138                64,
1139                &F(1.0),
1140            )
1141            .unwrap();
1142        assert_eq!(f64_result.error_of_function, F(0.0));
1143
1144        let rescued =
1145            cancellation_limited_root::<f128>(&mut diagnostics, &identity, 2.0_f64.powi(52), 0.3)
1146                .unwrap();
1147        let active_precision_limit =
1148            rescued.solution.epsilon() * rescued.solution.from_i64(ROOT_TOLERANCE);
1149        assert!(rescued.error_of_function.abs() > active_precision_limit);
1150    }
1151
1152    #[test]
1153    fn repeated_root_identities_are_paired_by_precision_local_occurrence() {
1154        let mut diagnostics = RadialRootDiagnostics::default();
1155        let identity = RadialRootIdentity::new("two LMB channel rays".to_string());
1156        let f64_precision: SymbolicaFloat = F::<f64>::default().into();
1157        let f128_precision: SymbolicaFloat = F::<f128>::default().into();
1158
1159        assert_eq!(
1160            diagnostics.next_call_key(&identity, &F::<f64>::default()),
1161            (identity.clone(), 0, f64_precision.prec())
1162        );
1163        assert_eq!(
1164            diagnostics.next_call_key(&identity, &F::<f64>::default()),
1165            (identity.clone(), 1, f64_precision.prec())
1166        );
1167        assert_eq!(
1168            diagnostics.next_call_key(&identity, &F::<f128>::default()),
1169            (identity.clone(), 0, f128_precision.prec())
1170        );
1171        assert_eq!(
1172            diagnostics.next_call_key(&identity, &F::<f128>::default()),
1173            (identity.clone(), 1, f128_precision.prec())
1174        );
1175        diagnostics.restart_precision_pass();
1176        assert_eq!(
1177            diagnostics.next_call_key(&identity, &F::<f128>::default()),
1178            (identity.clone(), 0, f128_precision.prec())
1179        );
1180        assert_eq!(
1181            diagnostics.next_call_key(&identity, &F::<f128>::default()),
1182            (identity, 1, f128_precision.prec())
1183        );
1184    }
1185
1186    #[test]
1187    fn precision_rescue_replays_at_the_same_precision() {
1188        let mut diagnostics = RadialRootDiagnostics::default();
1189        let identity = RadialRootIdentity::new("precise result replay".to_string());
1190        let large_momentum = 2.0_f64.powi(52);
1191
1192        assert!(matches!(
1193            cancellation_limited_root::<f64>(
1194                &mut diagnostics,
1195                &identity,
1196                large_momentum,
1197                1.0 / 3.0,
1198            ),
1199            Err(SafeguardedNewtonError::DidNotConverge { .. })
1200        ));
1201        let rescued = cancellation_limited_root::<f128>(
1202            &mut diagnostics,
1203            &identity,
1204            large_momentum,
1205            1.0 / 3.0,
1206        )
1207        .unwrap();
1208        diagnostics.restart_precision_pass();
1209        let replayed = cancellation_limited_root::<f128>(
1210            &mut diagnostics,
1211            &identity,
1212            large_momentum,
1213            1.0 / 3.0,
1214        )
1215        .unwrap();
1216
1217        for result in [rescued, replayed] {
1218            let active_precision_limit =
1219                result.solution.epsilon() * result.solution.from_i64(ROOT_TOLERANCE);
1220            assert!(result.error_of_function.abs() > active_precision_limit);
1221        }
1222    }
1223
1224    #[test]
1225    fn multiple_roots_are_rescued_in_one_higher_precision_pass() {
1226        let mut diagnostics = RadialRootDiagnostics::default();
1227        let identities = (0..3)
1228            .map(|index| RadialRootIdentity::new(format!("difficult root {index}")))
1229            .collect::<Vec<_>>();
1230
1231        for identity in &identities {
1232            assert!(matches!(
1233                cancellation_limited_root::<f64>(
1234                    &mut diagnostics,
1235                    identity,
1236                    2.0_f64.powi(52),
1237                    1.0 / 3.0,
1238                ),
1239                Err(SafeguardedNewtonError::DidNotConverge { .. })
1240            ));
1241        }
1242        for identity in &identities {
1243            cancellation_limited_root::<f128>(
1244                &mut diagnostics,
1245                identity,
1246                2.0_f64.powi(52),
1247                1.0 / 3.0,
1248            )
1249            .unwrap();
1250        }
1251    }
1252
1253    #[test]
1254    fn precision_rescue_rejects_a_stable_discontinuous_residual() {
1255        let mut diagnostics = RadialRootDiagnostics::default();
1256        let identity = RadialRootIdentity::new("stable discontinuity".to_string());
1257
1258        assert!(matches!(
1259            discontinuous_root::<f64>(&mut diagnostics, &identity, 1.0),
1260            Err(SafeguardedNewtonError::DidNotConverge { .. })
1261        ));
1262        assert!(matches!(
1263            discontinuous_root::<f128>(&mut diagnostics, &identity, 1.0),
1264            Err(SafeguardedNewtonError::DidNotConverge { .. })
1265        ));
1266    }
1267
1268    #[test]
1269    fn precision_rescue_rejects_an_improved_but_large_residual() {
1270        let mut diagnostics = RadialRootDiagnostics::default();
1271        let identity = RadialRootIdentity::new("shrinking discontinuity".to_string());
1272
1273        assert!(matches!(
1274            discontinuous_root::<f64>(&mut diagnostics, &identity, 1.0),
1275            Err(SafeguardedNewtonError::DidNotConverge { .. })
1276        ));
1277        assert!(matches!(
1278            discontinuous_root::<f128>(&mut diagnostics, &identity, 0.01),
1279            Err(SafeguardedNewtonError::DidNotConverge { .. })
1280        ));
1281    }
1282
1283    #[test]
1284    fn precision_rescue_rejects_an_improving_discontinuous_non_root() {
1285        let mut diagnostics = RadialRootDiagnostics::default();
1286        let identity = RadialRootIdentity::new("improving discontinuity".to_string());
1287        let f64_precision: SymbolicaFloat = F::<f64>::default().into();
1288        let f128_precision: SymbolicaFloat = F::<f128>::default().into();
1289        let lower_key = (identity.clone(), 0, f64_precision.prec());
1290        let higher_key = (identity.clone(), 0, f128_precision.prec());
1291
1292        assert!(matches!(
1293            discontinuous_root::<f64>(&mut diagnostics, &identity, 1.0e-12),
1294            Err(SafeguardedNewtonError::DidNotConverge { .. })
1295        ));
1296        let lower_precision = diagnostics.observations[&lower_key].clone();
1297
1298        assert!(matches!(
1299            discontinuous_root::<f128>(&mut diagnostics, &identity, 1.0e-14),
1300            Err(SafeguardedNewtonError::DidNotConverge { .. })
1301        ));
1302        let higher_precision = &diagnostics.observations[&higher_key];
1303
1304        // Residual improvement, the inherited f64 residual bound, and the Newton-correction
1305        // bound alone would accept this fabricated candidate even though the function has no
1306        // zero. The local sign/slope consistency check must be what rejects it.
1307        let residual_improvement =
1308            lower_precision.comparison_residual() / &higher_precision.residual;
1309        let required_improvement = SymbolicaFloat::with_val(
1310            residual_improvement.prec(),
1311            MINIMUM_PRECISION_RESIDUAL_IMPROVEMENT,
1312        );
1313        assert!(residual_improvement >= required_improvement);
1314        assert!(higher_precision.residual <= lower_precision.maximum_residual);
1315        assert!(
1316            higher_precision.relative_newton_correction <= lower_precision.relative_residual_limit
1317        );
1318        assert!(higher_precision.bracket_is_valid);
1319        assert!(
1320            !higher_precision
1321                .local_consistency
1322                .as_ref()
1323                .expect("residual-limited candidates have local diagnostics")
1324                .is_valid
1325        );
1326        assert!(
1327            higher_precision
1328                .precision_rescue_improvement(&lower_precision)
1329                .is_none()
1330        );
1331    }
1332}