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        /// Step size for finite difference Jacobian approximation.
245        const FD_JACOBIAN_EPS: f64 = 1e-8;
246        let eps = S::from_f64(FD_JACOBIAN_EPS);
247        let mut x_pert = x.to_vec();
248        let mut f_pert = vec![S::ZERO; n];
249
250        for j in 0..n {
251            let x_j = x[j];
252            let h = eps * (S::ONE + x_j.abs());
253
254            x_pert[j] = x_j + h;
255            system.eval(&x_pert, &mut f_pert);
256            x_pert[j] = x_j;
257
258            // Column j of Jacobian (row-major storage)
259            for i in 0..n {
260                jac[i * n + j] = (f_pert[i] - f0[i]) / h;
261            }
262        }
263    }
264
265    /// Build DenseMatrix from row-major data.
266    fn build_matrix(&self, data: &[S], n: usize) -> DenseMatrix<S> {
267        let mut m = DenseMatrix::zeros(n, n);
268        for i in 0..n {
269            for j in 0..n {
270                m.set(i, j, data[i * n + j]);
271            }
272        }
273        m
274    }
275
276    /// Armijo line search that atomically updates x and f.
277    ///
278    /// On return, `x` holds the accepted trial point and `f` holds the
279    /// residual at that point.  This eliminates the previous fragile
280    /// coupling where `x` and `f` were updated in separate locations.
281    fn line_search_update<Sys: NonlinearSystem<S>>(
282        &self,
283        system: &Sys,
284        x: &mut [S],
285        dx: &[S],
286        f_norm: S,
287        f: &mut [S],
288        n_eval_count: &mut usize,
289    ) {
290        let n = x.len();
291        let mut step = S::ONE;
292        let mut x_trial = vec![S::ZERO; n];
293        let mut f_trial = vec![S::ZERO; n];
294
295        let mut best_step = S::ONE;
296        let mut best_norm = S::INFINITY;
297
298        for _ in 0..self.options.max_backsteps {
299            for i in 0..n {
300                x_trial[i] = x[i] + step * dx[i];
301            }
302
303            system.eval(&x_trial, &mut f_trial);
304            *n_eval_count += 1;
305
306            let f_trial_norm = self.norm(&f_trial);
307
308            if f_trial_norm < best_norm {
309                best_norm = f_trial_norm;
310                best_step = step;
311            }
312
313            if f_trial_norm <= f_norm * (S::ONE - self.options.armijo * step) {
314                x.copy_from_slice(&x_trial);
315                f.copy_from_slice(&f_trial);
316                return;
317            }
318
319            step *= S::from_f64(0.5);
320            if step < self.options.min_step {
321                break;
322            }
323        }
324
325        // Fall back to best step found
326        for i in 0..n {
327            x[i] += best_step * dx[i];
328        }
329        system.eval(x, f);
330        *n_eval_count += 1;
331    }
332}
333
334/// Convenience function to solve F(x) = 0.
335pub fn newton_solve<S, Sys>(
336    system: &Sys,
337    x0: &[S],
338    options: Option<NewtonOptions<S>>,
339) -> Result<NewtonResult<S>, LinalgError>
340where
341    S: Scalar + SimpleEntity + Conjugate<Canonical = S> + ComplexField,
342    Sys: NonlinearSystem<S>,
343{
344    let opts = options.unwrap_or_default();
345    let solver = Newton::new(opts);
346    solver.solve(system, x0)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    /// Simple 1D system: f(x) = x^2 - 2 (roots at ±√2)
354    struct SquareRoot;
355
356    impl NonlinearSystem<f64> for SquareRoot {
357        fn dim(&self) -> usize {
358            1
359        }
360
361        fn eval(&self, x: &[f64], f: &mut [f64]) {
362            f[0] = x[0] * x[0] - 2.0;
363        }
364
365        fn jacobian(&self, x: &[f64], jac: &mut [f64]) {
366            jac[0] = 2.0 * x[0];
367        }
368
369        fn has_jacobian(&self) -> bool {
370            true
371        }
372    }
373
374    /// 2D system: x^2 + y^2 = 1, x - y = 0 (roots at ±(√2/2, √2/2))
375    struct CircleLine;
376
377    impl NonlinearSystem<f64> for CircleLine {
378        fn dim(&self) -> usize {
379            2
380        }
381
382        fn eval(&self, x: &[f64], f: &mut [f64]) {
383            f[0] = x[0] * x[0] + x[1] * x[1] - 1.0;
384            f[1] = x[0] - x[1];
385        }
386
387        fn jacobian(&self, x: &[f64], jac: &mut [f64]) {
388            // Row-major: [df1/dx, df1/dy, df2/dx, df2/dy]
389            jac[0] = 2.0 * x[0];
390            jac[1] = 2.0 * x[1];
391            jac[2] = 1.0;
392            jac[3] = -1.0;
393        }
394
395        fn has_jacobian(&self) -> bool {
396            true
397        }
398    }
399
400    /// Rosenbrock function minimum: f1 = 1-x, f2 = 10(y-x^2)
401    struct Rosenbrock;
402
403    impl NonlinearSystem<f64> for Rosenbrock {
404        fn dim(&self) -> usize {
405            2
406        }
407
408        fn eval(&self, x: &[f64], f: &mut [f64]) {
409            f[0] = 1.0 - x[0];
410            f[1] = 10.0 * (x[1] - x[0] * x[0]);
411        }
412
413        fn jacobian(&self, x: &[f64], jac: &mut [f64]) {
414            jac[0] = -1.0;
415            jac[1] = 0.0;
416            jac[2] = -20.0 * x[0];
417            jac[3] = 10.0;
418        }
419
420        fn has_jacobian(&self) -> bool {
421            true
422        }
423    }
424
425    #[test]
426    fn test_newton_sqrt2() {
427        let system = SquareRoot;
428        let options = NewtonOptions::default();
429        let solver = Newton::new(options);
430
431        let result = solver.solve(&system, &[1.5]).unwrap();
432
433        assert!(result.converged);
434        assert!((result.x[0] - 2.0_f64.sqrt()).abs() < 1e-10);
435    }
436
437    #[test]
438    fn test_newton_sqrt2_negative() {
439        let system = SquareRoot;
440        let solver = Newton::default_solver();
441
442        let result = solver.solve(&system, &[-1.5]).unwrap();
443
444        assert!(result.converged);
445        assert!((result.x[0] + 2.0_f64.sqrt()).abs() < 1e-10);
446    }
447
448    #[test]
449    fn test_newton_circle_line() {
450        let system = CircleLine;
451        let solver = Newton::default_solver();
452
453        let result = solver.solve(&system, &[0.5, 0.5]).unwrap();
454
455        assert!(result.converged);
456        let expected = (0.5_f64).sqrt();
457        assert!((result.x[0] - expected).abs() < 1e-10);
458        assert!((result.x[1] - expected).abs() < 1e-10);
459    }
460
461    #[test]
462    fn test_newton_rosenbrock() {
463        let system = Rosenbrock;
464        let options = NewtonOptions::default().line_search(true);
465        let solver = Newton::new(options);
466
467        let result = solver.solve(&system, &[-0.5, 0.5]).unwrap();
468
469        assert!(result.converged);
470        assert!((result.x[0] - 1.0).abs() < 1e-8);
471        assert!((result.x[1] - 1.0).abs() < 1e-8);
472    }
473
474    #[test]
475    fn test_newton_finite_difference() {
476        // Same as SquareRoot but without analytical Jacobian
477        struct SquareRootFD;
478
479        impl NonlinearSystem<f64> for SquareRootFD {
480            fn dim(&self) -> usize {
481                1
482            }
483            fn eval(&self, x: &[f64], f: &mut [f64]) {
484                f[0] = x[0] * x[0] - 2.0;
485            }
486        }
487
488        let system = SquareRootFD;
489        let solver = Newton::default_solver();
490
491        let result = solver.solve(&system, &[1.5]).unwrap();
492
493        assert!(result.converged);
494        assert!((result.x[0] - 2.0_f64.sqrt()).abs() < 1e-8);
495        assert!(result.n_eval > result.n_jac); // FD uses more evals
496    }
497
498    #[test]
499    fn test_newton_convenience_function() {
500        let system = SquareRoot;
501        let result = newton_solve(&system, &[1.5], None).unwrap();
502
503        assert!(result.converged);
504        assert!((result.x[0] - 2.0_f64.sqrt()).abs() < 1e-10);
505    }
506
507    #[test]
508    fn test_newton_no_line_search() {
509        let system = Rosenbrock;
510        let options = NewtonOptions::default().line_search(false);
511        let solver = Newton::new(options);
512
513        // Without line search, may still converge for good initial guess
514        let result = solver.solve(&system, &[0.9, 0.9]).unwrap();
515
516        assert!(result.converged);
517    }
518
519    // ============================================================================
520    // f32 Scalar Tests
521    // ============================================================================
522
523    #[test]
524    fn test_newton_sqrt2_f32() {
525        struct SquareRootF32;
526        impl NonlinearSystem<f32> for SquareRootF32 {
527            fn dim(&self) -> usize {
528                1
529            }
530            fn eval(&self, x: &[f32], f: &mut [f32]) {
531                f[0] = x[0] * x[0] - 2.0;
532            }
533            fn jacobian(&self, x: &[f32], jac: &mut [f32]) {
534                jac[0] = 2.0 * x[0];
535            }
536            fn has_jacobian(&self) -> bool {
537                true
538            }
539        }
540
541        // f32 has ~7 digits of precision, so use appropriate tolerances
542        let options: NewtonOptions<f32> = NewtonOptions::default().atol(1e-6).rtol(1e-6);
543        let solver = Newton::new(options);
544        let result = solver.solve(&SquareRootF32, &[1.5f32]).unwrap();
545
546        assert!(result.converged);
547        assert!((result.x[0] - 2.0f32.sqrt()).abs() < 1e-5);
548    }
549
550    // ============================================================================
551    // NaN / Infinity Tests
552    // ============================================================================
553
554    #[test]
555    fn test_newton_nan_initial_guess() {
556        let result = Newton::default_solver().solve(&SquareRoot, &[f64::NAN]);
557
558        // NaN input should either fail or not converge
559        match result {
560            Ok(r) => assert!(!r.converged || r.x[0].is_nan()),
561            Err(_) => {} // Also acceptable
562        }
563    }
564
565    #[test]
566    fn test_newton_dimension_mismatch() {
567        let result = Newton::default_solver().solve(&SquareRoot, &[1.0, 2.0]);
568        assert!(result.is_err());
569    }
570}