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    CovarianceOptions, Damping, DenseCholeskySolver, DenseMode, DenseQRSolver, ExplicitDenseSchur,
150    ExplicitSchurVariant, ExplicitSparseSchur, ImplicitSparseSchur, JacobianMode, LinearSolver,
151    LinearSolverType, SchurPreconditioner, SparseCholeskySolver, SparseMode, SparseQRSolver,
152    StructureAware,
153};
154use crate::optimizer::{
155    AssemblyBackend, ConvergenceParams, InitializedState, IterationStats, OptObserverVec,
156    OptimizerError, apply_negative_parameter_step, apply_parameter_step,
157};
158use faer::Mat;
159use std::time::{Duration, Instant};
160use tracing::debug;
161
162/// Policy for adapting the damping parameter λ between iterations.
163///
164/// Both policies use the same acceptance test —
165/// `ρ > `[`min_relative_decrease`](LevenbergMarquardtConfig::min_relative_decrease) —
166/// and differ only in how λ moves afterwards. They read disjoint sets of
167/// configuration fields, so the doc for each variant is also the list of knobs
168/// that have any effect under it.
169#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
170pub enum DampingUpdate {
171    /// Nielsen's rule (default).
172    ///
173    /// On an accepted step `λ ← λ · max(1/3, 1 − (2ρ − 1)³)` and ν resets to
174    /// [`damping_nu`](LevenbergMarquardtConfig::damping_nu); on a rejected step
175    /// `λ ← λ·ν` and `ν ← 2ν`, so consecutive failures escalate geometrically.
176    /// The cubic makes λ shrink smoothly with step quality rather than by a
177    /// fixed factor, which is why it is the default and what Ceres uses.
178    ///
179    /// Reads `damping_nu`. Ignores `damping_increase_factor`,
180    /// `damping_decrease_factor`, `min_step_quality`, `good_step_quality`.
181    #[default]
182    Nielsen,
183    /// Marquardt's classic three-band rule.
184    ///
185    /// `λ ← λ · damping_decrease_factor` when `ρ ≥ good_step_quality`,
186    /// `λ ← λ · damping_increase_factor` when `ρ ≤ min_step_quality` or the step
187    /// was rejected, and λ is left alone in between. Deterministic and easy to
188    /// reason about, at the cost of reacting to step quality in coarse steps.
189    ///
190    /// Reads `damping_increase_factor`, `damping_decrease_factor`,
191    /// `min_step_quality`, `good_step_quality`. Ignores `damping_nu`.
192    ///
193    /// Note that at the default `min_step_quality` of 0.0 the third branch is
194    /// unreachable, because a step with `ρ ≤ 0` is rejected before it is
195    /// consulted. Raise it above `min_relative_decrease` (0.25 is the textbook
196    /// value) to make the middle band do anything.
197    Marquardt,
198}
199
200/// Configuration parameters for the Levenberg-Marquardt optimizer.
201///
202/// Controls the adaptive damping strategy, convergence criteria, and numerical stability
203/// enhancements for the Levenberg-Marquardt algorithm.
204///
205/// # Builder Pattern
206///
207/// All configuration options can be set using the builder pattern:
208///
209/// ```
210/// use apex_solver::optimizer::levenberg_marquardt::LevenbergMarquardtConfig;
211///
212/// let config = LevenbergMarquardtConfig::new()
213///     .with_max_iterations(100)
214///     .with_damping(1e-3)
215///     .with_damping_bounds(1e-12, 1e12)
216///     .with_jacobi_scaling(true);
217/// ```
218///
219/// # Damping Parameter Behavior
220///
221/// The damping parameter λ controls the trade-off between Gauss-Newton and gradient descent:
222///
223/// - **Initial damping** (`damping`): Starting value (default: 1e-4)
224/// - **Damping bounds** (`damping_min`, `damping_max`): Valid range (default: 1e-12 to 1e12)
225/// - **Adaptation**: Automatically adjusted based on step quality using Nielsen's formula
226///
227/// # Convergence Criteria
228///
229/// The optimizer terminates when ANY of the following conditions is met:
230///
231/// - **Cost tolerance**: `|cost_k - cost_{k-1}| < cost_tolerance`
232/// - **Parameter tolerance**: `||step|| < parameter_tolerance`
233/// - **Gradient tolerance**: `||J^T·r|| < gradient_tolerance`
234/// - **Maximum iterations**: `iteration >= max_iterations`
235/// - **Timeout**: `elapsed_time >= timeout`
236///
237/// # See Also
238///
239/// - [`LevenbergMarquardt`] - The solver that uses this configuration
240/// - [`GaussNewtonConfig`](crate::optimizer::gauss_newton::GaussNewtonConfig) - Undamped variant
241/// - [`DogLegConfig`](crate::optimizer::dog_leg::DogLegConfig) - Trust region alternative
242#[derive(Clone)]
243pub struct LevenbergMarquardtConfig {
244    /// Type of linear solver for the linear systems
245    pub linear_solver_type: LinearSolverType,
246    /// Maximum number of iterations
247    pub max_iterations: usize,
248    /// Convergence tolerance for cost function
249    pub cost_tolerance: f64,
250    /// Convergence tolerance for parameter updates
251    pub parameter_tolerance: f64,
252    /// Convergence tolerance for gradient norm
253    pub gradient_tolerance: f64,
254    /// Timeout duration
255    pub timeout: Option<Duration>,
256    /// Initial damping parameter
257    pub damping: f64,
258    /// Minimum damping parameter
259    pub damping_min: f64,
260    /// Maximum damping parameter
261    pub damping_max: f64,
262    /// Factor λ is multiplied by when a step is rejected, or when
263    /// `ρ <= min_step_quality`.
264    ///
265    /// Read only under [`DampingUpdate::Marquardt`]. Default: 10.0
266    pub damping_increase_factor: f64,
267    /// Factor λ is multiplied by when `ρ >= good_step_quality`.
268    ///
269    /// Read only under [`DampingUpdate::Marquardt`]. Default: 0.3
270    pub damping_decrease_factor: f64,
271    /// Nielsen's ν: the initial and reset value of the rejection escalation
272    /// factor.
273    ///
274    /// Read only under [`DampingUpdate::Nielsen`]. Default: 2.0
275    pub damping_nu: f64,
276    /// Stop after this many consecutive rejected steps.
277    pub max_consecutive_rejected_steps: usize,
278    /// Step quality at or below which λ is increased even though the step was
279    /// accepted.
280    ///
281    /// Read only under [`DampingUpdate::Marquardt`]. At the default of 0.0 this
282    /// band is unreachable — every step with `ρ <= 0` has already been rejected
283    /// by [`min_relative_decrease`](Self::min_relative_decrease). Default: 0.0
284    pub min_step_quality: f64,
285    /// Step quality at or above which λ is decreased.
286    ///
287    /// Read only under [`DampingUpdate::Marquardt`]. Default: 0.75
288    pub good_step_quality: f64,
289    /// Lower clamp on the Marquardt damping diagonal (Ceres' `min_lm_diagonal`).
290    ///
291    /// The augmented system is `(JᵀJ + λ·D)·dx = −Jᵀr` with
292    /// `D_jj = clamp(JᵀJ_jj, min_diagonal, max_diagonal)`. Bounding `D` away
293    /// from zero guarantees that even a direction with negligible curvature is
294    /// damped. Setting `min_diagonal == max_diagonal == 1.0` gives `D = I`, i.e.
295    /// classic uniform `λI` damping.
296    ///
297    /// Default: 1e-6 (Ceres-compatible). See [`Damping`].
298    pub min_diagonal: f64,
299    /// Upper clamp on the Marquardt damping diagonal (Ceres' `max_lm_diagonal`).
300    ///
301    /// Keeps one very stiff column from dominating the damped system.
302    ///
303    /// Default: 1e32 (Ceres-compatible). See [`Damping`].
304    pub max_diagonal: f64,
305    /// How λ is adapted between iterations.
306    ///
307    /// Default: [`DampingUpdate::Nielsen`]
308    pub damping_update: DampingUpdate,
309    /// Minimum objective function cutoff (optional early termination)
310    ///
311    /// If set, optimization terminates when cost falls below this threshold.
312    /// Useful for early stopping when a "good enough" solution is acceptable.
313    ///
314    /// Default: None (disabled)
315    pub min_cost_threshold: Option<f64>,
316    /// Maximum condition number for Jacobian matrix (optional check)
317    ///
318    /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
319    /// threshold and terminates with IllConditionedJacobian status.
320    /// Note: Computing condition number is expensive, so this is disabled by default.
321    ///
322    /// Default: None (disabled)
323    pub max_condition_number: Option<f64>,
324    /// Minimum relative cost decrease for step acceptance
325    ///
326    /// Used in computing step quality (rho = actual_reduction / predicted_reduction).
327    /// Steps with rho < min_relative_decrease are rejected. Matches Ceres Solver's
328    /// min_relative_decrease parameter.
329    ///
330    /// Default: 1e-3 (Ceres-compatible)
331    pub min_relative_decrease: f64,
332    /// Use Jacobi column scaling (preconditioning)
333    ///
334    /// When enabled, normalizes Jacobian columns by their L2 norm before solving.
335    /// This can improve convergence for problems with mixed parameter scales
336    /// (e.g., positions in meters + angles in radians) but adds ~5-10% overhead.
337    ///
338    /// Default: false (to avoid performance overhead and faster convergence)
339    pub use_jacobi_scaling: bool,
340    /// Compute per-variable covariance matrices (uncertainty estimation)
341    ///
342    /// When enabled, computes covariance by re-linearizing the problem at the
343    /// solution and inverting the Gauss-Newton Hessian `H = JᵀJ` after
344    /// convergence. The full covariance matrix is extracted into per-variable
345    /// blocks stored in both Variable structs and SolverResult.
346    ///
347    /// Scaled (σ̂²·H⁻¹) versus unscaled (H⁻¹) covariance, the factorization
348    /// algorithm, and the pseudo-inverse cutoff are configured through
349    /// [`covariance_options`](Self::covariance_options).
350    ///
351    /// Default: false (to avoid performance overhead)
352    pub compute_covariances: bool,
353    /// Options for covariance estimation when `compute_covariances` is enabled.
354    ///
355    /// Defaults to unscaled `H⁻¹` via sparse Cholesky — the correct choice when
356    /// residuals are whitened by their measurement information matrix. Set
357    /// `apply_variance_scaling` for unweighted least squares, where the noise
358    /// scale `σ̂² = 2·cost/(m−n)` must be estimated from the fit. See
359    /// [`CovarianceOptions`](crate::linalg::covariance::CovarianceOptions).
360    pub covariance_options: CovarianceOptions,
361    /// [`ExplicitSparseSchur`] sub-variant (for
362    /// [`LinearSolverType::ExplicitSparseSchur`]).
363    ///
364    /// - [`ExplicitSchurVariant::Sparse`]: form S, sparse Cholesky. Most
365    ///   accurate; memory is O(kept_dof²).
366    /// - [`ExplicitSchurVariant::Chunked`]: form S straight from `J`, never
367    ///   materializing `JᵀJ`. Same algebra, far less memory on large problems.
368    /// - [`ExplicitSchurVariant::Iterative`]: form S, then PCG instead of
369    ///   Cholesky.
370    ///
371    /// To never form `S` at all, use
372    /// [`LinearSolverType::ImplicitSparseSchur`] instead — a different
373    /// solver, not a variant of this one.
374    ///
375    /// Default: `Sparse`
376    pub schur_variant: ExplicitSchurVariant,
377    /// Preconditioner for the PCG used by
378    /// [`LinearSolverType::ImplicitSparseSchur`] and by
379    /// [`ExplicitSchurVariant::Iterative`].
380    ///
381    /// - [`SchurPreconditioner::None`]: unpreconditioned CG.
382    /// - [`SchurPreconditioner::BlockDiagonal`]: block diagonal of `H_kk`.
383    /// - [`SchurPreconditioner::SchurJacobi`]: block diagonal of `S` itself —
384    ///   Ceres's `SCHUR_JACOBI`, and usually the best convergence.
385    ///
386    /// Ignored by the direct variants, which factorize instead of iterating.
387    ///
388    /// Default: `SchurJacobi`
389    pub schur_preconditioner: SchurPreconditioner,
390    /// Maximum PCG iterations per linear solve, for the iterative variants.
391    ///
392    /// Default: 200 (Ceres's default)
393    pub schur_cg_max_iterations: usize,
394    /// Relative residual tolerance ending a PCG solve, for the iterative
395    /// variants.
396    ///
397    /// Default: 1e-6
398    pub schur_cg_tolerance: f64,
399    /// Forcing-sequence parameter η for the Schur PCG paths (Ceres's
400    /// `Solver::Options::eta`).
401    ///
402    /// PCG stops once the quadratic model's relative improvement per iteration
403    /// falls below `η/i`, which is what makes early Newton steps cheap. `0.0`
404    /// disables the rule, leaving only `schur_cg_tolerance` and the iteration
405    /// cap — ask for that when you want an exact linear solve.
406    ///
407    /// Default: 0.1
408    pub schur_cg_q_tolerance: f64,
409    // Note: Visualization is now handled via the observer pattern.
410    // Use `solver.add_observer(RerunObserver::new(true)?)` to enable visualization.
411    // This provides cleaner separation of concerns and allows multiple observers.
412}
413
414impl Default for LevenbergMarquardtConfig {
415    fn default() -> Self {
416        Self {
417            linear_solver_type: LinearSolverType::default(),
418            // Ceres Solver default: 50 (changed from 100 for compatibility)
419            max_iterations: 50,
420            // Ceres Solver default: 1e-6 (changed from 1e-8 for compatibility)
421            cost_tolerance: 1e-6,
422            // Ceres Solver default: 1e-8 (unchanged)
423            parameter_tolerance: 1e-8,
424            // Ceres Solver default: 1e-10 (changed from 1e-8 for compatibility)
425            // Note: Typically should be 1e-4 * cost_tolerance per Ceres docs
426            gradient_tolerance: 1e-10,
427            timeout: None,
428            // Ceres-equivalent: its default `initial_trust_region_radius` of 1e4
429            // corresponds to λ = 1/radius = 1e-4. The previous 1e-3 was hand-tuned
430            // against uniform λI damping, where λ alone had to absorb the problem
431            // scale; with the Marquardt diagonal the scale lives in D instead.
432            damping: 1e-4,
433            damping_min: 1e-12,
434            damping_max: 1e12,
435            damping_increase_factor: 10.0,
436            damping_decrease_factor: 0.3,
437            damping_nu: 2.0,
438            max_consecutive_rejected_steps: 5,
439            min_step_quality: 0.0,
440            good_step_quality: 0.75,
441            min_diagonal: 1e-6,
442            max_diagonal: 1e32,
443            damping_update: DampingUpdate::default(),
444            // New Ceres-compatible parameters
445            min_cost_threshold: None,
446            max_condition_number: None,
447            min_relative_decrease: 1e-3,
448            // Existing parameters
449            // Jacobi scaling disabled by default for Schur solvers (incompatible with block structure)
450            // Enable manually for Cholesky/QR solvers on mixed-scale problems
451            use_jacobi_scaling: false,
452            compute_covariances: false,
453            covariance_options: CovarianceOptions::default(),
454            // Schur complement parameters
455            schur_variant: ExplicitSchurVariant::default(),
456            schur_preconditioner: SchurPreconditioner::default(),
457            schur_cg_max_iterations: 200,
458            schur_cg_tolerance: 1e-6,
459            schur_cg_q_tolerance: crate::linalg::schur::DEFAULT_ETA,
460        }
461    }
462}
463
464impl LevenbergMarquardtConfig {
465    /// Create a new Levenberg-Marquardt configuration with default values.
466    pub fn new() -> Self {
467        Self::default()
468    }
469
470    /// Set the linear solver type
471    pub fn with_linear_solver_type(mut self, linear_solver_type: LinearSolverType) -> Self {
472        self.linear_solver_type = linear_solver_type;
473        self
474    }
475
476    /// Set the maximum number of iterations
477    pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
478        self.max_iterations = max_iterations;
479        self
480    }
481
482    /// Set the cost tolerance
483    pub fn with_cost_tolerance(mut self, cost_tolerance: f64) -> Self {
484        self.cost_tolerance = cost_tolerance;
485        self
486    }
487
488    /// Set the parameter tolerance
489    pub fn with_parameter_tolerance(mut self, parameter_tolerance: f64) -> Self {
490        self.parameter_tolerance = parameter_tolerance;
491        self
492    }
493
494    /// Set the gradient tolerance
495    pub fn with_gradient_tolerance(mut self, gradient_tolerance: f64) -> Self {
496        self.gradient_tolerance = gradient_tolerance;
497        self
498    }
499
500    /// Set the timeout duration
501    pub fn with_timeout(mut self, timeout: Duration) -> Self {
502        self.timeout = Some(timeout);
503        self
504    }
505
506    /// Set the initial damping parameter.
507    pub fn with_damping(mut self, damping: f64) -> Self {
508        self.damping = damping;
509        self
510    }
511
512    /// Set the damping parameter bounds.
513    pub fn with_damping_bounds(mut self, min: f64, max: f64) -> Self {
514        self.damping_min = min;
515        self.damping_max = max;
516        self
517    }
518
519    /// Set the damping adjustment factors used by [`DampingUpdate::Marquardt`].
520    ///
521    /// These have no effect under the default [`DampingUpdate::Nielsen`] policy,
522    /// which derives both directions from ρ; pair this with
523    /// [`with_damping_update`](Self::with_damping_update).
524    pub fn with_damping_factors(mut self, increase: f64, decrease: f64) -> Self {
525        self.damping_increase_factor = increase;
526        self.damping_decrease_factor = decrease;
527        self
528    }
529
530    /// Select the policy that adapts λ between iterations.
531    pub fn with_damping_update(mut self, damping_update: DampingUpdate) -> Self {
532        self.damping_update = damping_update;
533        self
534    }
535
536    /// Set the clamp range for the Marquardt damping diagonal.
537    ///
538    /// The augmented system becomes `(JᵀJ + λ·D)·dx = −Jᵀr` with
539    /// `D_jj = clamp(JᵀJ_jj, min, max)`. Pass `(1.0, 1.0)` for classic uniform
540    /// `λI` damping.
541    pub fn with_diagonal_bounds(mut self, min: f64, max: f64) -> Self {
542        self.min_diagonal = min;
543        self.max_diagonal = max;
544        self
545    }
546
547    /// Set the step-quality band used by [`DampingUpdate::Marquardt`].
548    ///
549    /// λ is decreased at or above `good_quality` and increased at or below
550    /// `min_quality`; in between it is left alone. No effect under
551    /// [`DampingUpdate::Nielsen`].
552    pub fn with_step_quality(mut self, min_quality: f64, good_quality: f64) -> Self {
553        self.min_step_quality = min_quality;
554        self.good_step_quality = good_quality;
555        self
556    }
557
558    /// Set the trust region parameters.
559    ///
560    /// `radius` is ignored: this Levenberg-Marquardt implementation controls step
561    /// size through `damping` (Nielsen's update rule), not through a trust-region
562    /// radius, and the radius was never read by the algorithm. See
563    /// <https://github.com/amin-abouee/apex-solver/issues/40>.
564    #[deprecated(
565        since = "1.5.0",
566        note = "the `radius` argument is ignored — Levenberg-Marquardt here is damping-controlled. \
567                Use `with_damping`/`with_damping_bounds` to control step size, and \
568                `with_step_quality` for the quality thresholds."
569    )]
570    pub fn with_trust_region(self, _radius: f64, min_quality: f64, good_quality: f64) -> Self {
571        self.with_step_quality(min_quality, good_quality)
572    }
573
574    /// Set minimum objective function cutoff for early termination.
575    ///
576    /// When set, optimization terminates with MinCostThresholdReached status
577    /// if the cost falls below this threshold. Useful for early stopping when
578    /// a "good enough" solution is acceptable.
579    pub fn with_min_cost_threshold(mut self, min_cost: f64) -> Self {
580        self.min_cost_threshold = Some(min_cost);
581        self
582    }
583
584    /// No-op, retained for backward compatibility.
585    ///
586    /// This value was never read by the algorithm. Unlike Ceres, where
587    /// `min_trust_region_radius` terminates the solve when the radius collapses,
588    /// this implementation is damping-controlled and has no radius to compare
589    /// against. See <https://github.com/amin-abouee/apex-solver/issues/40>.
590    #[deprecated(
591        since = "1.5.0",
592        note = "no-op: Levenberg-Marquardt here is damping-controlled and never read this value. \
593                Use `with_damping_bounds` to bound the damping instead."
594    )]
595    pub fn with_min_trust_region_radius(self, _min_radius: f64) -> Self {
596        self
597    }
598
599    /// Set maximum condition number for Jacobian matrix.
600    ///
601    /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
602    /// threshold and terminates with IllConditionedJacobian status.
603    /// Note: Computing condition number is expensive, disabled by default.
604    pub fn with_max_condition_number(mut self, max_cond: f64) -> Self {
605        self.max_condition_number = Some(max_cond);
606        self
607    }
608
609    /// Set minimum relative cost decrease for step acceptance.
610    ///
611    /// Steps with rho = (actual_reduction / predicted_reduction) below this
612    /// threshold are rejected. Default: 1e-3 (Ceres-compatible)
613    pub fn with_min_relative_decrease(mut self, min_decrease: f64) -> Self {
614        self.min_relative_decrease = min_decrease;
615        self
616    }
617
618    /// Enable or disable Jacobi column scaling (preconditioning).
619    ///
620    /// When enabled, normalizes Jacobian columns by their L2 norm before solving.
621    /// Can improve convergence for mixed-scale problems but adds ~5-10% overhead.
622    pub fn with_jacobi_scaling(mut self, use_jacobi_scaling: bool) -> Self {
623        self.use_jacobi_scaling = use_jacobi_scaling;
624        self
625    }
626
627    /// Enable or disable covariance computation (uncertainty estimation).
628    ///
629    /// When enabled, computes the full covariance matrix by inverting the Hessian
630    /// after convergence, then extracts per-variable covariance blocks.
631    pub fn with_compute_covariances(mut self, compute_covariances: bool) -> Self {
632        self.compute_covariances = compute_covariances;
633        self
634    }
635
636    /// Set the options used for covariance estimation.
637    ///
638    /// Controls scaled (`σ̂²·H⁻¹`) versus unscaled (`H⁻¹`) covariance, the
639    /// factorization algorithm (sparse Cholesky or dense SVD pseudo-inverse),
640    /// and the singular-value cutoff. Only takes effect when
641    /// `compute_covariances` is enabled.
642    pub fn with_covariance_options(mut self, options: CovarianceOptions) -> Self {
643        self.covariance_options = options;
644        self
645    }
646
647    /// Set the [`ExplicitSparseSchur`] sub-variant (ignored by
648    /// [`LinearSolverType::ImplicitSparseSchur`] and
649    /// [`LinearSolverType::ExplicitDenseSchur`]).
650    pub fn with_schur_variant(mut self, variant: ExplicitSchurVariant) -> Self {
651        self.schur_variant = variant;
652        self
653    }
654
655    /// Set Schur complement preconditioner
656    pub fn with_schur_preconditioner(mut self, preconditioner: SchurPreconditioner) -> Self {
657        self.schur_preconditioner = preconditioner;
658        self
659    }
660
661    /// PCG budget for the iterative Schur variants.
662    pub fn with_schur_cg_params(mut self, max_iterations: usize, tolerance: f64) -> Self {
663        self.schur_cg_max_iterations = max_iterations;
664        self.schur_cg_tolerance = tolerance;
665        self
666    }
667
668    /// Set the PCG forcing-sequence parameter η. `0.0` disables the
669    /// quadratic-model stopping rule; see [`Self::schur_cg_q_tolerance`].
670    pub fn with_schur_cg_q_tolerance(mut self, q_tolerance: f64) -> Self {
671        self.schur_cg_q_tolerance = q_tolerance;
672        self
673    }
674
675    /// Configuration optimized for bundle adjustment problems.
676    ///
677    /// This preset uses settings tuned for large-scale bundle adjustment:
678    /// - **Schur complement solver**, direct sparse Cholesky on the reduced
679    ///   system — the fastest variant measured on every BAL dataset
680    /// - **Schur-Jacobi preconditioner**, used if an iterative variant is
681    ///   selected through [`Self::with_schur_variant`]
682    /// - **Moderate initial damping** (1e-3) - not too aggressive
683    /// - **20 max iterations** (early stop once RMSE is below a pixel)
684    /// - **Very tight tolerances** matching Ceres Solver for accurate reconstruction
685    ///
686    /// For problems where the reduced system `S` is too large to materialize,
687    /// switch to [`LinearSolverType::ImplicitSparseSchur`] (matrix-free) or
688    /// [`ExplicitSchurVariant::Chunked`] (never forms `JᵀJ`).
689    ///
690    /// This configuration matches Ceres Solver's recommended BA settings and
691    /// should achieve similar convergence quality.
692    ///
693    /// Note this preset selects the *solver*, not the *variables*: no landmark
694    /// is eliminated until it is marked with
695    /// [`Problem::mark_for_elimination`](crate::core::problem::Problem::mark_for_elimination).
696    /// A BA problem without marks solves correctly but without any Schur
697    /// reduction.
698    ///
699    /// # Example
700    ///
701    /// ```
702    /// use apex_solver::optimizer::levenberg_marquardt::LevenbergMarquardtConfig;
703    ///
704    /// let config = LevenbergMarquardtConfig::for_bundle_adjustment();
705    /// ```
706    pub fn for_bundle_adjustment() -> Self {
707        Self::default()
708            .with_linear_solver_type(LinearSolverType::ImplicitSparseSchur)
709            // Measured over the four BAL datasets, 3 runs each (time, and
710            // final RMSE against the exact reduced solve):
711            //
712            //   dataset     implicit          explicit/Sparse   ΔRMSE
713            //   Ladybug     18.76s  0.876538  75.24s  0.875283  +0.14%
714            //   Trafalgar    6.27s  0.798081   2.51s  0.808522  -1.29%
715            //   Dubrovnik   31.34s  0.768611  41.91s  0.787517  -2.40%
716            //   Venice      20.23s  0.752080  51.15s  0.747589  +0.60%
717            //
718            // 2.2x faster in total, better RMSE on two datasets and at most
719            // 0.6% worse on the other two, and it forms neither `JᵀJ` nor `S`
720            // so it is the only one of the four that keeps scaling. It loses
721            // only on Trafalgar, the smallest set, where the absolute cost is
722            // ~4s against ~56s saved on Ladybug.
723            //
724            // `ExplicitSparseSchur` remains the choice when the reduced solve
725            // must be *exact* — its step does not depend on a tolerance, and
726            // it is the reference every other path is measured against.
727            .with_schur_variant(ExplicitSchurVariant::Sparse)
728            .with_schur_preconditioner(SchurPreconditioner::SchurJacobi)
729            .with_damping(1e-3) // Moderate initial damping (Ceres default)
730            .with_max_iterations(20) // Reduced for early stop when RMSE < 1px
731            // Match Ceres tolerances for faster convergence
732            .with_cost_tolerance(1e-6) // Ceres function_tolerance (was 1e-12)
733            .with_parameter_tolerance(1e-8) // Ceres parameter_tolerance (was 1e-14)
734            .with_gradient_tolerance(1e-10) // Relaxed (was 1e-16)
735    }
736
737    /// Configuration tuned for 2D pose-graph odometry.
738    ///
739    /// Sweep winner on the 2D suite (M3500/mit/city10000/ring) and cubicle:
740    /// scalar λ·I damping (`min = max = 1` diagonal bounds, GTSAM's default)
741    /// is 2–7× faster than λ·diag(H) at unchanged cost there. On cubicle it
742    /// pairs with [`RepairStrategy::UnitWeight`](crate::core::noise::RepairStrategy)
743    /// for a 6.9× lower unweighted cost.
744    ///
745    /// # Example
746    ///
747    /// ```
748    /// use apex_solver::optimizer::levenberg_marquardt::LevenbergMarquardtConfig;
749    ///
750    /// let config = LevenbergMarquardtConfig::for_2d_pose_graph();
751    /// ```
752    pub fn for_2d_pose_graph() -> Self {
753        Self::default()
754            .with_damping(1e-4)
755            .with_diagonal_bounds(1.0, 1.0)
756    }
757
758    /// Configuration tuned for large 3D pose graphs with early cost plateaus
759    /// (parking-garage class).
760    ///
761    /// On such graphs the weighted objective plateaus while the unweighted
762    /// cost is already accurate, so looser 1e-3 cost/parameter tolerances stop
763    /// the plateau early (~2× faster at identical unweighted cost), and a
764    /// smaller initial damping (1e-5) keeps the accurate basin. Like
765    /// [`Self::for_2d_pose_graph`], this encodes a measured sweep winner
766    /// rather than a guess — see the odometry benchmark harness.
767    ///
768    /// # Example
769    ///
770    /// ```
771    /// use apex_solver::optimizer::levenberg_marquardt::LevenbergMarquardtConfig;
772    ///
773    /// let config = LevenbergMarquardtConfig::for_large_3d_pose_graph();
774    /// ```
775    pub fn for_large_3d_pose_graph() -> Self {
776        Self::default()
777            .with_damping(1e-5)
778            .with_cost_tolerance(1e-3)
779            .with_parameter_tolerance(1e-3)
780    }
781
782    /// Enable real-time visualization (graphical debugging).
783    ///
784    /// When enabled, optimization progress is logged to a Rerun viewer with:
785    /// - Time series plots of cost, gradient norm, damping, step quality
786    /// - Sparse Hessian matrix visualization as heat map
787    /// - Gradient vector visualization
788    /// - Real-time manifold state updates (for SE2/SE3 problems)
789    ///
790    /// **Note:** Requires the `visualization` feature to be enabled in `Cargo.toml`.
791    /// Use `verbose` for terminal logging.
792    ///
793    /// # Arguments
794    ///
795    /// * `enable` - Whether to enable visualization
796    // Note: with_visualization() method has been removed.
797    // Use the observer pattern instead:
798    //   let mut solver = LevenbergMarquardt::with_config(config);
799    //   solver.add_observer(RerunObserver::new(true)?);
800    // This provides cleaner separation and allows multiple observers.
801    ///   Print configuration parameters (verbose mode only)
802    pub fn print_configuration(&self) {
803        debug!(
804            "Configuration:\n  Solver:        Levenberg-Marquardt\n  Linear solver: {:?}\n  Schur variant: {:?}\n  Schur preconditioner: {:?}\n  Schur CG budget:     {} iters @ {:.2e}\n  Convergence Criteria:\n  Max iterations:      {}\n  Cost tolerance:      {:.2e}\n  Parameter tolerance: {:.2e}\n  Gradient tolerance:  {:.2e}\n  Timeout:             {:?}\n  Damping Parameters:\n  Initial damping:     {:.2e}\n  Damping range:       [{:.2e}, {:.2e}]\n  Increase factor:     {:.2}\n  Decrease factor:     {:.2}\n  Step Quality:\n  Min step quality:    {:.2}\n  Good step quality:   {:.2}\n  Numerical Settings:\n  Jacobi scaling:      {}\n  Compute covariances: {}",
805            self.linear_solver_type,
806            self.schur_variant,
807            self.schur_preconditioner,
808            self.schur_cg_max_iterations,
809            self.schur_cg_tolerance,
810            self.max_iterations,
811            self.cost_tolerance,
812            self.parameter_tolerance,
813            self.gradient_tolerance,
814            self.timeout,
815            self.damping,
816            self.damping_min,
817            self.damping_max,
818            self.damping_increase_factor,
819            self.damping_decrease_factor,
820            self.min_step_quality,
821            self.good_step_quality,
822            if self.use_jacobi_scaling {
823                "enabled"
824            } else {
825                "disabled"
826            },
827            if self.compute_covariances {
828                "enabled"
829            } else {
830                "disabled"
831            }
832        );
833    }
834}
835
836/// Result from step computation
837struct StepResult {
838    step: Mat<f64>,
839    gradient_norm: f64,
840    predicted_reduction: f64,
841}
842
843/// Result from step evaluation
844struct StepEvaluation {
845    accepted: bool,
846    cost_reduction: f64,
847    rho: f64,
848}
849
850/// Levenberg-Marquardt solver for nonlinear least squares optimization.
851///
852/// Implements the damped Gauss-Newton method with adaptive damping parameter λ that
853/// interpolates between Gauss-Newton and gradient descent based on step quality.
854///
855/// # Algorithm
856///
857/// At each iteration k:
858/// 1. Compute residual `r(xₖ)` and Jacobian `J(xₖ)`
859/// 2. Solve augmented system: `(J^T·J + λI)·h = -J^T·r`
860/// 3. Evaluate step quality: `ρ = (actual reduction) / (predicted reduction)`
861/// 4. If `ρ > 0`: Accept step and update `xₖ₊₁ = xₖ ⊕ h`, decrease λ
862/// 5. If `ρ ≤ 0`: Reject step (keep `xₖ₊₁ = xₖ`), increase λ
863/// 6. Check convergence criteria
864///
865/// The damping parameter λ is updated using Nielsen's smooth formula:
866/// `λₖ₊₁ = λₖ · max(1/3, 1 - (2ρ - 1)³)` for accepted steps,
867/// or `λₖ₊₁ = λₖ · ν` (with increasing ν) for rejected steps.
868///
869/// # Examples
870///
871/// ```no_run
872/// use apex_solver::optimizer::levenberg_marquardt::{LevenbergMarquardtConfig, LevenbergMarquardt};
873/// use apex_solver::linalg::LinearSolverType;
874///
875/// # fn main() {
876/// let config = LevenbergMarquardtConfig::new()
877///     .with_max_iterations(100)
878///     .with_damping(1e-3)
879///     .with_damping_bounds(1e-12, 1e12)
880///     .with_jacobi_scaling(true);
881///
882/// let mut solver = LevenbergMarquardt::with_config(config);
883/// # }
884/// ```
885///
886/// # See Also
887///
888/// - [`LevenbergMarquardtConfig`] - Configuration options
889/// - [`GaussNewton`](crate::optimizer::GaussNewton) - Undamped variant (faster but less robust)
890/// - [`DogLeg`](crate::optimizer::DogLeg) - Alternative trust region method
891pub struct LevenbergMarquardt {
892    config: LevenbergMarquardtConfig,
893    jacobi_scaling: Option<Vec<f64>>,
894    observers: OptObserverVec,
895    /// Run state: the live damping λ, seeded from `config.damping` at the start
896    /// of every solve.
897    ///
898    /// λ and ν are iteration state, not configuration. Keeping them here rather
899    /// than mutating `config` in place means a second `optimize()` call on the
900    /// same solver starts from the configured λ instead of inheriting whatever
901    /// the previous run happened to end on.
902    damping: f64,
903    /// Run state: Nielsen's ν, seeded from `config.damping_nu`.
904    damping_nu: f64,
905}
906
907impl Default for LevenbergMarquardt {
908    fn default() -> Self {
909        Self::new()
910    }
911}
912
913impl LevenbergMarquardt {
914    /// Create a new Levenberg-Marquardt solver with default configuration.
915    pub fn new() -> Self {
916        Self::with_config(LevenbergMarquardtConfig::default())
917    }
918
919    /// Create a new Levenberg-Marquardt solver with the given configuration.
920    pub fn with_config(config: LevenbergMarquardtConfig) -> Self {
921        Self {
922            damping: config.damping,
923            damping_nu: config.damping_nu,
924            config,
925            jacobi_scaling: None,
926            observers: OptObserverVec::new(),
927        }
928    }
929
930    /// Add an observer to monitor optimization progress.
931    ///
932    /// Observers are notified at each iteration with the current variable values.
933    /// This enables real-time visualization, logging, metrics collection, etc.
934    ///
935    /// # Arguments
936    ///
937    /// * `observer` - Any type implementing `OptObserver`
938    ///
939    /// # Examples
940    ///
941    /// ```no_run
942    /// use apex_solver::{LevenbergMarquardt, LevenbergMarquardtConfig};
943    /// # use apex_solver::core::problem::Problem;
944    /// # use std::collections::HashMap;
945    ///
946    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
947    /// let mut solver = LevenbergMarquardt::new();
948    ///
949    /// #[cfg(feature = "visualization")]
950    /// {
951    ///     use apex_solver::observers::RerunObserver;
952    ///     let rerun_observer = RerunObserver::new(true)?;
953    ///     solver.add_observer(rerun_observer);
954    /// }
955    ///
956    /// // ... optimize ...
957    /// # Ok(())
958    /// # }
959    /// ```
960    pub fn add_observer(&mut self, observer: impl crate::optimizer::OptObserver + 'static) {
961        self.observers.add(observer);
962    }
963
964    /// Adapt λ to the observed step quality `rho`.
965    ///
966    /// `accepted` is decided by the caller against
967    /// [`min_relative_decrease`](LevenbergMarquardtConfig::min_relative_decrease)
968    /// and passed in, so the acceptance threshold and the damping policy stay
969    /// independent — Ceres' `TrustRegionMinimizer` separates them the same way.
970    ///
971    /// Which fields are read depends on
972    /// [`damping_update`](LevenbergMarquardtConfig::damping_update); see
973    /// [`DampingUpdate`].
974    fn update_damping(&mut self, rho: f64, accepted: bool) {
975        match self.config.damping_update {
976            DampingUpdate::Nielsen => {
977                if accepted {
978                    // λ ← λ · max(1/3, 1 − (2ρ − 1)³), and ν resets.
979                    // Reference: Introduction to Optimization and Data Fitting,
980                    // Algorithm 6.18.
981                    let coff = 2.0 * rho - 1.0;
982                    self.damping *= (1.0_f64 / 3.0).max(1.0 - coff * coff * coff);
983                    self.damping_nu = self.config.damping_nu;
984                } else {
985                    self.damping *= self.damping_nu;
986                    self.damping_nu *= 2.0;
987                }
988            }
989            DampingUpdate::Marquardt => {
990                if !accepted {
991                    // A rejected step must always tighten the damping, whatever
992                    // ρ was: leaving λ unchanged would recompute the identical
993                    // step next iteration and stall.
994                    self.damping *= self.config.damping_increase_factor;
995                } else if rho >= self.config.good_step_quality {
996                    self.damping *= self.config.damping_decrease_factor;
997                } else if rho <= self.config.min_step_quality {
998                    self.damping *= self.config.damping_increase_factor;
999                }
1000            }
1001        }
1002        self.damping = self
1003            .damping
1004            .clamp(self.config.damping_min, self.config.damping_max);
1005    }
1006
1007    /// Compute optimization step by solving the augmented system (generic over assembly mode).
1008    fn compute_step_generic<M: AssemblyBackend>(
1009        &self,
1010        residuals: &Mat<f64>,
1011        scaled_jacobian: &M::Jacobian,
1012        linear_solver: &mut dyn LinearSolver<M>,
1013    ) -> Result<StepResult, OptimizerError> {
1014        // Solve the augmented equation (J̃ᵀJ̃ + λ·D)·dx̃ = −J̃ᵀr.
1015        let damping = Damping::new(
1016            self.damping,
1017            self.config.min_diagonal,
1018            self.config.max_diagonal,
1019        )?;
1020        let scaled_step = linear_solver
1021            .solve_augmented_equation(residuals, scaled_jacobian, &damping)
1022            .map_err(|e| OptimizerError::LinearSolveFailed(e.to_string()).log_with_source(e))?;
1023
1024        // Get the cached gradient (Jᵀr) and un-damped Hessian (JᵀJ) from the solver
1025        let gradient = linear_solver.get_gradient().ok_or_else(|| {
1026            OptimizerError::NumericalInstability("Gradient not available".into()).log()
1027        })?;
1028        // `gradient` is the (possibly Jacobi-scaled) gradient against
1029        // `scaled_jacobian`; report `gradient_tolerance`'s convergence check
1030        // against the un-scaled one (GitHub #57). `predicted_reduction`
1031        // below deliberately keeps using the scaled `gradient` — it must
1032        // stay in the same space as `scaled_step`/`hessian_step`.
1033        let gradient_norm = if self.config.use_jacobi_scaling {
1034            let scaling = self
1035                .jacobi_scaling
1036                .as_ref()
1037                .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
1038            crate::optimizer::unscaled_gradient_norm(gradient, scaling)
1039        } else {
1040            gradient.norm_l2()
1041        };
1042        let hessian_step = linear_solver
1043            .hessian_vec_product(&scaled_step)
1044            .ok_or_else(|| {
1045                OptimizerError::NumericalInstability("Hessian not available".into()).log()
1046            })?;
1047
1048        // Compute the predicted reduction BEFORE un-scaling the step: the
1049        // solver's cached gradient and Hessian are the *scaled* ones, and all
1050        // three vectors have to live in the same space. The predicted reduction
1051        // is a value of the quadratic model and is invariant under that change
1052        // of variables, so it is equally the predicted reduction of the
1053        // un-scaled step below.
1054        let predicted_reduction =
1055            crate::optimizer::compute_predicted_reduction(&scaled_step, gradient, &hessian_step);
1056
1057        // Apply inverse Jacobi scaling to get final step (if enabled)
1058        let step = if self.config.use_jacobi_scaling {
1059            let scaling = self
1060                .jacobi_scaling
1061                .as_ref()
1062                .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
1063            M::apply_inverse_scaling(&scaled_step, scaling)
1064        } else {
1065            scaled_step
1066        };
1067
1068        Ok(StepResult {
1069            step,
1070            gradient_norm,
1071            predicted_reduction,
1072        })
1073    }
1074
1075    /// Evaluate and apply step, handling acceptance/rejection based on step quality
1076    fn evaluate_and_apply_step(
1077        &mut self,
1078        step_result: &StepResult,
1079        state: &mut InitializedState,
1080        problem: &Problem,
1081    ) -> error::ApexSolverResult<StepEvaluation> {
1082        // Apply parameter updates using manifold operations
1083        let _step_norm = apply_parameter_step(
1084            &mut state.variables,
1085            step_result.step.as_ref(),
1086            &state.sorted_vars,
1087        );
1088
1089        // Compute new cost (residual only, no Jacobian needed for step evaluation)
1090        let (_new_residual, new_cost) = problem.compute_residual_and_cost_sparse_with_workspace(
1091            &state.variables,
1092            &mut state.workspace,
1093        )?;
1094
1095        // Compute step quality
1096        let rho = crate::optimizer::compute_step_quality(
1097            state.current_cost,
1098            new_cost,
1099            step_result.predicted_reduction,
1100        );
1101
1102        // Accept on step quality, then adapt λ. Ceres' TrustRegionMinimizer keeps
1103        // these two decisions separate for the same reason: the acceptance
1104        // threshold is a property of the problem, the damping update a property
1105        // of the chosen policy.
1106        let accepted = rho > self.config.min_relative_decrease;
1107        self.update_damping(rho, accepted);
1108
1109        let cost_reduction = if accepted {
1110            // Accept the step - parameters already updated
1111            let reduction = state.current_cost - new_cost;
1112            state.current_cost = new_cost;
1113            reduction
1114        } else {
1115            // Reject the step - revert parameter changes
1116            apply_negative_parameter_step(
1117                &mut state.variables,
1118                step_result.step.as_ref(),
1119                &state.sorted_vars,
1120            );
1121            0.0
1122        };
1123
1124        Ok(StepEvaluation {
1125            accepted,
1126            cost_reduction,
1127            rho,
1128        })
1129    }
1130
1131    /// Run optimization using the specified assembly mode and linear solver.
1132    ///
1133    /// This is the core generic optimization loop. The public `optimize()` method
1134    /// dispatches to this based on `LinearSolverType`.
1135    /// `state` is built once by [`Self::optimize`] and handed over here.
1136    ///
1137    /// Constructing it clones the variable slotmap, rebuilds the symbolic
1138    /// structure, allocates the assembly workspace and runs a full residual and
1139    /// cost evaluation, so the Schur path — which needs the variable index map
1140    /// to size its block structure — used to pay for all of that twice.
1141    fn optimize_with_mode<M: AssemblyBackend>(
1142        &mut self,
1143        problem: &mut Problem,
1144        linear_solver: &mut dyn LinearSolver<M>,
1145        mut state: crate::optimizer::InitializedState,
1146    ) -> crate::optimizer::OptimizeResult {
1147        let start_time = Instant::now();
1148        let mut iteration = 0;
1149        let mut cost_evaluations = 1;
1150        let mut jacobian_evaluations = 0;
1151        let mut successful_steps = 0;
1152        let mut unsuccessful_steps = 0;
1153        let mut consecutive_rejected = 0;
1154
1155        // Seed the run state from the configuration. λ and ν evolve during the
1156        // solve; resetting them here keeps `optimize()` idempotent when the same
1157        // solver instance is reused.
1158        self.damping = self.config.damping;
1159        self.damping_nu = self.config.damping_nu;
1160        self.jacobi_scaling = None;
1161
1162        // Initialize summary tracking variables
1163        let mut max_gradient_norm: f64 = 0.0;
1164        let mut max_parameter_update_norm: f64 = 0.0;
1165        let mut total_cost_reduction = 0.0;
1166        let mut final_gradient_norm;
1167        let mut final_parameter_update_norm;
1168
1169        // Initialize iteration statistics tracking
1170        let mut iteration_stats = Vec::with_capacity(self.config.max_iterations);
1171        let mut previous_cost = state.current_cost;
1172
1173        // Print configuration and header if debug level is enabled
1174        if tracing::enabled!(tracing::Level::DEBUG) {
1175            self.config.print_configuration();
1176            IterationStats::print_header();
1177        }
1178
1179        // Main optimization loop
1180        loop {
1181            let iter_start = Instant::now();
1182
1183            // Shared preamble: assemble, conditioning check, Jacobi scaling
1184            let (residuals, scaled_jacobian) = match crate::optimizer::iteration_preamble::<M>(
1185                problem,
1186                &mut state,
1187                &mut self.jacobi_scaling,
1188                self.config.use_jacobi_scaling,
1189                iteration,
1190                self.config.max_condition_number,
1191                &mut jacobian_evaluations,
1192            )? {
1193                crate::optimizer::IterationPreamble::Proceed {
1194                    residuals,
1195                    scaled_jacobian,
1196                } => (residuals, scaled_jacobian),
1197                crate::optimizer::IterationPreamble::EarlyExit(status) => {
1198                    let elapsed = start_time.elapsed();
1199                    self.observers.notify_complete(&state.variables, iteration);
1200                    return Ok(crate::optimizer::build_solver_result(
1201                        status,
1202                        iteration,
1203                        state,
1204                        elapsed,
1205                        0.0,
1206                        0.0,
1207                        cost_evaluations,
1208                        jacobian_evaluations,
1209                        None,
1210                    ));
1211                }
1212            };
1213
1214            // Compute optimization step
1215            let step_result =
1216                self.compute_step_generic::<M>(&residuals, &scaled_jacobian, linear_solver)?;
1217
1218            // Update tracking variables
1219            max_gradient_norm = max_gradient_norm.max(step_result.gradient_norm);
1220            final_gradient_norm = step_result.gradient_norm;
1221            let step_norm = step_result.step.norm_l2();
1222            max_parameter_update_norm = max_parameter_update_norm.max(step_norm);
1223            final_parameter_update_norm = step_norm;
1224
1225            // Evaluate and apply step (handles accept/reject)
1226            let step_eval = self.evaluate_and_apply_step(&step_result, &mut state, problem)?;
1227            cost_evaluations += 1;
1228
1229            // Update counters based on acceptance
1230            if step_eval.accepted {
1231                successful_steps += 1;
1232                consecutive_rejected = 0;
1233                total_cost_reduction += step_eval.cost_reduction;
1234            } else {
1235                unsuccessful_steps += 1;
1236                consecutive_rejected += 1;
1237            }
1238
1239            // OPTIMIZATION: Only collect iteration statistics if debug level is enabled
1240            if tracing::enabled!(tracing::Level::DEBUG) {
1241                let iter_elapsed_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
1242                let total_elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0;
1243
1244                let stats = IterationStats {
1245                    iteration,
1246                    cost: state.current_cost,
1247                    cost_change: previous_cost - state.current_cost,
1248                    gradient_norm: step_result.gradient_norm,
1249                    step_norm,
1250                    tr_ratio: step_eval.rho,
1251                    tr_radius: self.damping,
1252                    ls_iter: 0,
1253                    iter_time_ms: iter_elapsed_ms,
1254                    total_time_ms: total_elapsed_ms,
1255                    accepted: step_eval.accepted,
1256                };
1257
1258                iteration_stats.push(stats.clone());
1259                stats.print_line();
1260            }
1261
1262            previous_cost = state.current_cost;
1263
1264            // Notify all observers with current state
1265            crate::optimizer::notify_observers_generic::<M>(
1266                &mut self.observers,
1267                &state.variables,
1268                iteration,
1269                state.current_cost,
1270                step_result.gradient_norm,
1271                Some(self.damping),
1272                step_norm,
1273                Some(step_eval.rho),
1274                linear_solver,
1275            );
1276
1277            // Check convergence
1278            let elapsed = start_time.elapsed();
1279            let parameter_norm = crate::optimizer::compute_parameter_norm(&state.variables);
1280            let new_cost = state.current_cost;
1281            let cost_before_step = if step_eval.accepted {
1282                state.current_cost + step_eval.cost_reduction
1283            } else {
1284                state.current_cost
1285            };
1286
1287            let convergence_status = crate::optimizer::check_convergence(&ConvergenceParams {
1288                iteration,
1289                current_cost: cost_before_step,
1290                new_cost,
1291                parameter_norm,
1292                parameter_update_norm: step_norm,
1293                gradient_norm: step_result.gradient_norm,
1294                elapsed,
1295                step_accepted: step_eval.accepted,
1296                max_iterations: self.config.max_iterations,
1297                gradient_tolerance: self.config.gradient_tolerance,
1298                parameter_tolerance: self.config.parameter_tolerance,
1299                cost_tolerance: self.config.cost_tolerance,
1300                min_cost_threshold: self.config.min_cost_threshold,
1301                timeout: self.config.timeout,
1302                trust_region_radius: None,
1303                min_trust_region_radius: None,
1304            })
1305            .or_else(|| {
1306                // `check_convergence` returns early on a rejected step, so a solver that
1307                // rejects every trial step would otherwise run out the full iteration
1308                // budget without the cost ever changing.
1309                //
1310                // Both conditions are required. A run of rejections alone is normal — LM
1311                // raises damping and the next step succeeds. Only once damping has also
1312                // saturated at `damping_max` can it no longer shrink the step further, so
1313                // the state is provably stuck and the remaining iterations are wasted.
1314                let damping_saturated = self.damping >= self.config.damping_max;
1315                let stalled = consecutive_rejected >= self.config.max_consecutive_rejected_steps
1316                    && damping_saturated;
1317                stalled.then_some(crate::optimizer::OptimizationStatus::StalledNoProgress)
1318            });
1319
1320            if let Some(status) = convergence_status {
1321                if tracing::enabled!(tracing::Level::DEBUG) {
1322                    let summary = crate::optimizer::create_optimizer_summary(
1323                        "Levenberg-Marquardt",
1324                        state.initial_cost,
1325                        state.current_cost,
1326                        iteration + 1,
1327                        Some(successful_steps),
1328                        Some(unsuccessful_steps),
1329                        max_gradient_norm,
1330                        final_gradient_norm,
1331                        max_parameter_update_norm,
1332                        final_parameter_update_norm,
1333                        total_cost_reduction,
1334                        elapsed,
1335                        iteration_stats.clone(),
1336                        status.clone(),
1337                        Some(self.damping),
1338                        None,
1339                        Some(step_eval.rho),
1340                    );
1341                    debug!("{}", summary);
1342                }
1343
1344                // Compute covariances if enabled
1345                let covariances = if self.config.compute_covariances {
1346                    problem.compute_and_set_covariances(
1347                        &mut state.variables,
1348                        self.config.covariance_options,
1349                    )
1350                } else {
1351                    None
1352                };
1353
1354                // Notify observers that optimization is complete
1355                self.observers
1356                    .notify_complete(&state.variables, iteration + 1);
1357
1358                return Ok(crate::optimizer::build_solver_result(
1359                    status,
1360                    iteration + 1,
1361                    state,
1362                    elapsed,
1363                    final_gradient_norm,
1364                    final_parameter_update_norm,
1365                    cost_evaluations,
1366                    jacobian_evaluations,
1367                    covariances,
1368                ));
1369            }
1370
1371            iteration += 1;
1372        }
1373    }
1374
1375    /// Run optimization, dispatching based on `problem.jacobian_mode`.
1376    ///
1377    /// - `JacobianMode::Dense` → `DenseCholesky`, `DenseQR` or `ExplicitDenseSchur`
1378    /// - `JacobianMode::Sparse` → `SparseCholesky`, `SparseQR`,
1379    ///   `ExplicitSparseSchur` or `ImplicitSparseSchur`
1380    ///
1381    /// A solver that does not match the problem's Jacobian mode is rejected
1382    /// with [`OptimizerError::InvalidParameters`] rather than silently replaced,
1383    /// matching Gauss-Newton and Dog Leg. Previously a mismatch ran Cholesky in
1384    /// the problem's own mode, so the solver actually used could differ from the
1385    /// configured one with no signal.
1386    pub fn optimize(&mut self, problem: &mut Problem) -> crate::optimizer::OptimizeResult {
1387        // Chunk-wise elimination sweeps rows in chunk order, so each eliminated
1388        // variable's rows must be adjacent. Regroup before any structure is
1389        // built from the current layout — row order is a labelling, so this
1390        // leaves the problem and its cost unchanged.
1391        if matches!(
1392            self.config.linear_solver_type,
1393            LinearSolverType::ExplicitSparseSchur | LinearSolverType::ImplicitSparseSchur
1394        ) {
1395            problem.group_rows_for_elimination();
1396        }
1397
1398        // Built once here and moved into the chosen arm: the Schur solver needs
1399        // the variable index map to size its block structure, and rebuilding
1400        // the state inside `optimize_with_mode` would repeat the symbolic
1401        // construction and the initial cost evaluation.
1402        let state = crate::optimizer::initialize_optimization_state(problem)?;
1403
1404        match problem.jacobian_mode {
1405            JacobianMode::Dense => match self.config.linear_solver_type {
1406                LinearSolverType::DenseQR => {
1407                    let mut solver = DenseQRSolver::new();
1408                    self.optimize_with_mode::<DenseMode>(problem, &mut solver, state)
1409                }
1410                LinearSolverType::DenseCholesky => {
1411                    let mut solver = DenseCholeskySolver::new();
1412                    self.optimize_with_mode::<DenseMode>(problem, &mut solver, state)
1413                }
1414                LinearSolverType::ExplicitDenseSchur => {
1415                    let init = |e: crate::linalg::LinAlgError| {
1416                        OptimizerError::LinearSolveFailed(format!(
1417                            "Failed to initialize Schur solver: {e}"
1418                        ))
1419                        .log()
1420                    };
1421                    let mut solver = ExplicitDenseSchur::new();
1422                    solver
1423                        .initialize_structure(
1424                            &state.variables,
1425                            &state.variable_index_map,
1426                            &problem.schur_landmark_keys,
1427                        )
1428                        .map_err(init)?;
1429                    self.optimize_with_mode::<DenseMode>(problem, &mut solver, state)
1430                }
1431                other => Err(OptimizerError::InvalidParameters(format!(
1432                    "Levenberg-Marquardt in dense Jacobian mode supports DenseCholesky, \
1433                     DenseQR and ExplicitDenseSchur only; requested {other}"
1434                ))
1435                .into()),
1436            },
1437            JacobianMode::Sparse => match self.config.linear_solver_type {
1438                LinearSolverType::SparseQR => {
1439                    let mut solver = SparseQRSolver::new();
1440                    self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
1441                }
1442                LinearSolverType::ExplicitSparseSchur => {
1443                    let init = |e: crate::linalg::LinAlgError| {
1444                        OptimizerError::LinearSolveFailed(format!(
1445                            "Failed to initialize Schur solver: {e}"
1446                        ))
1447                        .log()
1448                    };
1449                    let mut solver = ExplicitSparseSchur::new()
1450                        .with_variant(self.config.schur_variant)
1451                        .with_preconditioner(self.config.schur_preconditioner)
1452                        .with_cg_params(
1453                            self.config.schur_cg_max_iterations,
1454                            self.config.schur_cg_tolerance,
1455                        )
1456                        .with_cg_q_tolerance(self.config.schur_cg_q_tolerance);
1457                    solver
1458                        .initialize_structure(
1459                            &state.variables,
1460                            &state.variable_index_map,
1461                            &problem.schur_landmark_keys,
1462                        )
1463                        .map_err(init)?;
1464                    self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
1465                }
1466                LinearSolverType::ImplicitSparseSchur => {
1467                    // Never forms S, so it is a different solver type rather
1468                    // than a mode of the explicit one — this is what keeps
1469                    // both the O(kept_dof²) buffer and `JᵀJ` itself out of
1470                    // the picture entirely.
1471                    let init = |e: crate::linalg::LinAlgError| {
1472                        OptimizerError::LinearSolveFailed(format!(
1473                            "Failed to initialize Schur solver: {e}"
1474                        ))
1475                        .log()
1476                    };
1477                    let mut solver = ImplicitSparseSchur::with_config(
1478                        self.config.schur_cg_max_iterations,
1479                        self.config.schur_cg_tolerance,
1480                        self.config.schur_preconditioner,
1481                    )
1482                    .with_cg_q_tolerance(self.config.schur_cg_q_tolerance);
1483                    solver
1484                        .initialize_structure(
1485                            &state.variables,
1486                            &state.variable_index_map,
1487                            &problem.schur_landmark_keys,
1488                        )
1489                        .map_err(init)?;
1490                    self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
1491                }
1492                LinearSolverType::SparseCholesky => {
1493                    let mut solver = SparseCholeskySolver::new();
1494                    self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
1495                }
1496                other => Err(OptimizerError::InvalidParameters(format!(
1497                    "Levenberg-Marquardt in sparse Jacobian mode supports SparseCholesky, \
1498                     SparseQR, ExplicitSparseSchur and ImplicitSparseSchur only; requested {other}"
1499                ))
1500                .into()),
1501            },
1502        }
1503    }
1504}
1505impl crate::optimizer::Optimizer for LevenbergMarquardt {
1506    fn optimize(&mut self, problem: &mut Problem) -> crate::optimizer::OptimizeResult {
1507        self.optimize(problem)
1508    }
1509}
1510
1511#[cfg(test)]
1512mod tests {
1513    use super::*;
1514    use crate::ManifoldType;
1515    use crate::core::VarKey;
1516    use crate::core::variable::ManifoldVariable;
1517    use crate::factors::Factor;
1518    use crate::optimizer::OptimizationStatus;
1519    use faer::prelude::ReborrowMut;
1520    use nalgebra::dvector;
1521    use slotmap::SlotMap;
1522
1523    type TestResult = Result<(), Box<dyn std::error::Error>>;
1524    /// Custom Rosenbrock Factor 1: r1 = 10(x2 - x1²)
1525    /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1526    #[derive(Debug, Clone)]
1527    struct RosenbrockFactor1;
1528
1529    impl Factor for RosenbrockFactor1 {
1530        fn linearize(
1531            &self,
1532            params: &[&[f64]],
1533            residual: &mut [f64],
1534            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1535        ) {
1536            let x1 = params[0][0];
1537            let x2 = params[1][0];
1538            residual[0] = 10.0 * (x2 - x1 * x1);
1539            if let Some(mut jac) = jacobian {
1540                *jac.rb_mut().get_mut(0, 0) = -20.0 * x1;
1541                *jac.rb_mut().get_mut(0, 1) = 10.0;
1542            }
1543        }
1544        fn residual_dim(&self) -> usize {
1545            1
1546        }
1547        fn jacobian_shape(&self) -> (usize, usize) {
1548            (1, 2)
1549        }
1550    }
1551
1552    /// Custom Rosenbrock Factor 2: r2 = 1 - x1
1553    /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1554    #[derive(Debug, Clone)]
1555    struct RosenbrockFactor2;
1556
1557    impl Factor for RosenbrockFactor2 {
1558        fn linearize(
1559            &self,
1560            params: &[&[f64]],
1561            residual: &mut [f64],
1562            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1563        ) {
1564            residual[0] = 1.0 - params[0][0];
1565            if let Some(mut jac) = jacobian {
1566                *jac.rb_mut().get_mut(0, 0) = -1.0;
1567            }
1568        }
1569        fn residual_dim(&self) -> usize {
1570            1
1571        }
1572        fn jacobian_shape(&self) -> (usize, usize) {
1573            (1, 1)
1574        }
1575    }
1576
1577    // -------------------------------------------------------------------------
1578    // Ceres-compatibility config fields: behaviour, not builder round-trips
1579    // -------------------------------------------------------------------------
1580
1581    /// `min_relative_decrease` gates step acceptance.
1582    ///
1583    /// Set just below 1.0, essentially no step qualifies — ρ reaches 1 only when
1584    /// the quadratic model is exact — so the solver must reject its way to a
1585    /// stall instead of converging. This is the check that the field is read at
1586    /// all: before it was wired up, both configurations produced identical runs.
1587    #[test]
1588    fn min_relative_decrease_gates_acceptance() -> TestResult {
1589        let mut permissive_problem = rosenbrock_problem();
1590        let permissive = LevenbergMarquardt::with_config(
1591            LevenbergMarquardtConfig::new()
1592                .with_max_iterations(100)
1593                .with_min_relative_decrease(1e-3),
1594        )
1595        .optimize(&mut permissive_problem)?;
1596
1597        let mut strict_problem = rosenbrock_problem();
1598        let strict = LevenbergMarquardt::with_config(
1599            LevenbergMarquardtConfig::new()
1600                .with_max_iterations(100)
1601                .with_min_relative_decrease(0.999_999),
1602        )
1603        .optimize(&mut strict_problem)?;
1604
1605        assert!(
1606            strict.final_cost > permissive.final_cost,
1607            "a near-1.0 acceptance threshold should reject nearly every step and \
1608             leave the cost higher: strict {:.3e} vs permissive {:.3e}",
1609            strict.final_cost,
1610            permissive.final_cost
1611        );
1612        Ok(())
1613    }
1614
1615    /// `min_diagonal` / `max_diagonal` change the damped system.
1616    ///
1617    /// Clamping both to 1.0 turns `λ·D` into `λI`; that must produce a
1618    /// different iterate sequence from the Marquardt default on a problem whose
1619    /// columns have unequal norms, which Rosenbrock's do.
1620    #[test]
1621    fn diagonal_bounds_select_between_marquardt_and_identity_damping() -> TestResult {
1622        let mut marquardt_problem = rosenbrock_problem();
1623        let marquardt = LevenbergMarquardt::with_config(
1624            LevenbergMarquardtConfig::new().with_max_iterations(100),
1625        )
1626        .optimize(&mut marquardt_problem)?;
1627
1628        let mut identity_problem = rosenbrock_problem();
1629        let identity = LevenbergMarquardt::with_config(
1630            LevenbergMarquardtConfig::new()
1631                .with_max_iterations(100)
1632                .with_diagonal_bounds(1.0, 1.0),
1633        )
1634        .optimize(&mut identity_problem)?;
1635
1636        assert_ne!(
1637            marquardt.iterations, identity.iterations,
1638            "λ·D and λI should not produce identical iterate counts on a \
1639             problem with unequal column norms — the bounds are being ignored"
1640        );
1641        // Both still solve Rosenbrock; the point is that they differ, not that
1642        // one is better on this particular problem.
1643        assert!(
1644            marquardt.final_cost < 1e-8,
1645            "marquardt cost {:.3e}",
1646            marquardt.final_cost
1647        );
1648        assert!(
1649            identity.final_cost < 1e-8,
1650            "identity cost {:.3e}",
1651            identity.final_cost
1652        );
1653        Ok(())
1654    }
1655
1656    /// `DampingUpdate::Marquardt` follows the configured factors exactly.
1657    ///
1658    /// Nielsen derives both directions from ρ, so the two policies must not
1659    /// produce the same λ trajectory from the same inputs.
1660    #[test]
1661    fn marquardt_policy_uses_the_configured_damping_factors() {
1662        let config = LevenbergMarquardtConfig::new()
1663            .with_damping(1.0)
1664            .with_damping_bounds(1e-15, 1e15)
1665            .with_damping_update(DampingUpdate::Marquardt)
1666            .with_damping_factors(10.0, 0.3)
1667            .with_step_quality(0.0, 0.75);
1668
1669        // Accepted, high quality → λ *= decrease_factor
1670        let mut solver = LevenbergMarquardt::with_config(config.clone());
1671        solver.update_damping(0.9, true);
1672        assert!(
1673            (solver.damping - 0.3).abs() < 1e-12,
1674            "got λ = {}",
1675            solver.damping
1676        );
1677
1678        // Rejected → λ *= increase_factor, regardless of ρ
1679        let mut solver = LevenbergMarquardt::with_config(config.clone());
1680        solver.update_damping(0.9, false);
1681        assert!(
1682            (solver.damping - 10.0).abs() < 1e-12,
1683            "got λ = {}",
1684            solver.damping
1685        );
1686
1687        // Accepted but mediocre (between min and good) → λ unchanged
1688        let mut solver = LevenbergMarquardt::with_config(config.clone());
1689        solver.update_damping(0.5, true);
1690        assert!(
1691            (solver.damping - 1.0).abs() < 1e-12,
1692            "got λ = {}",
1693            solver.damping
1694        );
1695    }
1696
1697    /// Nielsen is the default and is unaffected by the Marquardt factors.
1698    #[test]
1699    fn nielsen_policy_ignores_the_marquardt_factors() {
1700        let base = LevenbergMarquardtConfig::new()
1701            .with_damping(1.0)
1702            .with_damping_bounds(1e-15, 1e15);
1703
1704        let mut default_solver = LevenbergMarquardt::with_config(base.clone());
1705        default_solver.update_damping(0.9, true);
1706
1707        let mut retuned_solver =
1708            LevenbergMarquardt::with_config(base.with_damping_factors(1e6, 1e-6));
1709        retuned_solver.update_damping(0.9, true);
1710
1711        assert!(
1712            (default_solver.damping - retuned_solver.damping).abs() < 1e-15,
1713            "Nielsen must ignore damping_increase/decrease_factor, but λ differed: \
1714             {} vs {}",
1715            default_solver.damping,
1716            retuned_solver.damping
1717        );
1718    }
1719
1720    /// λ and ν are run state, so a second `optimize()` reproduces the first.
1721    ///
1722    /// Before they were separated from the config, the second call inherited the
1723    /// λ the first run ended on and silently produced a different answer.
1724    #[test]
1725    fn repeated_optimize_calls_are_reproducible() -> TestResult {
1726        let mut solver = LevenbergMarquardt::with_config(
1727            LevenbergMarquardtConfig::new().with_max_iterations(100),
1728        );
1729
1730        let mut first_problem = rosenbrock_problem();
1731        let first = solver.optimize(&mut first_problem)?;
1732
1733        let mut second_problem = rosenbrock_problem();
1734        let second = solver.optimize(&mut second_problem)?;
1735
1736        assert_eq!(
1737            first.iterations, second.iterations,
1738            "reusing a solver changed the iteration count: {} then {}",
1739            first.iterations, second.iterations
1740        );
1741        assert!(
1742            (first.final_cost - second.final_cost).abs() < 1e-15,
1743            "reusing a solver changed the final cost: {:.17e} then {:.17e}",
1744            first.final_cost,
1745            second.final_cost
1746        );
1747        Ok(())
1748    }
1749
1750    /// `max_condition_number` terminates on a variable no residual constrains.
1751    ///
1752    /// The unconstrained variable contributes an all-zero Jacobian column, so
1753    /// the condition-number lower bound is infinite and the check fires.
1754    #[test]
1755    fn max_condition_number_detects_an_unconstrained_variable() -> TestResult {
1756        let mut problem = rosenbrock_problem();
1757        // Nothing references this variable, so its column of J is empty.
1758        let _orphan = problem.add_variable(ManifoldType::RN, dvector![0.0]);
1759
1760        let result = LevenbergMarquardt::with_config(
1761            LevenbergMarquardtConfig::new()
1762                .with_max_iterations(10)
1763                .with_max_condition_number(1e12),
1764        )
1765        .optimize(&mut problem)?;
1766
1767        assert_eq!(
1768            result.status,
1769            OptimizationStatus::IllConditionedJacobian,
1770            "an unconstrained variable should trip the conditioning check"
1771        );
1772        Ok(())
1773    }
1774
1775    /// Without the check, the same unconstrained variable is *silently absorbed*
1776    /// by damping: `λ·D` materializes the missing diagonal, the orphan takes a
1777    /// near-zero step, and the solve runs to completion reporting success.
1778    ///
1779    /// This is what `max_condition_number` buys, and it matters more than it
1780    /// used to. Damping previously failed outright on a structurally empty
1781    /// diagonal, so a rank-deficient problem announced itself as a linear-solver
1782    /// error; now it converges quietly and the conditioning check is the only
1783    /// thing that reports the rank deficiency.
1784    #[test]
1785    fn without_the_check_an_unconstrained_variable_is_absorbed_silently() -> TestResult {
1786        let mut problem = rosenbrock_problem();
1787        let _orphan = problem.add_variable(ManifoldType::RN, dvector![0.0]);
1788
1789        let result = LevenbergMarquardt::with_config(
1790            LevenbergMarquardtConfig::new().with_max_iterations(10),
1791        )
1792        .optimize(&mut problem)?;
1793
1794        assert_ne!(
1795            result.status,
1796            OptimizationStatus::IllConditionedJacobian,
1797            "without the check nothing should report the rank deficiency"
1798        );
1799        Ok(())
1800    }
1801
1802    /// The check stays out of the way on a well-conditioned problem.
1803    #[test]
1804    fn max_condition_number_does_not_fire_on_a_healthy_problem() -> TestResult {
1805        let mut healthy_problem = rosenbrock_problem();
1806        let healthy = LevenbergMarquardt::with_config(
1807            LevenbergMarquardtConfig::new()
1808                .with_max_iterations(100)
1809                .with_max_condition_number(1e12),
1810        )
1811        .optimize(&mut healthy_problem)?;
1812        assert_ne!(
1813            healthy.status,
1814            OptimizationStatus::IllConditionedJacobian,
1815            "a well-conditioned problem must not trip the check"
1816        );
1817        Ok(())
1818    }
1819
1820    #[test]
1821    fn test_rosenbrock_optimization() -> TestResult {
1822        // Rosenbrock function test:
1823        // Minimize: r1² + r2² where
1824        //   r1 = 10(x2 - x1²)
1825        //   r2 = 1 - x1
1826        // Starting point: [-1.2, 1.0]
1827        // Expected minimum: [1.0, 1.0]
1828
1829        let mut problem = Problem::new(JacobianMode::Sparse);
1830        let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
1831        let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
1832
1833        // Add custom factors (demonstrates extensibility!)
1834        problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1835        problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1836
1837        // Configure Levenberg-Marquardt optimizer
1838        let config = LevenbergMarquardtConfig::new()
1839            .with_max_iterations(100)
1840            .with_cost_tolerance(1e-8)
1841            .with_parameter_tolerance(1e-8)
1842            .with_gradient_tolerance(1e-10);
1843
1844        let mut solver = LevenbergMarquardt::with_config(config);
1845        let result = solver.optimize(&mut problem)?;
1846
1847        // Extract final values
1848        let x1_final = result.parameters[x1].as_param_slice()[0];
1849        let x2_final = result.parameters[x2].as_param_slice()[0];
1850
1851        // Verify convergence to [1.0, 1.0]
1852        assert!(
1853            matches!(
1854                result.status,
1855                OptimizationStatus::Converged
1856                    | OptimizationStatus::CostToleranceReached
1857                    | OptimizationStatus::ParameterToleranceReached
1858                    | OptimizationStatus::GradientToleranceReached
1859            ),
1860            "Optimization should converge"
1861        );
1862        assert!(
1863            (x1_final - 1.0).abs() < 1e-4,
1864            "x1 should converge to 1.0, got {}",
1865            x1_final
1866        );
1867        assert!(
1868            (x2_final - 1.0).abs() < 1e-4,
1869            "x2 should converge to 1.0, got {}",
1870            x2_final
1871        );
1872        assert!(
1873            result.final_cost < 1e-6,
1874            "Final cost should be near zero, got {}",
1875            result.final_cost
1876        );
1877        Ok(())
1878    }
1879
1880    /// Trivial factor: r = x - target, J = [[1.0]]
1881    struct LinearFactor {
1882        target: f64,
1883    }
1884
1885    impl Factor for LinearFactor {
1886        fn linearize(
1887            &self,
1888            params: &[&[f64]],
1889            residual: &mut [f64],
1890            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1891        ) {
1892            residual[0] = params[0][0] - self.target;
1893            if let Some(mut jac) = jacobian {
1894                *jac.rb_mut().get_mut(0, 0) = 1.0;
1895            }
1896        }
1897        fn residual_dim(&self) -> usize {
1898            1
1899        }
1900        fn jacobian_shape(&self) -> (usize, usize) {
1901            (1, 1)
1902        }
1903    }
1904
1905    fn rosenbrock_problem() -> Problem {
1906        let mut problem = Problem::new(JacobianMode::Sparse);
1907        let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
1908        let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
1909        problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1910        problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1911        problem
1912    }
1913
1914    fn linear_problem(start: f64) -> Problem {
1915        let mut problem = Problem::new(JacobianMode::Sparse);
1916        let x = problem.add_variable(ManifoldType::RN, dvector![start]);
1917        problem.add_residual_block(&[x], Box::new(LinearFactor { target: 0.0 }), None);
1918        problem
1919    }
1920
1921    // -------------------------------------------------------------------------
1922    // Predicted reduction under Jacobi scaling (issue #43)
1923    // -------------------------------------------------------------------------
1924
1925    /// Two-variable factor with deliberately mismatched column scales:
1926    /// `r = [a·x1 - 1, x2 - 1]`, so the Jacobian columns have norms `a` and `1`.
1927    struct IllScaledFactor {
1928        a: f64,
1929    }
1930
1931    impl Factor for IllScaledFactor {
1932        fn linearize(
1933            &self,
1934            params: &[&[f64]],
1935            residual: &mut [f64],
1936            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1937        ) {
1938            let x1 = params[0][0];
1939            let x2 = params[1][0];
1940            residual[0] = self.a * x1 - 1.0;
1941            residual[1] = x2 - 1.0;
1942            if let Some(mut jac) = jacobian {
1943                *jac.rb_mut().get_mut(0, 0) = self.a;
1944                *jac.rb_mut().get_mut(0, 1) = 0.0;
1945                *jac.rb_mut().get_mut(1, 0) = 0.0;
1946                *jac.rb_mut().get_mut(1, 1) = 1.0;
1947            }
1948        }
1949        fn residual_dim(&self) -> usize {
1950            2
1951        }
1952        fn jacobian_shape(&self) -> (usize, usize) {
1953            (2, 2)
1954        }
1955    }
1956
1957    /// The predicted reduction must equal the reduction of the quadratic model
1958    /// `m(dx) = ½‖r + J·dx‖²` measured with the **un-scaled** `J` and the
1959    /// **un-scaled** step that the optimizer actually applies.
1960    ///
1961    /// This is the invariant issue #43 reports as broken: with Jacobi scaling on,
1962    /// the old code fed an un-scaled step to a formula that had already been
1963    /// substituted with the scaled normal equations, mixing the two spaces.
1964    ///
1965    /// <https://github.com/amin-abouee/apex-solver/issues/43>
1966    fn assert_predicted_reduction_matches_model(use_jacobi_scaling: bool) -> TestResult {
1967        let mut problem = Problem::new(JacobianMode::Sparse);
1968        let x1 = problem.add_variable(ManifoldType::RN, dvector![0.0]);
1969        let x2 = problem.add_variable(ManifoldType::RN, dvector![0.0]);
1970        problem.add_residual_block(&[x1, x2], Box::new(IllScaledFactor { a: 100.0 }), None);
1971
1972        let config = LevenbergMarquardtConfig::new()
1973            .with_damping(1e-1)
1974            .with_jacobi_scaling(use_jacobi_scaling);
1975        let mut solver = LevenbergMarquardt::with_config(config);
1976
1977        let mut state = crate::optimizer::initialize_optimization_state(&mut problem)?;
1978        let (residuals, jacobian) = SparseMode::assemble(
1979            &problem,
1980            &state.variables,
1981            &state.variable_index_map,
1982            state.symbolic_structure.as_ref(),
1983            state.total_dof,
1984            &mut state.workspace,
1985        )?;
1986
1987        let solver_jacobian = if use_jacobi_scaling {
1988            crate::optimizer::process_jacobian_generic::<SparseMode>(
1989                &jacobian,
1990                &mut solver.jacobi_scaling,
1991                0,
1992            )?
1993        } else {
1994            jacobian.clone()
1995        };
1996
1997        let mut linear_solver = SparseCholeskySolver::new();
1998        let step_result = solver.compute_step_generic::<SparseMode>(
1999            &residuals,
2000            &solver_jacobian,
2001            &mut linear_solver,
2002        )?;
2003
2004        // Reduction of the quadratic model, computed independently from the
2005        // un-scaled Jacobian and the step the optimizer will actually apply.
2006        let predicted_residual = &residuals + &jacobian * &step_result.step;
2007        let expected =
2008            0.5 * residuals.squared_norm_l2() - 0.5 * predicted_residual.squared_norm_l2();
2009
2010        assert!(
2011            (step_result.predicted_reduction - expected).abs() < 1e-9 * expected.abs().max(1.0),
2012            "predicted_reduction {} disagrees with the quadratic model {} \
2013             (use_jacobi_scaling = {})",
2014            step_result.predicted_reduction,
2015            expected,
2016            use_jacobi_scaling,
2017        );
2018        Ok(())
2019    }
2020
2021    #[test]
2022    fn test_lm_predicted_reduction_matches_model_without_scaling() -> TestResult {
2023        assert_predicted_reduction_matches_model(false)
2024    }
2025
2026    #[test]
2027    fn test_lm_predicted_reduction_matches_model_with_scaling() -> TestResult {
2028        assert_predicted_reduction_matches_model(true)
2029    }
2030
2031    /// GitHub #57: the reported `gradient_norm` must be `‖Jᵀr‖` from the
2032    /// *un-scaled* Jacobian — matching `gradient_tolerance`'s documented
2033    /// contract — regardless of whether Jacobi column scaling was used to
2034    /// condition the solve, not `‖J̃ᵀr‖` from the scaled one. `IllScaledFactor`
2035    /// (columns of norm 100 and 1) makes the two differ by roughly two
2036    /// orders of magnitude, so a stale scaled value is easy to detect.
2037    fn assert_gradient_norm_matches_unscaled_jacobian(use_jacobi_scaling: bool) -> TestResult {
2038        let mut problem = Problem::new(JacobianMode::Sparse);
2039        let x1 = problem.add_variable(ManifoldType::RN, dvector![0.0]);
2040        let x2 = problem.add_variable(ManifoldType::RN, dvector![0.0]);
2041        problem.add_residual_block(&[x1, x2], Box::new(IllScaledFactor { a: 100.0 }), None);
2042
2043        let mut state = crate::optimizer::initialize_optimization_state(&mut problem)?;
2044        let (residuals, jacobian) = SparseMode::assemble(
2045            &problem,
2046            &state.variables,
2047            &state.variable_index_map,
2048            state.symbolic_structure.as_ref(),
2049            state.total_dof,
2050            &mut state.workspace,
2051        )?;
2052
2053        // Reference: ||J^T r|| from a plain, un-scaled solve.
2054        let mut reference_solver = SparseCholeskySolver::new();
2055        reference_solver.solve_normal_equation(&residuals, &jacobian)?;
2056        let expected = reference_solver
2057            .get_gradient()
2058            .ok_or("reference solver produced no gradient")?
2059            .norm_l2();
2060
2061        let config = LevenbergMarquardtConfig::new()
2062            .with_damping(1e-1)
2063            .with_jacobi_scaling(use_jacobi_scaling);
2064        let mut solver = LevenbergMarquardt::with_config(config);
2065
2066        let solver_jacobian = if use_jacobi_scaling {
2067            crate::optimizer::process_jacobian_generic::<SparseMode>(
2068                &jacobian,
2069                &mut solver.jacobi_scaling,
2070                0,
2071            )?
2072        } else {
2073            jacobian.clone()
2074        };
2075
2076        let mut linear_solver = SparseCholeskySolver::new();
2077        let step_result = solver.compute_step_generic::<SparseMode>(
2078            &residuals,
2079            &solver_jacobian,
2080            &mut linear_solver,
2081        )?;
2082
2083        assert!(
2084            (step_result.gradient_norm - expected).abs() < 1e-6 * expected.max(1.0),
2085            "gradient_norm {} disagrees with the un-scaled ||J^T r|| {} \
2086             (use_jacobi_scaling = {})",
2087            step_result.gradient_norm,
2088            expected,
2089            use_jacobi_scaling,
2090        );
2091        Ok(())
2092    }
2093
2094    #[test]
2095    fn test_lm_gradient_norm_matches_unscaled_jacobian_without_scaling() -> TestResult {
2096        assert_gradient_norm_matches_unscaled_jacobian(false)
2097    }
2098
2099    #[test]
2100    fn test_lm_gradient_norm_matches_unscaled_jacobian_with_scaling() -> TestResult {
2101        assert_gradient_norm_matches_unscaled_jacobian(true)
2102    }
2103
2104    // -------------------------------------------------------------------------
2105    // Config builder tests
2106    // -------------------------------------------------------------------------
2107
2108    #[test]
2109    fn test_lm_config_default() {
2110        let cfg = LevenbergMarquardtConfig::default();
2111        assert_eq!(cfg.max_iterations, 50);
2112        assert!((cfg.cost_tolerance - 1e-6).abs() < 1e-15);
2113        assert!((cfg.damping - 1e-4).abs() < 1e-15);
2114        assert!(!cfg.use_jacobi_scaling);
2115        assert!(!cfg.compute_covariances);
2116        // Ceres' min_lm_diagonal / max_lm_diagonal.
2117        assert!((cfg.min_diagonal - 1e-6).abs() < 1e-15);
2118        assert!((cfg.max_diagonal - 1e32).abs() < 1e17);
2119        assert!((cfg.min_relative_decrease - 1e-3).abs() < 1e-15);
2120        assert_eq!(cfg.damping_update, DampingUpdate::Nielsen);
2121    }
2122
2123    #[test]
2124    fn test_lm_config_builders() {
2125        let cfg = LevenbergMarquardtConfig::new()
2126            .with_max_iterations(42)
2127            .with_cost_tolerance(1e-4)
2128            .with_parameter_tolerance(1e-5)
2129            .with_gradient_tolerance(1e-6)
2130            .with_damping(1e-2)
2131            .with_damping_bounds(1e-15, 1e15)
2132            .with_damping_factors(8.0, 0.2)
2133            .with_min_cost_threshold(1e-12)
2134            .with_jacobi_scaling(true)
2135            .with_compute_covariances(true)
2136            .with_linear_solver_type(LinearSolverType::SparseQR);
2137        assert_eq!(cfg.max_iterations, 42);
2138        assert!((cfg.cost_tolerance - 1e-4).abs() < 1e-20);
2139        assert!((cfg.parameter_tolerance - 1e-5).abs() < 1e-20);
2140        assert!((cfg.gradient_tolerance - 1e-6).abs() < 1e-20);
2141        assert!((cfg.damping - 1e-2).abs() < 1e-15);
2142        assert!((cfg.damping_min - 1e-15).abs() < 1e-25);
2143        assert!((cfg.damping_max - 1e15).abs() < 1.0);
2144        assert!((cfg.damping_increase_factor - 8.0).abs() < 1e-12);
2145        assert!((cfg.damping_decrease_factor - 0.2).abs() < 1e-12);
2146        assert!(cfg.min_cost_threshold.is_some());
2147        assert!(cfg.use_jacobi_scaling);
2148        assert!(cfg.compute_covariances);
2149        assert!(matches!(cfg.linear_solver_type, LinearSolverType::SparseQR));
2150    }
2151
2152    #[test]
2153    fn test_lm_for_bundle_adjustment() {
2154        let cfg = LevenbergMarquardtConfig::for_bundle_adjustment();
2155        assert!(matches!(
2156            cfg.linear_solver_type,
2157            LinearSolverType::ImplicitSparseSchur
2158        ));
2159        assert_eq!(cfg.max_iterations, 20);
2160        // The preset's speed rests on the forcing sequence being active.
2161        assert!((cfg.schur_cg_q_tolerance - crate::linalg::schur::DEFAULT_ETA).abs() < 1e-15);
2162    }
2163
2164    #[test]
2165    fn test_lm_print_configuration_no_panic() {
2166        LevenbergMarquardtConfig::default().print_configuration();
2167    }
2168
2169    #[test]
2170    fn test_lm_default_equals_new() {
2171        let a = LevenbergMarquardt::new();
2172        let b = LevenbergMarquardt::default();
2173        // Both should solve the same problem identically (smoke check)
2174        drop(a);
2175        drop(b);
2176    }
2177
2178    #[test]
2179    fn test_lm_with_config_method() {
2180        let cfg = LevenbergMarquardtConfig::new().with_max_iterations(7);
2181        let solver = LevenbergMarquardt::with_config(cfg);
2182        drop(solver);
2183    }
2184
2185    // -------------------------------------------------------------------------
2186    // Convergence termination paths
2187    // -------------------------------------------------------------------------
2188
2189    #[test]
2190    fn test_lm_max_iterations_termination() -> TestResult {
2191        let mut problem = rosenbrock_problem();
2192        let cfg = LevenbergMarquardtConfig::new().with_max_iterations(2);
2193        let mut solver = LevenbergMarquardt::with_config(cfg);
2194        let result = solver.optimize(&mut problem)?;
2195        assert_eq!(result.status, OptimizationStatus::MaxIterationsReached);
2196        assert!(result.iterations <= 3, "iterations={}", result.iterations);
2197        Ok(())
2198    }
2199
2200    #[test]
2201    fn test_lm_gradient_tolerance_convergence() -> TestResult {
2202        let mut problem = linear_problem(1.0);
2203        // Very loose gradient tolerance → triggers after first accepted step
2204        let cfg = LevenbergMarquardtConfig::new()
2205            .with_gradient_tolerance(1e3)
2206            .with_cost_tolerance(1e-20)
2207            .with_parameter_tolerance(1e-20);
2208        let mut solver = LevenbergMarquardt::with_config(cfg);
2209        let result = solver.optimize(&mut problem)?;
2210        assert_eq!(result.status, OptimizationStatus::GradientToleranceReached);
2211        Ok(())
2212    }
2213
2214    #[test]
2215    fn test_lm_min_cost_threshold() -> TestResult {
2216        let mut problem = rosenbrock_problem();
2217        // Set threshold very high so even initial cost triggers it
2218        let cfg = LevenbergMarquardtConfig::new()
2219            .with_min_cost_threshold(1e10)
2220            .with_cost_tolerance(1e-20)
2221            .with_gradient_tolerance(1e-20)
2222            .with_parameter_tolerance(1e-20);
2223        let mut solver = LevenbergMarquardt::with_config(cfg);
2224        let result = solver.optimize(&mut problem)?;
2225        assert_eq!(result.status, OptimizationStatus::MinCostThresholdReached);
2226        Ok(())
2227    }
2228
2229    #[test]
2230    fn test_lm_qr_solver() -> TestResult {
2231        let mut problem = rosenbrock_problem();
2232        let cfg = LevenbergMarquardtConfig::new()
2233            .with_linear_solver_type(LinearSolverType::SparseQR)
2234            .with_max_iterations(100);
2235        let mut solver = LevenbergMarquardt::with_config(cfg);
2236        let result = solver.optimize(&mut problem)?;
2237        assert!(result.final_cost < 1e-6);
2238        Ok(())
2239    }
2240
2241    #[test]
2242    fn test_lm_jacobi_scaling_enabled() -> TestResult {
2243        let mut problem = rosenbrock_problem();
2244        let cfg = LevenbergMarquardtConfig::new()
2245            .with_jacobi_scaling(true)
2246            .with_max_iterations(100);
2247        let mut solver = LevenbergMarquardt::with_config(cfg);
2248        let result = solver.optimize(&mut problem)?;
2249        assert!(result.final_cost < 1e-6);
2250        Ok(())
2251    }
2252
2253    #[test]
2254    fn test_lm_result_initial_cost_greater_than_final() -> TestResult {
2255        let mut problem = rosenbrock_problem();
2256        let mut solver = LevenbergMarquardt::new();
2257        let result = solver.optimize(&mut problem)?;
2258        assert!(
2259            result.initial_cost > result.final_cost,
2260            "initial={} final={}",
2261            result.initial_cost,
2262            result.final_cost
2263        );
2264        Ok(())
2265    }
2266
2267    #[test]
2268    fn test_lm_convergence_info_populated() -> TestResult {
2269        let mut problem = rosenbrock_problem();
2270        let mut solver = LevenbergMarquardt::new();
2271        let result = solver.optimize(&mut problem)?;
2272        assert!(result.convergence_info.is_some());
2273        Ok(())
2274    }
2275
2276    #[test]
2277    fn test_lm_iterations_positive() -> TestResult {
2278        let mut problem = rosenbrock_problem();
2279        let mut solver = LevenbergMarquardt::new();
2280        let result = solver.optimize(&mut problem)?;
2281        assert!(result.iterations > 0);
2282        Ok(())
2283    }
2284
2285    #[test]
2286    fn test_lm_timeout_config() {
2287        let cfg = LevenbergMarquardtConfig::new().with_timeout(Duration::from_secs(30));
2288        assert!(cfg.timeout.is_some());
2289    }
2290
2291    #[test]
2292    fn test_lm_config_schur_variant_and_preconditioner() {
2293        use crate::linalg::{ExplicitSchurVariant, SchurPreconditioner};
2294        let cfg = LevenbergMarquardtConfig::new()
2295            .with_schur_variant(ExplicitSchurVariant::Iterative)
2296            .with_schur_preconditioner(SchurPreconditioner::BlockDiagonal);
2297        assert!(matches!(cfg.schur_variant, ExplicitSchurVariant::Iterative));
2298        assert!(matches!(
2299            cfg.schur_preconditioner,
2300            SchurPreconditioner::BlockDiagonal
2301        ));
2302    }
2303
2304    // -------------------------------------------------------------------------
2305    // Dense Jacobian mode dispatch
2306    // -------------------------------------------------------------------------
2307
2308    /// Exercises the `JacobianMode::Dense + _ => DenseCholeskySolver` arm of `optimize()`.
2309    /// All existing tests use `JacobianMode::Sparse`, so this branch was previously uncovered.
2310    #[test]
2311    fn test_lm_dense_cholesky_solver() -> TestResult {
2312        let mut problem = Problem::new(JacobianMode::Dense);
2313        let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
2314        let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
2315        problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
2316        problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
2317
2318        // The solver must match the problem's mode; a mismatch is now an error
2319        // rather than a silent substitution, so ask for DenseCholesky by name.
2320        let cfg = LevenbergMarquardtConfig::new()
2321            .with_linear_solver_type(LinearSolverType::DenseCholesky)
2322            .with_max_iterations(100);
2323        let mut solver = LevenbergMarquardt::with_config(cfg);
2324        let result = solver.optimize(&mut problem)?;
2325        assert!(
2326            result.final_cost < 1e-6,
2327            "Dense Cholesky mode should converge Rosenbrock, got cost={}",
2328            result.final_cost
2329        );
2330        Ok(())
2331    }
2332
2333    /// Exercises the `JacobianMode::Dense + DenseQR` arm of `optimize()`.
2334    #[test]
2335    fn test_lm_dense_qr_solver() -> TestResult {
2336        let mut problem = Problem::new(JacobianMode::Dense);
2337        let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
2338        let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
2339        problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
2340        problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
2341
2342        let cfg = LevenbergMarquardtConfig::new()
2343            .with_linear_solver_type(LinearSolverType::DenseQR)
2344            .with_max_iterations(100);
2345        let mut solver = LevenbergMarquardt::with_config(cfg);
2346        let result = solver.optimize(&mut problem)?;
2347        assert!(
2348            result.final_cost < 1e-6,
2349            "Dense QR mode should converge Rosenbrock, got cost={}",
2350            result.final_cost
2351        );
2352        Ok(())
2353    }
2354
2355    // -------------------------------------------------------------------------
2356    // Covariance computation
2357    // -------------------------------------------------------------------------
2358
2359    /// Exercises the `if self.config.compute_covariances { ... }` block at convergence.
2360    /// This block was completely unreachable in prior tests.
2361    #[test]
2362    fn test_lm_compute_covariances_enabled() -> TestResult {
2363        let mut problem = rosenbrock_problem();
2364        let cfg = LevenbergMarquardtConfig::new()
2365            .with_max_iterations(100)
2366            .with_compute_covariances(true);
2367        let mut solver = LevenbergMarquardt::with_config(cfg);
2368        let result = solver.optimize(&mut problem)?;
2369        assert!(
2370            result.covariances.is_some(),
2371            "compute_covariances=true should populate result.covariances"
2372        );
2373        Ok(())
2374    }
2375
2376    // -------------------------------------------------------------------------
2377    // update_damping() direct unit tests
2378    // -------------------------------------------------------------------------
2379
2380    /// Nielsen: an accepted step decreases λ and resets ν.
2381    #[test]
2382    fn test_update_damping_accepted_step() {
2383        let cfg = LevenbergMarquardtConfig::new()
2384            .with_damping(1e-2)
2385            .with_damping_bounds(1e-15, 1e15);
2386        let mut solver = LevenbergMarquardt::with_config(cfg);
2387        let initial_damping = solver.damping;
2388
2389        solver.update_damping(0.8, true);
2390
2391        assert!(
2392            solver.damping < initial_damping,
2393            "accepted step should decrease damping: {} < {}",
2394            solver.damping,
2395            initial_damping
2396        );
2397        assert!(
2398            (solver.damping_nu - 2.0).abs() < 1e-15,
2399            "damping_nu should be reset to 2.0 after accepted step, got {}",
2400            solver.damping_nu
2401        );
2402    }
2403
2404    /// Nielsen: a rejected step increases λ and doubles ν.
2405    #[test]
2406    fn test_update_damping_rejected_step() {
2407        let cfg = LevenbergMarquardtConfig::new()
2408            .with_damping(1e-2)
2409            .with_damping_bounds(1e-15, 1e15);
2410        let initial_nu = cfg.damping_nu; // default 2.0
2411        let mut solver = LevenbergMarquardt::with_config(cfg);
2412        let initial_damping = solver.damping;
2413
2414        solver.update_damping(-0.5, false);
2415
2416        assert!(
2417            solver.damping > initial_damping,
2418            "rejected step should increase damping: {} > {}",
2419            solver.damping,
2420            initial_damping
2421        );
2422        assert!(
2423            (solver.damping_nu - initial_nu * 2.0).abs() < 1e-15,
2424            "damping_nu should double on rejected step: expected {}, got {}",
2425            initial_nu * 2.0,
2426            solver.damping_nu
2427        );
2428    }
2429
2430    // -------------------------------------------------------------------------
2431    // Untested config builder methods
2432    // -------------------------------------------------------------------------
2433
2434    /// Verifies `with_max_condition_number` and `with_min_relative_decrease` builder methods.
2435    #[test]
2436    fn test_lm_config_condition_number_and_relative_decrease() -> TestResult {
2437        let cfg = LevenbergMarquardtConfig::new()
2438            .with_max_condition_number(1e8)
2439            .with_min_relative_decrease(1e-4);
2440        let max_cond = cfg
2441            .max_condition_number
2442            .ok_or("max_condition_number should be Some")?;
2443        assert!((max_cond - 1e8).abs() < 1.0);
2444        assert!((cfg.min_relative_decrease - 1e-4).abs() < 1e-20);
2445        Ok(())
2446    }
2447
2448    // -------------------------------------------------------------------------
2449    // Observer integration
2450    // -------------------------------------------------------------------------
2451
2452    /// Verifies that `add_observer` registers an observer and `notify_complete` is called
2453    /// exactly once after optimization finishes.
2454    #[test]
2455    fn test_lm_add_observer_called_on_completion() -> TestResult {
2456        use crate::optimizer::OptObserver;
2457        use std::sync::{Arc, Mutex};
2458
2459        struct CountObserver {
2460            complete_calls: Arc<Mutex<usize>>,
2461        }
2462
2463        impl OptObserver for CountObserver {
2464            fn on_step(
2465                &self,
2466                _values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
2467                _iteration: usize,
2468            ) {
2469            }
2470
2471            fn on_optimization_complete(
2472                &self,
2473                _values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
2474                _iterations: usize,
2475            ) {
2476                if let Ok(mut guard) = self.complete_calls.lock() {
2477                    *guard += 1;
2478                }
2479            }
2480        }
2481
2482        let call_count = Arc::new(Mutex::new(0usize));
2483        let observer = CountObserver {
2484            complete_calls: Arc::clone(&call_count),
2485        };
2486
2487        let mut problem = rosenbrock_problem();
2488        let mut solver = LevenbergMarquardt::new();
2489        solver.add_observer(observer);
2490        let _ = solver.optimize(&mut problem)?;
2491
2492        let count = *call_count
2493            .lock()
2494            .map_err(|e| format!("mutex poisoned: {e}"))?;
2495        assert_eq!(
2496            count, 1,
2497            "on_optimization_complete should be called exactly once"
2498        );
2499        Ok(())
2500    }
2501
2502    /// A solver that does not match the problem's Jacobian mode must be
2503    /// rejected, not silently swapped for Cholesky.
2504    ///
2505    /// Regression for the `_ =>` arms that made LM the only optimizer to
2506    /// coerce a mismatch in silence.
2507    #[test]
2508    fn test_lm_rejects_solver_mode_mismatch() {
2509        fn rosenbrock(mode: JacobianMode) -> Problem {
2510            let mut problem = Problem::new(mode);
2511            let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
2512            let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
2513            problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
2514            problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
2515            problem
2516        }
2517
2518        // Dense problem, sparse solvers requested.
2519        for solver_type in [
2520            LinearSolverType::SparseCholesky,
2521            LinearSolverType::SparseQR,
2522            LinearSolverType::ExplicitSparseSchur,
2523            LinearSolverType::ImplicitSparseSchur,
2524        ] {
2525            let mut problem = rosenbrock(JacobianMode::Dense);
2526            let config = LevenbergMarquardtConfig::new().with_linear_solver_type(solver_type);
2527            let Err(err) = LevenbergMarquardt::with_config(config).optimize(&mut problem) else {
2528                panic!("dense problem must reject sparse solver {solver_type}");
2529            };
2530            assert!(
2531                err.to_string().contains("dense Jacobian mode"),
2532                "unexpected error for {solver_type}: {err}"
2533            );
2534        }
2535
2536        // Sparse problem, dense solvers requested.
2537        for solver_type in [
2538            LinearSolverType::DenseCholesky,
2539            LinearSolverType::DenseQR,
2540            LinearSolverType::ExplicitDenseSchur,
2541        ] {
2542            let mut problem = rosenbrock(JacobianMode::Sparse);
2543            let config = LevenbergMarquardtConfig::new().with_linear_solver_type(solver_type);
2544            let Err(err) = LevenbergMarquardt::with_config(config).optimize(&mut problem) else {
2545                panic!("sparse problem must reject dense solver {solver_type}");
2546            };
2547            assert!(
2548                err.to_string().contains("sparse Jacobian mode"),
2549                "unexpected error for {solver_type}: {err}"
2550            );
2551        }
2552    }
2553
2554    /// Every matching mode/solver pair must still run.
2555    #[test]
2556    fn test_lm_accepts_matching_solver_modes() -> TestResult {
2557        for (mode, solver_type) in [
2558            (JacobianMode::Dense, LinearSolverType::DenseCholesky),
2559            (JacobianMode::Dense, LinearSolverType::DenseQR),
2560            (JacobianMode::Sparse, LinearSolverType::SparseCholesky),
2561            (JacobianMode::Sparse, LinearSolverType::SparseQR),
2562        ] {
2563            let mut problem = Problem::new(mode);
2564            let x1 = problem.add_variable(ManifoldType::RN, dvector![-1.2]);
2565            let x2 = problem.add_variable(ManifoldType::RN, dvector![1.0]);
2566            problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
2567            problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
2568
2569            let config = LevenbergMarquardtConfig::new()
2570                .with_max_iterations(50)
2571                .with_linear_solver_type(solver_type);
2572            LevenbergMarquardt::with_config(config)
2573                .optimize(&mut problem)
2574                .map_err(|e| format!("{mode:?} + {solver_type} should be accepted: {e}"))?;
2575        }
2576        Ok(())
2577    }
2578}