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}