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    DenseCholeskySolver, DenseMode, DenseQRSolver, JacobianMode, LinearSolver, LinearSolverType,
122    SparseCholeskySolver, SparseMode, SparseQRSolver,
123};
124use crate::optimizer::{AssemblyBackend, IterationStats};
125
126/// Configuration parameters for the Gauss-Newton optimizer.
127///
128/// Controls the behavior of the Gauss-Newton algorithm including convergence criteria,
129/// linear solver selection, and numerical stability enhancements.
130///
131/// # Builder Pattern
132///
133/// All configuration options can be set using the builder pattern:
134///
135/// ```
136/// use apex_solver::optimizer::gauss_newton::GaussNewtonConfig;
137/// use apex_solver::linalg::LinearSolverType;
138///
139/// let config = GaussNewtonConfig::new()
140///     .with_max_iterations(50)
141///     .with_cost_tolerance(1e-6)
142///     .with_linear_solver_type(LinearSolverType::SparseQR);
143/// ```
144///
145/// # Convergence Criteria
146///
147/// The optimizer terminates when ANY of the following conditions is met:
148///
149/// - **Cost tolerance**: `|cost_k - cost_{k-1}| < cost_tolerance`
150/// - **Parameter tolerance**: `||step|| < parameter_tolerance`
151/// - **Gradient tolerance**: `||J^T·r|| < gradient_tolerance`
152/// - **Maximum iterations**: `iteration >= max_iterations`
153/// - **Timeout**: `elapsed_time >= timeout`
154///
155/// # See Also
156///
157/// - [`GaussNewton`] - The solver that uses this configuration
158/// - [`LevenbergMarquardtConfig`](crate::optimizer::levenberg_marquardt::LevenbergMarquardtConfig) - For adaptive damping
159/// - [`DogLegConfig`](crate::optimizer::dog_leg::DogLegConfig) - For trust region methods
160#[derive(Clone)]
161pub struct GaussNewtonConfig {
162    /// Type of linear solver for the linear systems
163    pub linear_solver_type: linalg::LinearSolverType,
164    /// Maximum number of iterations
165    pub max_iterations: usize,
166    /// Convergence tolerance for cost function
167    pub cost_tolerance: f64,
168    /// Convergence tolerance for parameter updates
169    pub parameter_tolerance: f64,
170    /// Convergence tolerance for gradient norm
171    pub gradient_tolerance: f64,
172    /// Timeout duration
173    pub timeout: Option<time::Duration>,
174    /// Use Jacobi column scaling (preconditioning)
175    ///
176    /// When enabled, normalizes Jacobian columns by their L2 norm before solving.
177    /// This can improve convergence for problems with mixed parameter scales
178    /// (e.g., positions in meters + angles in radians) but adds ~5-10% overhead.
179    ///
180    /// Default: false (Gauss-Newton is typically used on well-conditioned problems)
181    pub use_jacobi_scaling: bool,
182    /// Small regularization to ensure J^T·J is positive definite
183    ///
184    /// Pure Gauss-Newton (λ=0) can fail when J^T·J is singular or near-singular.
185    /// Adding a tiny diagonal regularization (e.g., 1e-10) ensures numerical stability
186    /// while maintaining the fast convergence of Gauss-Newton.
187    ///
188    /// Default: 1e-10 (very small, practically identical to pure Gauss-Newton)
189    pub min_diagonal: f64,
190
191    /// Minimum objective function cutoff (optional early termination)
192    ///
193    /// If set, optimization terminates when cost falls below this threshold.
194    /// Useful for early stopping when a "good enough" solution is acceptable.
195    ///
196    /// Default: None (disabled)
197    pub min_cost_threshold: Option<f64>,
198
199    /// Maximum condition number for Jacobian matrix (optional check)
200    ///
201    /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
202    /// threshold and terminates with IllConditionedJacobian status.
203    /// Note: Computing condition number is expensive, so this is disabled by default.
204    ///
205    /// Default: None (disabled)
206    pub max_condition_number: Option<f64>,
207
208    /// Compute per-variable covariance matrices (uncertainty estimation)
209    ///
210    /// When enabled, computes covariance by inverting the Hessian matrix after
211    /// convergence. The full covariance matrix is extracted into per-variable
212    /// blocks stored in both Variable structs and optimier::SolverResult.
213    ///
214    /// Default: false (to avoid performance overhead)
215    pub compute_covariances: bool,
216
217    /// Enable real-time visualization (graphical debugging).
218    ///
219    /// When enabled, optimization progress is logged to a Rerun viewer.
220    /// **Note:** Requires the `visualization` feature to be enabled in `Cargo.toml`.
221    ///
222    /// Default: false
223    #[cfg(feature = "visualization")]
224    pub enable_visualization: bool,
225}
226
227impl Default for GaussNewtonConfig {
228    fn default() -> Self {
229        Self {
230            linear_solver_type: linalg::LinearSolverType::default(),
231            // Ceres Solver default: 50 (changed from 100 for compatibility)
232            max_iterations: 50,
233            // Ceres Solver default: 1e-6 (changed from 1e-8 for compatibility)
234            cost_tolerance: 1e-6,
235            // Ceres Solver default: 1e-8 (unchanged)
236            parameter_tolerance: 1e-8,
237            // Ceres Solver default: 1e-10 (changed from 1e-8 for compatibility)
238            gradient_tolerance: 1e-10,
239            timeout: None,
240            use_jacobi_scaling: false,
241            min_diagonal: 1e-10,
242            // New Ceres-compatible termination parameters
243            min_cost_threshold: None,
244            max_condition_number: None,
245            compute_covariances: false,
246            #[cfg(feature = "visualization")]
247            enable_visualization: false,
248        }
249    }
250}
251
252impl GaussNewtonConfig {
253    /// Create a new Gauss-Newton configuration with default values.
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    /// Set the linear solver type
259    pub fn with_linear_solver_type(mut self, linear_solver_type: linalg::LinearSolverType) -> Self {
260        self.linear_solver_type = linear_solver_type;
261        self
262    }
263
264    /// Set the maximum number of iterations
265    pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
266        self.max_iterations = max_iterations;
267        self
268    }
269
270    /// Set the cost tolerance
271    pub fn with_cost_tolerance(mut self, cost_tolerance: f64) -> Self {
272        self.cost_tolerance = cost_tolerance;
273        self
274    }
275
276    /// Set the parameter tolerance
277    pub fn with_parameter_tolerance(mut self, parameter_tolerance: f64) -> Self {
278        self.parameter_tolerance = parameter_tolerance;
279        self
280    }
281
282    /// Set the gradient tolerance
283    pub fn with_gradient_tolerance(mut self, gradient_tolerance: f64) -> Self {
284        self.gradient_tolerance = gradient_tolerance;
285        self
286    }
287
288    /// Set the timeout duration
289    pub fn with_timeout(mut self, timeout: time::Duration) -> Self {
290        self.timeout = Some(timeout);
291        self
292    }
293
294    /// Enable or disable Jacobi column scaling (preconditioning).
295    ///
296    /// When enabled, normalizes Jacobian columns by their L2 norm before solving.
297    /// Can improve convergence for mixed-scale problems but adds ~5-10% overhead.
298    pub fn with_jacobi_scaling(mut self, use_jacobi_scaling: bool) -> Self {
299        self.use_jacobi_scaling = use_jacobi_scaling;
300        self
301    }
302
303    /// Set the minimum diagonal regularization for numerical stability.
304    ///
305    /// A small value (e.g., 1e-10) ensures J^T·J is positive definite while
306    /// maintaining the fast convergence of pure Gauss-Newton.
307    pub fn with_min_diagonal(mut self, min_diagonal: f64) -> Self {
308        self.min_diagonal = min_diagonal;
309        self
310    }
311
312    /// Set minimum objective function cutoff for early termination.
313    ///
314    /// When set, optimization terminates with MinCostThresholdReached status
315    /// if the cost falls below this threshold. Useful for early stopping when
316    /// a "good enough" solution is acceptable.
317    pub fn with_min_cost_threshold(mut self, min_cost: f64) -> Self {
318        self.min_cost_threshold = Some(min_cost);
319        self
320    }
321
322    /// Set maximum condition number for Jacobian matrix.
323    ///
324    /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
325    /// threshold and terminates with IllConditionedJacobian status.
326    /// Note: Computing condition number is expensive, disabled by default.
327    pub fn with_max_condition_number(mut self, max_cond: f64) -> Self {
328        self.max_condition_number = Some(max_cond);
329        self
330    }
331
332    /// Enable or disable covariance computation (uncertainty estimation).
333    ///
334    /// When enabled, computes the full covariance matrix by inverting the Hessian
335    /// after convergence, then extracts per-variable covariance blocks.
336    pub fn with_compute_covariances(mut self, compute_covariances: bool) -> Self {
337        self.compute_covariances = compute_covariances;
338        self
339    }
340
341    /// Enable real-time visualization.
342    ///
343    /// **Note:** Requires the `visualization` feature to be enabled in `Cargo.toml`.
344    ///
345    /// # Arguments
346    ///
347    /// * `enable` - Whether to enable visualization
348    #[cfg(feature = "visualization")]
349    pub fn with_visualization(mut self, enable: bool) -> Self {
350        self.enable_visualization = enable;
351        self
352    }
353
354    /// Print configuration parameters (info level logging)
355    pub fn print_configuration(&self) {
356        debug!(
357            "\nConfiguration:\n  Solver:        Gauss-Newton\n  Linear solver: {:?}\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: {}",
358            self.linear_solver_type,
359            self.max_iterations,
360            self.cost_tolerance,
361            self.parameter_tolerance,
362            self.gradient_tolerance,
363            self.timeout,
364            if self.use_jacobi_scaling {
365                "enabled"
366            } else {
367                "disabled"
368            },
369            if self.compute_covariances {
370                "enabled"
371            } else {
372                "disabled"
373            }
374        );
375    }
376}
377
378/// Result from step computation
379struct StepResult {
380    step: faer::Mat<f64>,
381    gradient_norm: f64,
382}
383
384/// Result from cost evaluation
385struct CostEvaluation {
386    new_cost: f64,
387    cost_reduction: f64,
388}
389
390/// Gauss-Newton solver for nonlinear least squares optimization.
391///
392/// Implements the classical Gauss-Newton algorithm which solves `J^T·J·h = -J^T·r` at each
393/// iteration to find the step `h`. This provides fast quadratic convergence near the solution
394/// but may diverge for poor initial guesses or ill-conditioned problems.
395///
396/// # Algorithm
397///
398/// At each iteration k:
399/// 1. Compute residual `r(xₖ)` and Jacobian `J(xₖ)`
400/// 2. Form normal equations: `(J^T·J)·h = -J^T·r`
401/// 3. Solve for step `h` using Cholesky or QR factorization
402/// 4. Update parameters: `xₖ₊₁ = xₖ ⊕ h` (manifold plus operation)
403/// 5. Check convergence criteria
404///
405/// # Examples
406///
407/// ```no_run
408/// use apex_solver::optimizer::GaussNewton;
409/// use apex_solver::core::problem::Problem;
410/// use apex_solver::JacobianMode;
411///
412/// # type TestResult = Result<(), Box<dyn std::error::Error>>;
413/// # fn main() -> TestResult {
414/// let mut problem = Problem::new(JacobianMode::Sparse);
415/// // ... add factors to problem ...
416///
417/// let mut solver = GaussNewton::new();
418/// let result = solver.optimize(&mut problem)?;
419/// # Ok(())
420/// # }
421/// ```
422///
423/// # See Also
424///
425/// - [`GaussNewtonConfig`] - Configuration options
426/// - [`LevenbergMarquardt`](crate::optimizer::LevenbergMarquardt) - For adaptive damping
427/// - [`DogLeg`](crate::optimizer::DogLeg) - For trust region control
428pub struct GaussNewton {
429    config: GaussNewtonConfig,
430    jacobi_scaling: Option<Vec<f64>>,
431    observers: optimizer::OptObserverVec,
432}
433
434impl Default for GaussNewton {
435    fn default() -> Self {
436        Self::new()
437    }
438}
439
440impl GaussNewton {
441    /// Create a new Gauss-Newton solver with default configuration.
442    pub fn new() -> Self {
443        Self::with_config(GaussNewtonConfig::default())
444    }
445
446    /// Create a new Gauss-Newton solver with the given configuration.
447    pub fn with_config(config: GaussNewtonConfig) -> Self {
448        Self {
449            config,
450            jacobi_scaling: None,
451            observers: optimizer::OptObserverVec::new(),
452        }
453    }
454
455    /// Add an observer to the solver.
456    ///
457    /// Observers are notified at each iteration with the current variable values.
458    /// This enables real-time visualization, logging, metrics collection, etc.
459    ///
460    /// # Examples
461    ///
462    /// ```no_run
463    /// use apex_solver::optimizer::GaussNewton;
464    /// # use apex_solver::optimizer::OptObserver;
465    /// # use apex_solver::core::VarKey;
466    /// # use apex_solver::core::variable::ManifoldVariable;
467    /// # use slotmap::SlotMap;
468    ///
469    /// # struct MyObserver;
470    /// # impl OptObserver for MyObserver {
471    /// #     fn on_step(&self, _: &SlotMap<VarKey, Box<dyn ManifoldVariable>>, _: usize) {}
472    /// # }
473    /// let mut solver = GaussNewton::new();
474    /// solver.add_observer(MyObserver);
475    /// ```
476    pub fn add_observer(&mut self, observer: impl optimizer::OptObserver + 'static) {
477        self.observers.add(observer);
478    }
479
480    /// Compute Gauss-Newton step by solving the normal equations (generic over assembly mode).
481    fn compute_step_generic<M: AssemblyBackend>(
482        &self,
483        residuals: &faer::Mat<f64>,
484        scaled_jacobian: &M::Jacobian,
485        linear_solver: &mut dyn LinearSolver<M>,
486    ) -> Result<StepResult, optimizer::OptimizerError> {
487        // Solve the Gauss-Newton equation: J^T·J·Δx = -J^T·r
488        let residuals_owned = residuals.as_ref().to_owned();
489        let scaled_step = linear_solver
490            .solve_normal_equation(&residuals_owned, scaled_jacobian)
491            .map_err(|e| {
492                optimizer::OptimizerError::LinearSolveFailed(e.to_string()).log_with_source(e)
493            })?;
494
495        // Get gradient from the solver (J^T * r)
496        let gradient = linear_solver.get_gradient().ok_or_else(|| {
497            optimizer::OptimizerError::NumericalInstability("Gradient not available".into()).log()
498        })?;
499        let gradient_norm = gradient.norm_l2();
500
501        // Apply inverse Jacobi scaling to get final step (if enabled)
502        let step = if self.config.use_jacobi_scaling {
503            let scaling = self
504                .jacobi_scaling
505                .as_ref()
506                .ok_or_else(|| optimizer::OptimizerError::JacobiScalingNotInitialized.log())?;
507            M::apply_inverse_scaling(&scaled_step, scaling)
508        } else {
509            scaled_step
510        };
511
512        Ok(StepResult {
513            step,
514            gradient_norm,
515        })
516    }
517
518    /// Apply step to parameters and evaluate new cost
519    fn apply_step_and_evaluate_cost(
520        &self,
521        step_result: &StepResult,
522        state: &mut optimizer::InitializedState,
523        problem: &problem::Problem,
524    ) -> error::ApexSolverResult<CostEvaluation> {
525        // Apply parameter updates using manifold operations
526        let _step_norm = optimizer::apply_parameter_step(
527            &mut state.variables,
528            step_result.step.as_ref(),
529            &state.sorted_vars,
530        );
531
532        // Compute new cost (residual only, no Jacobian needed for step evaluation)
533        let new_residual = problem.compute_residual_sparse(&state.variables)?;
534        let new_cost = optimizer::compute_cost(&new_residual);
535
536        // Compute cost reduction
537        let cost_reduction = state.current_cost - new_cost;
538
539        // Update current cost
540        state.current_cost = new_cost;
541
542        Ok(CostEvaluation {
543            new_cost,
544            cost_reduction,
545        })
546    }
547
548    /// Run optimization using the specified assembly mode and linear solver.
549    fn optimize_with_mode<M: AssemblyBackend>(
550        &mut self,
551        problem: &mut problem::Problem,
552        linear_solver: &mut dyn LinearSolver<M>,
553    ) -> optimizer::OptimizeResult {
554        let start_time = time::Instant::now();
555        let mut iteration = 0;
556        let mut cost_evaluations = 1; // Initial cost evaluation
557        let mut jacobian_evaluations = 0;
558
559        // Initialize optimization state
560        let mut state = optimizer::initialize_optimization_state(problem)?;
561
562        // Initialize summary tracking variables
563        let mut max_gradient_norm: f64 = 0.0;
564        let mut max_parameter_update_norm: f64 = 0.0;
565        let mut total_cost_reduction = 0.0;
566        let mut final_gradient_norm;
567        let mut final_parameter_update_norm;
568
569        // Initialize iteration statistics tracking
570        let mut iteration_stats = Vec::with_capacity(self.config.max_iterations);
571        let mut previous_cost = state.current_cost;
572
573        // Print configuration and header if debug level is enabled
574        if tracing::enabled!(tracing::Level::DEBUG) {
575            self.config.print_configuration();
576            IterationStats::print_header();
577        }
578
579        // Main optimization loop
580        loop {
581            let iter_start = time::Instant::now();
582
583            // Evaluate residuals and Jacobian using the assembly mode
584            let (residuals, jacobian) = M::assemble(
585                problem,
586                &state.variables,
587                &state.variable_index_map,
588                state.symbolic_structure.as_ref(),
589                state.total_dof,
590            )?;
591            jacobian_evaluations += 1;
592
593            // Process Jacobian (apply scaling if enabled)
594            let scaled_jacobian = if self.config.use_jacobi_scaling {
595                optimizer::process_jacobian_generic::<M>(
596                    &jacobian,
597                    &mut self.jacobi_scaling,
598                    iteration,
599                )?
600            } else {
601                jacobian
602            };
603
604            // Compute Gauss-Newton step
605            let step_result =
606                self.compute_step_generic::<M>(&residuals, &scaled_jacobian, linear_solver)?;
607
608            // Update tracking variables
609            max_gradient_norm = max_gradient_norm.max(step_result.gradient_norm);
610            final_gradient_norm = step_result.gradient_norm;
611            let step_norm = step_result.step.norm_l2();
612            max_parameter_update_norm = max_parameter_update_norm.max(step_norm);
613            final_parameter_update_norm = step_norm;
614
615            // Capture cost before applying step (for convergence check)
616            let cost_before_step = state.current_cost;
617
618            // Apply step and evaluate new cost
619            let cost_eval = self.apply_step_and_evaluate_cost(&step_result, &mut state, problem)?;
620            cost_evaluations += 1;
621            total_cost_reduction += cost_eval.cost_reduction;
622
623            // OPTIMIZATION: Only collect iteration statistics if debug level is enabled
624            if tracing::enabled!(tracing::Level::DEBUG) {
625                let iter_elapsed_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
626                let total_elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0;
627
628                let stats = IterationStats {
629                    iteration,
630                    cost: state.current_cost,
631                    cost_change: previous_cost - state.current_cost,
632                    gradient_norm: step_result.gradient_norm,
633                    step_norm,
634                    tr_ratio: 0.0,  // Not used in Gauss-Newton
635                    tr_radius: 0.0, // Not used in Gauss-Newton
636                    ls_iter: 0,     // Direct solver (Cholesky) has no iterations
637                    iter_time_ms: iter_elapsed_ms,
638                    total_time_ms: total_elapsed_ms,
639                    accepted: true, // Gauss-Newton always accepts steps
640                };
641
642                iteration_stats.push(stats.clone());
643                stats.print_line();
644            }
645
646            previous_cost = state.current_cost;
647
648            // Notify all observers with current state
649            optimizer::notify_observers_generic::<M>(
650                &mut self.observers,
651                &state.variables,
652                iteration,
653                state.current_cost,
654                step_result.gradient_norm,
655                None, // Gauss-Newton doesn't use damping
656                step_norm,
657                None, // Gauss-Newton doesn't use step quality
658                linear_solver,
659            );
660
661            // Compute parameter norm for convergence check
662            let parameter_norm = optimizer::compute_parameter_norm(&state.variables);
663
664            // Check convergence using comprehensive termination criteria
665            let elapsed = start_time.elapsed();
666            if let Some(status) = optimizer::check_convergence(&optimizer::ConvergenceParams {
667                iteration,
668                current_cost: cost_before_step,
669                new_cost: cost_eval.new_cost,
670                parameter_norm,
671                parameter_update_norm: step_norm,
672                gradient_norm: step_result.gradient_norm,
673                elapsed,
674                step_accepted: true, // GN always accepts
675                max_iterations: self.config.max_iterations,
676                gradient_tolerance: self.config.gradient_tolerance,
677                parameter_tolerance: self.config.parameter_tolerance,
678                cost_tolerance: self.config.cost_tolerance,
679                min_cost_threshold: self.config.min_cost_threshold,
680                timeout: self.config.timeout,
681                trust_region_radius: None,
682                min_trust_region_radius: None,
683            }) {
684                // Print summary only if debug level is enabled
685                if tracing::enabled!(tracing::Level::DEBUG) {
686                    let summary = optimizer::create_optimizer_summary(
687                        "Gauss-Newton",
688                        state.initial_cost,
689                        state.current_cost,
690                        iteration + 1,
691                        None,
692                        None,
693                        max_gradient_norm,
694                        final_gradient_norm,
695                        max_parameter_update_norm,
696                        final_parameter_update_norm,
697                        total_cost_reduction,
698                        elapsed,
699                        iteration_stats.clone(),
700                        status.clone(),
701                        None,
702                        None,
703                        None,
704                    );
705                    debug!("{}", summary);
706                }
707
708                // Compute covariances if enabled
709                let covariances = if self.config.compute_covariances {
710                    problem.compute_and_set_covariances_generic::<M>(
711                        linear_solver,
712                        &mut state.variables,
713                        &state.variable_index_map,
714                    )
715                } else {
716                    None
717                };
718
719                return Ok(optimizer::build_solver_result(
720                    status,
721                    iteration + 1,
722                    state,
723                    elapsed,
724                    final_gradient_norm,
725                    final_parameter_update_norm,
726                    cost_evaluations,
727                    jacobian_evaluations,
728                    covariances,
729                ));
730            }
731
732            iteration += 1;
733        }
734    }
735
736    /// Run optimization, automatically selecting sparse or dense path based on config.
737    pub fn optimize(&mut self, problem: &mut problem::Problem) -> optimizer::OptimizeResult {
738        match problem.jacobian_mode {
739            JacobianMode::Dense => match self.config.linear_solver_type {
740                LinearSolverType::DenseQR => {
741                    let mut solver = DenseQRSolver::new();
742                    self.optimize_with_mode::<DenseMode>(problem, &mut solver)
743                }
744                _ => {
745                    let mut solver = DenseCholeskySolver::new();
746                    self.optimize_with_mode::<DenseMode>(problem, &mut solver)
747                }
748            },
749            JacobianMode::Sparse => match self.config.linear_solver_type {
750                linalg::LinearSolverType::SparseQR => {
751                    let mut solver = SparseQRSolver::new();
752                    self.optimize_with_mode::<SparseMode>(problem, &mut solver)
753                }
754                _ => {
755                    // SparseCholesky (default), SparseSchurComplement or DenseCholesky with
756                    // sparse mode → SparseCholeskySolver
757                    let mut solver = SparseCholeskySolver::new();
758                    self.optimize_with_mode::<SparseMode>(problem, &mut solver)
759                }
760            },
761        }
762    }
763}
764
765impl optimizer::Optimizer for GaussNewton {
766    fn optimize(&mut self, problem: &mut problem::Problem) -> optimizer::OptimizeResult {
767        self.optimize(problem)
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use crate::{core::problem, factors, linalg::JacobianMode, optimizer};
774    use apex_manifolds as manifold;
775    use faer::prelude::ReborrowMut;
776    use nalgebra::dvector;
777
778    type TestResult = Result<(), Box<dyn std::error::Error>>;
779
780    /// Custom Rosenbrock Factor 1: r1 = 10(x2 - x1²)
781    /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
782    #[derive(Debug, Clone)]
783    struct RosenbrockFactor1;
784
785    impl factors::Factor for RosenbrockFactor1 {
786        fn linearize(
787            &self,
788            params: &[&[f64]],
789            residual: &mut [f64],
790            jacobian: Option<faer::mat::MatMut<'_, f64>>,
791        ) {
792            let x1 = params[0][0];
793            let x2 = params[1][0];
794            residual[0] = 10.0 * (x2 - x1 * x1);
795            if let Some(mut jac) = jacobian {
796                *jac.rb_mut().get_mut(0, 0) = -20.0 * x1;
797                *jac.rb_mut().get_mut(0, 1) = 10.0;
798            }
799        }
800        fn residual_dim(&self) -> usize {
801            1
802        }
803        fn jacobian_shape(&self) -> (usize, usize) {
804            (1, 2)
805        }
806    }
807
808    /// Custom Rosenbrock Factor 2: r2 = 1 - x1
809    /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
810    #[derive(Debug, Clone)]
811    struct RosenbrockFactor2;
812
813    impl factors::Factor for RosenbrockFactor2 {
814        fn linearize(
815            &self,
816            params: &[&[f64]],
817            residual: &mut [f64],
818            jacobian: Option<faer::mat::MatMut<'_, f64>>,
819        ) {
820            residual[0] = 1.0 - params[0][0];
821            if let Some(mut jac) = jacobian {
822                *jac.rb_mut().get_mut(0, 0) = -1.0;
823            }
824        }
825        fn residual_dim(&self) -> usize {
826            1
827        }
828        fn jacobian_shape(&self) -> (usize, usize) {
829            (1, 1)
830        }
831    }
832
833    #[test]
834    fn test_rosenbrock_optimization() -> TestResult {
835        // Rosenbrock function test:
836        // Minimize: r1² + r2² where
837        //   r1 = 10(x2 - x1²)
838        //   r2 = 1 - x1
839        // Starting point: [-1.2, 1.0]
840        // Expected minimum: [1.0, 1.0]
841
842        let mut problem = problem::Problem::new(JacobianMode::Sparse);
843        let x1 = problem.add_variable(manifold::ManifoldType::RN, dvector![-1.2]);
844        let x2 = problem.add_variable(manifold::ManifoldType::RN, dvector![1.0]);
845
846        // Add custom factors (demonstrates extensibility!)
847        problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
848        problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
849
850        // Configure Gauss-Newton optimizer
851        let config = optimizer::gauss_newton::GaussNewtonConfig::new()
852            .with_max_iterations(100)
853            .with_cost_tolerance(1e-8)
854            .with_parameter_tolerance(1e-8)
855            .with_gradient_tolerance(1e-10);
856
857        let mut solver = optimizer::GaussNewton::with_config(config);
858        let result = solver.optimize(&mut problem)?;
859
860        // Extract final values
861        let x1_final = result.parameters[x1].as_param_slice()[0];
862        let x2_final = result.parameters[x2].as_param_slice()[0];
863
864        // Verify convergence to [1.0, 1.0]
865        assert!(
866            matches!(
867                result.status,
868                optimizer::OptimizationStatus::Converged
869                    | optimizer::OptimizationStatus::CostToleranceReached
870                    | optimizer::OptimizationStatus::ParameterToleranceReached
871                    | optimizer::OptimizationStatus::GradientToleranceReached
872            ),
873            "Optimization should converge"
874        );
875        assert!(
876            (x1_final - 1.0).abs() < 1e-4,
877            "x1 should converge to 1.0, got {}",
878            x1_final
879        );
880        assert!(
881            (x2_final - 1.0).abs() < 1e-4,
882            "x2 should converge to 1.0, got {}",
883            x2_final
884        );
885        assert!(
886            result.final_cost < 1e-6,
887            "Final cost should be near zero, got {}",
888            result.final_cost
889        );
890        Ok(())
891    }
892
893    /// Trivial factor: r = x - target, J = [[1.0]]
894    struct LinearFactor {
895        target: f64,
896    }
897
898    impl factors::Factor for LinearFactor {
899        fn linearize(
900            &self,
901            params: &[&[f64]],
902            residual: &mut [f64],
903            jacobian: Option<faer::mat::MatMut<'_, f64>>,
904        ) {
905            residual[0] = params[0][0] - self.target;
906            if let Some(mut jac) = jacobian {
907                *jac.rb_mut().get_mut(0, 0) = 1.0;
908            }
909        }
910        fn residual_dim(&self) -> usize {
911            1
912        }
913        fn jacobian_shape(&self) -> (usize, usize) {
914            (1, 1)
915        }
916    }
917
918    fn rosenbrock_problem() -> problem::Problem {
919        let mut prob = problem::Problem::new(JacobianMode::Sparse);
920        let x1 = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![-1.2]);
921        let x2 = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![1.0]);
922        prob.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
923        prob.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
924        prob
925    }
926
927    fn linear_problem(start: f64) -> problem::Problem {
928        let mut prob = problem::Problem::new(JacobianMode::Sparse);
929        let x = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![start]);
930        prob.add_residual_block(&[x], Box::new(LinearFactor { target: 0.0 }), None);
931        prob
932    }
933
934    // -------------------------------------------------------------------------
935    // GaussNewtonConfig builder tests
936    // -------------------------------------------------------------------------
937
938    #[test]
939    fn test_gn_config_default() {
940        let cfg = optimizer::gauss_newton::GaussNewtonConfig::default();
941        assert_eq!(cfg.max_iterations, 50);
942        assert!((cfg.cost_tolerance - 1e-6).abs() < 1e-15);
943        assert!(!cfg.use_jacobi_scaling);
944        assert!(!cfg.compute_covariances);
945    }
946
947    #[test]
948    fn test_gn_config_builders() {
949        use crate::linalg::LinearSolverType;
950        let cfg = optimizer::gauss_newton::GaussNewtonConfig::new()
951            .with_max_iterations(15)
952            .with_cost_tolerance(1e-5)
953            .with_parameter_tolerance(1e-6)
954            .with_gradient_tolerance(1e-7)
955            .with_jacobi_scaling(true)
956            .with_min_diagonal(1e-8)
957            .with_min_cost_threshold(1e-10)
958            .with_compute_covariances(true)
959            .with_linear_solver_type(LinearSolverType::SparseQR);
960        assert_eq!(cfg.max_iterations, 15);
961        assert!((cfg.cost_tolerance - 1e-5).abs() < 1e-20);
962        assert!(cfg.use_jacobi_scaling);
963        assert!(cfg.min_cost_threshold.is_some());
964        assert!(cfg.compute_covariances);
965        assert!(matches!(cfg.linear_solver_type, LinearSolverType::SparseQR));
966    }
967
968    #[test]
969    fn test_gn_print_configuration_no_panic() {
970        optimizer::gauss_newton::GaussNewtonConfig::default().print_configuration();
971    }
972
973    #[test]
974    fn test_gn_default_equals_new() {
975        let _a = optimizer::GaussNewton::new();
976        let _b = optimizer::GaussNewton::default();
977    }
978
979    #[test]
980    fn test_gn_with_config_method() {
981        let cfg = optimizer::gauss_newton::GaussNewtonConfig::new().with_max_iterations(3);
982        let _solver = optimizer::GaussNewton::with_config(cfg);
983    }
984
985    // -------------------------------------------------------------------------
986    // Convergence termination paths
987    // -------------------------------------------------------------------------
988
989    #[test]
990    fn test_gn_max_iterations_termination() -> TestResult {
991        let mut problem = rosenbrock_problem();
992        let cfg = optimizer::gauss_newton::GaussNewtonConfig::new().with_max_iterations(2);
993        let mut solver = optimizer::GaussNewton::with_config(cfg);
994        let result = solver.optimize(&mut problem)?;
995        assert_eq!(
996            result.status,
997            optimizer::OptimizationStatus::MaxIterationsReached
998        );
999        Ok(())
1000    }
1001
1002    #[test]
1003    fn test_gn_gradient_tolerance_convergence() -> TestResult {
1004        let mut problem = linear_problem(1.0);
1005        let cfg = optimizer::gauss_newton::GaussNewtonConfig::new()
1006            .with_gradient_tolerance(1e3)
1007            .with_cost_tolerance(1e-20)
1008            .with_parameter_tolerance(1e-20);
1009        let mut solver = optimizer::GaussNewton::with_config(cfg);
1010        let result = solver.optimize(&mut problem)?;
1011        assert_eq!(
1012            result.status,
1013            optimizer::OptimizationStatus::GradientToleranceReached
1014        );
1015        Ok(())
1016    }
1017
1018    #[test]
1019    fn test_gn_cost_tolerance_convergence() -> TestResult {
1020        let mut problem = rosenbrock_problem();
1021        let cfg = optimizer::gauss_newton::GaussNewtonConfig::new()
1022            .with_cost_tolerance(1e2) // very loose
1023            .with_gradient_tolerance(1e-20)
1024            .with_parameter_tolerance(1e-20);
1025        let mut solver = optimizer::GaussNewton::with_config(cfg);
1026        let result = solver.optimize(&mut problem)?;
1027        assert!(matches!(
1028            result.status,
1029            optimizer::OptimizationStatus::CostToleranceReached
1030                | optimizer::OptimizationStatus::GradientToleranceReached
1031                | optimizer::OptimizationStatus::ParameterToleranceReached
1032                | optimizer::OptimizationStatus::Converged
1033        ));
1034        Ok(())
1035    }
1036
1037    #[test]
1038    fn test_gn_qr_solver() -> TestResult {
1039        use crate::linalg::LinearSolverType;
1040        let mut problem = rosenbrock_problem();
1041        let cfg = optimizer::gauss_newton::GaussNewtonConfig::new()
1042            .with_linear_solver_type(LinearSolverType::SparseQR)
1043            .with_max_iterations(100);
1044        let mut solver = optimizer::GaussNewton::with_config(cfg);
1045        let result = solver.optimize(&mut problem)?;
1046        assert!(result.final_cost < 1e-6);
1047        Ok(())
1048    }
1049
1050    #[test]
1051    fn test_gn_jacobi_scaling_enabled() -> TestResult {
1052        let mut problem = rosenbrock_problem();
1053        let cfg = optimizer::gauss_newton::GaussNewtonConfig::new()
1054            .with_jacobi_scaling(true)
1055            .with_max_iterations(100);
1056        let mut solver = optimizer::GaussNewton::with_config(cfg);
1057        let result = solver.optimize(&mut problem)?;
1058        assert!(result.final_cost < 1e-6);
1059        Ok(())
1060    }
1061
1062    #[test]
1063    fn test_gn_min_cost_threshold() -> TestResult {
1064        let mut problem = rosenbrock_problem();
1065        let cfg = optimizer::gauss_newton::GaussNewtonConfig::new()
1066            .with_min_cost_threshold(1e10)
1067            .with_cost_tolerance(1e-20)
1068            .with_gradient_tolerance(1e-20)
1069            .with_parameter_tolerance(1e-20);
1070        let mut solver = optimizer::GaussNewton::with_config(cfg);
1071        let result = solver.optimize(&mut problem)?;
1072        assert_eq!(
1073            result.status,
1074            optimizer::OptimizationStatus::MinCostThresholdReached
1075        );
1076        Ok(())
1077    }
1078
1079    #[test]
1080    fn test_gn_result_fields() -> TestResult {
1081        let mut problem = rosenbrock_problem();
1082        let mut solver = optimizer::GaussNewton::new();
1083        let result = solver.optimize(&mut problem)?;
1084        assert!(result.initial_cost > result.final_cost);
1085        assert!(result.iterations > 0);
1086        Ok(())
1087    }
1088
1089    #[test]
1090    fn test_gn_convergence_info_populated() -> TestResult {
1091        let mut problem = rosenbrock_problem();
1092        let mut solver = optimizer::GaussNewton::new();
1093        let result = solver.optimize(&mut problem)?;
1094        assert!(result.convergence_info.is_some());
1095        Ok(())
1096    }
1097
1098    #[test]
1099    fn test_gn_timeout_config() {
1100        let cfg = optimizer::gauss_newton::GaussNewtonConfig::new()
1101            .with_timeout(std::time::Duration::from_secs(60));
1102        assert!(cfg.timeout.is_some());
1103    }
1104}