geop_core_math/solvers/newton.rs
1//! Newton refinement of a root a subdivision search has isolated — shared
2//! by `geop_core_geometry`'s `NurbCurve::refine_parameter_at_point` and the
3//! intersection searches' `refine_crossing`s — and Newton's method on one
4//! unknown from a seed ([`newton_1d`]), for a point that is a free choice
5//! up to where a function vanishes.
6//!
7//! Subdivision and Newton divide the work. Subdivision is the global
8//! method: it reliably finds and separates every solution, and recognizes
9//! coincidence even for a partial overlap, but converges only one bit per
10//! split — squeezing a parameter down to machine accuracy would take ~50
11//! levels against the ~7 that isolate it. Newton cannot find anything, but
12//! polishes an isolated solution quadratically. So the searches subdivide
13//! to isolate, and a caller about to *use* a parameter — as a split point,
14//! where `NurbCurve::split` cannot absorb a `min_subdivision_size`-wide one
15//! (Boehm insertion amplifies its width without bound) — refines it here.
16
17use std::fmt::Debug;
18
19use crate::{
20 geop_error::{GeopError, GeopResult},
21 scalars::Scalar,
22};
23
24use super::iterate::{End, Iterate, Iterated, Repeat, Step, iterate};
25
26/// Newton iterations. Quadratic convergence makes a handful plenty; this
27/// cannot change which solutions are found, only how tightly an
28/// already-isolated one is pinned down.
29const ITERATIONS: usize = 12;
30
31/// Refine `start` — a box a subdivision search proved holds a root — by
32/// Newton, within `domain`. `step` takes an iterate to the next one,
33/// unsharpened, or `None` where Newton cannot go on (a singular Jacobian at
34/// a tangential crossing or a pole, an evaluation failing).
35///
36/// Every iterate but the last is sharpened, which is legitimate: it is only
37/// a seed for the next step. The final step is left unsharpened, so the
38/// returned widths honestly state how well the root is determined (see
39/// "Sharpen only where the value is a free choice" in `AGENTS.md`). Each
40/// iterate is clamped into `domain` — sharpened first, so the clamp's
41/// comparisons are exact — rather than given up on: a foot point can step
42/// outside the domain on the way in.
43///
44/// A seed repeating one before it ends the iteration early with the same
45/// result ([`super::iterate::Repeat::Cycle`]).
46///
47/// The result is intersected with `start`: both enclose the same root, so
48/// their intersection does too, and is tighter than either. Infallible by
49/// construction: if Newton stops, or ends disjoint from `start` (it has
50/// wandered out of the box the search proved the root lies in), `start` is
51/// returned unchanged. Refinement can only tighten, never fail.
52///
53/// Plain, unverified Newton: it does not itself certify existence or
54/// uniqueness, so callers check that the refined box can still hold what
55/// they are looking for.
56pub fn refine<const N: usize>(
57 start: [Scalar; N],
58 domain: [(Scalar, Scalar); N],
59 step: impl Fn([Scalar; N]) -> Option<[Scalar; N]>,
60) -> [Scalar; N] {
61 let clamp = |x: [Scalar; N]| std::array::from_fn(|i| x[i].clamped(domain[i].0, domain[i].1));
62 let sharp = start.map(|s| s.sharpen());
63 let Ok(Iterated {
64 answer: x,
65 end: End::Exhausted,
66 ..
67 }) = iterate::<_, ()>(
68 sharp,
69 sharp,
70 ITERATIONS,
71 Repeat::Cycle,
72 |next| (clamp(next.map(|s| s.sharpen())), clamp(*next)),
73 // A step over a singular Jacobian can come back as everything: there
74 // is no seed in that, and its middle is no number.
75 |x| {
76 let next = step(*x).filter(|next| next.iter().all(|s| s.is_finite()));
77 Ok(next.map_or(Step::Stuck, Step::Next))
78 },
79 )
80 else {
81 return start;
82 };
83 if (0..N).any(|i| !x[i].could_be_equal(start[i])) {
84 return start;
85 }
86 std::array::from_fn(|i| start[i].intersect(x[i]))
87}
88
89/// A number [`newton_1d`] runs in: plain `f64`, or a scalar.
90pub trait Newton1d: Iterate + Copy + Debug {
91 /// Whether `self`, a function's value, could be zero.
92 fn could_be_zero(self) -> bool;
93 /// The Newton step `value / slope` and its size, or why there is none.
94 fn step(value: Self, slope: Self) -> GeopResult<(Self, f64)>;
95 /// `self - step`, as the seed of the next step: clamped into
96 /// `(lo, hi)`, and sharp — it is a free choice.
97 fn settle(self, step: Self, lo: Self, hi: Self) -> Self;
98}
99
100impl Newton1d for f64 {
101 fn could_be_zero(self) -> bool {
102 self == 0.0
103 }
104 fn step(value: Self, slope: Self) -> GeopResult<(Self, f64)> {
105 if slope == 0.0 {
106 return Err(GeopError::new(format!(
107 "Newton's step: the slope is zero where the value is {value:?}"
108 )));
109 }
110 let step = value / slope;
111 Ok((step, step.abs()))
112 }
113 fn settle(self, step: Self, lo: Self, hi: Self) -> Self {
114 (self - step).clamp(lo, hi)
115 }
116}
117
118/// Sharpened *before* clamping: `clamped`'s comparisons are exact on a
119/// sharp value, so the seed stays in the domain.
120impl Newton1d for Scalar {
121 fn could_be_zero(self) -> bool {
122 self.could_be_equal(Scalar::ZERO)
123 }
124 fn step(value: Self, slope: Self) -> GeopResult<(Self, f64)> {
125 let step = value.div(slope)?;
126 Ok((step, step.abs().upper().to_f64()))
127 }
128 fn settle(self, step: Self, lo: Self, hi: Self) -> Self {
129 self.sub(step).sharpen().clamped(lo, hi)
130 }
131}
132
133/// When [`newton_1d`] has arrived.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum Arrive {
136 /// Where the value could be zero.
137 AtZero,
138 /// Where a step is no shorter than the one before: as close as the
139 /// arithmetic gets.
140 StepsStopShrinking,
141 /// After every step: the seed the last one left.
142 Exhausted,
143}
144
145/// Newton's method on one unknown from `seed`, at most `steps` steps,
146/// within `(lo, hi)`. `at(x)` gives the function's value and slope at `x`.
147/// Every iterate is a sharp seed clamped into `(lo, hi)`: what is returned
148/// is a point as good as any other where the function vanishes, not an
149/// enclosure of the root. `seed` itself is asked as it is.
150///
151/// The last seed, and whether it arrived as `arrive` says (always, for
152/// [`Arrive::Exhausted`]). A slope with no step — zero, or one that could
153/// be — is an error.
154pub fn newton_1d<T: Newton1d, E: From<GeopError>>(
155 seed: T,
156 (lo, hi): (T, T),
157 steps: usize,
158 arrive: Arrive,
159 mut at: impl FnMut(T) -> Result<(T, T), E>,
160) -> Result<(T, bool), E> {
161 let mut last = f64::INFINITY;
162 // Whether a step is done depends on the one before only when it stops
163 // shrinking; otherwise a repeated seed shows where the steps would end.
164 let repeat = match arrive {
165 Arrive::StepsStopShrinking => Repeat::Never,
166 Arrive::AtZero | Arrive::Exhausted => Repeat::Cycle,
167 };
168 let iterated = iterate(
169 seed,
170 seed,
171 steps,
172 repeat,
173 |next| (*next, *next),
174 |&x| -> Result<Step<T>, E> {
175 let (value, slope) = at(x)?;
176 if arrive == Arrive::AtZero && value.could_be_zero() {
177 return Ok(Step::Done);
178 }
179 let (step, size) = T::step(value, slope)?;
180 if arrive == Arrive::StepsStopShrinking {
181 // A NaN step is no shorter either.
182 if size.partial_cmp(&last) != Some(std::cmp::Ordering::Less) {
183 return Ok(Step::Done);
184 }
185 last = size;
186 }
187 Ok(Step::Next(x.settle(step, lo, hi)))
188 },
189 )?;
190 let arrived = iterated.end == End::Done || arrive == Arrive::Exhausted;
191 Ok((iterated.seed, arrived))
192}
193
194#[cfg(test)]
195mod tests {
196 /// A step that comes back as everything, as one over a singular Jacobian
197 /// does, leaves the box as it was: refinement only tightens.
198 #[test]
199 fn refining_past_a_singular_step_gives_the_box_back() {
200 let start = [Scalar::new(0.25, 0.75)];
201 let domain = [(Scalar::ZERO, Scalar::ONE)];
202 let refined = super::refine(start, domain, |_| Some([Scalar::ENTIRE]));
203 assert_eq!(refined, start);
204 }
205
206 use super::*;
207
208 /// Each stopping rule ends where it says: at a value that could be
209 /// zero, where the steps stop shrinking, or after every step.
210 #[test]
211 fn newton_1d_arrives_as_asked() {
212 let square = |x: f64| Ok::<_, GeopError>((x * x - 2.0, 2.0 * x));
213 let root = |arrive| newton_1d(1.0, (0.0, 2.0), 50, arrive, square).unwrap();
214 let (shrunk, arrived) = root(Arrive::StepsStopShrinking);
215 assert!(arrived && (shrunk * shrunk - 2.0).abs() <= 4.0 * f64::EPSILON);
216 assert_eq!(root(Arrive::Exhausted), (shrunk, true));
217 // The value at a float is hardly ever exactly zero.
218 let at_zero = newton_1d(3.0, (0.0, 4.0), 50, Arrive::AtZero, |x: f64| {
219 Ok::<_, GeopError>((x - 2.0, 1.0))
220 });
221 assert_eq!(at_zero.unwrap(), (2.0, true));
222 }
223}