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}