Skip to main content

apex_solver/optimizer/
gauss_newton.rs

1//! Gauss-Newton optimization algorithm implementation.
2//!
3//! The Gauss-Newton method is a fundamental iterative algorithm for solving nonlinear least squares problems
4//! of the form:
5//!
6//! ```text
7//! min f(x) = ½||r(x)||² = ½Σᵢ rᵢ(x)²
8//! ```
9//!
10//! where `r: ℝⁿ → ℝᵐ` is the residual vector function.
11//!
12//! # Algorithm Overview
13//!
14//! The Gauss-Newton method solves the normal equations at each iteration:
15//!
16//! ```text
17//! J^T·J·h = -J^T·r
18//! ```
19//!
20//! where:
21//! - `J` is the Jacobian matrix (m × n) of partial derivatives ∂rᵢ/∂xⱼ
22//! - `r` is the residual vector (m × 1)
23//! - `h` is the step vector (n × 1)
24//!
25//! The approximated Hessian `H ≈ J^T·J` replaces the true Hessian `∇²f = J^T·J + Σᵢ rᵢ·∇²rᵢ`,
26//! which works well when residuals are small or nearly linear.
27//!
28//! ## Convergence Properties
29//!
30//! - **Quadratic convergence** near the solution when the Gauss-Newton approximation is valid
31//! - **May diverge** if the initial guess is far from the optimum or the problem is ill-conditioned
32//! - **No step size control** - always takes the full Newton step without damping
33//!
34//! ## When to Use
35//!
36//! Gauss-Newton is most effective when:
37//! - The problem is well-conditioned with `J^T·J` having good numerical properties
38//! - The initial parameter guess is close to the solution
39//! - Fast convergence is prioritized over robustness
40//! - Residuals at the solution are expected to be small
41//!
42//! For ill-conditioned problems or poor initial guesses, consider:
43//! - [`LevenbergMarquardt`](crate::optimizer::LevenbergMarquardt) for adaptive damping
44//! - [`DogLeg`](crate::optimizer::DogLeg) for trust region control
45//!
46//! # Implementation Features
47//!
48//! - **Sparse matrix support**: Efficient handling of large-scale problems via `faer` sparse library
49//! - **Robust linear solvers**: Choice between Cholesky (fast) and QR (stable) factorizations
50//! - **Jacobi scaling**: Optional diagonal preconditioning to improve conditioning
51//! - **Manifold operations**: Support for optimization on Lie groups (SE2, SE3, SO2, SO3)
52//! - **Comprehensive diagnostics**: Detailed convergence and performance summaries
53//!
54//! # Mathematical Background
55//!
56//! At each iteration k, the algorithm:
57//!
58//! 1. **Linearizes** the problem around current estimate xₖ: `r(xₖ + h) ≈ r(xₖ) + J(xₖ)·h`
59//! 2. **Solves** the normal equations for step h: `J^T·J·h = -J^T·r`
60//! 3. **Updates** parameters: `xₖ₊₁ = xₖ ⊕ h` (using manifold plus operation)
61//! 4. **Checks** convergence criteria (cost, gradient, parameter change)
62//!
63//! The method terminates when cost change, gradient norm, or parameter update fall below
64//! specified tolerances, or when maximum iterations are reached.
65//!
66//! # Examples
67//!
68//! ## Basic usage
69//!
70//! ```no_run
71//! use apex_solver::optimizer::GaussNewton;
72//! use apex_solver::core::problem::Problem;
73//! use apex_solver::JacobianMode;
74//!
75//! # type TestResult = Result<(), Box<dyn std::error::Error>>;
76//! # fn main() -> TestResult {
77//! // Create optimization problem
78//! let mut problem = Problem::new(JacobianMode::Sparse);
79//! // ... add residual blocks (factors) to problem ...
80//!
81//! // Create solver with default configuration
82//! let mut solver = GaussNewton::new();
83//!
84//! // Run optimization
85//! let result = solver.optimize(&mut problem)?;
86//! # Ok(())
87//! # }
88//! ```
89//!
90//! ## Advanced configuration
91//!
92//! ```no_run
93//! use apex_solver::optimizer::gauss_newton::{GaussNewtonConfig, GaussNewton};
94//! use apex_solver::linalg::LinearSolverType;
95//!
96//! # fn main() {
97//! let config = GaussNewtonConfig::new()
98//!     .with_max_iterations(100)
99//!     .with_cost_tolerance(1e-8)
100//!     .with_parameter_tolerance(1e-8)
101//!     .with_gradient_tolerance(1e-10)
102//!     .with_linear_solver_type(LinearSolverType::SparseQR)  // More stable
103//!     .with_jacobi_scaling(true);  // Improve conditioning
104//!
105//! let mut solver = GaussNewton::with_config(config);
106//! # }
107//! ```
108//!
109//! # References
110//!
111//! - Nocedal, J. & Wright, S. (2006). *Numerical Optimization* (2nd ed.). Springer. Chapter 10.
112//! - Madsen, K., Nielsen, H. B., & Tingleff, O. (2004). *Methods for Non-Linear Least Squares Problems* (2nd ed.).
113//! - Björck, Å. (1996). *Numerical Methods for Least Squares Problems*. SIAM.
114
115use crate::error::ErrorLogging;
116use crate::{core::problem, error, linalg, optimizer};
117use std::time;
118use tracing::debug;
119
120use crate::linalg::{
121    CovarianceOptions, Damping, DenseCholeskySolver, DenseMode, DenseQRSolver, ExplicitDenseSchur,
122    ExplicitSchurVariant, ExplicitSparseSchur, ImplicitSparseSchur, JacobianMode, LinearSolver,
123    LinearSolverType, SchurPreconditioner, SparseCholeskySolver, SparseMode, SparseQRSolver,
124    StructureAware,
125};
126use crate::optimizer::{AssemblyBackend, IterationStats};
127
128/// Configuration parameters for the Gauss-Newton optimizer.
129///
130/// Controls the behavior of the Gauss-Newton algorithm including convergence criteria,
131/// linear solver selection, and numerical stability enhancements.
132///
133/// # Builder Pattern
134///
135/// All configuration options can be set using the builder pattern:
136///
137/// ```
138/// use apex_solver::optimizer::gauss_newton::GaussNewtonConfig;
139/// use apex_solver::linalg::LinearSolverType;
140///
141/// let config = GaussNewtonConfig::new()
142///     .with_max_iterations(50)
143///     .with_cost_tolerance(1e-6)
144///     .with_linear_solver_type(LinearSolverType::SparseQR);
145/// ```
146///
147/// # Convergence Criteria
148///
149/// The optimizer terminates when ANY of the following conditions is met:
150///
151/// - **Cost tolerance**: `|cost_k - cost_{k-1}| < cost_tolerance`
152/// - **Parameter tolerance**: `||step|| < parameter_tolerance`
153/// - **Gradient tolerance**: `||J^T·r|| < gradient_tolerance`
154/// - **Maximum iterations**: `iteration >= max_iterations`
155/// - **Timeout**: `elapsed_time >= timeout`
156///
157/// # See Also
158///
159/// - [`GaussNewton`] - The solver that uses this configuration
160/// - [`LevenbergMarquardtConfig`](crate::optimizer::levenberg_marquardt::LevenbergMarquardtConfig) - For adaptive damping
161/// - [`DogLegConfig`](crate::optimizer::dog_leg::DogLegConfig) - For trust region methods
162#[derive(Clone)]
163pub struct GaussNewtonConfig {
164    /// Type of linear solver for the linear systems
165    pub linear_solver_type: linalg::LinearSolverType,
166    /// Maximum number of iterations
167    pub max_iterations: usize,
168    /// Convergence tolerance for cost function
169    pub cost_tolerance: f64,
170    /// Convergence tolerance for parameter updates
171    pub parameter_tolerance: f64,
172    /// Convergence tolerance for gradient norm
173    pub gradient_tolerance: f64,
174    /// Timeout duration
175    pub timeout: Option<time::Duration>,
176    /// Use Jacobi column scaling (preconditioning)
177    ///
178    /// When enabled, normalizes Jacobian columns by their L2 norm before solving.
179    /// This can improve convergence for problems with mixed parameter scales
180    /// (e.g., positions in meters + angles in radians) but adds ~5-10% overhead.
181    ///
182    /// Default: false (Gauss-Newton is typically used on well-conditioned problems)
183    pub use_jacobi_scaling: bool,
184    /// Uniform diagonal regularization added to `JᵀJ` before each solve.
185    ///
186    /// Pure Gauss-Newton (λ=0) fails outright when `JᵀJ` is singular. Solving
187    /// `(JᵀJ + min_diagonal·I)·Δx = −Jᵀr` instead keeps the factorization well
188    /// posed at a cost that is invisible against any real curvature. Set to
189    /// `0.0` for the un-regularized normal equations.
190    ///
191    /// Default: 1e-10 (very small, practically identical to pure Gauss-Newton)
192    pub min_diagonal: f64,
193
194    /// Minimum objective function cutoff (optional early termination)
195    ///
196    /// If set, optimization terminates when cost falls below this threshold.
197    /// Useful for early stopping when a "good enough" solution is acceptable.
198    ///
199    /// Default: None (disabled)
200    pub min_cost_threshold: Option<f64>,
201
202    /// Maximum condition number for Jacobian matrix (optional check)
203    ///
204    /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
205    /// threshold and terminates with IllConditionedJacobian status.
206    /// Note: Computing condition number is expensive, so this is disabled by default.
207    ///
208    /// Default: None (disabled)
209    pub max_condition_number: Option<f64>,
210
211    /// Compute per-variable covariance matrices (uncertainty estimation)
212    ///
213    /// When enabled, computes covariance by re-linearizing the problem at the
214    /// solution and inverting the Gauss-Newton Hessian `H = JᵀJ` after
215    /// convergence. The full covariance matrix is extracted into per-variable
216    /// blocks stored in both Variable structs and optimier::SolverResult.
217    ///
218    /// Scaled (σ̂²·H⁻¹) versus unscaled (H⁻¹) covariance, the factorization
219    /// algorithm, and the pseudo-inverse cutoff are configured through
220    /// [`covariance_options`](Self::covariance_options).
221    ///
222    /// Default: false (to avoid performance overhead)
223    pub compute_covariances: bool,
224
225    /// Options for covariance estimation when `compute_covariances` is enabled.
226    ///
227    /// Defaults to unscaled `H⁻¹` via sparse Cholesky — the correct choice when
228    /// residuals are whitened by their measurement information matrix. Set
229    /// `apply_variance_scaling` for unweighted least squares, where the noise
230    /// scale `σ̂² = 2·cost/(m−n)` must be estimated from the fit. See
231    /// [`CovarianceOptions`](crate::linalg::covariance::CovarianceOptions).
232    pub covariance_options: CovarianceOptions,
233
234    /// [`ExplicitSparseSchur`] sub-variant (for
235    /// [`LinearSolverType::ExplicitSparseSchur`]).
236    ///
237    /// - [`ExplicitSchurVariant::Sparse`]: form S, sparse Cholesky. Most
238    ///   accurate; memory is O(kept_dof²).
239    /// - [`ExplicitSchurVariant::Chunked`]: form S straight from `J`, never
240    ///   materializing `JᵀJ`. Same algebra, far less memory on large problems.
241    /// - [`ExplicitSchurVariant::Iterative`]: form S, then PCG instead of
242    ///   Cholesky.
243    ///
244    /// To never form `S` at all, use
245    /// [`LinearSolverType::ImplicitSparseSchur`] instead — a different
246    /// solver, not a variant of this one.
247    ///
248    /// Default: `Sparse`
249    pub schur_variant: ExplicitSchurVariant,
250    /// Preconditioner for the PCG used by the iterative Schur variants.
251    ///
252    /// - [`SchurPreconditioner::None`]: unconditioned CG.
253    /// - [`SchurPreconditioner::BlockDiagonal`]: block diagonal of `H_kk`.
254    /// - [`SchurPreconditioner::SchurJacobi`]: block diagonal of `S` itself —
255    ///   Ceres's `SCHUR_JACOBI`, and usually the best convergence.
256    ///
257    /// Ignored by the direct variants, which factorize instead of iterating.
258    ///
259    /// Default: `SchurJacobi`
260    pub schur_preconditioner: SchurPreconditioner,
261    /// Maximum PCG iterations per linear solve, for the iterative variants.
262    ///
263    /// Default: 200 (Ceres's default)
264    pub schur_cg_max_iterations: usize,
265    /// Relative residual tolerance ending a PCG solve, for the iterative
266    /// variants.
267    ///
268    /// Default: 1e-6
269    pub schur_cg_tolerance: f64,
270    /// Forcing-sequence parameter η for the Schur PCG paths (Ceres's
271    /// `Solver::Options::eta`).
272    ///
273    /// PCG stops once the quadratic model's relative improvement per iteration
274    /// falls below `η/i`, which is what makes early Newton steps cheap. `0.0`
275    /// disables the rule, leaving only `schur_cg_tolerance` and the iteration
276    /// cap — ask for that when you want an exact linear solve.
277    ///
278    /// Default: 0.1
279    pub schur_cg_q_tolerance: f64,
280
281    /// Deprecated: never read. Visualization goes through the observer pattern.
282    ///
283    /// Setting this has no effect. Attach an observer to the solver instead:
284    /// `solver.add_observer(RerunObserver::new(true)?)`, which is how
285    /// Levenberg-Marquardt has always done it and works for every optimizer.
286    ///
287    /// Default: false
288    #[cfg(feature = "visualization")]
289    #[deprecated(
290        since = "1.6.0",
291        note = "never read; attach a `RerunObserver` with `add_observer` instead"
292    )]
293    pub enable_visualization: bool,
294}
295
296impl Default for GaussNewtonConfig {
297    fn default() -> Self {
298        Self {
299            linear_solver_type: linalg::LinearSolverType::default(),
300            // Ceres Solver default: 50 (changed from 100 for compatibility)
301            max_iterations: 50,
302            // Ceres Solver default: 1e-6 (changed from 1e-8 for compatibility)
303            cost_tolerance: 1e-6,
304            // Ceres Solver default: 1e-8 (unchanged)
305            parameter_tolerance: 1e-8,
306            // Ceres Solver default: 1e-10 (changed from 1e-8 for compatibility)
307            gradient_tolerance: 1e-10,
308            timeout: None,
309            use_jacobi_scaling: false,
310            min_diagonal: 1e-10,
311            // New Ceres-compatible termination parameters
312            min_cost_threshold: None,
313            max_condition_number: None,
314            compute_covariances: false,
315            covariance_options: CovarianceOptions::default(),
316            schur_variant: ExplicitSchurVariant::default(),
317            schur_preconditioner: SchurPreconditioner::default(),
318            schur_cg_max_iterations: 200,
319            schur_cg_tolerance: 1e-6,
320            schur_cg_q_tolerance: crate::linalg::schur::DEFAULT_ETA,
321            #[cfg(feature = "visualization")]
322            #[allow(deprecated)]
323            enable_visualization: false,
324        }
325    }
326}
327
328impl GaussNewtonConfig {
329    /// Create a new Gauss-Newton configuration with default values.
330    pub fn new() -> Self {
331        Self::default()
332    }
333
334    /// Set the linear solver type
335    pub fn with_linear_solver_type(mut self, linear_solver_type: linalg::LinearSolverType) -> Self {
336        self.linear_solver_type = linear_solver_type;
337        self
338    }
339
340    /// Set the maximum number of iterations
341    pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
342        self.max_iterations = max_iterations;
343        self
344    }
345
346    /// Set the cost tolerance
347    pub fn with_cost_tolerance(mut self, cost_tolerance: f64) -> Self {
348        self.cost_tolerance = cost_tolerance;
349        self
350    }
351
352    /// Set the parameter tolerance
353    pub fn with_parameter_tolerance(mut self, parameter_tolerance: f64) -> Self {
354        self.parameter_tolerance = parameter_tolerance;
355        self
356    }
357
358    /// Set the gradient tolerance
359    pub fn with_gradient_tolerance(mut self, gradient_tolerance: f64) -> Self {
360        self.gradient_tolerance = gradient_tolerance;
361        self
362    }
363
364    /// Set the timeout duration
365    pub fn with_timeout(mut self, timeout: time::Duration) -> Self {
366        self.timeout = Some(timeout);
367        self
368    }
369
370    /// Enable or disable Jacobi column scaling (preconditioning).
371    ///
372    /// When enabled, normalizes Jacobian columns by their L2 norm before solving.
373    /// Can improve convergence for mixed-scale problems but adds ~5-10% overhead.
374    pub fn with_jacobi_scaling(mut self, use_jacobi_scaling: bool) -> Self {
375        self.use_jacobi_scaling = use_jacobi_scaling;
376        self
377    }
378
379    /// Set the minimum diagonal regularization for numerical stability.
380    ///
381    /// A small value (e.g., 1e-10) ensures J^T·J is positive definite while
382    /// maintaining the fast convergence of pure Gauss-Newton.
383    pub fn with_min_diagonal(mut self, min_diagonal: f64) -> Self {
384        self.min_diagonal = min_diagonal;
385        self
386    }
387
388    /// Set minimum objective function cutoff for early termination.
389    ///
390    /// When set, optimization terminates with MinCostThresholdReached status
391    /// if the cost falls below this threshold. Useful for early stopping when
392    /// a "good enough" solution is acceptable.
393    pub fn with_min_cost_threshold(mut self, min_cost: f64) -> Self {
394        self.min_cost_threshold = Some(min_cost);
395        self
396    }
397
398    /// Set maximum condition number for Jacobian matrix.
399    ///
400    /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
401    /// threshold and terminates with IllConditionedJacobian status.
402    /// Note: Computing condition number is expensive, disabled by default.
403    pub fn with_max_condition_number(mut self, max_cond: f64) -> Self {
404        self.max_condition_number = Some(max_cond);
405        self
406    }
407
408    /// Enable or disable covariance computation (uncertainty estimation).
409    ///
410    /// When enabled, computes the full covariance matrix by inverting the Hessian
411    /// after convergence, then extracts per-variable covariance blocks.
412    pub fn with_compute_covariances(mut self, compute_covariances: bool) -> Self {
413        self.compute_covariances = compute_covariances;
414        self
415    }
416
417    /// Set the options used for covariance estimation.
418    ///
419    /// Controls scaled (`σ̂²·H⁻¹`) versus unscaled (`H⁻¹`) covariance, the
420    /// factorization algorithm (sparse Cholesky or dense SVD pseudo-inverse),
421    /// and the singular-value cutoff. Only takes effect when
422    /// `compute_covariances` is enabled.
423    pub fn with_covariance_options(mut self, options: CovarianceOptions) -> Self {
424        self.covariance_options = options;
425        self
426    }
427
428    /// Set the [`ExplicitSparseSchur`] sub-variant.
429    ///
430    /// Takes effect when `linear_solver_type` is
431    /// [`LinearSolverType::ExplicitSparseSchur`].
432    pub fn with_schur_variant(mut self, variant: ExplicitSchurVariant) -> Self {
433        self.schur_variant = variant;
434        self
435    }
436
437    /// Set Schur complement preconditioner for the iterative variants
438    pub fn with_schur_preconditioner(mut self, preconditioner: SchurPreconditioner) -> Self {
439        self.schur_preconditioner = preconditioner;
440        self
441    }
442
443    /// PCG budget for the iterative Schur variants.
444    pub fn with_schur_cg_params(mut self, max_iterations: usize, tolerance: f64) -> Self {
445        self.schur_cg_max_iterations = max_iterations;
446        self.schur_cg_tolerance = tolerance;
447        self
448    }
449
450    /// Set the PCG forcing-sequence parameter η. `0.0` disables the
451    /// quadratic-model stopping rule; see [`Self::schur_cg_q_tolerance`].
452    pub fn with_schur_cg_q_tolerance(mut self, q_tolerance: f64) -> Self {
453        self.schur_cg_q_tolerance = q_tolerance;
454        self
455    }
456
457    /// Deprecated: no-op. See [`enable_visualization`](Self::enable_visualization).
458    #[cfg(feature = "visualization")]
459    #[deprecated(
460        since = "1.6.0",
461        note = "no-op; attach a `RerunObserver` with `add_observer` instead"
462    )]
463    #[allow(deprecated)]
464    pub fn with_visualization(mut self, enable: bool) -> Self {
465        self.enable_visualization = enable;
466        self
467    }
468
469    /// Print configuration parameters (info level logging)
470    pub fn print_configuration(&self) {
471        debug!(
472            "\nConfiguration:\n  Solver:        Gauss-Newton\n  Linear solver: {:?}\n  Schur variant: {:?}\n  Schur preconditioner: {:?}\n  Schur CG budget:     {} iters @ {:.2e}\n  Convergence Criteria:\n  Max iterations:      {}\n  Cost tolerance:      {:.2e}\n  Parameter tolerance: {:.2e}\n  Gradient tolerance:  {:.2e}\n  Timeout:             {:?}\n  Numerical Settings:\n  Jacobi scaling:      {}\n  Compute covariances: {}",
473            self.linear_solver_type,
474            self.schur_variant,
475            self.schur_preconditioner,
476            self.schur_cg_max_iterations,
477            self.schur_cg_tolerance,
478            self.max_iterations,
479            self.cost_tolerance,
480            self.parameter_tolerance,
481            self.gradient_tolerance,
482            self.timeout,
483            if self.use_jacobi_scaling {
484                "enabled"
485            } else {
486                "disabled"
487            },
488            if self.compute_covariances {
489                "enabled"
490            } else {
491                "disabled"
492            }
493        );
494    }
495}
496
497/// Result from step computation
498struct StepResult {
499    step: faer::Mat<f64>,
500    gradient_norm: f64,
501}
502
503/// Result from cost evaluation
504struct CostEvaluation {
505    new_cost: f64,
506    cost_reduction: f64,
507}
508
509/// Gauss-Newton solver for nonlinear least squares optimization.
510///
511/// Implements the classical Gauss-Newton algorithm which solves `J^T·J·h = -J^T·r` at each
512/// iteration to find the step `h`. This provides fast quadratic convergence near the solution
513/// but may diverge for poor initial guesses or ill-conditioned problems.
514///
515/// # Algorithm
516///
517/// At each iteration k:
518/// 1. Compute residual `r(xₖ)` and Jacobian `J(xₖ)`
519/// 2. Form normal equations: `(J^T·J)·h = -J^T·r`
520/// 3. Solve for step `h` using Cholesky or QR factorization
521/// 4. Update parameters: `xₖ₊₁ = xₖ ⊕ h` (manifold plus operation)
522/// 5. Check convergence criteria
523///
524/// # Examples
525///
526/// ```no_run
527/// use apex_solver::optimizer::GaussNewton;
528/// use apex_solver::core::problem::Problem;
529/// use apex_solver::JacobianMode;
530///
531/// # type TestResult = Result<(), Box<dyn std::error::Error>>;
532/// # fn main() -> TestResult {
533/// let mut problem = Problem::new(JacobianMode::Sparse);
534/// // ... add factors to problem ...
535///
536/// let mut solver = GaussNewton::new();
537/// let result = solver.optimize(&mut problem)?;
538/// # Ok(())
539/// # }
540/// ```
541///
542/// # See Also
543///
544/// - [`GaussNewtonConfig`] - Configuration options
545/// - [`LevenbergMarquardt`](crate::optimizer::LevenbergMarquardt) - For adaptive damping
546/// - [`DogLeg`](crate::optimizer::DogLeg) - For trust region control
547pub struct GaussNewton {
548    config: GaussNewtonConfig,
549    jacobi_scaling: Option<Vec<f64>>,
550    observers: optimizer::OptObserverVec,
551}
552
553impl Default for GaussNewton {
554    fn default() -> Self {
555        Self::new()
556    }
557}
558
559impl GaussNewton {
560    /// Create a new Gauss-Newton solver with default configuration.
561    pub fn new() -> Self {
562        Self::with_config(GaussNewtonConfig::default())
563    }
564
565    /// Create a new Gauss-Newton solver with the given configuration.
566    pub fn with_config(config: GaussNewtonConfig) -> Self {
567        Self {
568            config,
569            jacobi_scaling: None,
570            observers: optimizer::OptObserverVec::new(),
571        }
572    }
573
574    /// Add an observer to the solver.
575    ///
576    /// Observers are notified at each iteration with the current variable values.
577    /// This enables real-time visualization, logging, metrics collection, etc.
578    ///
579    /// # Examples
580    ///
581    /// ```no_run
582    /// use apex_solver::optimizer::GaussNewton;
583    /// # use apex_solver::optimizer::OptObserver;
584    /// # use apex_solver::core::VarKey;
585    /// # use apex_solver::core::variable::ManifoldVariable;
586    /// # use slotmap::SlotMap;
587    ///
588    /// # struct MyObserver;
589    /// # impl OptObserver for MyObserver {
590    /// #     fn on_step(&self, _: &SlotMap<VarKey, Box<dyn ManifoldVariable>>, _: usize) {}
591    /// # }
592    /// let mut solver = GaussNewton::new();
593    /// solver.add_observer(MyObserver);
594    /// ```
595    pub fn add_observer(&mut self, observer: impl optimizer::OptObserver + 'static) {
596        self.observers.add(observer);
597    }
598
599    /// Compute Gauss-Newton step by solving the normal equations (generic over assembly mode).
600    fn compute_step_generic<M: AssemblyBackend>(
601        &self,
602        residuals: &faer::Mat<f64>,
603        scaled_jacobian: &M::Jacobian,
604        linear_solver: &mut dyn LinearSolver<M>,
605    ) -> Result<StepResult, optimizer::OptimizerError> {
606        // Solve the Gauss-Newton equation: (JᵀJ + min_diagonal·I)·Δx = −Jᵀr.
607        //
608        // The regularizer is uniform λI, not the Marquardt diagonal: it is a
609        // guard against an exactly singular JᵀJ, not a trust region, and at the
610        // default of 1e-10 it is numerically invisible next to any real
611        // curvature. `min_diagonal = 0` selects the un-regularized normal
612        // equations, i.e. textbook Gauss-Newton.
613        let scaled_step = if self.config.min_diagonal > 0.0 {
614            let damping = Damping::identity(self.config.min_diagonal);
615            linear_solver.solve_augmented_equation(residuals, scaled_jacobian, &damping)
616        } else {
617            linear_solver.solve_normal_equation(residuals, scaled_jacobian)
618        }
619        .map_err(|e| {
620            optimizer::OptimizerError::LinearSolveFailed(e.to_string()).log_with_source(e)
621        })?;
622
623        // Get gradient from the solver (J^T * r) — scaled when Jacobi scaling
624        // is enabled, since `scaled_jacobian` is what the solver last saw.
625        let gradient = linear_solver.get_gradient().ok_or_else(|| {
626            optimizer::OptimizerError::NumericalInstability("Gradient not available".into()).log()
627        })?;
628
629        // Apply inverse Jacobi scaling to get final step (if enabled), and
630        // report `gradient_tolerance`'s convergence check against the same
631        // un-scaled gradient in both cases (GitHub #57).
632        let (step, gradient_norm) = if self.config.use_jacobi_scaling {
633            let scaling = self
634                .jacobi_scaling
635                .as_ref()
636                .ok_or_else(|| optimizer::OptimizerError::JacobiScalingNotInitialized.log())?;
637            (
638                M::apply_inverse_scaling(&scaled_step, scaling),
639                optimizer::unscaled_gradient_norm(gradient, scaling),
640            )
641        } else {
642            (scaled_step, gradient.norm_l2())
643        };
644
645        Ok(StepResult {
646            step,
647            gradient_norm,
648        })
649    }
650
651    /// Apply step to parameters and evaluate new cost
652    fn apply_step_and_evaluate_cost(
653        &self,
654        step_result: &StepResult,
655        state: &mut optimizer::InitializedState,
656        problem: &problem::Problem,
657    ) -> error::ApexSolverResult<CostEvaluation> {
658        // Apply parameter updates using manifold operations
659        let _step_norm = optimizer::apply_parameter_step(
660            &mut state.variables,
661            step_result.step.as_ref(),
662            &state.sorted_vars,
663        );
664
665        // Compute new cost (residual only, no Jacobian needed for step evaluation).
666        // Reuses the solve workspace instead of allocating one per iteration,
667        // like Levenberg-Marquardt does.
668        let (_new_residual, new_cost) = problem.compute_residual_and_cost_sparse_with_workspace(
669            &state.variables,
670            &mut state.workspace,
671        )?;
672
673        // Compute cost reduction
674        let cost_reduction = state.current_cost - new_cost;
675
676        // Update current cost
677        state.current_cost = new_cost;
678
679        Ok(CostEvaluation {
680            new_cost,
681            cost_reduction,
682        })
683    }
684
685    /// Run optimization using the specified assembly mode and linear solver.
686    ///
687    /// `state` is built once by [`Self::optimize`] and handed over here —
688    /// see `LevenbergMarquardt::optimize_with_mode` for why: the Schur path
689    /// needs the variable index map to size its block structure, and rebuilding
690    /// it here would repeat the symbolic construction and cost evaluation.
691    fn optimize_with_mode<M: AssemblyBackend>(
692        &mut self,
693        problem: &mut problem::Problem,
694        linear_solver: &mut dyn LinearSolver<M>,
695        mut state: optimizer::InitializedState,
696    ) -> optimizer::OptimizeResult {
697        let start_time = time::Instant::now();
698        let mut iteration = 0;
699        let mut cost_evaluations = 1; // Initial cost evaluation
700        let mut jacobian_evaluations = 0;
701
702        // Initialize summary tracking variables
703        let mut max_gradient_norm: f64 = 0.0;
704        let mut max_parameter_update_norm: f64 = 0.0;
705        let mut total_cost_reduction = 0.0;
706        let mut final_gradient_norm;
707        let mut final_parameter_update_norm;
708
709        // Initialize iteration statistics tracking
710        let mut iteration_stats = Vec::with_capacity(self.config.max_iterations);
711        let mut previous_cost = state.current_cost;
712
713        // Print configuration and header if debug level is enabled
714        if tracing::enabled!(tracing::Level::DEBUG) {
715            self.config.print_configuration();
716            IterationStats::print_header();
717        }
718
719        // Main optimization loop
720        loop {
721            let iter_start = time::Instant::now();
722
723            // Shared preamble: assemble, conditioning check, Jacobi scaling
724            let (residuals, scaled_jacobian) = match optimizer::iteration_preamble::<M>(
725                problem,
726                &mut state,
727                &mut self.jacobi_scaling,
728                self.config.use_jacobi_scaling,
729                iteration,
730                self.config.max_condition_number,
731                &mut jacobian_evaluations,
732            )? {
733                optimizer::IterationPreamble::Proceed {
734                    residuals,
735                    scaled_jacobian,
736                } => (residuals, scaled_jacobian),
737                optimizer::IterationPreamble::EarlyExit(status) => {
738                    let elapsed = start_time.elapsed();
739                    self.observers.notify_complete(&state.variables, iteration);
740                    return Ok(optimizer::build_solver_result(
741                        status,
742                        iteration,
743                        state,
744                        elapsed,
745                        0.0,
746                        0.0,
747                        cost_evaluations,
748                        jacobian_evaluations,
749                        None,
750                    ));
751                }
752            };
753
754            // Compute Gauss-Newton step
755            let step_result =
756                self.compute_step_generic::<M>(&residuals, &scaled_jacobian, linear_solver)?;
757
758            // Update tracking variables
759            max_gradient_norm = max_gradient_norm.max(step_result.gradient_norm);
760            final_gradient_norm = step_result.gradient_norm;
761            let step_norm = step_result.step.norm_l2();
762            max_parameter_update_norm = max_parameter_update_norm.max(step_norm);
763            final_parameter_update_norm = step_norm;
764
765            // Capture cost before applying step (for convergence check)
766            let cost_before_step = state.current_cost;
767
768            // Apply step and evaluate new cost
769            let cost_eval = self.apply_step_and_evaluate_cost(&step_result, &mut state, problem)?;
770            cost_evaluations += 1;
771            total_cost_reduction += cost_eval.cost_reduction;
772
773            // OPTIMIZATION: Only collect iteration statistics if debug level is enabled
774            if tracing::enabled!(tracing::Level::DEBUG) {
775                let iter_elapsed_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
776                let total_elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0;
777
778                let stats = IterationStats {
779                    iteration,
780                    cost: state.current_cost,
781                    cost_change: previous_cost - state.current_cost,
782                    gradient_norm: step_result.gradient_norm,
783                    step_norm,
784                    tr_ratio: 0.0,  // Not used in Gauss-Newton
785                    tr_radius: 0.0, // Not used in Gauss-Newton
786                    ls_iter: 0,     // Direct solver (Cholesky) has no iterations
787                    iter_time_ms: iter_elapsed_ms,
788                    total_time_ms: total_elapsed_ms,
789                    accepted: true, // Gauss-Newton always accepts steps
790                };
791
792                iteration_stats.push(stats.clone());
793                stats.print_line();
794            }
795
796            previous_cost = state.current_cost;
797
798            // Notify all observers with current state
799            optimizer::notify_observers_generic::<M>(
800                &mut self.observers,
801                &state.variables,
802                iteration,
803                state.current_cost,
804                step_result.gradient_norm,
805                None, // Gauss-Newton doesn't use damping
806                step_norm,
807                None, // Gauss-Newton doesn't use step quality
808                linear_solver,
809            );
810
811            // Compute parameter norm for convergence check
812            let parameter_norm = optimizer::compute_parameter_norm(&state.variables);
813
814            // Check convergence using comprehensive termination criteria
815            let elapsed = start_time.elapsed();
816            if let Some(status) = optimizer::check_convergence(&optimizer::ConvergenceParams {
817                iteration,
818                current_cost: cost_before_step,
819                new_cost: cost_eval.new_cost,
820                parameter_norm,
821                parameter_update_norm: step_norm,
822                gradient_norm: step_result.gradient_norm,
823                elapsed,
824                step_accepted: true, // GN always accepts
825                max_iterations: self.config.max_iterations,
826                gradient_tolerance: self.config.gradient_tolerance,
827                parameter_tolerance: self.config.parameter_tolerance,
828                cost_tolerance: self.config.cost_tolerance,
829                min_cost_threshold: self.config.min_cost_threshold,
830                timeout: self.config.timeout,
831                trust_region_radius: None,
832                min_trust_region_radius: None,
833            }) {
834                // Print summary only if debug level is enabled
835                if tracing::enabled!(tracing::Level::DEBUG) {
836                    let summary = optimizer::create_optimizer_summary(
837                        "Gauss-Newton",
838                        state.initial_cost,
839                        state.current_cost,
840                        iteration + 1,
841                        None,
842                        None,
843                        max_gradient_norm,
844                        final_gradient_norm,
845                        max_parameter_update_norm,
846                        final_parameter_update_norm,
847                        total_cost_reduction,
848                        elapsed,
849                        iteration_stats.clone(),
850                        status.clone(),
851                        None,
852                        None,
853                        None,
854                    );
855                    debug!("{}", summary);
856                }
857
858                // Compute covariances if enabled
859                let covariances = if self.config.compute_covariances {
860                    problem.compute_and_set_covariances(
861                        &mut state.variables,
862                        self.config.covariance_options,
863                    )
864                } else {
865                    None
866                };
867
868                return Ok(optimizer::build_solver_result(
869                    status,
870                    iteration + 1,
871                    state,
872                    elapsed,
873                    final_gradient_norm,
874                    final_parameter_update_norm,
875                    cost_evaluations,
876                    jacobian_evaluations,
877                    covariances,
878                ));
879            }
880
881            iteration += 1;
882        }
883    }
884
885    /// Run optimization, automatically selecting sparse or dense path based on config.
886    ///
887    /// Only solver/mode combinations that match are dispatched; anything else
888    /// returns an error rather than silently substituting a different solver.
889    /// `ExplicitSparseSchur`/`ImplicitSparseSchur` group residual rows by
890    /// eliminated variable and initialize the Schur block structure from the
891    /// shared initial state, exactly like Levenberg-Marquardt.
892    pub fn optimize(&mut self, problem: &mut problem::Problem) -> optimizer::OptimizeResult {
893        // Chunk-wise elimination sweeps rows in chunk order, so each eliminated
894        // variable's rows must be adjacent. Regroup before any structure is
895        // built from the current layout — row order is a labelling, so this
896        // leaves the problem and its cost unchanged.
897        if matches!(
898            self.config.linear_solver_type,
899            LinearSolverType::ExplicitSparseSchur | LinearSolverType::ImplicitSparseSchur
900        ) {
901            problem.group_rows_for_elimination();
902        }
903
904        // Built once here and moved into the chosen arm (see
905        // `optimize_with_mode`).
906        let state = optimizer::initialize_optimization_state(problem)?;
907
908        match problem.jacobian_mode {
909            JacobianMode::Dense => match self.config.linear_solver_type {
910                LinearSolverType::DenseQR => {
911                    let mut solver = DenseQRSolver::new();
912                    self.optimize_with_mode::<DenseMode>(problem, &mut solver, state)
913                }
914                LinearSolverType::DenseCholesky => {
915                    let mut solver = DenseCholeskySolver::new();
916                    self.optimize_with_mode::<DenseMode>(problem, &mut solver, state)
917                }
918                LinearSolverType::ExplicitDenseSchur => {
919                    let init = |e: linalg::LinAlgError| {
920                        optimizer::OptimizerError::LinearSolveFailed(format!(
921                            "Failed to initialize Schur solver: {e}"
922                        ))
923                        .log()
924                    };
925                    let mut solver = ExplicitDenseSchur::new();
926                    solver
927                        .initialize_structure(
928                            &state.variables,
929                            &state.variable_index_map,
930                            &problem.schur_landmark_keys,
931                        )
932                        .map_err(init)?;
933                    self.optimize_with_mode::<DenseMode>(problem, &mut solver, state)
934                }
935                other => Err(optimizer::OptimizerError::InvalidParameters(format!(
936                    "Gauss-Newton in dense Jacobian mode supports DenseCholesky, DenseQR and \
937                     ExplicitDenseSchur only; requested {other}"
938                ))
939                .into()),
940            },
941            JacobianMode::Sparse => match self.config.linear_solver_type {
942                linalg::LinearSolverType::SparseQR => {
943                    let mut solver = SparseQRSolver::new();
944                    self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
945                }
946                linalg::LinearSolverType::SparseCholesky => {
947                    let mut solver = SparseCholeskySolver::new();
948                    self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
949                }
950                LinearSolverType::ExplicitSparseSchur => {
951                    let init = |e: linalg::LinAlgError| {
952                        optimizer::OptimizerError::LinearSolveFailed(format!(
953                            "Failed to initialize Schur solver: {e}"
954                        ))
955                        .log()
956                    };
957                    let mut solver = ExplicitSparseSchur::new()
958                        .with_variant(self.config.schur_variant)
959                        .with_preconditioner(self.config.schur_preconditioner)
960                        .with_cg_params(
961                            self.config.schur_cg_max_iterations,
962                            self.config.schur_cg_tolerance,
963                        )
964                        .with_cg_q_tolerance(self.config.schur_cg_q_tolerance);
965                    solver
966                        .initialize_structure(
967                            &state.variables,
968                            &state.variable_index_map,
969                            &problem.schur_landmark_keys,
970                        )
971                        .map_err(init)?;
972                    self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
973                }
974                LinearSolverType::ImplicitSparseSchur => {
975                    // Never forms S, so it is a different solver type rather
976                    // than a mode of the explicit one — this is what keeps
977                    // both the O(kept_dof²) buffer and `JᵀJ` itself out of
978                    // the picture entirely.
979                    let init = |e: linalg::LinAlgError| {
980                        optimizer::OptimizerError::LinearSolveFailed(format!(
981                            "Failed to initialize Schur solver: {e}"
982                        ))
983                        .log()
984                    };
985                    let mut solver = ImplicitSparseSchur::with_config(
986                        self.config.schur_cg_max_iterations,
987                        self.config.schur_cg_tolerance,
988                        self.config.schur_preconditioner,
989                    )
990                    .with_cg_q_tolerance(self.config.schur_cg_q_tolerance);
991                    solver
992                        .initialize_structure(
993                            &state.variables,
994                            &state.variable_index_map,
995                            &problem.schur_landmark_keys,
996                        )
997                        .map_err(init)?;
998                    self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
999                }
1000                other => Err(optimizer::OptimizerError::InvalidParameters(format!(
1001                    "Gauss-Newton in sparse Jacobian mode supports SparseCholesky, SparseQR, \
1002                     ExplicitSparseSchur and ImplicitSparseSchur only; requested {other}"
1003                ))
1004                .into()),
1005            },
1006        }
1007    }
1008}
1009
1010impl optimizer::Optimizer for GaussNewton {
1011    fn optimize(&mut self, problem: &mut problem::Problem) -> optimizer::OptimizeResult {
1012        self.optimize(problem)
1013    }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::{GaussNewton, GaussNewtonConfig};
1019    use crate::{core::problem, factors, linalg::JacobianMode, optimizer};
1020    use apex_manifolds as manifold;
1021    use faer::prelude::ReborrowMut;
1022    use nalgebra::dvector;
1023
1024    type TestResult = Result<(), Box<dyn std::error::Error>>;
1025
1026    /// Custom Rosenbrock Factor 1: r1 = 10(x2 - x1²)
1027    /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1028    #[derive(Debug, Clone)]
1029    struct RosenbrockFactor1;
1030
1031    impl factors::Factor for RosenbrockFactor1 {
1032        fn linearize(
1033            &self,
1034            params: &[&[f64]],
1035            residual: &mut [f64],
1036            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1037        ) {
1038            let x1 = params[0][0];
1039            let x2 = params[1][0];
1040            residual[0] = 10.0 * (x2 - x1 * x1);
1041            if let Some(mut jac) = jacobian {
1042                *jac.rb_mut().get_mut(0, 0) = -20.0 * x1;
1043                *jac.rb_mut().get_mut(0, 1) = 10.0;
1044            }
1045        }
1046        fn residual_dim(&self) -> usize {
1047            1
1048        }
1049        fn jacobian_shape(&self) -> (usize, usize) {
1050            (1, 2)
1051        }
1052    }
1053
1054    /// Custom Rosenbrock Factor 2: r2 = 1 - x1
1055    /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1056    #[derive(Debug, Clone)]
1057    struct RosenbrockFactor2;
1058
1059    impl factors::Factor for RosenbrockFactor2 {
1060        fn linearize(
1061            &self,
1062            params: &[&[f64]],
1063            residual: &mut [f64],
1064            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1065        ) {
1066            residual[0] = 1.0 - params[0][0];
1067            if let Some(mut jac) = jacobian {
1068                *jac.rb_mut().get_mut(0, 0) = -1.0;
1069            }
1070        }
1071        fn residual_dim(&self) -> usize {
1072            1
1073        }
1074        fn jacobian_shape(&self) -> (usize, usize) {
1075            (1, 1)
1076        }
1077    }
1078
1079    #[test]
1080    fn test_rosenbrock_optimization() -> TestResult {
1081        // Rosenbrock function test:
1082        // Minimize: r1² + r2² where
1083        //   r1 = 10(x2 - x1²)
1084        //   r2 = 1 - x1
1085        // Starting point: [-1.2, 1.0]
1086        // Expected minimum: [1.0, 1.0]
1087
1088        let mut problem = problem::Problem::new(JacobianMode::Sparse);
1089        let x1 = problem.add_variable(manifold::ManifoldType::RN, dvector![-1.2]);
1090        let x2 = problem.add_variable(manifold::ManifoldType::RN, dvector![1.0]);
1091
1092        // Add custom factors (demonstrates extensibility!)
1093        problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1094        problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1095
1096        // Configure Gauss-Newton optimizer
1097        let config = GaussNewtonConfig::new()
1098            .with_max_iterations(100)
1099            .with_cost_tolerance(1e-8)
1100            .with_parameter_tolerance(1e-8)
1101            .with_gradient_tolerance(1e-10);
1102
1103        let mut solver = optimizer::GaussNewton::with_config(config);
1104        let result = solver.optimize(&mut problem)?;
1105
1106        // Extract final values
1107        let x1_final = result.parameters[x1].as_param_slice()[0];
1108        let x2_final = result.parameters[x2].as_param_slice()[0];
1109
1110        // Verify convergence to [1.0, 1.0]
1111        assert!(
1112            matches!(
1113                result.status,
1114                optimizer::OptimizationStatus::Converged
1115                    | optimizer::OptimizationStatus::CostToleranceReached
1116                    | optimizer::OptimizationStatus::ParameterToleranceReached
1117                    | optimizer::OptimizationStatus::GradientToleranceReached
1118            ),
1119            "Optimization should converge"
1120        );
1121        assert!(
1122            (x1_final - 1.0).abs() < 1e-4,
1123            "x1 should converge to 1.0, got {}",
1124            x1_final
1125        );
1126        assert!(
1127            (x2_final - 1.0).abs() < 1e-4,
1128            "x2 should converge to 1.0, got {}",
1129            x2_final
1130        );
1131        assert!(
1132            result.final_cost < 1e-6,
1133            "Final cost should be near zero, got {}",
1134            result.final_cost
1135        );
1136        Ok(())
1137    }
1138
1139    /// Trivial factor: r = x - target, J = [[1.0]]
1140    struct LinearFactor {
1141        target: f64,
1142    }
1143
1144    impl factors::Factor for LinearFactor {
1145        fn linearize(
1146            &self,
1147            params: &[&[f64]],
1148            residual: &mut [f64],
1149            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1150        ) {
1151            residual[0] = params[0][0] - self.target;
1152            if let Some(mut jac) = jacobian {
1153                *jac.rb_mut().get_mut(0, 0) = 1.0;
1154            }
1155        }
1156        fn residual_dim(&self) -> usize {
1157            1
1158        }
1159        fn jacobian_shape(&self) -> (usize, usize) {
1160            (1, 1)
1161        }
1162    }
1163
1164    fn rosenbrock_problem() -> problem::Problem {
1165        let mut prob = problem::Problem::new(JacobianMode::Sparse);
1166        let x1 = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![-1.2]);
1167        let x2 = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![1.0]);
1168        prob.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1169        prob.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1170        prob
1171    }
1172
1173    fn linear_problem(start: f64) -> problem::Problem {
1174        let mut prob = problem::Problem::new(JacobianMode::Sparse);
1175        let x = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![start]);
1176        prob.add_residual_block(&[x], Box::new(LinearFactor { target: 0.0 }), None);
1177        prob
1178    }
1179
1180    // -------------------------------------------------------------------------
1181    // Ceres-compatibility config fields
1182    // -------------------------------------------------------------------------
1183
1184    /// A rank-deficient `JᵀJ` is solvable with the regularizer and not without.
1185    ///
1186    /// Two variables enter the residual only through their sum, so `JᵀJ` is
1187    /// exactly singular: the un-regularized normal equations have no unique
1188    /// solution. `min_diagonal` is what the doc-comment always promised and what
1189    /// the solver never applied.
1190    #[test]
1191    fn gauss_newton_min_diagonal_regularizes_a_singular_hessian() -> TestResult {
1192        /// r = (x + y) - 1, so ∂r/∂x = ∂r/∂y = 1 and JᵀJ = [[1,1],[1,1]].
1193        #[derive(Debug)]
1194        struct SumFactor;
1195        impl crate::factors::Factor for SumFactor {
1196            fn linearize(
1197                &self,
1198                params: &[&[f64]],
1199                residual: &mut [f64],
1200                jacobian: Option<faer::mat::MatMut<'_, f64>>,
1201            ) {
1202                residual[0] = params[0][0] + params[1][0] - 1.0;
1203                if let Some(mut jac) = jacobian {
1204                    use faer::prelude::ReborrowMut;
1205                    *jac.rb_mut().get_mut(0, 0) = 1.0;
1206                    *jac.rb_mut().get_mut(0, 1) = 1.0;
1207                }
1208            }
1209            fn residual_dim(&self) -> usize {
1210                1
1211            }
1212            fn jacobian_shape(&self) -> (usize, usize) {
1213                (1, 2)
1214            }
1215        }
1216
1217        let build = || {
1218            let mut prob = problem::Problem::new(JacobianMode::Sparse);
1219            let x = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![0.0]);
1220            let y = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![0.0]);
1221            prob.add_residual_block(&[x, y], Box::new(SumFactor), None);
1222            prob
1223        };
1224
1225        let mut regularized_problem = build();
1226        let regularized = GaussNewton::with_config(
1227            GaussNewtonConfig::new()
1228                .with_max_iterations(20)
1229                .with_min_diagonal(1e-10),
1230        )
1231        .optimize(&mut regularized_problem)?;
1232        assert!(
1233            regularized.final_cost < 1e-12,
1234            "with regularization the singular system should still reach the \
1235             minimum-norm solution, got cost {:.3e}",
1236            regularized.final_cost
1237        );
1238
1239        let mut unregularized_problem = build();
1240        let unregularized = GaussNewton::with_config(
1241            GaussNewtonConfig::new()
1242                .with_max_iterations(20)
1243                .with_min_diagonal(0.0),
1244        )
1245        .optimize(&mut unregularized_problem);
1246        assert!(
1247            unregularized.is_err(),
1248            "without regularization the exactly singular JᵀJ should fail to \
1249             factorize, but the solve returned Ok — min_diagonal is not being read"
1250        );
1251        Ok(())
1252    }
1253
1254    /// `max_condition_number` detects a variable no residual constrains.
1255    #[test]
1256    fn gauss_newton_max_condition_number_detects_an_unconstrained_variable() -> TestResult {
1257        let mut problem = rosenbrock_problem();
1258        let _orphan = problem.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![0.0]);
1259
1260        let result = GaussNewton::with_config(
1261            GaussNewtonConfig::new()
1262                .with_max_iterations(10)
1263                .with_max_condition_number(1e12),
1264        )
1265        .optimize(&mut problem)?;
1266
1267        assert_eq!(
1268            result.status,
1269            optimizer::OptimizationStatus::IllConditionedJacobian
1270        );
1271        Ok(())
1272    }
1273
1274    // -------------------------------------------------------------------------
1275    // GaussNewtonConfig builder tests
1276    // -------------------------------------------------------------------------
1277
1278    #[test]
1279    fn test_gn_config_default() {
1280        let cfg = optimizer::gauss_newton::GaussNewtonConfig::default();
1281        assert_eq!(cfg.max_iterations, 50);
1282        assert!((cfg.cost_tolerance - 1e-6).abs() < 1e-15);
1283        assert!(!cfg.use_jacobi_scaling);
1284        assert!(!cfg.compute_covariances);
1285    }
1286
1287    #[test]
1288    fn test_gn_config_builders() {
1289        use crate::linalg::LinearSolverType;
1290        let cfg = GaussNewtonConfig::new()
1291            .with_max_iterations(15)
1292            .with_cost_tolerance(1e-5)
1293            .with_parameter_tolerance(1e-6)
1294            .with_gradient_tolerance(1e-7)
1295            .with_jacobi_scaling(true)
1296            .with_min_diagonal(1e-8)
1297            .with_min_cost_threshold(1e-10)
1298            .with_compute_covariances(true)
1299            .with_linear_solver_type(LinearSolverType::SparseQR);
1300        assert_eq!(cfg.max_iterations, 15);
1301        assert!((cfg.cost_tolerance - 1e-5).abs() < 1e-20);
1302        assert!(cfg.use_jacobi_scaling);
1303        assert!(cfg.min_cost_threshold.is_some());
1304        assert!(cfg.compute_covariances);
1305        assert!(matches!(cfg.linear_solver_type, LinearSolverType::SparseQR));
1306    }
1307
1308    #[test]
1309    fn test_gn_print_configuration_no_panic() {
1310        optimizer::gauss_newton::GaussNewtonConfig::default().print_configuration();
1311    }
1312
1313    #[test]
1314    fn test_gn_default_equals_new() {
1315        let _a = optimizer::GaussNewton::new();
1316        let _b = optimizer::GaussNewton::default();
1317    }
1318
1319    #[test]
1320    fn test_gn_with_config_method() {
1321        let cfg = GaussNewtonConfig::new().with_max_iterations(3);
1322        let _solver = GaussNewton::with_config(cfg);
1323    }
1324
1325    // -------------------------------------------------------------------------
1326    // Convergence termination paths
1327    // -------------------------------------------------------------------------
1328
1329    #[test]
1330    fn test_gn_max_iterations_termination() -> TestResult {
1331        let mut problem = rosenbrock_problem();
1332        let cfg = GaussNewtonConfig::new().with_max_iterations(2);
1333        let mut solver = GaussNewton::with_config(cfg);
1334        let result = solver.optimize(&mut problem)?;
1335        assert_eq!(
1336            result.status,
1337            optimizer::OptimizationStatus::MaxIterationsReached
1338        );
1339        Ok(())
1340    }
1341
1342    #[test]
1343    fn test_gn_gradient_tolerance_convergence() -> TestResult {
1344        let mut problem = linear_problem(1.0);
1345        let cfg = GaussNewtonConfig::new()
1346            .with_gradient_tolerance(1e3)
1347            .with_cost_tolerance(1e-20)
1348            .with_parameter_tolerance(1e-20);
1349        let mut solver = GaussNewton::with_config(cfg);
1350        let result = solver.optimize(&mut problem)?;
1351        assert_eq!(
1352            result.status,
1353            optimizer::OptimizationStatus::GradientToleranceReached
1354        );
1355        Ok(())
1356    }
1357
1358    #[test]
1359    fn test_gn_cost_tolerance_convergence() -> TestResult {
1360        let mut problem = rosenbrock_problem();
1361        let cfg = GaussNewtonConfig::new()
1362            .with_cost_tolerance(1e2) // very loose
1363            .with_gradient_tolerance(1e-20)
1364            .with_parameter_tolerance(1e-20);
1365        let mut solver = GaussNewton::with_config(cfg);
1366        let result = solver.optimize(&mut problem)?;
1367        assert!(matches!(
1368            result.status,
1369            optimizer::OptimizationStatus::CostToleranceReached
1370                | optimizer::OptimizationStatus::GradientToleranceReached
1371                | optimizer::OptimizationStatus::ParameterToleranceReached
1372                | optimizer::OptimizationStatus::Converged
1373        ));
1374        Ok(())
1375    }
1376
1377    #[test]
1378    fn test_gn_qr_solver() -> TestResult {
1379        use crate::linalg::LinearSolverType;
1380        let mut problem = rosenbrock_problem();
1381        let cfg = GaussNewtonConfig::new()
1382            .with_linear_solver_type(LinearSolverType::SparseQR)
1383            .with_max_iterations(100);
1384        let mut solver = GaussNewton::with_config(cfg);
1385        let result = solver.optimize(&mut problem)?;
1386        assert!(result.final_cost < 1e-6);
1387        Ok(())
1388    }
1389
1390    #[test]
1391    fn test_gn_jacobi_scaling_enabled() -> TestResult {
1392        let mut problem = rosenbrock_problem();
1393        let cfg = GaussNewtonConfig::new()
1394            .with_jacobi_scaling(true)
1395            .with_max_iterations(100);
1396        let mut solver = GaussNewton::with_config(cfg);
1397        let result = solver.optimize(&mut problem)?;
1398        assert!(result.final_cost < 1e-6);
1399        Ok(())
1400    }
1401
1402    #[test]
1403    fn test_gn_min_cost_threshold() -> TestResult {
1404        let mut problem = rosenbrock_problem();
1405        let cfg = GaussNewtonConfig::new()
1406            .with_min_cost_threshold(1e10)
1407            .with_cost_tolerance(1e-20)
1408            .with_gradient_tolerance(1e-20)
1409            .with_parameter_tolerance(1e-20);
1410        let mut solver = GaussNewton::with_config(cfg);
1411        let result = solver.optimize(&mut problem)?;
1412        assert_eq!(
1413            result.status,
1414            optimizer::OptimizationStatus::MinCostThresholdReached
1415        );
1416        Ok(())
1417    }
1418
1419    #[test]
1420    fn test_gn_result_fields() -> TestResult {
1421        let mut problem = rosenbrock_problem();
1422        let mut solver = optimizer::GaussNewton::new();
1423        let result = solver.optimize(&mut problem)?;
1424        assert!(result.initial_cost > result.final_cost);
1425        assert!(result.iterations > 0);
1426        Ok(())
1427    }
1428
1429    #[test]
1430    fn test_gn_convergence_info_populated() -> TestResult {
1431        let mut problem = rosenbrock_problem();
1432        let mut solver = optimizer::GaussNewton::new();
1433        let result = solver.optimize(&mut problem)?;
1434        assert!(result.convergence_info.is_some());
1435        Ok(())
1436    }
1437
1438    #[test]
1439    fn test_gn_timeout_config() {
1440        let cfg = GaussNewtonConfig::new().with_timeout(std::time::Duration::from_secs(60));
1441        assert!(cfg.timeout.is_some());
1442    }
1443}