Skip to main content

numra_nonlinear/
newton.rs

1//! Newton-Raphson solver with optional line search.
2//!
3//! Solves systems of nonlinear equations F(x) = 0 using Newton's method:
4//!   x_{n+1} = x_n - J^{-1} F(x_n)
5//!
6//! where J = ∂F/∂x is the Jacobian matrix.
7//!
8//! Author: Moussa Leblouba
9//! Date: 5 March 2026
10//! Modified: 2 May 2026
11
12use faer::{ComplexField, Conjugate, SimpleEntity};
13use numra_core::Scalar;
14use numra_linalg::{DenseMatrix, LUFactorization, LinalgError, Matrix};
15
16/// Options for the Newton solver.
17#[derive(Clone, Debug)]
18pub struct NewtonOptions<S: Scalar> {
19    /// Maximum number of iterations.
20    pub max_iter: usize,
21    /// Absolute tolerance for residual norm.
22    pub atol: S,
23    /// Relative tolerance for residual norm.
24    pub rtol: S,
25    /// Enable line search for globalization.
26    pub line_search: bool,
27    /// Minimum step size in line search.
28    pub min_step: S,
29    /// Maximum step reduction iterations.
30    pub max_backsteps: usize,
31    /// Armijo parameter (sufficient decrease).
32    pub armijo: S,
33}
34
35impl<S: Scalar> Default for NewtonOptions<S> {
36    fn default() -> Self {
37        Self {
38            max_iter: 50,
39            atol: S::from_f64(1e-10),
40            rtol: S::from_f64(1e-10),
41            line_search: true,
42            min_step: S::from_f64(1e-4),
43            max_backsteps: 10,
44            armijo: S::from_f64(1e-4),
45        }
46    }
47}
48
49impl<S: Scalar> NewtonOptions<S> {
50    /// Create new options with default values.
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// Set maximum iterations.
56    pub fn max_iter(mut self, max_iter: usize) -> Self {
57        self.max_iter = max_iter;
58        self
59    }
60
61    /// Set absolute tolerance.
62    pub fn atol(mut self, atol: S) -> Self {
63        self.atol = atol;
64        self
65    }
66
67    /// Set relative tolerance.
68    pub fn rtol(mut self, rtol: S) -> Self {
69        self.rtol = rtol;
70        self
71    }
72
73    /// Enable or disable line search.
74    pub fn line_search(mut self, enable: bool) -> Self {
75        self.line_search = enable;
76        self
77    }
78}
79
80/// Result of Newton iteration.
81#[derive(Clone, Debug)]
82pub struct NewtonResult<S: Scalar> {
83    /// Solution vector.
84    pub x: Vec<S>,
85    /// Final residual norm.
86    pub residual_norm: S,
87    /// Number of iterations performed.
88    pub iterations: usize,
89    /// Number of function evaluations.
90    pub n_eval: usize,
91    /// Number of Jacobian evaluations.
92    pub n_jac: usize,
93    /// Whether the solver converged.
94    pub converged: bool,
95    /// Convergence message.
96    pub message: String,
97}
98
99/// Trait for nonlinear systems F(x) = 0.
100pub trait NonlinearSystem<S: Scalar>: Send + Sync {
101    /// Dimension of the system.
102    fn dim(&self) -> usize;
103
104    /// Evaluate F(x) and store result in f.
105    fn eval(&self, x: &[S], f: &mut [S]);
106
107    /// Evaluate the Jacobian J = ∂F/∂x.
108    /// If None, finite differences will be used.
109    fn jacobian(&self, x: &[S], jac: &mut [S]) {
110        let _ = (x, jac);
111        // Default: not implemented, use finite differences
112    }
113
114    /// Whether analytical Jacobian is available.
115    fn has_jacobian(&self) -> bool {
116        false
117    }
118}
119
120/// Newton-Raphson solver.
121pub struct Newton<S: Scalar> {
122    options: NewtonOptions<S>,
123}
124
125impl<S: Scalar + SimpleEntity + Conjugate<Canonical = S> + ComplexField> Newton<S> {
126    /// Create a new Newton solver with given options.
127    pub fn new(options: NewtonOptions<S>) -> Self {
128        Self { options }
129    }
130
131    /// Create a Newton solver with default options.
132    pub fn default_solver() -> Self {
133        Self::new(NewtonOptions::default())
134    }
135
136    /// Solve the nonlinear system F(x) = 0 starting from x0.
137    pub fn solve<Sys: NonlinearSystem<S>>(
138        &self,
139        system: &Sys,
140        x0: &[S],
141    ) -> Result<NewtonResult<S>, LinalgError> {
142        let n = system.dim();
143        if x0.len() != n {
144            return Err(LinalgError::DimensionMismatch {
145                expected: (n, 1),
146                actual: (x0.len(), 1),
147            });
148        }
149
150        let mut x = x0.to_vec();
151        let mut f = vec![S::ZERO; n];
152        let mut jac_data = vec![S::ZERO; n * n];
153        let mut dx = vec![S::ZERO; n];
154
155        let mut n_eval = 0;
156        let mut n_jac = 0;
157        let mut converged = false;
158        let mut message = String::new();
159
160        // Evaluate initial residual
161        system.eval(&x, &mut f);
162        n_eval += 1;
163        let mut f_norm = self.norm(&f);
164        let f0_norm = f_norm;
165
166        for iter in 0..self.options.max_iter {
167            // Check convergence
168            let tol = self.options.atol + self.options.rtol * f0_norm;
169            if f_norm < tol {
170                converged = true;
171                message = format!("Converged after {} iterations", iter);
172                break;
173            }
174
175            // Compute Jacobian
176            if system.has_jacobian() {
177                system.jacobian(&x, &mut jac_data);
178            } else {
179                self.finite_difference_jacobian(system, &x, &f, &mut jac_data);
180                n_eval += n; // n extra evaluations for FD
181            }
182            n_jac += 1;
183
184            // Build Jacobian matrix and solve J * dx = -f
185            let jac_matrix = self.build_matrix(&jac_data, n);
186            let lu = LUFactorization::new(&jac_matrix)?;
187
188            // Negate f for solve
189            let neg_f: Vec<S> = f.iter().map(|&v| -v).collect();
190            dx = lu.solve(&neg_f)?;
191
192            // Update x and f: line search atomically updates both,
193            // plain Newton steps and evaluates separately.
194            if self.options.line_search {
195                self.line_search_update(system, &mut x, &dx, f_norm, &mut f, &mut n_eval);
196            } else {
197                for i in 0..n {
198                    x[i] += dx[i];
199                }
200                system.eval(&x, &mut f);
201                n_eval += 1;
202            }
203            f_norm = self.norm(&f);
204        }
205
206        if !converged {
207            message = format!(
208                "Did not converge after {} iterations, residual = {:.2e}",
209                self.options.max_iter,
210                f_norm.to_f64()
211            );
212        }
213
214        Ok(NewtonResult {
215            x,
216            residual_norm: f_norm,
217            iterations: if converged {
218                self.options.max_iter.min(n_jac)
219            } else {
220                self.options.max_iter
221            },
222            n_eval,
223            n_jac,
224            converged,
225            message,
226        })
227    }
228
229    /// Compute 2-norm of a vector.
230    fn norm(&self, v: &[S]) -> S {
231        let sum: S = v.iter().fold(S::ZERO, |acc, &x| acc + x * x);
232        sum.sqrt()
233    }
234
235    /// Finite difference Jacobian approximation.
236    fn finite_difference_jacobian<Sys: NonlinearSystem<S>>(
237        &self,
238        system: &Sys,
239        x: &[S],
240        f0: &[S],
241        jac: &mut [S],
242    ) {
243        let n = system.dim();
244        let h_factor = S::EPSILON.sqrt();
245        let mut x_pert = x.to_vec();
246        let mut f_pert = vec![S::ZERO; n];
247
248        for j in 0..n {
249            let x_j = x[j];
250            let h = h_factor * (S::ONE + x_j.abs());
251
252            x_pert[j] = x_j + h;
253            system.eval(&x_pert, &mut f_pert);
254            x_pert[j] = x_j;
255
256            // Column j of Jacobian (row-major storage)
257            for i in 0..n {
258                jac[i * n + j] = (f_pert[i] - f0[i]) / h;
259            }
260        }
261    }
262
263    /// Build DenseMatrix from row-major data.
264    fn build_matrix(&self, data: &[S], n: usize) -> DenseMatrix<S> {
265        let mut m = DenseMatrix::zeros(n, n);
266        for i in 0..n {
267            for j in 0..n {
268                m.set(i, j, data[i * n + j]);
269            }
270        }
271        m
272    }
273
274    /// Armijo line search that atomically updates x and f.
275    ///
276    /// On return, `x` holds the accepted trial point and `f` holds the
277    /// residual at that point.  This eliminates the previous fragile
278    /// coupling where `x` and `f` were updated in separate locations.
279    fn line_search_update<Sys: NonlinearSystem<S>>(
280        &self,
281        system: &Sys,
282        x: &mut [S],
283        dx: &[S],
284        f_norm: S,
285        f: &mut [S],
286        n_eval_count: &mut usize,
287    ) {
288        let n = x.len();
289        let mut step = S::ONE;
290        let mut x_trial = vec![S::ZERO; n];
291        let mut f_trial = vec![S::ZERO; n];
292
293        let mut best_step = S::ONE;
294        let mut best_norm = S::INFINITY;
295
296        for _ in 0..self.options.max_backsteps {
297            for i in 0..n {
298                x_trial[i] = x[i] + step * dx[i];
299            }
300
301            system.eval(&x_trial, &mut f_trial);
302            *n_eval_count += 1;
303
304            let f_trial_norm = self.norm(&f_trial);
305
306            if f_trial_norm < best_norm {
307                best_norm = f_trial_norm;
308                best_step = step;
309            }
310
311            if f_trial_norm <= f_norm * (S::ONE - self.options.armijo * step) {
312                x.copy_from_slice(&x_trial);
313                f.copy_from_slice(&f_trial);
314                return;
315            }
316
317            step *= S::from_f64(0.5);
318            if step < self.options.min_step {
319                break;
320            }
321        }
322
323        // Fall back to best step found
324        for i in 0..n {
325            x[i] += best_step * dx[i];
326        }
327        system.eval(x, f);
328        *n_eval_count += 1;
329    }
330}
331
332/// Convenience function to solve F(x) = 0.
333pub fn newton_solve<S, Sys>(
334    system: &Sys,
335    x0: &[S],
336    options: Option<NewtonOptions<S>>,
337) -> Result<NewtonResult<S>, LinalgError>
338where
339    S: Scalar + SimpleEntity + Conjugate<Canonical = S> + ComplexField,
340    Sys: NonlinearSystem<S>,
341{
342    let opts = options.unwrap_or_default();
343    let solver = Newton::new(opts);
344    solver.solve(system, x0)
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    /// Simple 1D system: f(x) = x^2 - 2 (roots at ±√2)
352    struct SquareRoot;
353
354    impl NonlinearSystem<f64> for SquareRoot {
355        fn dim(&self) -> usize {
356            1
357        }
358
359        fn eval(&self, x: &[f64], f: &mut [f64]) {
360            f[0] = x[0] * x[0] - 2.0;
361        }
362
363        fn jacobian(&self, x: &[f64], jac: &mut [f64]) {
364            jac[0] = 2.0 * x[0];
365        }
366
367        fn has_jacobian(&self) -> bool {
368            true
369        }
370    }
371
372    /// 2D system: x^2 + y^2 = 1, x - y = 0 (roots at ±(√2/2, √2/2))
373    struct CircleLine;
374
375    impl NonlinearSystem<f64> for CircleLine {
376        fn dim(&self) -> usize {
377            2
378        }
379
380        fn eval(&self, x: &[f64], f: &mut [f64]) {
381            f[0] = x[0] * x[0] + x[1] * x[1] - 1.0;
382            f[1] = x[0] - x[1];
383        }
384
385        fn jacobian(&self, x: &[f64], jac: &mut [f64]) {
386            // Row-major: [df1/dx, df1/dy, df2/dx, df2/dy]
387            jac[0] = 2.0 * x[0];
388            jac[1] = 2.0 * x[1];
389            jac[2] = 1.0;
390            jac[3] = -1.0;
391        }
392
393        fn has_jacobian(&self) -> bool {
394            true
395        }
396    }
397
398    /// Rosenbrock function minimum: f1 = 1-x, f2 = 10(y-x^2)
399    struct Rosenbrock;
400
401    impl NonlinearSystem<f64> for Rosenbrock {
402        fn dim(&self) -> usize {
403            2
404        }
405
406        fn eval(&self, x: &[f64], f: &mut [f64]) {
407            f[0] = 1.0 - x[0];
408            f[1] = 10.0 * (x[1] - x[0] * x[0]);
409        }
410
411        fn jacobian(&self, x: &[f64], jac: &mut [f64]) {
412            jac[0] = -1.0;
413            jac[1] = 0.0;
414            jac[2] = -20.0 * x[0];
415            jac[3] = 10.0;
416        }
417
418        fn has_jacobian(&self) -> bool {
419            true
420        }
421    }
422
423    #[test]
424    fn test_newton_sqrt2() {
425        let system = SquareRoot;
426        let options = NewtonOptions::default();
427        let solver = Newton::new(options);
428
429        let result = solver.solve(&system, &[1.5]).unwrap();
430
431        assert!(result.converged);
432        assert!((result.x[0] - 2.0_f64.sqrt()).abs() < 1e-10);
433    }
434
435    #[test]
436    fn test_newton_sqrt2_negative() {
437        let system = SquareRoot;
438        let solver = Newton::default_solver();
439
440        let result = solver.solve(&system, &[-1.5]).unwrap();
441
442        assert!(result.converged);
443        assert!((result.x[0] + 2.0_f64.sqrt()).abs() < 1e-10);
444    }
445
446    #[test]
447    fn test_newton_circle_line() {
448        let system = CircleLine;
449        let solver = Newton::default_solver();
450
451        let result = solver.solve(&system, &[0.5, 0.5]).unwrap();
452
453        assert!(result.converged);
454        let expected = (0.5_f64).sqrt();
455        assert!((result.x[0] - expected).abs() < 1e-10);
456        assert!((result.x[1] - expected).abs() < 1e-10);
457    }
458
459    #[test]
460    fn test_newton_rosenbrock() {
461        let system = Rosenbrock;
462        let options = NewtonOptions::default().line_search(true);
463        let solver = Newton::new(options);
464
465        let result = solver.solve(&system, &[-0.5, 0.5]).unwrap();
466
467        assert!(result.converged);
468        assert!((result.x[0] - 1.0).abs() < 1e-8);
469        assert!((result.x[1] - 1.0).abs() < 1e-8);
470    }
471
472    #[test]
473    fn test_newton_finite_difference() {
474        // Same as SquareRoot but without analytical Jacobian
475        struct SquareRootFD;
476
477        impl NonlinearSystem<f64> for SquareRootFD {
478            fn dim(&self) -> usize {
479                1
480            }
481            fn eval(&self, x: &[f64], f: &mut [f64]) {
482                f[0] = x[0] * x[0] - 2.0;
483            }
484        }
485
486        let system = SquareRootFD;
487        let solver = Newton::default_solver();
488
489        let result = solver.solve(&system, &[1.5]).unwrap();
490
491        assert!(result.converged);
492        assert!((result.x[0] - 2.0_f64.sqrt()).abs() < 1e-8);
493        assert!(result.n_eval > result.n_jac); // FD uses more evals
494    }
495
496    #[test]
497    fn test_newton_convenience_function() {
498        let system = SquareRoot;
499        let result = newton_solve(&system, &[1.5], None).unwrap();
500
501        assert!(result.converged);
502        assert!((result.x[0] - 2.0_f64.sqrt()).abs() < 1e-10);
503    }
504
505    #[test]
506    fn test_newton_no_line_search() {
507        let system = Rosenbrock;
508        let options = NewtonOptions::default().line_search(false);
509        let solver = Newton::new(options);
510
511        // Without line search, may still converge for good initial guess
512        let result = solver.solve(&system, &[0.9, 0.9]).unwrap();
513
514        assert!(result.converged);
515    }
516
517    // ============================================================================
518    // f32 Scalar Tests
519    // ============================================================================
520
521    #[test]
522    fn test_newton_sqrt2_f32() {
523        struct SquareRootF32;
524        impl NonlinearSystem<f32> for SquareRootF32 {
525            fn dim(&self) -> usize {
526                1
527            }
528            fn eval(&self, x: &[f32], f: &mut [f32]) {
529                f[0] = x[0] * x[0] - 2.0;
530            }
531            fn jacobian(&self, x: &[f32], jac: &mut [f32]) {
532                jac[0] = 2.0 * x[0];
533            }
534            fn has_jacobian(&self) -> bool {
535                true
536            }
537        }
538
539        // f32 has ~7 digits of precision, so use appropriate tolerances
540        let options: NewtonOptions<f32> = NewtonOptions::default().atol(1e-6).rtol(1e-6);
541        let solver = Newton::new(options);
542        let result = solver.solve(&SquareRootF32, &[1.5f32]).unwrap();
543
544        assert!(result.converged);
545        assert!((result.x[0] - 2.0f32.sqrt()).abs() < 1e-5);
546    }
547
548    // ============================================================================
549    // NaN / Infinity Tests
550    // ============================================================================
551
552    #[test]
553    fn test_newton_nan_initial_guess() {
554        let result = Newton::default_solver().solve(&SquareRoot, &[f64::NAN]);
555
556        // NaN input should either fail or not converge
557        match result {
558            Ok(r) => assert!(!r.converged || r.x[0].is_nan()),
559            Err(_) => {} // Also acceptable
560        }
561    }
562
563    #[test]
564    fn test_newton_dimension_mismatch() {
565        let result = Newton::default_solver().solve(&SquareRoot, &[1.0, 2.0]);
566        assert!(result.is_err());
567    }
568}