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}