apex_solver/optimizer/levenberg_marquardt.rs
1//! Levenberg-Marquardt algorithm implementation.
2//!
3//! The Levenberg-Marquardt (LM) method is a robust and widely-used algorithm for solving
4//! nonlinear least squares problems 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 Levenberg-Marquardt method solves the damped normal equations at each iteration:
15//!
16//! ```text
17//! (J^T·J + λI)·h = -J^T·r
18//! ```
19//!
20//! where:
21//! - `J` is the Jacobian matrix (m × n)
22//! - `r` is the residual vector (m × 1)
23//! - `h` is the step vector (n × 1)
24//! - `λ` is the adaptive damping parameter (scalar)
25//! - `I` is the identity matrix (or diagonal scaling matrix)
26//!
27//! ## Damping Parameter Strategy
28//!
29//! The damping parameter λ adapts based on step quality:
30//!
31//! - **λ → 0** (small damping): Behaves like Gauss-Newton with fast quadratic convergence
32//! - **λ → ∞** (large damping): Behaves like gradient descent with guaranteed descent direction
33//!
34//! This interpolation between Newton and gradient descent provides excellent robustness
35//! while maintaining fast convergence near the solution.
36//!
37//! ## Step Acceptance and Damping Update
38//!
39//! The algorithm evaluates each proposed step using the gain ratio:
40//!
41//! ```text
42//! ρ = (actual reduction) / (predicted reduction)
43//! = [f(xₖ) - f(xₖ + h)] / [f(xₖ) - L(h)]
44//! ```
45//!
46//! where `L(h) = f(xₖ) + h^T·g + ½h^T·H·h` is the local quadratic model.
47//!
48//! **Step acceptance:**
49//! - If `ρ > 0`: Accept step (cost decreased), decrease λ to trust the model more
50//! - If `ρ ≤ 0`: Reject step (cost increased), increase λ to be more conservative
51//!
52//! **Damping update** (Nielsen's formula):
53//! ```text
54//! λₖ₊₁ = λₖ · max(1/3, 1 - (2ρ - 1)³)
55//! ```
56//!
57//! This provides smooth, data-driven adaptation of the damping parameter.
58//!
59//! ## Convergence Properties
60//!
61//! - **Global convergence**: Guaranteed to find a stationary point from any starting guess
62//! - **Local quadratic convergence**: Near the solution, behaves like Gauss-Newton
63//! - **Robust to poor initialization**: Adaptive damping prevents divergence
64//! - **Handles ill-conditioning**: Large λ stabilizes nearly singular Hessian
65//!
66//! ## When to Use
67//!
68//! Levenberg-Marquardt is the best general-purpose choice when:
69//! - Initial parameter guess may be far from the optimum
70//! - Problem conditioning is unknown
71//! - Robustness is prioritized over raw speed
72//! - You want reliable convergence across diverse problem types
73//!
74//! For problems with specific structure, consider:
75//! - [`GaussNewton`](crate::optimizer::GaussNewton) if well-conditioned with good initialization
76//! - [`DogLeg`](crate::optimizer::DogLeg) for explicit trust region control
77//!
78//! # Implementation Features
79//!
80//! - **Sparse matrix support**: Efficient handling of large-scale problems via `faer` sparse library
81//! - **Adaptive damping**: Nielsen's formula for smooth parameter adaptation
82//! - **Robust linear solvers**: Cholesky (fast) or QR (stable) factorization
83//! - **Jacobi scaling**: Optional diagonal preconditioning for mixed-scale problems
84//! - **Covariance computation**: Optional uncertainty quantification after convergence
85//! - **Manifold operations**: Native support for optimization on Lie groups (SE2, SE3, SO2, SO3)
86//! - **Comprehensive diagnostics**: Detailed summaries of convergence and performance
87//!
88//! # Mathematical Background
89//!
90//! The augmented Hessian `J^T·J + λI` combines two beneficial properties:
91//!
92//! 1. **Positive definiteness**: Always solvable even when `J^T·J` is singular
93//! 2. **Regularization**: Prevents taking steps in poorly-determined directions
94//!
95//! The trust region interpretation: λ controls an implicit spherical trust region where
96//! larger λ restricts step size, ensuring the linear model remains valid.
97//!
98//! # Examples
99//!
100//! ## Basic usage
101//!
102//! ```no_run
103//! use apex_solver::LevenbergMarquardt;
104//! use apex_solver::core::problem::Problem;
105//! use apex_solver::JacobianMode;
106//!
107//! # type TestResult = Result<(), Box<dyn std::error::Error>>;
108//! # fn main() -> TestResult {
109//! let mut problem = Problem::new(JacobianMode::Sparse);
110//! // ... add residual blocks (factors) to problem ...
111//!
112//! let mut solver = LevenbergMarquardt::new();
113//! let result = solver.optimize(&mut problem)?;
114//!
115//! # Ok(())
116//! # }
117//! ```
118//!
119//! ## Advanced configuration
120//!
121//! ```no_run
122//! use apex_solver::optimizer::levenberg_marquardt::{LevenbergMarquardtConfig, LevenbergMarquardt};
123//! use apex_solver::linalg::LinearSolverType;
124//!
125//! # fn main() {
126//! let config = LevenbergMarquardtConfig::new()
127//! .with_max_iterations(100)
128//! .with_cost_tolerance(1e-6)
129//! .with_damping(1e-3) // Initial damping
130//! .with_damping_bounds(1e-12, 1e12) // Min/max damping
131//! .with_jacobi_scaling(true); // Improve conditioning
132//!
133//! let mut solver = LevenbergMarquardt::with_config(config);
134//! # }
135//! ```
136//!
137//! # References
138//!
139//! - Levenberg, K. (1944). "A Method for the Solution of Certain Non-Linear Problems in Least Squares". *Quarterly of Applied Mathematics*.
140//! - Marquardt, D. W. (1963). "An Algorithm for Least-Squares Estimation of Nonlinear Parameters". *Journal of the Society for Industrial and Applied Mathematics*.
141//! - Madsen, K., Nielsen, H. B., & Tingleff, O. (2004). *Methods for Non-Linear Least Squares Problems* (2nd ed.). Chapter 3.
142//! - Nocedal, J. & Wright, S. (2006). *Numerical Optimization* (2nd ed.). Springer. Chapter 10.
143//! - Nielsen, H. B. (1999). "Damping Parameter in Marquardt's Method". Technical Report IMM-REP-1999-05.
144
145use crate::core::problem::Problem;
146use crate::error;
147use crate::error::ErrorLogging;
148use crate::linalg::{
149 DenseCholeskySolver, DenseMode, DenseQRSolver, JacobianMode, LinearSolver, LinearSolverType,
150 SchurPreconditioner, SchurVariant, SparseCholeskySolver, SparseMode, SparseQRSolver,
151 SparseSchurComplementSolver, StructureAware,
152};
153use crate::optimizer::{
154 AssemblyBackend, ConvergenceParams, InitializedState, IterationStats, OptObserverVec,
155 OptimizerError, apply_negative_parameter_step, apply_parameter_step, compute_cost,
156};
157use faer::Mat;
158use std::time::{Duration, Instant};
159use tracing::debug;
160
161/// Configuration parameters for the Levenberg-Marquardt optimizer.
162///
163/// Controls the adaptive damping strategy, convergence criteria, and numerical stability
164/// enhancements for the Levenberg-Marquardt algorithm.
165///
166/// # Builder Pattern
167///
168/// All configuration options can be set using the builder pattern:
169///
170/// ```
171/// use apex_solver::optimizer::levenberg_marquardt::LevenbergMarquardtConfig;
172///
173/// let config = LevenbergMarquardtConfig::new()
174/// .with_max_iterations(100)
175/// .with_damping(1e-3)
176/// .with_damping_bounds(1e-12, 1e12)
177/// .with_jacobi_scaling(true);
178/// ```
179///
180/// # Damping Parameter Behavior
181///
182/// The damping parameter λ controls the trade-off between Gauss-Newton and gradient descent:
183///
184/// - **Initial damping** (`damping`): Starting value (default: 1e-4)
185/// - **Damping bounds** (`damping_min`, `damping_max`): Valid range (default: 1e-12 to 1e12)
186/// - **Adaptation**: Automatically adjusted based on step quality using Nielsen's formula
187///
188/// # Convergence Criteria
189///
190/// The optimizer terminates when ANY of the following conditions is met:
191///
192/// - **Cost tolerance**: `|cost_k - cost_{k-1}| < cost_tolerance`
193/// - **Parameter tolerance**: `||step|| < parameter_tolerance`
194/// - **Gradient tolerance**: `||J^T·r|| < gradient_tolerance`
195/// - **Maximum iterations**: `iteration >= max_iterations`
196/// - **Timeout**: `elapsed_time >= timeout`
197///
198/// # See Also
199///
200/// - [`LevenbergMarquardt`] - The solver that uses this configuration
201/// - [`GaussNewtonConfig`](crate::optimizer::gauss_newton::GaussNewtonConfig) - Undamped variant
202/// - [`DogLegConfig`](crate::optimizer::dog_leg::DogLegConfig) - Trust region alternative
203#[derive(Clone)]
204pub struct LevenbergMarquardtConfig {
205 /// Type of linear solver for the linear systems
206 pub linear_solver_type: LinearSolverType,
207 /// Maximum number of iterations
208 pub max_iterations: usize,
209 /// Convergence tolerance for cost function
210 pub cost_tolerance: f64,
211 /// Convergence tolerance for parameter updates
212 pub parameter_tolerance: f64,
213 /// Convergence tolerance for gradient norm
214 pub gradient_tolerance: f64,
215 /// Timeout duration
216 pub timeout: Option<Duration>,
217 /// Initial damping parameter
218 pub damping: f64,
219 /// Minimum damping parameter
220 pub damping_min: f64,
221 /// Maximum damping parameter
222 pub damping_max: f64,
223 /// Damping increase factor (when step rejected)
224 pub damping_increase_factor: f64,
225 /// Damping decrease factor (when step accepted)
226 pub damping_decrease_factor: f64,
227 /// Damping nu parameter
228 pub damping_nu: f64,
229 /// Stop after this many consecutive rejected steps.
230 pub max_consecutive_rejected_steps: usize,
231 /// Trust region radius
232 pub trust_region_radius: f64,
233 /// Minimum step quality for acceptance
234 pub min_step_quality: f64,
235 /// Good step quality threshold
236 pub good_step_quality: f64,
237 /// Minimum diagonal value for regularization
238 pub min_diagonal: f64,
239 /// Maximum diagonal value for regularization
240 pub max_diagonal: f64,
241 /// Minimum objective function cutoff (optional early termination)
242 ///
243 /// If set, optimization terminates when cost falls below this threshold.
244 /// Useful for early stopping when a "good enough" solution is acceptable.
245 ///
246 /// Default: None (disabled)
247 pub min_cost_threshold: Option<f64>,
248 /// Minimum trust region radius before termination
249 ///
250 /// When the trust region radius falls below this value, the optimizer
251 /// terminates as it indicates the search has converged or the problem
252 /// is ill-conditioned. Matches Ceres Solver's min_trust_region_radius.
253 ///
254 /// Default: 1e-32 (Ceres-compatible)
255 pub min_trust_region_radius: f64,
256 /// Maximum condition number for Jacobian matrix (optional check)
257 ///
258 /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
259 /// threshold and terminates with IllConditionedJacobian status.
260 /// Note: Computing condition number is expensive, so this is disabled by default.
261 ///
262 /// Default: None (disabled)
263 pub max_condition_number: Option<f64>,
264 /// Minimum relative cost decrease for step acceptance
265 ///
266 /// Used in computing step quality (rho = actual_reduction / predicted_reduction).
267 /// Steps with rho < min_relative_decrease are rejected. Matches Ceres Solver's
268 /// min_relative_decrease parameter.
269 ///
270 /// Default: 1e-3 (Ceres-compatible)
271 pub min_relative_decrease: f64,
272 /// Use Jacobi column scaling (preconditioning)
273 ///
274 /// When enabled, normalizes Jacobian columns by their L2 norm before solving.
275 /// This can improve convergence for problems with mixed parameter scales
276 /// (e.g., positions in meters + angles in radians) but adds ~5-10% overhead.
277 ///
278 /// Default: false (to avoid performance overhead and faster convergence)
279 pub use_jacobi_scaling: bool,
280 /// Compute per-variable covariance matrices (uncertainty estimation)
281 ///
282 /// When enabled, computes covariance by inverting the Hessian matrix after
283 /// convergence. The full covariance matrix is extracted into per-variable
284 /// blocks stored in both Variable structs and SolverResult.
285 ///
286 /// Default: false (to avoid performance overhead)
287 pub compute_covariances: bool,
288 /// Schur complement solver variant (for bundle adjustment problems)
289 ///
290 /// When using LinearSolverType::SparseSchurComplement, this determines which
291 /// variant of the Schur complement method to use:
292 /// - Sparse: Direct sparse Cholesky factorization (most accurate, moderate speed)
293 /// - Iterative: Preconditioned Conjugate Gradients (memory efficient, good for large problems)
294 /// - PowerSeries: Power series approximation (fastest, less accurate)
295 ///
296 /// Default: Sparse
297 pub schur_variant: SchurVariant,
298 /// Schur complement preconditioner type
299 ///
300 /// Determines the preconditioning strategy for iterative Schur methods:
301 /// - Diagonal: Simple diagonal preconditioner (fast, less effective)
302 /// - BlockDiagonal: Block-diagonal preconditioner (balanced)
303 /// - IncompleteCholesky: Incomplete Cholesky factorization (slower, more effective)
304 ///
305 /// Default: Diagonal
306 pub schur_preconditioner: SchurPreconditioner,
307 // Note: Visualization is now handled via the observer pattern.
308 // Use `solver.add_observer(RerunObserver::new(true)?)` to enable visualization.
309 // This provides cleaner separation of concerns and allows multiple observers.
310}
311
312impl Default for LevenbergMarquardtConfig {
313 fn default() -> Self {
314 Self {
315 linear_solver_type: LinearSolverType::default(),
316 // Ceres Solver default: 50 (changed from 100 for compatibility)
317 max_iterations: 50,
318 // Ceres Solver default: 1e-6 (changed from 1e-8 for compatibility)
319 cost_tolerance: 1e-6,
320 // Ceres Solver default: 1e-8 (unchanged)
321 parameter_tolerance: 1e-8,
322 // Ceres Solver default: 1e-10 (changed from 1e-8 for compatibility)
323 // Note: Typically should be 1e-4 * cost_tolerance per Ceres docs
324 gradient_tolerance: 1e-10,
325 timeout: None,
326 damping: 1e-3, // Increased from 1e-4 for better initial convergence on BA
327 damping_min: 1e-12,
328 damping_max: 1e12,
329 damping_increase_factor: 10.0,
330 damping_decrease_factor: 0.3,
331 damping_nu: 2.0,
332 max_consecutive_rejected_steps: 5,
333 trust_region_radius: 1e4,
334 min_step_quality: 0.0,
335 good_step_quality: 0.75,
336 min_diagonal: 1e-6,
337 max_diagonal: 1e32,
338 // New Ceres-compatible parameters
339 min_cost_threshold: None,
340 min_trust_region_radius: 1e-32,
341 max_condition_number: None,
342 min_relative_decrease: 1e-3,
343 // Existing parameters
344 // Jacobi scaling disabled by default for Schur solvers (incompatible with block structure)
345 // Enable manually for Cholesky/QR solvers on mixed-scale problems
346 use_jacobi_scaling: false,
347 compute_covariances: false,
348 // Schur complement parameters
349 schur_variant: SchurVariant::default(),
350 schur_preconditioner: SchurPreconditioner::default(),
351 }
352 }
353}
354
355impl LevenbergMarquardtConfig {
356 /// Create a new Levenberg-Marquardt configuration with default values.
357 pub fn new() -> Self {
358 Self::default()
359 }
360
361 /// Set the linear solver type
362 pub fn with_linear_solver_type(mut self, linear_solver_type: LinearSolverType) -> Self {
363 self.linear_solver_type = linear_solver_type;
364 self
365 }
366
367 /// Set the maximum number of iterations
368 pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
369 self.max_iterations = max_iterations;
370 self
371 }
372
373 /// Set the cost tolerance
374 pub fn with_cost_tolerance(mut self, cost_tolerance: f64) -> Self {
375 self.cost_tolerance = cost_tolerance;
376 self
377 }
378
379 /// Set the parameter tolerance
380 pub fn with_parameter_tolerance(mut self, parameter_tolerance: f64) -> Self {
381 self.parameter_tolerance = parameter_tolerance;
382 self
383 }
384
385 /// Set the gradient tolerance
386 pub fn with_gradient_tolerance(mut self, gradient_tolerance: f64) -> Self {
387 self.gradient_tolerance = gradient_tolerance;
388 self
389 }
390
391 /// Set the timeout duration
392 pub fn with_timeout(mut self, timeout: Duration) -> Self {
393 self.timeout = Some(timeout);
394 self
395 }
396
397 /// Set the initial damping parameter.
398 pub fn with_damping(mut self, damping: f64) -> Self {
399 self.damping = damping;
400 self
401 }
402
403 /// Set the damping parameter bounds.
404 pub fn with_damping_bounds(mut self, min: f64, max: f64) -> Self {
405 self.damping_min = min;
406 self.damping_max = max;
407 self
408 }
409
410 /// Set the damping adjustment factors.
411 pub fn with_damping_factors(mut self, increase: f64, decrease: f64) -> Self {
412 self.damping_increase_factor = increase;
413 self.damping_decrease_factor = decrease;
414 self
415 }
416
417 /// Set the trust region parameters.
418 pub fn with_trust_region(mut self, radius: f64, min_quality: f64, good_quality: f64) -> Self {
419 self.trust_region_radius = radius;
420 self.min_step_quality = min_quality;
421 self.good_step_quality = good_quality;
422 self
423 }
424
425 /// Set minimum objective function cutoff for early termination.
426 ///
427 /// When set, optimization terminates with MinCostThresholdReached status
428 /// if the cost falls below this threshold. Useful for early stopping when
429 /// a "good enough" solution is acceptable.
430 pub fn with_min_cost_threshold(mut self, min_cost: f64) -> Self {
431 self.min_cost_threshold = Some(min_cost);
432 self
433 }
434
435 /// Set minimum trust region radius before termination.
436 ///
437 /// When the trust region radius falls below this value, optimization
438 /// terminates with TrustRegionRadiusTooSmall status.
439 /// Default: 1e-32 (Ceres-compatible)
440 pub fn with_min_trust_region_radius(mut self, min_radius: f64) -> Self {
441 self.min_trust_region_radius = min_radius;
442 self
443 }
444
445 /// Set maximum condition number for Jacobian matrix.
446 ///
447 /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
448 /// threshold and terminates with IllConditionedJacobian status.
449 /// Note: Computing condition number is expensive, disabled by default.
450 pub fn with_max_condition_number(mut self, max_cond: f64) -> Self {
451 self.max_condition_number = Some(max_cond);
452 self
453 }
454
455 /// Set minimum relative cost decrease for step acceptance.
456 ///
457 /// Steps with rho = (actual_reduction / predicted_reduction) below this
458 /// threshold are rejected. Default: 1e-3 (Ceres-compatible)
459 pub fn with_min_relative_decrease(mut self, min_decrease: f64) -> Self {
460 self.min_relative_decrease = min_decrease;
461 self
462 }
463
464 /// Enable or disable Jacobi column scaling (preconditioning).
465 ///
466 /// When enabled, normalizes Jacobian columns by their L2 norm before solving.
467 /// Can improve convergence for mixed-scale problems but adds ~5-10% overhead.
468 pub fn with_jacobi_scaling(mut self, use_jacobi_scaling: bool) -> Self {
469 self.use_jacobi_scaling = use_jacobi_scaling;
470 self
471 }
472
473 /// Enable or disable covariance computation (uncertainty estimation).
474 ///
475 /// When enabled, computes the full covariance matrix by inverting the Hessian
476 /// after convergence, then extracts per-variable covariance blocks.
477 pub fn with_compute_covariances(mut self, compute_covariances: bool) -> Self {
478 self.compute_covariances = compute_covariances;
479 self
480 }
481
482 /// Set Schur complement solver variant
483 pub fn with_schur_variant(mut self, variant: SchurVariant) -> Self {
484 self.schur_variant = variant;
485 self
486 }
487
488 /// Set Schur complement preconditioner
489 pub fn with_schur_preconditioner(mut self, preconditioner: SchurPreconditioner) -> Self {
490 self.schur_preconditioner = preconditioner;
491 self
492 }
493
494 /// Configuration optimized for bundle adjustment problems.
495 ///
496 /// This preset uses settings tuned for large-scale bundle adjustment:
497 /// - **Schur complement solver** with iterative PCG (memory efficient)
498 /// - **Schur-Jacobi preconditioner** (Ceres-style, best PCG convergence)
499 /// - **Moderate initial damping** (1e-3) - not too aggressive
500 /// - **200 max iterations** (BA often needs more iterations for full convergence)
501 /// - **Very tight tolerances** matching Ceres Solver for accurate reconstruction
502 ///
503 /// This configuration matches Ceres Solver's recommended BA settings and
504 /// should achieve similar convergence quality.
505 ///
506 /// # Example
507 ///
508 /// ```
509 /// use apex_solver::optimizer::levenberg_marquardt::LevenbergMarquardtConfig;
510 ///
511 /// let config = LevenbergMarquardtConfig::for_bundle_adjustment();
512 /// ```
513 pub fn for_bundle_adjustment() -> Self {
514 Self::default()
515 .with_linear_solver_type(LinearSolverType::SparseSchurComplement)
516 .with_schur_variant(SchurVariant::Iterative)
517 .with_schur_preconditioner(SchurPreconditioner::SchurJacobi)
518 .with_damping(1e-3) // Moderate initial damping (Ceres default)
519 .with_max_iterations(20) // Reduced for early stop when RMSE < 1px
520 // Match Ceres tolerances for faster convergence
521 .with_cost_tolerance(1e-6) // Ceres function_tolerance (was 1e-12)
522 .with_parameter_tolerance(1e-8) // Ceres parameter_tolerance (was 1e-14)
523 .with_gradient_tolerance(1e-10) // Relaxed (was 1e-16)
524 }
525
526 /// Enable real-time visualization (graphical debugging).
527 ///
528 /// When enabled, optimization progress is logged to a Rerun viewer with:
529 /// - Time series plots of cost, gradient norm, damping, step quality
530 /// - Sparse Hessian matrix visualization as heat map
531 /// - Gradient vector visualization
532 /// - Real-time manifold state updates (for SE2/SE3 problems)
533 ///
534 /// **Note:** Requires the `visualization` feature to be enabled in `Cargo.toml`.
535 /// Use `verbose` for terminal logging.
536 ///
537 /// # Arguments
538 ///
539 /// * `enable` - Whether to enable visualization
540 // Note: with_visualization() method has been removed.
541 // Use the observer pattern instead:
542 // let mut solver = LevenbergMarquardt::with_config(config);
543 // solver.add_observer(RerunObserver::new(true)?);
544 // This provides cleaner separation and allows multiple observers.
545 /// Print configuration parameters (verbose mode only)
546 pub fn print_configuration(&self) {
547 debug!(
548 "Configuration:\n Solver: Levenberg-Marquardt\n Linear solver: {:?}\n Convergence Criteria:\n Max iterations: {}\n Cost tolerance: {:.2e}\n Parameter tolerance: {:.2e}\n Gradient tolerance: {:.2e}\n Timeout: {:?}\n Damping Parameters:\n Initial damping: {:.2e}\n Damping range: [{:.2e}, {:.2e}]\n Increase factor: {:.2}\n Decrease factor: {:.2}\n Trust Region:\n Initial radius: {:.2e}\n Min step quality: {:.2}\n Good step quality: {:.2}\n Numerical Settings:\n Jacobi scaling: {}\n Compute covariances: {}",
549 self.linear_solver_type,
550 self.max_iterations,
551 self.cost_tolerance,
552 self.parameter_tolerance,
553 self.gradient_tolerance,
554 self.timeout,
555 self.damping,
556 self.damping_min,
557 self.damping_max,
558 self.damping_increase_factor,
559 self.damping_decrease_factor,
560 self.trust_region_radius,
561 self.min_step_quality,
562 self.good_step_quality,
563 if self.use_jacobi_scaling {
564 "enabled"
565 } else {
566 "disabled"
567 },
568 if self.compute_covariances {
569 "enabled"
570 } else {
571 "disabled"
572 }
573 );
574 }
575}
576
577/// Result from step computation
578struct StepResult {
579 step: Mat<f64>,
580 gradient_norm: f64,
581 predicted_reduction: f64,
582}
583
584/// Result from step evaluation
585struct StepEvaluation {
586 accepted: bool,
587 cost_reduction: f64,
588 rho: f64,
589}
590
591/// Levenberg-Marquardt solver for nonlinear least squares optimization.
592///
593/// Implements the damped Gauss-Newton method with adaptive damping parameter λ that
594/// interpolates between Gauss-Newton and gradient descent based on step quality.
595///
596/// # Algorithm
597///
598/// At each iteration k:
599/// 1. Compute residual `r(xₖ)` and Jacobian `J(xₖ)`
600/// 2. Solve augmented system: `(J^T·J + λI)·h = -J^T·r`
601/// 3. Evaluate step quality: `ρ = (actual reduction) / (predicted reduction)`
602/// 4. If `ρ > 0`: Accept step and update `xₖ₊₁ = xₖ ⊕ h`, decrease λ
603/// 5. If `ρ ≤ 0`: Reject step (keep `xₖ₊₁ = xₖ`), increase λ
604/// 6. Check convergence criteria
605///
606/// The damping parameter λ is updated using Nielsen's smooth formula:
607/// `λₖ₊₁ = λₖ · max(1/3, 1 - (2ρ - 1)³)` for accepted steps,
608/// or `λₖ₊₁ = λₖ · ν` (with increasing ν) for rejected steps.
609///
610/// # Examples
611///
612/// ```no_run
613/// use apex_solver::optimizer::levenberg_marquardt::{LevenbergMarquardtConfig, LevenbergMarquardt};
614/// use apex_solver::linalg::LinearSolverType;
615///
616/// # fn main() {
617/// let config = LevenbergMarquardtConfig::new()
618/// .with_max_iterations(100)
619/// .with_damping(1e-3)
620/// .with_damping_bounds(1e-12, 1e12)
621/// .with_jacobi_scaling(true);
622///
623/// let mut solver = LevenbergMarquardt::with_config(config);
624/// # }
625/// ```
626///
627/// # See Also
628///
629/// - [`LevenbergMarquardtConfig`] - Configuration options
630/// - [`GaussNewton`](crate::optimizer::GaussNewton) - Undamped variant (faster but less robust)
631/// - [`DogLeg`](crate::optimizer::DogLeg) - Alternative trust region method
632pub struct LevenbergMarquardt {
633 config: LevenbergMarquardtConfig,
634 jacobi_scaling: Option<Vec<f64>>,
635 observers: OptObserverVec,
636}
637
638impl Default for LevenbergMarquardt {
639 fn default() -> Self {
640 Self::new()
641 }
642}
643
644impl LevenbergMarquardt {
645 /// Create a new Levenberg-Marquardt solver with default configuration.
646 pub fn new() -> Self {
647 Self::with_config(LevenbergMarquardtConfig::default())
648 }
649
650 /// Create a new Levenberg-Marquardt solver with the given configuration.
651 pub fn with_config(config: LevenbergMarquardtConfig) -> Self {
652 Self {
653 config,
654 jacobi_scaling: None,
655 observers: OptObserverVec::new(),
656 }
657 }
658
659 /// Add an observer to monitor optimization progress.
660 ///
661 /// Observers are notified at each iteration with the current variable values.
662 /// This enables real-time visualization, logging, metrics collection, etc.
663 ///
664 /// # Arguments
665 ///
666 /// * `observer` - Any type implementing `OptObserver`
667 ///
668 /// # Examples
669 ///
670 /// ```no_run
671 /// use apex_solver::{LevenbergMarquardt, LevenbergMarquardtConfig};
672 /// # use apex_solver::core::problem::Problem;
673 /// # use std::collections::HashMap;
674 ///
675 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
676 /// let mut solver = LevenbergMarquardt::new();
677 ///
678 /// #[cfg(feature = "visualization")]
679 /// {
680 /// use apex_solver::observers::RerunObserver;
681 /// let rerun_observer = RerunObserver::new(true)?;
682 /// solver.add_observer(rerun_observer);
683 /// }
684 ///
685 /// // ... optimize ...
686 /// # Ok(())
687 /// # }
688 /// ```
689 pub fn add_observer(&mut self, observer: impl crate::optimizer::OptObserver + 'static) {
690 self.observers.add(observer);
691 }
692
693 /// Update damping parameter based on step quality using trust region approach
694 /// Reference: Introduction to Optimization and Data Fitting
695 /// Algorithm 6.18
696 fn update_damping(&mut self, rho: f64) -> bool {
697 if rho > 0.0 {
698 // Step accepted - decrease damping
699 let coff = 2.0 * rho - 1.0;
700 self.config.damping *= (1.0_f64 / 3.0).max(1.0 - coff * coff * coff);
701 self.config.damping = self.config.damping.max(self.config.damping_min);
702 self.config.damping_nu = 2.0;
703 true
704 } else {
705 // Step rejected - increase damping
706 self.config.damping *= self.config.damping_nu;
707 self.config.damping_nu *= 2.0;
708 self.config.damping = self.config.damping.min(self.config.damping_max);
709 false
710 }
711 }
712
713 /// Compute predicted cost reduction from linear model
714 /// Standard LM formula: 0.5 * step^T * (damping * step - gradient)
715 fn compute_predicted_reduction(&self, step: &Mat<f64>, gradient: &Mat<f64>) -> f64 {
716 // Standard Levenberg-Marquardt predicted reduction formula
717 // predicted_reduction = -step^T * gradient - 0.5 * step^T * H * step
718 // = 0.5 * step^T * (damping * step - gradient)
719 let diff = self.config.damping * step - gradient;
720 (0.5 * step.transpose() * &diff)[(0, 0)]
721 }
722
723 /// Compute optimization step by solving the augmented system (generic over assembly mode).
724 fn compute_step_generic<M: AssemblyBackend>(
725 &self,
726 residuals: &Mat<f64>,
727 scaled_jacobian: &M::Jacobian,
728 linear_solver: &mut dyn LinearSolver<M>,
729 ) -> Result<StepResult, OptimizerError> {
730 // Solve augmented equation: (J_scaled^T * J_scaled + λI) * dx_scaled = -J_scaled^T * r
731 let residuals_owned = residuals.as_ref().to_owned();
732 let scaled_step = linear_solver
733 .solve_augmented_equation(&residuals_owned, scaled_jacobian, self.config.damping)
734 .map_err(|e| OptimizerError::LinearSolveFailed(e.to_string()).log_with_source(e))?;
735
736 // Get cached gradient from the solver
737 let gradient = linear_solver.get_gradient().ok_or_else(|| {
738 OptimizerError::NumericalInstability("Gradient not available".into()).log()
739 })?;
740 let gradient_norm = gradient.norm_l2();
741
742 // Apply inverse Jacobi scaling to get final step (if enabled)
743 let step = if self.config.use_jacobi_scaling {
744 let scaling = self
745 .jacobi_scaling
746 .as_ref()
747 .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
748 M::apply_inverse_scaling(&scaled_step, scaling)
749 } else {
750 scaled_step
751 };
752
753 // Compute predicted reduction using scaled values
754 let predicted_reduction = self.compute_predicted_reduction(&step, gradient);
755
756 Ok(StepResult {
757 step,
758 gradient_norm,
759 predicted_reduction,
760 })
761 }
762
763 /// Evaluate and apply step, handling acceptance/rejection based on step quality
764 fn evaluate_and_apply_step(
765 &mut self,
766 step_result: &StepResult,
767 state: &mut InitializedState,
768 problem: &Problem,
769 ) -> error::ApexSolverResult<StepEvaluation> {
770 // Apply parameter updates using manifold operations
771 let _step_norm = apply_parameter_step(
772 &mut state.variables,
773 step_result.step.as_ref(),
774 &state.sorted_vars,
775 );
776
777 // Compute new cost (residual only, no Jacobian needed for step evaluation)
778 let new_residual = problem.compute_residual_sparse(&state.variables)?;
779 let new_cost = compute_cost(&new_residual);
780
781 // Compute step quality
782 let rho = crate::optimizer::compute_step_quality(
783 state.current_cost,
784 new_cost,
785 step_result.predicted_reduction,
786 );
787
788 // Update damping and decide whether to accept step
789 let accepted = self.update_damping(rho);
790
791 let cost_reduction = if accepted {
792 // Accept the step - parameters already updated
793 let reduction = state.current_cost - new_cost;
794 state.current_cost = new_cost;
795 reduction
796 } else {
797 // Reject the step - revert parameter changes
798 apply_negative_parameter_step(
799 &mut state.variables,
800 step_result.step.as_ref(),
801 &state.sorted_vars,
802 );
803 0.0
804 };
805
806 Ok(StepEvaluation {
807 accepted,
808 cost_reduction,
809 rho,
810 })
811 }
812
813 /// Run optimization using the specified assembly mode and linear solver.
814 ///
815 /// This is the core generic optimization loop. The public `optimize()` method
816 /// dispatches to this based on `LinearSolverType`.
817 fn optimize_with_mode<M: AssemblyBackend>(
818 &mut self,
819 problem: &mut Problem,
820 linear_solver: &mut dyn LinearSolver<M>,
821 ) -> crate::optimizer::OptimizeResult {
822 let start_time = Instant::now();
823 let mut iteration = 0;
824 let mut cost_evaluations = 1;
825 let mut jacobian_evaluations = 0;
826 let mut successful_steps = 0;
827 let mut unsuccessful_steps = 0;
828 let mut consecutive_rejected = 0;
829
830 // Initialize optimization state
831 let mut state = crate::optimizer::initialize_optimization_state(problem)?;
832
833 // Initialize summary tracking variables
834 let mut max_gradient_norm: f64 = 0.0;
835 let mut max_parameter_update_norm: f64 = 0.0;
836 let mut total_cost_reduction = 0.0;
837 let mut final_gradient_norm;
838 let mut final_parameter_update_norm;
839
840 // Initialize iteration statistics tracking
841 let mut iteration_stats = Vec::with_capacity(self.config.max_iterations);
842 let mut previous_cost = state.current_cost;
843
844 // Print configuration and header if debug level is enabled
845 if tracing::enabled!(tracing::Level::DEBUG) {
846 self.config.print_configuration();
847 IterationStats::print_header();
848 }
849
850 // Main optimization loop
851 loop {
852 let iter_start = Instant::now();
853
854 // Evaluate residuals and Jacobian using the assembly mode
855 let (residuals, jacobian) = M::assemble(
856 problem,
857 &state.variables,
858 &state.variable_index_map,
859 state.symbolic_structure.as_ref(),
860 state.total_dof,
861 )?;
862 jacobian_evaluations += 1;
863
864 // Process Jacobian (apply scaling if enabled)
865 let scaled_jacobian = if self.config.use_jacobi_scaling {
866 crate::optimizer::process_jacobian_generic::<M>(
867 &jacobian,
868 &mut self.jacobi_scaling,
869 iteration,
870 )?
871 } else {
872 jacobian
873 };
874
875 // Compute optimization step
876 let step_result =
877 self.compute_step_generic::<M>(&residuals, &scaled_jacobian, linear_solver)?;
878
879 // Update tracking variables
880 max_gradient_norm = max_gradient_norm.max(step_result.gradient_norm);
881 final_gradient_norm = step_result.gradient_norm;
882 let step_norm = step_result.step.norm_l2();
883 max_parameter_update_norm = max_parameter_update_norm.max(step_norm);
884 final_parameter_update_norm = step_norm;
885
886 // Evaluate and apply step (handles accept/reject)
887 let step_eval = self.evaluate_and_apply_step(&step_result, &mut state, problem)?;
888 cost_evaluations += 1;
889
890 // Update counters based on acceptance
891 if step_eval.accepted {
892 successful_steps += 1;
893 consecutive_rejected = 0;
894 total_cost_reduction += step_eval.cost_reduction;
895 } else {
896 unsuccessful_steps += 1;
897 consecutive_rejected += 1;
898 }
899
900 // OPTIMIZATION: Only collect iteration statistics if debug level is enabled
901 if tracing::enabled!(tracing::Level::DEBUG) {
902 let iter_elapsed_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
903 let total_elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0;
904
905 let stats = IterationStats {
906 iteration,
907 cost: state.current_cost,
908 cost_change: previous_cost - state.current_cost,
909 gradient_norm: step_result.gradient_norm,
910 step_norm,
911 tr_ratio: step_eval.rho,
912 tr_radius: self.config.damping,
913 ls_iter: 0,
914 iter_time_ms: iter_elapsed_ms,
915 total_time_ms: total_elapsed_ms,
916 accepted: step_eval.accepted,
917 };
918
919 iteration_stats.push(stats.clone());
920 stats.print_line();
921 }
922
923 previous_cost = state.current_cost;
924
925 // Notify all observers with current state
926 crate::optimizer::notify_observers_generic::<M>(
927 &mut self.observers,
928 &state.variables,
929 iteration,
930 state.current_cost,
931 step_result.gradient_norm,
932 Some(self.config.damping),
933 step_norm,
934 Some(step_eval.rho),
935 linear_solver,
936 );
937
938 // Check convergence
939 let elapsed = start_time.elapsed();
940 let parameter_norm = crate::optimizer::compute_parameter_norm(&state.variables);
941 let new_cost = state.current_cost;
942 let cost_before_step = if step_eval.accepted {
943 state.current_cost + step_eval.cost_reduction
944 } else {
945 state.current_cost
946 };
947
948 let convergence_status = crate::optimizer::check_convergence(&ConvergenceParams {
949 iteration,
950 current_cost: cost_before_step,
951 new_cost,
952 parameter_norm,
953 parameter_update_norm: step_norm,
954 gradient_norm: step_result.gradient_norm,
955 elapsed,
956 step_accepted: step_eval.accepted,
957 max_iterations: self.config.max_iterations,
958 gradient_tolerance: self.config.gradient_tolerance,
959 parameter_tolerance: self.config.parameter_tolerance,
960 cost_tolerance: self.config.cost_tolerance,
961 min_cost_threshold: self.config.min_cost_threshold,
962 timeout: self.config.timeout,
963 trust_region_radius: Some(self.config.trust_region_radius),
964 min_trust_region_radius: Some(self.config.min_trust_region_radius),
965 })
966 .or_else(|| {
967 // `check_convergence` returns early on a rejected step, so a solver that
968 // rejects every trial step would otherwise run out the full iteration
969 // budget without the cost ever changing.
970 //
971 // Both conditions are required. A run of rejections alone is normal — LM
972 // raises damping and the next step succeeds. Only once damping has also
973 // saturated at `damping_max` can it no longer shrink the step further, so
974 // the state is provably stuck and the remaining iterations are wasted.
975 let damping_saturated = self.config.damping >= self.config.damping_max;
976 let stalled = consecutive_rejected >= self.config.max_consecutive_rejected_steps
977 && damping_saturated;
978 stalled.then_some(crate::optimizer::OptimizationStatus::StalledNoProgress)
979 });
980
981 if let Some(status) = convergence_status {
982 if tracing::enabled!(tracing::Level::DEBUG) {
983 let summary = crate::optimizer::create_optimizer_summary(
984 "Levenberg-Marquardt",
985 state.initial_cost,
986 state.current_cost,
987 iteration + 1,
988 Some(successful_steps),
989 Some(unsuccessful_steps),
990 max_gradient_norm,
991 final_gradient_norm,
992 max_parameter_update_norm,
993 final_parameter_update_norm,
994 total_cost_reduction,
995 elapsed,
996 iteration_stats.clone(),
997 status.clone(),
998 Some(self.config.damping),
999 None,
1000 Some(step_eval.rho),
1001 );
1002 debug!("{}", summary);
1003 }
1004
1005 // Compute covariances if enabled
1006 let covariances = if self.config.compute_covariances {
1007 problem.compute_and_set_covariances_generic::<M>(
1008 linear_solver,
1009 &mut state.variables,
1010 &state.variable_index_map,
1011 )
1012 } else {
1013 None
1014 };
1015
1016 // Notify observers that optimization is complete
1017 self.observers
1018 .notify_complete(&state.variables, iteration + 1);
1019
1020 return Ok(crate::optimizer::build_solver_result(
1021 status,
1022 iteration + 1,
1023 state,
1024 elapsed,
1025 final_gradient_norm,
1026 final_parameter_update_norm,
1027 cost_evaluations,
1028 jacobian_evaluations,
1029 covariances,
1030 ));
1031 }
1032
1033 iteration += 1;
1034 }
1035 }
1036
1037 /// Run optimization, dispatching based on `problem.jacobian_mode`.
1038 ///
1039 /// - `JacobianMode::Dense` → always uses `DenseCholeskySolver`
1040 /// - `JacobianMode::Sparse` → uses the solver selected by `config.linear_solver_type`
1041 pub fn optimize(&mut self, problem: &mut Problem) -> crate::optimizer::OptimizeResult {
1042 match problem.jacobian_mode {
1043 JacobianMode::Dense => match self.config.linear_solver_type {
1044 LinearSolverType::DenseQR => {
1045 let mut solver = DenseQRSolver::new();
1046 self.optimize_with_mode::<DenseMode>(problem, &mut solver)
1047 }
1048 _ => {
1049 let mut solver = DenseCholeskySolver::new();
1050 self.optimize_with_mode::<DenseMode>(problem, &mut solver)
1051 }
1052 },
1053 JacobianMode::Sparse => match self.config.linear_solver_type {
1054 LinearSolverType::SparseQR => {
1055 let mut solver = SparseQRSolver::new();
1056 self.optimize_with_mode::<SparseMode>(problem, &mut solver)
1057 }
1058 LinearSolverType::SparseSchurComplement => {
1059 let state = crate::optimizer::initialize_optimization_state(problem)?;
1060 let mut solver = SparseSchurComplementSolver::new()
1061 .with_variant(self.config.schur_variant)
1062 .with_preconditioner(self.config.schur_preconditioner);
1063 solver
1064 .initialize_structure(
1065 &state.variables,
1066 &state.variable_index_map,
1067 &problem.schur_landmark_keys,
1068 )
1069 .map_err(|e| {
1070 OptimizerError::LinearSolveFailed(format!(
1071 "Failed to initialize Schur solver: {}",
1072 e
1073 ))
1074 .log()
1075 })?;
1076 self.optimize_with_mode::<SparseMode>(problem, &mut solver)
1077 }
1078 _ => {
1079 let mut solver = SparseCholeskySolver::new();
1080 self.optimize_with_mode::<SparseMode>(problem, &mut solver)
1081 }
1082 },
1083 }
1084 }
1085}
1086impl crate::optimizer::Optimizer for LevenbergMarquardt {
1087 fn optimize(&mut self, problem: &mut Problem) -> crate::optimizer::OptimizeResult {
1088 self.optimize(problem)
1089 }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095 use crate::ManifoldType;
1096 use crate::core::VarKey;
1097 use crate::core::variable::ManifoldVariable;
1098 use crate::factors::Factor;
1099 use crate::optimizer::OptimizationStatus;
1100 use faer::prelude::ReborrowMut;
1101 use nalgebra::dvector;
1102 use slotmap::SlotMap;
1103
1104 type TestResult = Result<(), Box<dyn std::error::Error>>;
1105 /// Custom Rosenbrock Factor 1: r1 = 10(x2 - x1²)
1106 /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1107 #[derive(Debug, Clone)]
1108 struct RosenbrockFactor1;
1109
1110 impl Factor for RosenbrockFactor1 {
1111 fn linearize(
1112 &self,
1113 params: &[&[f64]],
1114 residual: &mut [f64],
1115 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1116 ) {
1117 let x1 = params[0][0];
1118 let x2 = params[1][0];
1119 residual[0] = 10.0 * (x2 - x1 * x1);
1120 if let Some(mut jac) = jacobian {
1121 *jac.rb_mut().get_mut(0, 0) = -20.0 * x1;
1122 *jac.rb_mut().get_mut(0, 1) = 10.0;
1123 }
1124 }
1125 fn residual_dim(&self) -> usize {
1126 1
1127 }
1128 fn jacobian_shape(&self) -> (usize, usize) {
1129 (1, 2)
1130 }
1131 }
1132
1133 /// Custom Rosenbrock Factor 2: r2 = 1 - x1
1134 /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1135 #[derive(Debug, Clone)]
1136 struct RosenbrockFactor2;
1137
1138 impl Factor for RosenbrockFactor2 {
1139 fn linearize(
1140 &self,
1141 params: &[&[f64]],
1142 residual: &mut [f64],
1143 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1144 ) {
1145 residual[0] = 1.0 - params[0][0];
1146 if let Some(mut jac) = jacobian {
1147 *jac.rb_mut().get_mut(0, 0) = -1.0;
1148 }
1149 }
1150 fn residual_dim(&self) -> usize {
1151 1
1152 }
1153 fn jacobian_shape(&self) -> (usize, usize) {
1154 (1, 1)
1155 }
1156 }
1157
1158 #[test]
1159 fn test_rosenbrock_optimization() -> TestResult {
1160 // Rosenbrock function test:
1161 // Minimize: r1² + r2² where
1162 // r1 = 10(x2 - x1²)
1163 // r2 = 1 - x1
1164 // Starting point: [-1.2, 1.0]
1165 // Expected minimum: [1.0, 1.0]
1166
1167 let mut problem = Problem::new(JacobianMode::Sparse);
1168 let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
1169 let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
1170
1171 // Add custom factors (demonstrates extensibility!)
1172 problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1173 problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1174
1175 // Configure Levenberg-Marquardt optimizer
1176 let config = LevenbergMarquardtConfig::new()
1177 .with_max_iterations(100)
1178 .with_cost_tolerance(1e-8)
1179 .with_parameter_tolerance(1e-8)
1180 .with_gradient_tolerance(1e-10);
1181
1182 let mut solver = LevenbergMarquardt::with_config(config);
1183 let result = solver.optimize(&mut problem)?;
1184
1185 // Extract final values
1186 let x1_final = result.parameters[x1].as_param_slice()[0];
1187 let x2_final = result.parameters[x2].as_param_slice()[0];
1188
1189 // Verify convergence to [1.0, 1.0]
1190 assert!(
1191 matches!(
1192 result.status,
1193 OptimizationStatus::Converged
1194 | OptimizationStatus::CostToleranceReached
1195 | OptimizationStatus::ParameterToleranceReached
1196 | OptimizationStatus::GradientToleranceReached
1197 ),
1198 "Optimization should converge"
1199 );
1200 assert!(
1201 (x1_final - 1.0).abs() < 1e-4,
1202 "x1 should converge to 1.0, got {}",
1203 x1_final
1204 );
1205 assert!(
1206 (x2_final - 1.0).abs() < 1e-4,
1207 "x2 should converge to 1.0, got {}",
1208 x2_final
1209 );
1210 assert!(
1211 result.final_cost < 1e-6,
1212 "Final cost should be near zero, got {}",
1213 result.final_cost
1214 );
1215 Ok(())
1216 }
1217
1218 /// Trivial factor: r = x - target, J = [[1.0]]
1219 struct LinearFactor {
1220 target: f64,
1221 }
1222
1223 impl Factor for LinearFactor {
1224 fn linearize(
1225 &self,
1226 params: &[&[f64]],
1227 residual: &mut [f64],
1228 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1229 ) {
1230 residual[0] = params[0][0] - self.target;
1231 if let Some(mut jac) = jacobian {
1232 *jac.rb_mut().get_mut(0, 0) = 1.0;
1233 }
1234 }
1235 fn residual_dim(&self) -> usize {
1236 1
1237 }
1238 fn jacobian_shape(&self) -> (usize, usize) {
1239 (1, 1)
1240 }
1241 }
1242
1243 fn rosenbrock_problem() -> Problem {
1244 let mut problem = Problem::new(JacobianMode::Sparse);
1245 let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
1246 let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
1247 problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1248 problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1249 problem
1250 }
1251
1252 fn linear_problem(start: f64) -> Problem {
1253 let mut problem = Problem::new(JacobianMode::Sparse);
1254 let x = problem.add_variable(ManifoldType::RN, dvector![start]);
1255 problem.add_residual_block(&[x], Box::new(LinearFactor { target: 0.0 }), None);
1256 problem
1257 }
1258
1259 // -------------------------------------------------------------------------
1260 // Config builder tests
1261 // -------------------------------------------------------------------------
1262
1263 #[test]
1264 fn test_lm_config_default() {
1265 let cfg = LevenbergMarquardtConfig::default();
1266 assert_eq!(cfg.max_iterations, 50);
1267 assert!((cfg.cost_tolerance - 1e-6).abs() < 1e-15);
1268 assert!((cfg.damping - 1e-3).abs() < 1e-15);
1269 assert!(!cfg.use_jacobi_scaling);
1270 assert!(!cfg.compute_covariances);
1271 }
1272
1273 #[test]
1274 fn test_lm_config_builders() {
1275 let cfg = LevenbergMarquardtConfig::new()
1276 .with_max_iterations(42)
1277 .with_cost_tolerance(1e-4)
1278 .with_parameter_tolerance(1e-5)
1279 .with_gradient_tolerance(1e-6)
1280 .with_damping(1e-2)
1281 .with_damping_bounds(1e-15, 1e15)
1282 .with_damping_factors(8.0, 0.2)
1283 .with_trust_region(500.0, 0.1, 0.8)
1284 .with_min_cost_threshold(1e-12)
1285 .with_min_trust_region_radius(1e-35)
1286 .with_jacobi_scaling(true)
1287 .with_compute_covariances(true)
1288 .with_linear_solver_type(LinearSolverType::SparseQR);
1289 assert_eq!(cfg.max_iterations, 42);
1290 assert!((cfg.cost_tolerance - 1e-4).abs() < 1e-20);
1291 assert!((cfg.parameter_tolerance - 1e-5).abs() < 1e-20);
1292 assert!((cfg.gradient_tolerance - 1e-6).abs() < 1e-20);
1293 assert!((cfg.damping - 1e-2).abs() < 1e-15);
1294 assert!((cfg.damping_min - 1e-15).abs() < 1e-25);
1295 assert!((cfg.damping_max - 1e15).abs() < 1.0);
1296 assert!((cfg.damping_increase_factor - 8.0).abs() < 1e-12);
1297 assert!((cfg.damping_decrease_factor - 0.2).abs() < 1e-12);
1298 assert!((cfg.trust_region_radius - 500.0).abs() < 1e-10);
1299 assert!(cfg.min_cost_threshold.is_some());
1300 assert!(cfg.use_jacobi_scaling);
1301 assert!(cfg.compute_covariances);
1302 assert!(matches!(cfg.linear_solver_type, LinearSolverType::SparseQR));
1303 }
1304
1305 #[test]
1306 fn test_lm_for_bundle_adjustment() {
1307 let cfg = LevenbergMarquardtConfig::for_bundle_adjustment();
1308 assert!(matches!(
1309 cfg.linear_solver_type,
1310 LinearSolverType::SparseSchurComplement
1311 ));
1312 assert_eq!(cfg.max_iterations, 20);
1313 }
1314
1315 #[test]
1316 fn test_lm_print_configuration_no_panic() {
1317 LevenbergMarquardtConfig::default().print_configuration();
1318 }
1319
1320 #[test]
1321 fn test_lm_default_equals_new() {
1322 let a = LevenbergMarquardt::new();
1323 let b = LevenbergMarquardt::default();
1324 // Both should solve the same problem identically (smoke check)
1325 drop(a);
1326 drop(b);
1327 }
1328
1329 #[test]
1330 fn test_lm_with_config_method() {
1331 let cfg = LevenbergMarquardtConfig::new().with_max_iterations(7);
1332 let solver = LevenbergMarquardt::with_config(cfg);
1333 drop(solver);
1334 }
1335
1336 // -------------------------------------------------------------------------
1337 // Convergence termination paths
1338 // -------------------------------------------------------------------------
1339
1340 #[test]
1341 fn test_lm_max_iterations_termination() -> TestResult {
1342 let mut problem = rosenbrock_problem();
1343 let cfg = LevenbergMarquardtConfig::new().with_max_iterations(2);
1344 let mut solver = LevenbergMarquardt::with_config(cfg);
1345 let result = solver.optimize(&mut problem)?;
1346 assert_eq!(result.status, OptimizationStatus::MaxIterationsReached);
1347 assert!(result.iterations <= 3, "iterations={}", result.iterations);
1348 Ok(())
1349 }
1350
1351 #[test]
1352 fn test_lm_gradient_tolerance_convergence() -> TestResult {
1353 let mut problem = linear_problem(1.0);
1354 // Very loose gradient tolerance → triggers after first accepted step
1355 let cfg = LevenbergMarquardtConfig::new()
1356 .with_gradient_tolerance(1e3)
1357 .with_cost_tolerance(1e-20)
1358 .with_parameter_tolerance(1e-20);
1359 let mut solver = LevenbergMarquardt::with_config(cfg);
1360 let result = solver.optimize(&mut problem)?;
1361 assert_eq!(result.status, OptimizationStatus::GradientToleranceReached);
1362 Ok(())
1363 }
1364
1365 #[test]
1366 fn test_lm_min_cost_threshold() -> TestResult {
1367 let mut problem = rosenbrock_problem();
1368 // Set threshold very high so even initial cost triggers it
1369 let cfg = LevenbergMarquardtConfig::new()
1370 .with_min_cost_threshold(1e10)
1371 .with_cost_tolerance(1e-20)
1372 .with_gradient_tolerance(1e-20)
1373 .with_parameter_tolerance(1e-20);
1374 let mut solver = LevenbergMarquardt::with_config(cfg);
1375 let result = solver.optimize(&mut problem)?;
1376 assert_eq!(result.status, OptimizationStatus::MinCostThresholdReached);
1377 Ok(())
1378 }
1379
1380 #[test]
1381 fn test_lm_qr_solver() -> TestResult {
1382 let mut problem = rosenbrock_problem();
1383 let cfg = LevenbergMarquardtConfig::new()
1384 .with_linear_solver_type(LinearSolverType::SparseQR)
1385 .with_max_iterations(100);
1386 let mut solver = LevenbergMarquardt::with_config(cfg);
1387 let result = solver.optimize(&mut problem)?;
1388 assert!(result.final_cost < 1e-6);
1389 Ok(())
1390 }
1391
1392 #[test]
1393 fn test_lm_jacobi_scaling_enabled() -> TestResult {
1394 let mut problem = rosenbrock_problem();
1395 let cfg = LevenbergMarquardtConfig::new()
1396 .with_jacobi_scaling(true)
1397 .with_max_iterations(100);
1398 let mut solver = LevenbergMarquardt::with_config(cfg);
1399 let result = solver.optimize(&mut problem)?;
1400 assert!(result.final_cost < 1e-6);
1401 Ok(())
1402 }
1403
1404 #[test]
1405 fn test_lm_result_initial_cost_greater_than_final() -> TestResult {
1406 let mut problem = rosenbrock_problem();
1407 let mut solver = LevenbergMarquardt::new();
1408 let result = solver.optimize(&mut problem)?;
1409 assert!(
1410 result.initial_cost > result.final_cost,
1411 "initial={} final={}",
1412 result.initial_cost,
1413 result.final_cost
1414 );
1415 Ok(())
1416 }
1417
1418 #[test]
1419 fn test_lm_convergence_info_populated() -> TestResult {
1420 let mut problem = rosenbrock_problem();
1421 let mut solver = LevenbergMarquardt::new();
1422 let result = solver.optimize(&mut problem)?;
1423 assert!(result.convergence_info.is_some());
1424 Ok(())
1425 }
1426
1427 #[test]
1428 fn test_lm_iterations_positive() -> TestResult {
1429 let mut problem = rosenbrock_problem();
1430 let mut solver = LevenbergMarquardt::new();
1431 let result = solver.optimize(&mut problem)?;
1432 assert!(result.iterations > 0);
1433 Ok(())
1434 }
1435
1436 #[test]
1437 fn test_lm_timeout_config() {
1438 let cfg = LevenbergMarquardtConfig::new().with_timeout(Duration::from_secs(30));
1439 assert!(cfg.timeout.is_some());
1440 }
1441
1442 #[test]
1443 fn test_lm_config_schur_variant_and_preconditioner() {
1444 use crate::linalg::{SchurPreconditioner, SchurVariant};
1445 let cfg = LevenbergMarquardtConfig::new()
1446 .with_schur_variant(SchurVariant::Iterative)
1447 .with_schur_preconditioner(SchurPreconditioner::BlockDiagonal);
1448 assert!(matches!(cfg.schur_variant, SchurVariant::Iterative));
1449 assert!(matches!(
1450 cfg.schur_preconditioner,
1451 SchurPreconditioner::BlockDiagonal
1452 ));
1453 }
1454
1455 // -------------------------------------------------------------------------
1456 // Dense Jacobian mode dispatch
1457 // -------------------------------------------------------------------------
1458
1459 /// Exercises the `JacobianMode::Dense + _ => DenseCholeskySolver` arm of `optimize()`.
1460 /// All existing tests use `JacobianMode::Sparse`, so this branch was previously uncovered.
1461 #[test]
1462 fn test_lm_dense_cholesky_solver() -> TestResult {
1463 let mut problem = Problem::new(JacobianMode::Dense);
1464 let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
1465 let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
1466 problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1467 problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1468
1469 // Default linear solver type (SparseCholesky) with Dense mode → DenseCholeskySolver
1470 let cfg = LevenbergMarquardtConfig::new().with_max_iterations(100);
1471 let mut solver = LevenbergMarquardt::with_config(cfg);
1472 let result = solver.optimize(&mut problem)?;
1473 assert!(
1474 result.final_cost < 1e-6,
1475 "Dense Cholesky mode should converge Rosenbrock, got cost={}",
1476 result.final_cost
1477 );
1478 Ok(())
1479 }
1480
1481 /// Exercises the `JacobianMode::Dense + DenseQR` arm of `optimize()`.
1482 #[test]
1483 fn test_lm_dense_qr_solver() -> TestResult {
1484 let mut problem = Problem::new(JacobianMode::Dense);
1485 let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
1486 let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
1487 problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1488 problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1489
1490 let cfg = LevenbergMarquardtConfig::new()
1491 .with_linear_solver_type(LinearSolverType::DenseQR)
1492 .with_max_iterations(100);
1493 let mut solver = LevenbergMarquardt::with_config(cfg);
1494 let result = solver.optimize(&mut problem)?;
1495 assert!(
1496 result.final_cost < 1e-6,
1497 "Dense QR mode should converge Rosenbrock, got cost={}",
1498 result.final_cost
1499 );
1500 Ok(())
1501 }
1502
1503 // -------------------------------------------------------------------------
1504 // Covariance computation
1505 // -------------------------------------------------------------------------
1506
1507 /// Exercises the `if self.config.compute_covariances { ... }` block at convergence.
1508 /// This block was completely unreachable in prior tests.
1509 #[test]
1510 fn test_lm_compute_covariances_enabled() -> TestResult {
1511 let mut problem = rosenbrock_problem();
1512 let cfg = LevenbergMarquardtConfig::new()
1513 .with_max_iterations(100)
1514 .with_compute_covariances(true);
1515 let mut solver = LevenbergMarquardt::with_config(cfg);
1516 let result = solver.optimize(&mut problem)?;
1517 assert!(
1518 result.covariances.is_some(),
1519 "compute_covariances=true should populate result.covariances"
1520 );
1521 Ok(())
1522 }
1523
1524 // -------------------------------------------------------------------------
1525 // update_damping() direct unit tests
1526 // -------------------------------------------------------------------------
1527
1528 /// `update_damping(rho > 0)` should accept the step, decrease damping, and reset nu.
1529 #[test]
1530 fn test_update_damping_accepted_step() {
1531 let cfg = LevenbergMarquardtConfig::new()
1532 .with_damping(1e-2)
1533 .with_damping_bounds(1e-15, 1e15);
1534 let mut solver = LevenbergMarquardt::with_config(cfg);
1535 let initial_damping = solver.config.damping;
1536
1537 // rho = 0.8 > 0 → accepted branch
1538 let accepted = solver.update_damping(0.8);
1539
1540 assert!(accepted, "rho > 0 should return true (step accepted)");
1541 assert!(
1542 solver.config.damping < initial_damping,
1543 "accepted step should decrease damping: {} < {}",
1544 solver.config.damping,
1545 initial_damping
1546 );
1547 // damping_nu should be reset to 2.0 on acceptance
1548 assert!(
1549 (solver.config.damping_nu - 2.0).abs() < 1e-15,
1550 "damping_nu should be reset to 2.0 after accepted step, got {}",
1551 solver.config.damping_nu
1552 );
1553 }
1554
1555 /// `update_damping(rho <= 0)` should reject the step, increase damping, and double nu.
1556 #[test]
1557 fn test_update_damping_rejected_step() {
1558 let cfg = LevenbergMarquardtConfig::new()
1559 .with_damping(1e-2)
1560 .with_damping_bounds(1e-15, 1e15);
1561 let initial_nu = cfg.damping_nu; // default 2.0
1562 let mut solver = LevenbergMarquardt::with_config(cfg);
1563 let initial_damping = solver.config.damping;
1564
1565 // rho = -0.5 <= 0 → rejected branch
1566 let rejected = solver.update_damping(-0.5);
1567
1568 assert!(!rejected, "rho <= 0 should return false (step rejected)");
1569 assert!(
1570 solver.config.damping > initial_damping,
1571 "rejected step should increase damping: {} > {}",
1572 solver.config.damping,
1573 initial_damping
1574 );
1575 // damping_nu doubles on rejection
1576 assert!(
1577 (solver.config.damping_nu - initial_nu * 2.0).abs() < 1e-15,
1578 "damping_nu should double on rejected step: expected {}, got {}",
1579 initial_nu * 2.0,
1580 solver.config.damping_nu
1581 );
1582 }
1583
1584 // -------------------------------------------------------------------------
1585 // Untested config builder methods
1586 // -------------------------------------------------------------------------
1587
1588 /// Verifies `with_max_condition_number` and `with_min_relative_decrease` builder methods.
1589 #[test]
1590 fn test_lm_config_condition_number_and_relative_decrease() -> TestResult {
1591 let cfg = LevenbergMarquardtConfig::new()
1592 .with_max_condition_number(1e8)
1593 .with_min_relative_decrease(1e-4);
1594 let max_cond = cfg
1595 .max_condition_number
1596 .ok_or("max_condition_number should be Some")?;
1597 assert!((max_cond - 1e8).abs() < 1.0);
1598 assert!((cfg.min_relative_decrease - 1e-4).abs() < 1e-20);
1599 Ok(())
1600 }
1601
1602 // -------------------------------------------------------------------------
1603 // Observer integration
1604 // -------------------------------------------------------------------------
1605
1606 /// Verifies that `add_observer` registers an observer and `notify_complete` is called
1607 /// exactly once after optimization finishes.
1608 #[test]
1609 fn test_lm_add_observer_called_on_completion() -> TestResult {
1610 use crate::optimizer::OptObserver;
1611 use std::sync::{Arc, Mutex};
1612
1613 struct CountObserver {
1614 complete_calls: Arc<Mutex<usize>>,
1615 }
1616
1617 impl OptObserver for CountObserver {
1618 fn on_step(
1619 &self,
1620 _values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1621 _iteration: usize,
1622 ) {
1623 }
1624
1625 fn on_optimization_complete(
1626 &self,
1627 _values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1628 _iterations: usize,
1629 ) {
1630 if let Ok(mut guard) = self.complete_calls.lock() {
1631 *guard += 1;
1632 }
1633 }
1634 }
1635
1636 let call_count = Arc::new(Mutex::new(0usize));
1637 let observer = CountObserver {
1638 complete_calls: Arc::clone(&call_count),
1639 };
1640
1641 let mut problem = rosenbrock_problem();
1642 let mut solver = LevenbergMarquardt::new();
1643 solver.add_observer(observer);
1644 let _ = solver.optimize(&mut problem)?;
1645
1646 let count = *call_count
1647 .lock()
1648 .map_err(|e| format!("mutex poisoned: {e}"))?;
1649 assert_eq!(
1650 count, 1,
1651 "on_optimization_complete should be called exactly once"
1652 );
1653 Ok(())
1654 }
1655}