Skip to main content

pounce_presolve/
block_solve.rs

1//! Lightweight damped-Newton solver for square ≤ 8-dim auxiliary
2//! blocks, plus the `BlockSolver` trait for the larger-block fallback.
3//!
4//! PR 6 of the auxiliary-presolve port (issue #53). Implementation:
5//!
6//! - dense LU with partial pivoting + forward/back-substitution
7//!   (~50 lines, self-contained);
8//! - damped Newton iteration with halving line search on the
9//!   `||F||_∞` norm;
10//! - `BlockSolver` trait so PR 11 can plug in an IPM-backed
11//!   fallback for blocks larger than `max_dim`.
12//!
13//! ripopt anchor: `src/auxiliary_preprocessing.rs:1078-1182`.
14
15use pounce_common::types::Number;
16
17/// Tunables for the damped Newton loop.
18#[derive(Debug, Clone)]
19pub struct BlockSolveOptions {
20    /// Maximum outer Newton iterations.
21    pub max_iter: usize,
22    /// Convergence tolerance on `||F(x)||_∞`.
23    pub tol: Number,
24    /// Smallest backtracking step before declaring divergence.
25    pub min_step: Number,
26    /// Refuse to solve blocks larger than this. Defaults to 8;
27    /// PR 11 lifts it via an IPM-backed fallback impl.
28    pub max_dim: usize,
29    /// PR #60 review nit: optional per-variable bounds on the
30    /// Newton iterate. When set, the solver rejects any final
31    /// solution with `x[i] < lower[i] - tol` or `x[i] > upper[i] +
32    /// tol`, returning `BlockSolveError::OutOfBounds` instead of
33    /// `Ok`. Lengths must equal the block dimension.
34    pub bounds: Option<(Vec<Number>, Vec<Number>)>,
35}
36
37impl Default for BlockSolveOptions {
38    fn default() -> Self {
39        Self {
40            max_iter: 30,
41            tol: 1e-8,
42            min_step: 1e-6,
43            max_dim: 8,
44            bounds: None,
45        }
46    }
47}
48
49/// Why a block solve failed.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum BlockSolveError {
52    /// Line search shrank below `min_step` without reducing the
53    /// residual.
54    Diverged,
55    /// LU encountered a near-zero pivot.
56    Singular,
57    /// `eqs.dim() > options.max_dim`.
58    TooLarge,
59    /// Reached `max_iter` without converging.
60    MaxIterReached,
61    /// User callback returned `false` from `eval` or `jacobian`.
62    EvalFailed,
63    /// PR #60 review nit: Newton converged but the final iterate
64    /// lies outside `BlockSolveOptions::bounds`. The orchestrator
65    /// converts this into `AuxiliaryRejectionReason::OutOfBounds`
66    /// so the block isn't applied.
67    OutOfBounds,
68}
69
70/// Successful block solve.
71#[derive(Debug, Clone)]
72pub struct BlockSolveOutcome {
73    /// The converged variable values.
74    pub x: Vec<Number>,
75    /// `||F(x)||_∞` at the returned point.
76    pub residual_norm: Number,
77    /// Number of outer Newton iterations performed.
78    pub iterations: usize,
79}
80
81/// The user-supplied residual + Jacobian callbacks for one block.
82/// `jacobian` writes a `dim × dim` dense matrix in row-major order.
83pub trait BlockEquations {
84    fn dim(&self) -> usize;
85    fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool;
86    fn jacobian(&mut self, x: &[Number], jac_row_major: &mut [Number]) -> bool;
87}
88
89/// Strategy for solving an auxiliary block. PR 6 ships the
90/// damped-Newton implementation; PR 11 will add an IPM-backed
91/// fallback that handles blocks the lightweight Newton refuses.
92pub trait BlockSolver {
93    fn solve(
94        &mut self,
95        x0: &[Number],
96        eqs: &mut dyn BlockEquations,
97        options: &BlockSolveOptions,
98    ) -> Result<BlockSolveOutcome, BlockSolveError>;
99}
100
101/// Damped Newton with halving line search and dense LU.
102#[derive(Debug, Default, Clone, Copy)]
103pub struct DampedNewtonSolver;
104
105/// Solver for blocks exceeding `auxiliary_max_block_dim`. The
106/// orchestrator falls through to this when the lightweight Newton
107/// rejects a block as too large.
108///
109/// PR 11 ships [`RelaxedNewtonSolver`] as the default impl — same
110/// algorithm as `DampedNewtonSolver` but with a higher dimension
111/// cap and more iterations. The architectural seam is what matters:
112/// a future `pounce-algorithm`-side impl can drop in here without
113/// touching the orchestrator.
114pub trait LargeBlockSolver {
115    fn solve_large(
116        &mut self,
117        x0: &[Number],
118        eqs: &mut dyn BlockEquations,
119        options: &BlockSolveOptions,
120    ) -> Result<BlockSolveOutcome, BlockSolveError>;
121}
122
123/// Default [`LargeBlockSolver`]: same damped Newton, looser limits
124/// (`max_dim = 64`, `max_iter = 200`, `min_step = 1e-10`).
125/// Handles "well-behaved but too-big" blocks. Diverges-out-of-the-box
126/// blocks need the real IPM-backed solver from `pounce-algorithm`,
127/// tracked as a follow-up.
128#[derive(Debug, Default, Clone, Copy)]
129pub struct RelaxedNewtonSolver;
130
131impl LargeBlockSolver for RelaxedNewtonSolver {
132    fn solve_large(
133        &mut self,
134        x0: &[Number],
135        eqs: &mut dyn BlockEquations,
136        options: &BlockSolveOptions,
137    ) -> Result<BlockSolveOutcome, BlockSolveError> {
138        // Inherit the caller's `tol` but loosen the iteration and
139        // dimension caps. The `max_dim` ceiling on the relaxed path
140        // is the real switch; without bumping it, Newton would
141        // reject before the line-search budget matters.
142        let relaxed = BlockSolveOptions {
143            tol: options.tol,
144            max_iter: options.max_iter.max(200),
145            min_step: options.min_step.min(1e-10),
146            max_dim: options.max_dim.max(64),
147            bounds: options.bounds.clone(),
148        };
149        DampedNewtonSolver.solve(x0, eqs, &relaxed)
150    }
151}
152
153impl BlockSolver for DampedNewtonSolver {
154    /// Solve `F(x) = 0` for a small block, starting from `x0`.
155    ///
156    /// # Example
157    ///
158    /// ```
159    /// use pounce_common::types::Number;
160    /// use pounce_presolve::block_solve::{
161    ///     BlockEquations, BlockSolveOptions, BlockSolver, DampedNewtonSolver,
162    /// };
163    ///
164    /// // F = [x + y - 1; x - y] = 0  →  (x, y) = (0.5, 0.5).
165    /// struct Eqs;
166    /// impl BlockEquations for Eqs {
167    ///     fn dim(&self) -> usize { 2 }
168    ///     fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool {
169    ///         f[0] = x[0] + x[1] - 1.0;
170    ///         f[1] = x[0] - x[1];
171    ///         true
172    ///     }
173    ///     fn jacobian(&mut self, _x: &[Number], j: &mut [Number]) -> bool {
174    ///         j.copy_from_slice(&[1.0, 1.0, 1.0, -1.0]);
175    ///         true
176    ///     }
177    /// }
178    /// let mut solver = DampedNewtonSolver;
179    /// let mut eqs = Eqs;
180    /// let opt = BlockSolveOptions::default();
181    /// let out = solver.solve(&[0.0, 0.0], &mut eqs, &opt).unwrap();
182    /// assert!((out.x[0] - 0.5).abs() < 1e-10);
183    /// assert!((out.x[1] - 0.5).abs() < 1e-10);
184    /// ```
185    fn solve(
186        &mut self,
187        x0: &[Number],
188        eqs: &mut dyn BlockEquations,
189        options: &BlockSolveOptions,
190    ) -> Result<BlockSolveOutcome, BlockSolveError> {
191        let n = eqs.dim();
192        if n > options.max_dim {
193            return Err(BlockSolveError::TooLarge);
194        }
195        if x0.len() != n {
196            return Err(BlockSolveError::EvalFailed);
197        }
198
199        let mut x: Vec<Number> = x0.to_vec();
200        let mut f = vec![0.0; n];
201        let mut f_new = vec![0.0; n];
202        let mut jac = vec![0.0; n * n];
203        let mut rhs = vec![0.0; n];
204        let mut x_new = vec![0.0; n];
205
206        if !eqs.eval(&x, &mut f) {
207            return Err(BlockSolveError::EvalFailed);
208        }
209        let mut res = inf_norm(&f);
210
211        for iter in 0..options.max_iter {
212            if res < options.tol {
213                check_bounds(&x, options)?;
214                return Ok(BlockSolveOutcome {
215                    x,
216                    residual_norm: res,
217                    iterations: iter,
218                });
219            }
220            if !eqs.jacobian(&x, &mut jac) {
221                return Err(BlockSolveError::EvalFailed);
222            }
223            for i in 0..n {
224                rhs[i] = -f[i];
225            }
226            let piv =
227                lu_factor_partial_pivot(&mut jac, n).map_err(|_| BlockSolveError::Singular)?;
228            lu_solve(&jac, &piv, &mut rhs, n);
229            // `rhs` now holds the Newton step `dx`.
230
231            // Halving line search.
232            let mut alpha: Number = 1.0;
233            let mut accepted = false;
234            while alpha >= options.min_step {
235                for i in 0..n {
236                    x_new[i] = x[i] + alpha * rhs[i];
237                }
238                if !eqs.eval(&x_new, &mut f_new) {
239                    return Err(BlockSolveError::EvalFailed);
240                }
241                let res_new = inf_norm(&f_new);
242                if res_new < res {
243                    x.copy_from_slice(&x_new);
244                    f.copy_from_slice(&f_new);
245                    res = res_new;
246                    accepted = true;
247                    break;
248                }
249                alpha *= 0.5;
250            }
251            if !accepted {
252                return Err(BlockSolveError::Diverged);
253            }
254        }
255        // Check one last time — if max_iter brought us to tol, accept.
256        if res < options.tol {
257            check_bounds(&x, options)?;
258            Ok(BlockSolveOutcome {
259                x,
260                residual_norm: res,
261                iterations: options.max_iter,
262            })
263        } else {
264            Err(BlockSolveError::MaxIterReached)
265        }
266    }
267}
268
269/// Check that every entry of `x` lies within the optional bounds
270/// from `BlockSolveOptions::bounds` (PR #60 review nit). Returns
271/// `Err(OutOfBounds)` on violation; `Ok(())` when no bounds were
272/// supplied or all entries pass.
273fn check_bounds(x: &[Number], options: &BlockSolveOptions) -> Result<(), BlockSolveError> {
274    if let Some((lo, hi)) = &options.bounds {
275        // Tolerate small slop for finite-precision tol.
276        let slop = options.tol.max(1e-12);
277        for (i, &xi) in x.iter().enumerate() {
278            if xi < lo[i] - slop || xi > hi[i] + slop {
279                return Err(BlockSolveError::OutOfBounds);
280            }
281        }
282    }
283    Ok(())
284}
285
286fn inf_norm(v: &[Number]) -> Number {
287    v.iter().map(|x| x.abs()).fold(0.0, Number::max)
288}
289
290/// In-place LU factorisation with partial pivoting on a row-major
291/// `n × n` matrix. Returns the row-permutation vector `piv` where
292/// `piv[k]` is the original row now in position `k`. Pivots smaller
293/// than `1e-14` are treated as zero and cause a `Singular` error.
294///
295/// `pub(crate)` so PR 7's `reduction_frame` can reuse it for the
296/// multiplier-recovery solve.
297pub(crate) fn lu_factor_partial_pivot(a: &mut [Number], n: usize) -> Result<Vec<usize>, ()> {
298    let mut piv: Vec<usize> = (0..n).collect();
299    for k in 0..n {
300        // Find pivot row.
301        let mut max_abs: Number = 0.0;
302        let mut pivot_row = k;
303        for i in k..n {
304            let v = a[i * n + k].abs();
305            if v > max_abs {
306                max_abs = v;
307                pivot_row = i;
308            }
309        }
310        if max_abs < 1e-14 {
311            return Err(());
312        }
313        if pivot_row != k {
314            for j in 0..n {
315                a.swap(k * n + j, pivot_row * n + j);
316            }
317            piv.swap(k, pivot_row);
318        }
319        // Eliminate below.
320        let pivot = a[k * n + k];
321        for i in (k + 1)..n {
322            let factor = a[i * n + k] / pivot;
323            a[i * n + k] = factor;
324            for j in (k + 1)..n {
325                let upper = a[k * n + j];
326                a[i * n + j] -= factor * upper;
327            }
328        }
329    }
330    Ok(piv)
331}
332
333/// Solve `LUx = Pb` in place using a factorisation from
334/// [`lu_factor_partial_pivot`]. `b` is overwritten with the
335/// solution.
336pub(crate) fn lu_solve(a: &[Number], piv: &[usize], b: &mut [Number], n: usize) {
337    // Permute b.
338    let mut pb = vec![0.0; n];
339    for i in 0..n {
340        pb[i] = b[piv[i]];
341    }
342    // Forward substitution Ly = Pb (L has unit diagonal).
343    for i in 0..n {
344        let mut sum = pb[i];
345        for j in 0..i {
346            sum -= a[i * n + j] * pb[j];
347        }
348        pb[i] = sum;
349    }
350    // Back substitution Ux = y.
351    for i in (0..n).rev() {
352        let mut sum = pb[i];
353        for j in (i + 1)..n {
354            sum -= a[i * n + j] * pb[j];
355        }
356        pb[i] = sum / a[i * n + i];
357    }
358    b.copy_from_slice(&pb);
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    // -------- LU helpers ----------------------------------------------
366
367    #[test]
368    fn lu_solves_identity() {
369        let mut a = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
370        let mut b = vec![1.0, 2.0, 3.0];
371        let piv = lu_factor_partial_pivot(&mut a, 3).expect("non-singular");
372        lu_solve(&a, &piv, &mut b, 3);
373        assert!((b[0] - 1.0).abs() < 1e-12);
374        assert!((b[1] - 2.0).abs() < 1e-12);
375        assert!((b[2] - 3.0).abs() < 1e-12);
376    }
377
378    #[test]
379    fn lu_solves_pivoted_2x2() {
380        // [[0, 1], [1, 0]] x = [3, 4]  →  x = [4, 3].
381        let mut a = vec![0.0, 1.0, 1.0, 0.0];
382        let mut b = vec![3.0, 4.0];
383        let piv = lu_factor_partial_pivot(&mut a, 2).expect("non-singular");
384        lu_solve(&a, &piv, &mut b, 2);
385        assert!((b[0] - 4.0).abs() < 1e-12);
386        assert!((b[1] - 3.0).abs() < 1e-12);
387    }
388
389    #[test]
390    fn lu_detects_singular() {
391        // Rank-1 matrix.
392        let mut a = vec![1.0, 2.0, 2.0, 4.0];
393        let result = lu_factor_partial_pivot(&mut a, 2);
394        assert!(result.is_err());
395    }
396
397    // -------- LargeBlockSolver -----------------------------------------
398
399    #[test]
400    fn relaxed_newton_solves_10_var_linear() {
401        // 10×10 identity-like system. The default DampedNewtonSolver
402        // rejects this with `TooLarge` because n=10 > max_dim=8;
403        // RelaxedNewtonSolver bumps max_dim and solves to within tol.
404        let n = 10;
405        let mut a = vec![0.0; n * n];
406        let mut b = vec![0.0; n];
407        for i in 0..n {
408            a[i * n + i] = 2.0;
409            b[i] = (i + 1) as Number;
410        }
411        struct Lin {
412            a: Vec<Number>,
413            b: Vec<Number>,
414            n: usize,
415        }
416        impl BlockEquations for Lin {
417            fn dim(&self) -> usize {
418                self.n
419            }
420            fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool {
421                for i in 0..self.n {
422                    let mut s = -self.b[i];
423                    for j in 0..self.n {
424                        s += self.a[i * self.n + j] * x[j];
425                    }
426                    f[i] = s;
427                }
428                true
429            }
430            fn jacobian(&mut self, _x: &[Number], j: &mut [Number]) -> bool {
431                j.copy_from_slice(&self.a);
432                true
433            }
434        }
435        let mut eqs = Lin { a, b, n };
436        // Caller-side options keep max_dim at 8 (the default);
437        // RelaxedNewtonSolver relaxes internally.
438        let opts = BlockSolveOptions::default();
439
440        // First: confirm the default solver rejects.
441        let lightweight_err = DampedNewtonSolver
442            .solve(&vec![0.0; n], &mut eqs, &opts)
443            .expect_err("default should reject 10-dim block");
444        assert_eq!(lightweight_err, BlockSolveError::TooLarge);
445
446        // Now: RelaxedNewtonSolver solves it.
447        let out = RelaxedNewtonSolver
448            .solve_large(&vec![0.0; n], &mut eqs, &opts)
449            .expect("relaxed solver handles 10-dim");
450        for i in 0..n {
451            assert!((out.x[i] - (i + 1) as Number / 2.0).abs() < 1e-10);
452        }
453    }
454
455    // -------- Newton solver --------------------------------------------
456
457    struct LinearF {
458        a: Vec<Number>,
459        b: Vec<Number>,
460        n: usize,
461    }
462    impl BlockEquations for LinearF {
463        fn dim(&self) -> usize {
464            self.n
465        }
466        fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool {
467            for i in 0..self.n {
468                let mut s = -self.b[i];
469                for j in 0..self.n {
470                    s += self.a[i * self.n + j] * x[j];
471                }
472                f[i] = s;
473            }
474            true
475        }
476        fn jacobian(&mut self, _x: &[Number], j: &mut [Number]) -> bool {
477            j.copy_from_slice(&self.a);
478            true
479        }
480    }
481
482    #[test]
483    fn newton_linear_1d() {
484        // F(x) = x - 3 = 0 → x = 3.
485        let mut eqs = LinearF {
486            a: vec![1.0],
487            b: vec![3.0],
488            n: 1,
489        };
490        let opt = BlockSolveOptions::default();
491        let out = DampedNewtonSolver
492            .solve(&[0.0], &mut eqs, &opt)
493            .expect("converges");
494        assert!((out.x[0] - 3.0).abs() < 1e-10);
495        assert!(out.residual_norm < 1e-10);
496    }
497
498    #[test]
499    fn newton_linear_2d() {
500        // [x + y - 1; x - y] = 0 → (0.5, 0.5).
501        let mut eqs = LinearF {
502            a: vec![1.0, 1.0, 1.0, -1.0],
503            b: vec![1.0, 0.0],
504            n: 2,
505        };
506        let opt = BlockSolveOptions::default();
507        let out = DampedNewtonSolver
508            .solve(&[0.0, 0.0], &mut eqs, &opt)
509            .expect("converges");
510        assert!((out.x[0] - 0.5).abs() < 1e-10);
511        assert!((out.x[1] - 0.5).abs() < 1e-10);
512    }
513
514    struct Quadratic;
515    impl BlockEquations for Quadratic {
516        fn dim(&self) -> usize {
517            1
518        }
519        fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool {
520            f[0] = x[0] * x[0] - 4.0;
521            true
522        }
523        fn jacobian(&mut self, x: &[Number], j: &mut [Number]) -> bool {
524            j[0] = 2.0 * x[0];
525            true
526        }
527    }
528
529    #[test]
530    fn newton_quadratic() {
531        // x² - 4 = 0 → x = 2 (from positive starting point).
532        let mut eqs = Quadratic;
533        let opt = BlockSolveOptions::default();
534        let out = DampedNewtonSolver
535            .solve(&[1.0], &mut eqs, &opt)
536            .expect("converges");
537        assert!((out.x[0] - 2.0).abs() < 1e-8);
538    }
539
540    /// {x² + y² + z² = 14, x + y + z = 6, xyz = 6}.
541    /// Solution (up to permutation): (1, 2, 3).
542    struct ThreeVar;
543    impl BlockEquations for ThreeVar {
544        fn dim(&self) -> usize {
545            3
546        }
547        fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool {
548            f[0] = x[0] * x[0] + x[1] * x[1] + x[2] * x[2] - 14.0;
549            f[1] = x[0] + x[1] + x[2] - 6.0;
550            f[2] = x[0] * x[1] * x[2] - 6.0;
551            true
552        }
553        fn jacobian(&mut self, x: &[Number], j: &mut [Number]) -> bool {
554            j[0] = 2.0 * x[0];
555            j[1] = 2.0 * x[1];
556            j[2] = 2.0 * x[2];
557            j[3] = 1.0;
558            j[4] = 1.0;
559            j[5] = 1.0;
560            j[6] = x[1] * x[2];
561            j[7] = x[0] * x[2];
562            j[8] = x[0] * x[1];
563            true
564        }
565    }
566
567    #[test]
568    fn newton_3var_nonlinear() {
569        // Start near (1, 2, 3) to avoid permutation ambiguity.
570        let mut eqs = ThreeVar;
571        let opt = BlockSolveOptions::default();
572        let out = DampedNewtonSolver
573            .solve(&[1.1, 1.9, 3.05], &mut eqs, &opt)
574            .expect("converges");
575        // Verify (x, y, z) is some permutation of (1, 2, 3) and the
576        // residual is small.
577        let mut sorted = out.x.clone();
578        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
579        assert!((sorted[0] - 1.0).abs() < 1e-6);
580        assert!((sorted[1] - 2.0).abs() < 1e-6);
581        assert!((sorted[2] - 3.0).abs() < 1e-6);
582        assert!(out.residual_norm < 1e-8);
583    }
584
585    /// Newton on tan(x) — derivative grows fast and direction is bad.
586    /// From x0 close to π/2 the Newton step overshoots into a
587    /// different branch and the residual keeps growing; line search
588    /// can't recover.
589    struct Tan;
590    impl BlockEquations for Tan {
591        fn dim(&self) -> usize {
592            1
593        }
594        fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool {
595            f[0] = x[0].tan();
596            true
597        }
598        fn jacobian(&mut self, x: &[Number], j: &mut [Number]) -> bool {
599            let c = x[0].cos();
600            j[0] = 1.0 / (c * c);
601            true
602        }
603    }
604
605    #[test]
606    fn newton_diverges_when_starting_point_bad() {
607        let mut eqs = Tan;
608        let opt = BlockSolveOptions {
609            max_iter: 30,
610            tol: 1e-10,
611            // Start at x near π/2 — tan(x) is huge, derivative is
612            // huge, but a Newton step overshoots into a region where
613            // tan crosses a branch and the residual jumps. The line
614            // search either can't accept any step (Diverged) or we
615            // hit MaxIterReached oscillating. Either is acceptable
616            // failure here; the key is that we do NOT report
617            // success.
618            ..Default::default()
619        };
620        let result = DampedNewtonSolver.solve(&[1.5], &mut eqs, &opt);
621        match result {
622            Err(BlockSolveError::Diverged)
623            | Err(BlockSolveError::MaxIterReached)
624            | Err(BlockSolveError::Singular) => {}
625            Ok(out) => {
626                // If it did converge, residual must be tiny — but
627                // we don't expect this with x0 = 1.5.
628                assert!(out.residual_norm < opt.tol);
629            }
630            Err(e) => panic!("unexpected error variant: {e:?}"),
631        }
632    }
633
634    #[test]
635    fn newton_rejects_too_large() {
636        struct Big;
637        impl BlockEquations for Big {
638            fn dim(&self) -> usize {
639                9
640            }
641            fn eval(&mut self, _x: &[Number], f: &mut [Number]) -> bool {
642                for v in f.iter_mut() {
643                    *v = 0.0;
644                }
645                true
646            }
647            fn jacobian(&mut self, _x: &[Number], j: &mut [Number]) -> bool {
648                for v in j.iter_mut() {
649                    *v = 0.0;
650                }
651                true
652            }
653        }
654        let mut eqs = Big;
655        let opt = BlockSolveOptions::default();
656        let err = DampedNewtonSolver
657            .solve(&[0.0; 9], &mut eqs, &opt)
658            .expect_err("should reject");
659        assert_eq!(err, BlockSolveError::TooLarge);
660    }
661
662    /// Small LCG-based RNG used by the fuzz tests below. Captures
663    /// state in a struct (not a closure) so the borrow checker
664    /// doesn't trip when we use both `next_u64` and `unit` in the
665    /// same scope.
666    struct FuzzRng(u64);
667    impl FuzzRng {
668        fn new(seed: u64) -> Self {
669            Self(seed)
670        }
671        fn next_u64(&mut self) -> u64 {
672            self.0 = self
673                .0
674                .wrapping_mul(6364136223846793005)
675                .wrapping_add(1442695040888963407);
676            self.0 >> 32
677        }
678        fn unit(&mut self) -> Number {
679            let raw = (self.next_u64() & 0x3fff_ffff) as Number;
680            raw / (1u64 << 29) as Number - 1.0
681        }
682    }
683
684    /// Random-linear fuzz: build a well-conditioned random N×N
685    /// linear system, solve it both via the public Newton path AND
686    /// via a direct LU solve, and check they agree to 1e-10. This
687    /// catches bugs in Newton's wrapper logic (line search,
688    /// convergence check, RHS sign, scratch buffer reuse) without
689    /// being a tautology — the LU pieces are tested independently
690    /// in the LU tests above.
691    #[test]
692    fn newton_fuzz_random_linear_vs_direct_lu() {
693        let mut rng = FuzzRng::new(0xfeed_face_dead_b33f);
694
695        let mut tested = 0usize;
696        for _ in 0..30 {
697            let n = 1 + (rng.next_u64() % 6) as usize; // N ∈ [1, 6]
698                                                       // Build A with strong diagonal + small off-diag entries so
699                                                       // it's well-conditioned regardless of the random seed.
700            let mut a = vec![0.0; n * n];
701            for i in 0..n {
702                for j in 0..n {
703                    a[i * n + j] = if i == j {
704                        2.0 + rng.unit().abs()
705                    } else {
706                        0.3 * rng.unit()
707                    };
708                }
709            }
710            // Pick a random target solution x_star and form b = A x_star.
711            let x_star: Vec<Number> = (0..n).map(|_| rng.unit()).collect();
712            let mut b = vec![0.0; n];
713            for i in 0..n {
714                let mut s = 0.0;
715                for j in 0..n {
716                    s += a[i * n + j] * x_star[j];
717                }
718                b[i] = s;
719            }
720
721            // Reference: direct LU solve.
722            let mut a_ref = a.clone();
723            let piv = lu_factor_partial_pivot(&mut a_ref, n).expect("well-conditioned");
724            let mut x_lu = b.clone();
725            lu_solve(&a_ref, &piv, &mut x_lu, n);
726
727            // Newton: F(x) = A x - b = 0.
728            struct LinSys {
729                a: Vec<Number>,
730                b: Vec<Number>,
731                n: usize,
732            }
733            impl BlockEquations for LinSys {
734                fn dim(&self) -> usize {
735                    self.n
736                }
737                fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool {
738                    for i in 0..self.n {
739                        let mut s = -self.b[i];
740                        for j in 0..self.n {
741                            s += self.a[i * self.n + j] * x[j];
742                        }
743                        f[i] = s;
744                    }
745                    true
746                }
747                fn jacobian(&mut self, _x: &[Number], j: &mut [Number]) -> bool {
748                    j.copy_from_slice(&self.a);
749                    true
750                }
751            }
752            let mut eqs = LinSys {
753                a: a.clone(),
754                b: b.clone(),
755                n,
756            };
757            // Start at the origin so Newton actually iterates.
758            let x0 = vec![0.0; n];
759            let opt = BlockSolveOptions::default();
760            let out = DampedNewtonSolver
761                .solve(&x0, &mut eqs, &opt)
762                .expect("Newton converges on a well-conditioned linear system");
763
764            // Agreement: Newton's solution matches the direct LU
765            // solution (which also matches x_star, by construction).
766            let mut max_diff: Number = 0.0;
767            for i in 0..n {
768                max_diff = max_diff.max((out.x[i] - x_lu[i]).abs());
769                max_diff = max_diff.max((out.x[i] - x_star[i]).abs());
770            }
771            assert!(
772                max_diff < 1e-10,
773                "Newton vs LU disagreement of {max_diff:.3e} on n={n}"
774            );
775            tested += 1;
776        }
777        assert_eq!(tested, 30);
778    }
779
780    /// Mildly-nonlinear fuzz: `F(x) = A (x - x*) + ε (x - x*) ⊙ (x - x*)`
781    /// has a root at `x = x*` and a non-trivial Jacobian. Verify
782    /// Newton finds the root starting close enough.
783    #[test]
784    fn newton_fuzz_nonlinear_quadratic_root() {
785        let mut rng = FuzzRng::new(0xcafe_b00b_1337_5eed);
786
787        for trial in 0..20 {
788            let n = 1 + (rng.next_u64() % 4) as usize; // N ∈ [1, 4]
789                                                       // Diagonally dominant A.
790            let mut a = vec![0.0; n * n];
791            for i in 0..n {
792                for j in 0..n {
793                    a[i * n + j] = if i == j {
794                        3.0 + rng.unit().abs()
795                    } else {
796                        0.2 * rng.unit()
797                    };
798                }
799            }
800            let x_star: Vec<Number> = (0..n).map(|_| rng.unit()).collect();
801            // ε small enough that linearization at x_star ≈ A.
802            let eps = 0.1;
803
804            struct Nl {
805                a: Vec<Number>,
806                x_star: Vec<Number>,
807                eps: Number,
808                n: usize,
809            }
810            impl BlockEquations for Nl {
811                fn dim(&self) -> usize {
812                    self.n
813                }
814                fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool {
815                    for i in 0..self.n {
816                        let mut s = 0.0;
817                        for j in 0..self.n {
818                            s += self.a[i * self.n + j] * (x[j] - self.x_star[j]);
819                        }
820                        let dxi = x[i] - self.x_star[i];
821                        s += self.eps * dxi * dxi;
822                        f[i] = s;
823                    }
824                    true
825                }
826                fn jacobian(&mut self, x: &[Number], j: &mut [Number]) -> bool {
827                    for i in 0..self.n {
828                        for k in 0..self.n {
829                            let mut v = self.a[i * self.n + k];
830                            if i == k {
831                                v += 2.0 * self.eps * (x[i] - self.x_star[i]);
832                            }
833                            j[i * self.n + k] = v;
834                        }
835                    }
836                    true
837                }
838            }
839
840            // Start near x_star.
841            let x0: Vec<Number> = x_star.iter().map(|&v| v + 0.1 * rng.unit()).collect();
842            let mut eqs = Nl {
843                a: a.clone(),
844                x_star: x_star.clone(),
845                eps,
846                n,
847            };
848            let opt = BlockSolveOptions::default();
849            let out = DampedNewtonSolver
850                .solve(&x0, &mut eqs, &opt)
851                .unwrap_or_else(|e| panic!("trial {trial} (n={n}): {e:?}"));
852            let mut max_err: Number = 0.0;
853            for i in 0..n {
854                max_err = max_err.max((out.x[i] - x_star[i]).abs());
855            }
856            assert!(
857                max_err < 1e-7,
858                "trial {trial}: Newton missed root by {max_err:.3e}"
859            );
860        }
861    }
862
863    #[test]
864    fn newton_eval_failure_propagates() {
865        struct Failing;
866        impl BlockEquations for Failing {
867            fn dim(&self) -> usize {
868                1
869            }
870            fn eval(&mut self, _x: &[Number], _f: &mut [Number]) -> bool {
871                false
872            }
873            fn jacobian(&mut self, _x: &[Number], _j: &mut [Number]) -> bool {
874                true
875            }
876        }
877        let mut eqs = Failing;
878        let opt = BlockSolveOptions::default();
879        let err = DampedNewtonSolver
880            .solve(&[0.0], &mut eqs, &opt)
881            .expect_err("eval fails");
882        assert_eq!(err, BlockSolveError::EvalFailed);
883    }
884
885    /// PR #60 review nit: when Newton converges to a root outside
886    /// `BlockSolveOptions::bounds`, the solver returns
887    /// `OutOfBounds` rather than `Ok`. Use the linear 1-d system
888    /// `x - 3 = 0` (root at 3) with bounds [0, 2].
889    #[test]
890    fn newton_out_of_bounds_rejects() {
891        let mut eqs = LinearF {
892            a: vec![1.0],
893            b: vec![3.0],
894            n: 1,
895        };
896        let opt = BlockSolveOptions {
897            bounds: Some((vec![0.0], vec![2.0])),
898            ..Default::default()
899        };
900        let err = DampedNewtonSolver
901            .solve(&[0.0], &mut eqs, &opt)
902            .expect_err("root outside bounds");
903        assert_eq!(err, BlockSolveError::OutOfBounds);
904    }
905
906    /// Sanity check: with bounds that DO contain the root, the
907    /// solver accepts normally.
908    #[test]
909    fn newton_in_bounds_accepts() {
910        let mut eqs = LinearF {
911            a: vec![1.0],
912            b: vec![3.0],
913            n: 1,
914        };
915        let opt = BlockSolveOptions {
916            bounds: Some((vec![0.0], vec![10.0])),
917            ..Default::default()
918        };
919        let out = DampedNewtonSolver
920            .solve(&[0.0], &mut eqs, &opt)
921            .expect("root inside bounds");
922        assert!((out.x[0] - 3.0).abs() < 1e-10);
923    }
924}