Skip to main content

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}