apex_solver/optimizer/dog_leg.rs
1//! Dog Leg trust region optimization algorithm implementation.
2//!
3//! The Dog Leg method is a robust trust region algorithm for solving nonlinear least squares problems:
4//!
5//! ```text
6//! min f(x) = ½||r(x)||² = ½Σᵢ rᵢ(x)²
7//! ```
8//!
9//! where `r: ℝⁿ → ℝᵐ` is the residual vector function.
10//!
11//! # Algorithm Overview
12//!
13//! Powell's Dog Leg method constructs a piecewise linear path within a spherical trust region
14//! of radius Δ, connecting three key points:
15//!
16//! 1. **Origin** (current position)
17//! 2. **Cauchy Point** p_c = -α·g (optimal steepest descent step)
18//! 3. **Gauss-Newton Point** h_gn (full Newton step solving J^T·J·h = -J^T·r)
19//!
20//! The "dog leg" path travels from the origin to the Cauchy point, then from the Cauchy point
21//! toward the Gauss-Newton point, stopping at the trust region boundary.
22//!
23//! ## Step Selection Strategy
24//!
25//! Given trust region radius Δ, the algorithm selects a step h based on three cases:
26//!
27//! **Case 1: GN step inside trust region** (`||h_gn|| ≤ Δ`)
28//! ```text
29//! h = h_gn (full Gauss-Newton step)
30//! ```
31//!
32//! **Case 2: Even Cauchy point outside trust region** (`||p_c|| ≥ Δ`)
33//! ```text
34//! h = (Δ / ||g||) · (-g) (scaled steepest descent to boundary)
35//! ```
36//!
37//! **Case 3: Dog leg interpolation** (`||p_c|| < Δ < ||h_gn||`)
38//! ```text
39//! h(β) = p_c + β·(h_gn - p_c), where β ∈ [0,1] satisfies ||h(β)|| = Δ
40//! ```
41//!
42//! ## Cauchy Point Computation
43//!
44//! The Cauchy point is the optimal step along the steepest descent direction -g:
45//!
46//! ```text
47//! α = (g^T·g) / (g^T·H·g) where H = J^T·J
48//! p_c = -α·g
49//! ```
50//!
51//! This minimizes the quadratic model along the gradient direction.
52//!
53//! ## Trust Region Management
54//!
55//! The trust region radius Δ adapts based on the gain ratio:
56//!
57//! ```text
58//! ρ = (actual reduction) / (predicted reduction)
59//! ```
60//!
61//! **Good step** (`ρ > 0.75`): Increase radius `Δ ← max(Δ, 3·||h||)`
62//! **Poor step** (`ρ < 0.25`): Decrease radius `Δ ← Δ/2`
63//! **Moderate step**: Keep radius unchanged
64//!
65//! # Advanced Features (Ceres Solver Enhancements)
66//!
67//! This implementation includes several improvements from Google's Ceres Solver:
68//!
69//! ## 1. Adaptive μ Regularization
70//!
71//! Instead of solving `J^T·J·h = -J^T·r` directly, we solve:
72//!
73//! ```text
74//! (J^T·J + μI)·h = -J^T·r
75//! ```
76//!
77//! where μ adapts to handle ill-conditioned Hessians:
78//! - **Increases** (×10) when linear solve fails
79//! - **Decreases** (÷5) when steps are accepted
80//! - **Bounded** between `min_mu` (1e-8) and `max_mu` (1.0)
81//!
82//! Default `initial_mu = 1e-4` provides good numerical stability.
83//!
84//! ## 2. Numerically Robust Beta Computation
85//!
86//! When computing β for dog leg interpolation, solves: `||p_c + β·v||² = Δ²`
87//!
88//! Uses two formulas to avoid catastrophic cancellation:
89//! ```text
90//! If b ≤ 0: β = (-b + √(b²-ac)) / a (standard formula)
91//! If b > 0: β = -c / (b + √(b²-ac)) (alternative, avoids cancellation)
92//! ```
93//!
94//! ## 3. Step Reuse Mechanism
95//!
96//! When a step is rejected, caches the Gauss-Newton step, Cauchy point, and gradient.
97//! On the next iteration (with smaller Δ), reuses these cached values instead of
98//! recomputing them. This avoids expensive linear solves when trust region shrinks.
99//!
100//! **Safety limits:**
101//! - Maximum 5 consecutive reuses before forcing fresh computation
102//! - Cache invalidated when steps are accepted (parameters have moved)
103//!
104//! ## 4. Jacobi Scaling (Diagonal Preconditioning)
105//!
106//! Optionally applies column scaling to J before forming J^T·J:
107//! ```text
108//! S_ii = 1 / (1 + ||J_i||) where J_i is column i
109//! ```
110//!
111//! This creates an **elliptical trust region** instead of spherical, improving
112//! convergence for problems with mixed parameter scales.
113//!
114//! # Mathematical Background
115//!
116//! ## Why Dog Leg Works
117//!
118//! The dog leg path is a cheap approximation to the optimal trust region step
119//! (which would require solving a constrained optimization problem). It:
120//!
121//! 1. Exploits the fact that optimal steps often lie near the 2D subspace
122//! spanned by the gradient and Gauss-Newton directions
123//! 2. Provides global convergence guarantees (always finds descent direction)
124//! 3. Achieves local quadratic convergence (like Gauss-Newton near solution)
125//! 4. Requires only one linear solve per iteration (same as Gauss-Newton)
126//!
127//! ## Convergence Properties
128//!
129//! - **Global convergence**: Guaranteed descent at each iteration
130//! - **Local quadratic convergence**: Reduces to Gauss-Newton near solution
131//! - **Robustness**: Handles ill-conditioning via trust region + μ regularization
132//! - **Efficiency**: Comparable cost to Gauss-Newton with better reliability
133//!
134//! # When to Use
135//!
136//! Dog Leg is an excellent choice when:
137//! - You want explicit control over step size (via trust region radius)
138//! - The problem may be ill-conditioned
139//! - You need guaranteed descent at each iteration
140//! - Initial guess may be poor but you want reliable convergence
141//!
142//! Compared to alternatives:
143//! - **vs Gauss-Newton**: More robust but similar computational cost
144//! - **vs Levenberg-Marquardt**: Explicit trust region vs implicit damping
145//! - Both Dog Leg and LM are excellent general-purpose choices
146//!
147//! # Examples
148//!
149//! ## Basic usage
150//!
151//! ```no_run
152//! use apex_solver::optimizer::DogLeg;
153//! use apex_solver::core::problem::Problem;
154//! use apex_solver::JacobianMode;
155//!
156//! # type TestResult = Result<(), Box<dyn std::error::Error>>;
157//! # fn main() -> TestResult {
158//! let mut problem = Problem::new(JacobianMode::Sparse);
159//! // ... add residual blocks (factors) to problem ...
160//!
161//! let mut solver = DogLeg::new();
162//! let result = solver.optimize(&mut problem)?;
163//! # Ok(())
164//! # }
165//! ```
166//!
167//! ## Advanced configuration with Ceres enhancements
168//!
169//! ```no_run
170//! use apex_solver::optimizer::dog_leg::{DogLegConfig, DogLeg};
171//! use apex_solver::linalg::LinearSolverType;
172//!
173//! # fn main() {
174//! let config = DogLegConfig::new()
175//! .with_max_iterations(100)
176//! .with_trust_region_radius(1e4) // Large initial radius
177//! .with_trust_region_bounds(1e-3, 1e6) // Min/max radius
178//! .with_mu_params(1e-4, 1e-8, 1.0, 10.0) // Conservative regularization
179//! .with_jacobi_scaling(true) // Enable elliptical trust regions
180//! .with_step_reuse(true); // Enable Ceres-style caching
181//!
182//! let mut solver = DogLeg::with_config(config);
183//! # }
184//! ```
185//!
186//! # References
187//!
188//! - Powell, M. J. D. (1970). "A Hybrid Method for Nonlinear Equations". *Numerical Methods for Nonlinear Algebraic Equations*. Gordon and Breach.
189//! - Nocedal, J. & Wright, S. (2006). *Numerical Optimization* (2nd ed.). Springer. Chapter 4 (Trust Region Methods).
190//! - Madsen, K., Nielsen, H. B., & Tingleff, O. (2004). *Methods for Non-Linear Least Squares Problems* (2nd ed.). Chapter 6.
191//! - Conn, A. R., Gould, N. I. M., & Toint, P. L. (2000). *Trust-Region Methods*. SIAM.
192//! - Ceres Solver: <http://ceres-solver.org/> - Google's C++ nonlinear least squares library
193
194use crate::error::ErrorLogging;
195use crate::{core::problem, error, linalg, optimizer};
196use std::{fmt, time};
197use tracing::debug;
198
199use crate::linalg::{
200 CovarianceOptions, Damping, DenseCholeskySolver, DenseMode, DenseQRSolver, ExplicitDenseSchur,
201 ExplicitSchurVariant, ExplicitSparseSchur, ImplicitSparseSchur, JacobianMode, LinearSolver,
202 LinearSolverType, SchurPreconditioner, SparseCholeskySolver, SparseMode, SparseQRSolver,
203 StructureAware,
204};
205use crate::optimizer::{AssemblyBackend, IterationStats};
206
207/// Configuration parameters for the Dog Leg trust region optimizer.
208///
209/// Controls trust region management, convergence criteria, adaptive regularization,
210/// and Ceres Solver enhancements for the Dog Leg algorithm.
211///
212/// # Builder Pattern
213///
214/// All configuration options can be set using the builder pattern:
215///
216/// ```
217/// use apex_solver::optimizer::dog_leg::DogLegConfig;
218///
219/// let config = DogLegConfig::new()
220/// .with_max_iterations(100)
221/// .with_trust_region_radius(1e4)
222/// .with_mu_params(1e-4, 1e-8, 1.0, 10.0)
223/// .with_jacobi_scaling(true)
224/// .with_step_reuse(true);
225/// ```
226///
227/// # Trust Region Behavior
228///
229/// The trust region radius Δ controls the maximum allowed step size:
230///
231/// - **Initial radius** (`trust_region_radius`): Starting value (default: 1e4)
232/// - **Bounds** (`trust_region_min`, `trust_region_max`): Valid range (default: 1e-3 to 1e6)
233/// - **Adaptation**: Increases for good steps, decreases for poor steps
234///
235/// # Adaptive μ Regularization (Ceres Enhancement)
236///
237/// Controls the regularization parameter in `(J^T·J + μI)·h = -J^T·r`:
238///
239/// - `initial_mu`: Starting value (default: 1e-4 for numerical stability)
240/// - `min_mu`, `max_mu`: Bounds (default: 1e-8 to 1.0)
241/// - `mu_increase_factor`: Multiplier when solve fails (default: 10.0)
242///
243/// # Convergence Criteria
244///
245/// The optimizer terminates when ANY of the following conditions is met:
246///
247/// - **Cost tolerance**: `|cost_k - cost_{k-1}| < cost_tolerance`
248/// - **Parameter tolerance**: `||step|| < parameter_tolerance`
249/// - **Gradient tolerance**: `||J^T·r|| < gradient_tolerance`
250/// - **Maximum iterations**: `iteration >= max_iterations`
251/// - **Timeout**: `elapsed_time >= timeout`
252///
253/// # See Also
254///
255/// - [`DogLeg`] - The solver that uses this configuration
256/// - [`LevenbergMarquardtConfig`](crate::optimizer::levenberg_marquardt::LevenbergMarquardtConfig) - Alternative damping approach
257/// - [`GaussNewtonConfig`](crate::optimizer::gauss_newton::GaussNewtonConfig) - Undamped variant
258#[derive(Clone)]
259pub struct DogLegConfig {
260 /// Type of linear solver for the linear systems
261 pub linear_solver_type: linalg::LinearSolverType,
262 /// Maximum number of iterations
263 pub max_iterations: usize,
264 /// Convergence tolerance for cost function
265 pub cost_tolerance: f64,
266 /// Convergence tolerance for parameter updates
267 pub parameter_tolerance: f64,
268 /// Convergence tolerance for gradient norm
269 pub gradient_tolerance: f64,
270 /// Timeout duration
271 pub timeout: Option<time::Duration>,
272 /// Initial trust region radius
273 pub trust_region_radius: f64,
274 /// Minimum trust region radius
275 pub trust_region_min: f64,
276 /// Maximum trust region radius
277 pub trust_region_max: f64,
278 /// Trust region increase factor (for good steps, rho > 0.75)
279 pub trust_region_increase_factor: f64,
280 /// Trust region decrease factor (for poor steps, rho < 0.25)
281 pub trust_region_decrease_factor: f64,
282 /// Deprecated: superseded by
283 /// [`min_relative_decrease`](Self::min_relative_decrease).
284 ///
285 /// Both named the same thing — the ρ below which a step is rejected — and
286 /// only one can be the gate. `min_relative_decrease` won because it carries
287 /// Ceres' name and semantics and exists on the Levenberg-Marquardt config
288 /// too, so the acceptance rule is the same across optimizers.
289 #[deprecated(
290 since = "1.6.0",
291 note = "never read; the acceptance threshold is `min_relative_decrease`"
292 )]
293 pub min_step_quality: f64,
294 /// Good step quality threshold (typically 0.75)
295 pub good_step_quality: f64,
296 /// Poor step quality threshold (typically 0.25)
297 pub poor_step_quality: f64,
298 /// Use Jacobi column scaling (preconditioning)
299 pub use_jacobi_scaling: bool,
300
301 // Ceres-style adaptive mu regularization parameters
302 /// Initial mu regularization parameter for Gauss-Newton step
303 pub initial_mu: f64,
304 /// Minimum mu regularization parameter
305 pub min_mu: f64,
306 /// Maximum mu regularization parameter
307 pub max_mu: f64,
308 /// Factor to increase mu when linear solver fails
309 pub mu_increase_factor: f64,
310
311 // Ceres-style step reuse optimization
312 /// Enable step reuse after rejection (Ceres-style efficiency optimization)
313 pub enable_step_reuse: bool,
314
315 /// Minimum objective function cutoff (optional early termination)
316 ///
317 /// If set, optimization terminates when cost falls below this threshold.
318 /// Useful for early stopping when a "good enough" solution is acceptable.
319 ///
320 /// Default: None (disabled)
321 pub min_cost_threshold: Option<f64>,
322
323 /// Maximum condition number for Jacobian matrix (optional check)
324 ///
325 /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
326 /// threshold and terminates with IllConditionedJacobian status.
327 /// Note: Computing condition number is expensive, so this is disabled by default.
328 ///
329 /// Default: None (disabled)
330 pub max_condition_number: Option<f64>,
331
332 /// Minimum relative cost decrease for step acceptance
333 ///
334 /// Used in computing step quality (rho = actual_reduction / predicted_reduction).
335 /// Steps with rho < min_relative_decrease are rejected. Matches Ceres Solver's
336 /// min_relative_decrease parameter.
337 ///
338 /// Default: 1e-3 (Ceres-compatible)
339 pub min_relative_decrease: f64,
340
341 /// Compute per-variable covariance matrices (uncertainty estimation)
342 ///
343 /// When enabled, computes covariance by re-linearizing the problem at the
344 /// solution and inverting the Gauss-Newton Hessian `H = JᵀJ` after
345 /// convergence. The full covariance matrix is extracted into per-variable
346 /// blocks stored in both Variable structs and optimizer::SolverResult.
347 ///
348 /// Scaled (σ̂²·H⁻¹) versus unscaled (H⁻¹) covariance, the factorization
349 /// algorithm, and the pseudo-inverse cutoff are configured through
350 /// [`covariance_options`](Self::covariance_options).
351 ///
352 /// Default: false (to avoid performance overhead)
353 pub compute_covariances: bool,
354
355 /// Options for covariance estimation when `compute_covariances` is enabled.
356 ///
357 /// Defaults to unscaled `H⁻¹` via sparse Cholesky — the correct choice when
358 /// residuals are whitened by their measurement information matrix. Set
359 /// `apply_variance_scaling` for unweighted least squares, where the noise
360 /// scale `σ̂² = 2·cost/(m−n)` must be estimated from the fit. See
361 /// [`CovarianceOptions`](crate::linalg::covariance::CovarianceOptions).
362 pub covariance_options: CovarianceOptions,
363
364 /// [`ExplicitSparseSchur`] sub-variant (for
365 /// [`LinearSolverType::ExplicitSparseSchur`]).
366 ///
367 /// - [`ExplicitSchurVariant::Sparse`]: form S, sparse Cholesky. Most
368 /// accurate; memory is O(kept_dof²).
369 /// - [`ExplicitSchurVariant::Chunked`]: form S straight from `J`, never
370 /// materializing `JᵀJ`. Same algebra, far less memory on large problems.
371 /// - [`ExplicitSchurVariant::Iterative`]: form S, then PCG instead of
372 /// Cholesky.
373 ///
374 /// To never form `S` at all, use
375 /// [`LinearSolverType::ImplicitSparseSchur`] instead — a different
376 /// solver, not a variant of this one.
377 ///
378 /// The Cauchy point and predicted reduction use the solver's
379 /// `hessian_vec_product`, which every variant serves (the chunked path
380 /// evaluates `Jᵀ(J·v)` from the retained Jacobian).
381 ///
382 /// Default: `Sparse`
383 pub schur_variant: ExplicitSchurVariant,
384 /// Preconditioner for the PCG used by the iterative Schur variants.
385 ///
386 /// - [`SchurPreconditioner::None`]: unconditioned CG.
387 /// - [`SchurPreconditioner::BlockDiagonal`]: block diagonal of `H_kk`.
388 /// - [`SchurPreconditioner::SchurJacobi`]: block diagonal of `S` itself —
389 /// Ceres's `SCHUR_JACOBI`, and usually the best convergence.
390 ///
391 /// Ignored by the direct variants, which factorize instead of iterating.
392 ///
393 /// Default: `SchurJacobi`
394 pub schur_preconditioner: SchurPreconditioner,
395 /// Maximum PCG iterations per linear solve, for the iterative variants.
396 ///
397 /// Default: 200 (Ceres's default)
398 pub schur_cg_max_iterations: usize,
399 /// Relative residual tolerance ending a PCG solve, for the iterative
400 /// variants.
401 ///
402 /// Default: 1e-6
403 pub schur_cg_tolerance: f64,
404 /// Forcing-sequence parameter η for the Schur PCG paths (Ceres's
405 /// `Solver::Options::eta`).
406 ///
407 /// PCG stops once the quadratic model's relative improvement per iteration
408 /// falls below `η/i`, which is what makes early Newton steps cheap. `0.0`
409 /// disables the rule, leaving only `schur_cg_tolerance` and the iteration
410 /// cap — ask for that when you want an exact linear solve.
411 ///
412 /// Default: 0.1
413 pub schur_cg_q_tolerance: f64,
414
415 /// Deprecated: never read. Visualization goes through the observer pattern.
416 ///
417 /// Setting this has no effect. Attach an observer to the solver instead:
418 /// `solver.add_observer(RerunObserver::new(true)?)`, which is how
419 /// Levenberg-Marquardt has always done it and works for every optimizer.
420 ///
421 /// Default: false
422 #[cfg(feature = "visualization")]
423 #[deprecated(
424 since = "1.6.0",
425 note = "never read; attach a `RerunObserver` with `add_observer` instead"
426 )]
427 pub enable_visualization: bool,
428}
429
430impl Default for DogLegConfig {
431 #[allow(deprecated)]
432 fn default() -> Self {
433 Self {
434 linear_solver_type: linalg::LinearSolverType::default(),
435 // Ceres Solver default: 50 (changed from 100 for compatibility)
436 max_iterations: 50,
437 // Ceres Solver default: 1e-6 (changed from 1e-8 for compatibility)
438 cost_tolerance: 1e-6,
439 // Ceres Solver default: 1e-8 (unchanged)
440 parameter_tolerance: 1e-8,
441 // Ceres Solver default: 1e-10 (changed from 1e-8 for compatibility)
442 gradient_tolerance: 1e-10,
443 timeout: None,
444 // Ceres-style: larger initial radius for better global convergence
445 trust_region_radius: 1e4,
446 trust_region_min: 1e-12,
447 trust_region_max: 1e12,
448 // Ceres uses adaptive increase (max(radius, 3*step_norm)),
449 // but we keep factor for simpler config
450 trust_region_increase_factor: 3.0,
451 trust_region_decrease_factor: 0.5,
452 min_step_quality: 0.0,
453 good_step_quality: 0.75,
454 poor_step_quality: 0.25,
455 // Ceres-style: Enable diagonal scaling by default for elliptical trust region
456 use_jacobi_scaling: true,
457
458 // Ceres-style adaptive mu regularization defaults
459 // Start with more conservative regularization to avoid singular Hessian
460 initial_mu: 1e-4,
461 min_mu: 1e-8,
462 max_mu: 1.0,
463 mu_increase_factor: 10.0,
464
465 // Ceres-style step reuse optimization
466 enable_step_reuse: true,
467
468 // New Ceres-compatible termination parameters
469 min_cost_threshold: None,
470 max_condition_number: None,
471 min_relative_decrease: 1e-3,
472
473 compute_covariances: false,
474 covariance_options: CovarianceOptions::default(),
475 schur_variant: ExplicitSchurVariant::default(),
476 schur_preconditioner: SchurPreconditioner::default(),
477 schur_cg_max_iterations: 200,
478 schur_cg_tolerance: 1e-6,
479 schur_cg_q_tolerance: crate::linalg::schur::DEFAULT_ETA,
480 #[cfg(feature = "visualization")]
481 #[allow(deprecated)]
482 enable_visualization: false,
483 }
484 }
485}
486
487impl DogLegConfig {
488 /// Create a new Dog Leg configuration with default values.
489 pub fn new() -> Self {
490 Self::default()
491 }
492
493 /// Set the linear solver type
494 pub fn with_linear_solver_type(mut self, linear_solver_type: linalg::LinearSolverType) -> Self {
495 self.linear_solver_type = linear_solver_type;
496 self
497 }
498
499 /// Set the maximum number of iterations
500 pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
501 self.max_iterations = max_iterations;
502 self
503 }
504
505 /// Set the cost tolerance
506 pub fn with_cost_tolerance(mut self, cost_tolerance: f64) -> Self {
507 self.cost_tolerance = cost_tolerance;
508 self
509 }
510
511 /// Set the parameter tolerance
512 pub fn with_parameter_tolerance(mut self, parameter_tolerance: f64) -> Self {
513 self.parameter_tolerance = parameter_tolerance;
514 self
515 }
516
517 /// Set the gradient tolerance
518 pub fn with_gradient_tolerance(mut self, gradient_tolerance: f64) -> Self {
519 self.gradient_tolerance = gradient_tolerance;
520 self
521 }
522
523 /// Set the timeout duration
524 pub fn with_timeout(mut self, timeout: time::Duration) -> Self {
525 self.timeout = Some(timeout);
526 self
527 }
528
529 /// Set the initial trust region radius
530 pub fn with_trust_region_radius(mut self, radius: f64) -> Self {
531 self.trust_region_radius = radius;
532 self
533 }
534
535 /// Set the trust region radius bounds
536 pub fn with_trust_region_bounds(mut self, min: f64, max: f64) -> Self {
537 self.trust_region_min = min;
538 self.trust_region_max = max;
539 self
540 }
541
542 /// Set the trust region adjustment factors
543 pub fn with_trust_region_factors(mut self, increase: f64, decrease: f64) -> Self {
544 self.trust_region_increase_factor = increase;
545 self.trust_region_decrease_factor = decrease;
546 self
547 }
548
549 /// Set the trust region quality thresholds.
550 ///
551 /// `min_quality` is accepted for source compatibility but has no effect —
552 /// see [`DogLegConfig::min_step_quality`]. Use
553 /// [`with_min_relative_decrease`](Self::with_min_relative_decrease) to move
554 /// the acceptance threshold.
555 #[allow(deprecated)]
556 pub fn with_step_quality_thresholds(
557 mut self,
558 min_quality: f64,
559 poor_quality: f64,
560 good_quality: f64,
561 ) -> Self {
562 self.min_step_quality = min_quality;
563 self.poor_step_quality = poor_quality;
564 self.good_step_quality = good_quality;
565 self
566 }
567
568 /// Enable or disable Jacobi column scaling (preconditioning)
569 pub fn with_jacobi_scaling(mut self, use_jacobi_scaling: bool) -> Self {
570 self.use_jacobi_scaling = use_jacobi_scaling;
571 self
572 }
573
574 /// Set adaptive mu regularization parameters (Ceres-style)
575 pub fn with_mu_params(
576 mut self,
577 initial_mu: f64,
578 min_mu: f64,
579 max_mu: f64,
580 increase_factor: f64,
581 ) -> Self {
582 self.initial_mu = initial_mu;
583 self.min_mu = min_mu;
584 self.max_mu = max_mu;
585 self.mu_increase_factor = increase_factor;
586 self
587 }
588
589 /// Enable or disable step reuse optimization (Ceres-style)
590 pub fn with_step_reuse(mut self, enable_step_reuse: bool) -> Self {
591 self.enable_step_reuse = enable_step_reuse;
592 self
593 }
594
595 /// Set minimum objective function cutoff for early termination.
596 ///
597 /// When set, optimization terminates with MinCostThresholdReached status
598 /// if the cost falls below this threshold. Useful for early stopping when
599 /// a "good enough" solution is acceptable.
600 pub fn with_min_cost_threshold(mut self, min_cost: f64) -> Self {
601 self.min_cost_threshold = Some(min_cost);
602 self
603 }
604
605 /// Set maximum condition number for Jacobian matrix.
606 ///
607 /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
608 /// threshold and terminates with IllConditionedJacobian status.
609 /// Note: Computing condition number is expensive, disabled by default.
610 pub fn with_max_condition_number(mut self, max_cond: f64) -> Self {
611 self.max_condition_number = Some(max_cond);
612 self
613 }
614
615 /// Set minimum relative cost decrease for step acceptance.
616 ///
617 /// Steps with rho = (actual_reduction / predicted_reduction) below this
618 /// threshold are rejected. Default: 1e-3 (Ceres-compatible)
619 pub fn with_min_relative_decrease(mut self, min_decrease: f64) -> Self {
620 self.min_relative_decrease = min_decrease;
621 self
622 }
623
624 /// Enable or disable covariance computation (uncertainty estimation).
625 ///
626 /// When enabled, computes the full covariance matrix by inverting the Hessian
627 /// after convergence, then extracts per-variable covariance blocks.
628 pub fn with_compute_covariances(mut self, compute_covariances: bool) -> Self {
629 self.compute_covariances = compute_covariances;
630 self
631 }
632
633 /// Set the options used for covariance estimation.
634 ///
635 /// Controls scaled (`σ̂²·H⁻¹`) versus unscaled (`H⁻¹`) covariance, the
636 /// factorization algorithm (sparse Cholesky or dense SVD pseudo-inverse),
637 /// and the singular-value cutoff. Only takes effect when
638 /// `compute_covariances` is enabled.
639 pub fn with_covariance_options(mut self, options: CovarianceOptions) -> Self {
640 self.covariance_options = options;
641 self
642 }
643
644 /// Set the [`ExplicitSparseSchur`] sub-variant.
645 ///
646 /// Takes effect when `linear_solver_type` is
647 /// [`LinearSolverType::ExplicitSparseSchur`].
648 pub fn with_schur_variant(mut self, variant: ExplicitSchurVariant) -> Self {
649 self.schur_variant = variant;
650 self
651 }
652
653 /// Set Schur complement preconditioner for the iterative variants
654 pub fn with_schur_preconditioner(mut self, preconditioner: SchurPreconditioner) -> Self {
655 self.schur_preconditioner = preconditioner;
656 self
657 }
658
659 /// PCG budget for the iterative Schur variants.
660 pub fn with_schur_cg_params(mut self, max_iterations: usize, tolerance: f64) -> Self {
661 self.schur_cg_max_iterations = max_iterations;
662 self.schur_cg_tolerance = tolerance;
663 self
664 }
665
666 /// Set the PCG forcing-sequence parameter η. `0.0` disables the
667 /// quadratic-model stopping rule; see [`Self::schur_cg_q_tolerance`].
668 pub fn with_schur_cg_q_tolerance(mut self, q_tolerance: f64) -> Self {
669 self.schur_cg_q_tolerance = q_tolerance;
670 self
671 }
672
673 /// Deprecated: no-op. See [`enable_visualization`](Self::enable_visualization).
674 #[cfg(feature = "visualization")]
675 #[deprecated(
676 since = "1.6.0",
677 note = "no-op; attach a `RerunObserver` with `add_observer` instead"
678 )]
679 #[allow(deprecated)]
680 pub fn with_visualization(mut self, enable: bool) -> Self {
681 self.enable_visualization = enable;
682 self
683 }
684
685 /// Print configuration parameters (info level logging)
686 #[allow(deprecated)]
687 pub fn print_configuration(&self) {
688 debug!(
689 "Configuration:\n Solver: Dog-Leg\n Linear solver: {:?}\n Schur variant: {:?}\n Schur preconditioner: {:?}\n Schur CG budget: {} iters @ {:.2e}\n Loss function: N/A\n\nConvergence Criteria:\n Max iterations: {}\n Cost tolerance: {:.2e}\n Parameter tolerance: {:.2e}\n Gradient tolerance: {:.2e}\n Timeout: {:?}\n\nTrust Region:\n Initial radius: {:.2e}\n Radius range: [{:.2e}, {:.2e}]\n Min step quality: {:.2}\n Good step quality: {:.2}\n Poor step quality: {:.2}\n\nRegularization:\n Initial mu: {:.2e}\n Mu range: [{:.2e}, {:.2e}]\n Mu increase factor: {:.2}\n\nNumerical Settings:\n Jacobi scaling: {}\n Step reuse: {}\n Compute covariances: {}",
690 self.linear_solver_type,
691 self.schur_variant,
692 self.schur_preconditioner,
693 self.schur_cg_max_iterations,
694 self.schur_cg_tolerance,
695 self.max_iterations,
696 self.cost_tolerance,
697 self.parameter_tolerance,
698 self.gradient_tolerance,
699 self.timeout,
700 self.trust_region_radius,
701 self.trust_region_min,
702 self.trust_region_max,
703 self.min_step_quality,
704 self.good_step_quality,
705 self.poor_step_quality,
706 self.initial_mu,
707 self.min_mu,
708 self.max_mu,
709 self.mu_increase_factor,
710 if self.use_jacobi_scaling {
711 "enabled"
712 } else {
713 "disabled"
714 },
715 if self.enable_step_reuse {
716 "enabled"
717 } else {
718 "disabled"
719 },
720 if self.compute_covariances {
721 "enabled"
722 } else {
723 "disabled"
724 }
725 );
726 }
727}
728
729/// Result from step computation
730struct StepResult {
731 step: faer::Mat<f64>,
732 gradient_norm: f64,
733 predicted_reduction: f64,
734 /// ‖step‖ measured in the space the trust-region radius bounds.
735 ///
736 /// Equal to `‖step‖` when Jacobi scaling is off; when it is on, the radius
737 /// constrains the scaled step, so the radius update has to be fed this norm
738 /// and not the un-scaled one.
739 scaled_step_norm: f64,
740}
741
742/// Type of step taken
743#[derive(Debug, Clone, Copy)]
744enum StepType {
745 /// Full Gauss-Newton step
746 GaussNewton,
747 /// Scaled steepest descent (Cauchy point)
748 SteepestDescent,
749 /// Dog leg interpolation
750 DogLeg,
751}
752
753impl fmt::Display for StepType {
754 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
755 match self {
756 StepType::GaussNewton => write!(f, "GN"),
757 StepType::SteepestDescent => write!(f, "SD"),
758 StepType::DogLeg => write!(f, "DL"),
759 }
760 }
761}
762
763/// Result from step evaluation
764struct StepEvaluation {
765 accepted: bool,
766 cost_reduction: f64,
767 rho: f64,
768}
769
770/// Dog Leg trust region solver for nonlinear least squares optimization.
771///
772/// Implements Powell's Dog Leg algorithm with Ceres Solver enhancements including
773/// adaptive μ regularization, numerically robust beta computation, and step reuse caching.
774///
775/// # Algorithm
776///
777/// At each iteration k:
778/// 1. Compute residual `r(xₖ)` and Jacobian `J(xₖ)`
779/// 2. Solve for Gauss-Newton step: `(J^T·J + μI)·h_gn = -J^T·r`
780/// 3. Compute steepest descent direction: `-g` where `g = J^T·r`
781/// 4. Compute Cauchy point: `p_c = -α·g` (optimal along steepest descent)
782/// 5. Construct dog leg step based on trust region radius Δ:
783/// - If `||h_gn|| ≤ Δ`: Take full GN step
784/// - Else if `||p_c|| ≥ Δ`: Take scaled SD to boundary
785/// - Else: Interpolate `h = p_c + β·(h_gn - p_c)` where `||h|| = Δ`
786/// 6. Evaluate gain ratio: `ρ = (actual reduction) / (predicted reduction)`
787/// 7. Update trust region radius based on ρ
788/// 8. Accept/reject step and update parameters
789///
790/// # Ceres Solver Enhancements
791///
792/// This implementation includes four major improvements from Google's Ceres Solver:
793///
794/// **1. Adaptive μ Regularization:** Dynamically adjusts regularization parameter
795/// to handle ill-conditioned Hessians (increases on failure, decreases on success).
796///
797/// **2. Numerically Robust Beta:** Uses two formulas for computing dog leg
798/// interpolation parameter β to avoid catastrophic cancellation.
799///
800/// **3. Step Reuse Mechanism:** Caches GN step, Cauchy point, and gradient when
801/// steps are rejected. Limited to 5 consecutive reuses to prevent staleness.
802///
803/// **4. Jacobi Scaling:** Optional diagonal preconditioning creates elliptical
804/// trust regions for better handling of mixed-scale problems.
805///
806/// # Examples
807///
808/// ```no_run
809/// use apex_solver::optimizer::DogLeg;
810/// use apex_solver::core::problem::Problem;
811/// use apex_solver::JacobianMode;
812///
813/// # type TestResult = Result<(), Box<dyn std::error::Error>>;
814/// # fn main() -> TestResult {
815/// let mut problem = Problem::new(JacobianMode::Sparse);
816/// // ... add factors to problem ...
817///
818/// let mut solver = DogLeg::new();
819/// let result = solver.optimize(&mut problem)?;
820/// # Ok(())
821/// # }
822/// ```
823///
824/// # See Also
825///
826/// - [`DogLegConfig`] - Configuration options
827/// - [`LevenbergMarquardt`](crate::optimizer::LevenbergMarquardt) - Alternative adaptive damping
828/// - [`GaussNewton`](crate::optimizer::GaussNewton) - Undamped variant
829pub struct DogLeg {
830 config: DogLegConfig,
831 jacobi_scaling: Option<Vec<f64>>,
832 observers: optimizer::OptObserverVec,
833
834 // Adaptive mu regularization (Ceres-style)
835 mu: f64,
836 min_mu: f64,
837 max_mu: f64,
838 mu_increase_factor: f64,
839 /// Run state: the live trust-region radius, seeded from
840 /// `config.trust_region_radius` at the start of every solve.
841 ///
842 /// The radius is iteration state, not configuration. Keeping it here rather
843 /// than mutating `config` in place means a second `optimize()` call on the
844 /// same solver starts from the configured radius instead of inheriting
845 /// whatever the previous run collapsed to.
846 trust_region_radius: f64,
847
848 // Step reuse mechanism (Ceres-style efficiency optimization)
849 reuse_step_on_rejection: bool,
850 cached_gn_step: Option<faer::Mat<f64>>,
851 cached_cauchy_point: Option<faer::Mat<f64>>,
852 cached_gradient: Option<faer::Mat<f64>>,
853 cached_alpha: Option<f64>,
854 cache_reuse_count: usize, // Track consecutive reuses to prevent staleness
855}
856
857impl Default for DogLeg {
858 fn default() -> Self {
859 Self::new()
860 }
861}
862
863impl DogLeg {
864 /// Create a new Dog Leg solver with default configuration.
865 pub fn new() -> Self {
866 Self::with_config(DogLegConfig::default())
867 }
868
869 /// Create a new Dog Leg solver with the given configuration.
870 pub fn with_config(config: DogLegConfig) -> Self {
871 Self {
872 // Initialize adaptive mu from config
873 mu: config.initial_mu,
874 min_mu: config.min_mu,
875 max_mu: config.max_mu,
876 mu_increase_factor: config.mu_increase_factor,
877 trust_region_radius: config.trust_region_radius,
878
879 // Initialize step reuse mechanism (disabled initially, enabled after first rejection)
880 reuse_step_on_rejection: false,
881 cached_gn_step: None,
882 cached_cauchy_point: None,
883 cached_gradient: None,
884 cached_alpha: None,
885 cache_reuse_count: 0,
886
887 config,
888 jacobi_scaling: None,
889 observers: optimizer::OptObserverVec::new(),
890 }
891 }
892
893 /// Add an observer to the solver.
894 ///
895 /// Observers are notified at each iteration with the current variable values.
896 /// This enables real-time visualization, logging, metrics collection, etc.
897 ///
898 /// # Examples
899 ///
900 /// ```no_run
901 /// use apex_solver::optimizer::DogLeg;
902 /// # use apex_solver::optimizer::OptObserver;
903 /// # use apex_solver::core::VarKey;
904 /// # use apex_solver::core::variable::ManifoldVariable;
905 /// # use slotmap::SlotMap;
906 ///
907 /// # struct MyObserver;
908 /// # impl OptObserver for MyObserver {
909 /// # fn on_step(&self, _: &SlotMap<VarKey, Box<dyn ManifoldVariable>>, _: usize) {}
910 /// # }
911 /// let mut solver = DogLeg::new();
912 /// solver.add_observer(MyObserver);
913 /// ```
914 pub fn add_observer(&mut self, observer: impl optimizer::OptObserver + 'static) {
915 self.observers.add(observer);
916 }
917
918 /// Compute Cauchy point and optimal step length for steepest descent (generic).
919 ///
920 /// Returns (alpha, cauchy_point) where:
921 /// - alpha: optimal step length α = ||g||² / (g^T H g)
922 /// - cauchy_point: p_c = -α * g (the Cauchy point)
923 ///
924 /// `h_g` is `H·g`; only the Hessian's action is needed here, never the
925 /// matrix itself.
926 fn compute_cauchy_point_and_alpha_generic(
927 &self,
928 gradient: &faer::Mat<f64>,
929 h_g: &faer::Mat<f64>,
930 ) -> (f64, faer::Mat<f64>) {
931 // Optimal step size along steepest descent: α = (g^T*g) / (g^T*H*g)
932 let g_norm_sq_mat = gradient.transpose() * gradient;
933 let g_norm_sq = g_norm_sq_mat[(0, 0)];
934
935 let g_h_g_mat = gradient.transpose() * h_g;
936 let g_h_g = g_h_g_mat[(0, 0)];
937
938 // Avoid division by zero
939 let alpha = if g_h_g.abs() > 1e-15 {
940 g_norm_sq / g_h_g
941 } else {
942 1.0
943 };
944
945 // Compute Cauchy point: p_c = -α * gradient
946 let mut cauchy_point = faer::Mat::zeros(gradient.nrows(), 1);
947 for i in 0..gradient.nrows() {
948 cauchy_point[(i, 0)] = -alpha * gradient[(i, 0)];
949 }
950
951 (alpha, cauchy_point)
952 }
953
954 /// Compute the dog leg step using Powell's Dog Leg method
955 ///
956 /// The dog leg path consists of two segments:
957 /// 1. From origin to Cauchy point (optimal along steepest descent)
958 /// 2. From Cauchy point to Gauss-Newton step
959 ///
960 /// Arguments:
961 /// - steepest_descent_dir: -gradient (steepest descent direction, not scaled)
962 /// - cauchy_point: p_c = α * (-gradient), the optimal steepest descent step
963 /// - h_gn: Gauss-Newton step
964 /// - delta: Trust region radius
965 ///
966 /// Returns: (step, step_type)
967 fn compute_dog_leg_step(
968 &self,
969 steepest_descent_dir: &faer::Mat<f64>,
970 cauchy_point: &faer::Mat<f64>,
971 h_gn: &faer::Mat<f64>,
972 delta: f64,
973 ) -> (faer::Mat<f64>, StepType) {
974 let gn_norm = h_gn.norm_l2();
975 let cauchy_norm = cauchy_point.norm_l2();
976 let sd_norm = steepest_descent_dir.norm_l2();
977
978 // Case 1: Full Gauss-Newton step fits in trust region
979 if gn_norm <= delta {
980 return (h_gn.clone(), StepType::GaussNewton);
981 }
982
983 // Case 2: Even Cauchy point is outside trust region
984 // Scale steepest descent direction to boundary: (delta / ||δ_sd||) * δ_sd
985 if cauchy_norm >= delta {
986 let mut scaled_sd = faer::Mat::zeros(steepest_descent_dir.nrows(), 1);
987 let scale = delta / sd_norm;
988 for i in 0..steepest_descent_dir.nrows() {
989 scaled_sd[(i, 0)] = steepest_descent_dir[(i, 0)] * scale;
990 }
991 return (scaled_sd, StepType::SteepestDescent);
992 }
993
994 // Case 3: Dog leg interpolation between Cauchy point and GN step
995 // Use Ceres-style numerically robust formula
996 //
997 // Following Ceres solver implementation for numerical stability:
998 // Compute intersection of trust region boundary with line from Cauchy point to GN step
999 //
1000 // Let v = δ_gn - p_c
1001 // Solve: ||p_c + β*v||² = delta²
1002 // This gives: a*β² + 2*b*β + c = 0
1003 // where:
1004 // a = v^T·v = ||v||²
1005 // b = p_c^T·v
1006 // c = p_c^T·p_c - delta² = ||p_c||² - delta²
1007
1008 let mut v = faer::Mat::zeros(cauchy_point.nrows(), 1);
1009 for i in 0..cauchy_point.nrows() {
1010 v[(i, 0)] = h_gn[(i, 0)] - cauchy_point[(i, 0)];
1011 }
1012
1013 // Compute coefficients
1014 let v_squared_norm = v.transpose() * &v;
1015 let a = v_squared_norm[(0, 0)];
1016
1017 let pc_dot_v = cauchy_point.transpose() * &v;
1018 let b = pc_dot_v[(0, 0)];
1019
1020 let c = cauchy_norm * cauchy_norm - delta * delta;
1021
1022 // Ceres-style numerically robust beta computation
1023 // Uses two different formulas based on sign of b to avoid catastrophic cancellation
1024 let d_squared = b * b - a * c;
1025
1026 let beta = if d_squared < 0.0 {
1027 // Should not happen geometrically, but handle gracefully
1028 1.0
1029 } else if a.abs() < 1e-15 {
1030 // Degenerate case: v is nearly zero
1031 1.0
1032 } else {
1033 let d = d_squared.sqrt();
1034
1035 // Ceres formula: choose formula based on sign of b to avoid cancellation
1036 // If b <= 0: beta = (-b + d) / a (standard formula, no cancellation)
1037 // If b > 0: beta = -c / (b + d) (alternative formula, avoids cancellation)
1038 if b <= 0.0 { (-b + d) / a } else { -c / (b + d) }
1039 };
1040
1041 // Clamp beta to [0, 1] for safety
1042 let beta = beta.clamp(0.0, 1.0);
1043
1044 // Compute dog leg step: p_dl = p_c + β*(δ_gn - p_c)
1045 let mut dog_leg = faer::Mat::zeros(cauchy_point.nrows(), 1);
1046 for i in 0..cauchy_point.nrows() {
1047 dog_leg[(i, 0)] = cauchy_point[(i, 0)] + beta * v[(i, 0)];
1048 }
1049
1050 (dog_leg, StepType::DogLeg)
1051 }
1052
1053 /// Update the trust-region radius from the step quality (Ceres-style).
1054 ///
1055 /// `scaled_step_norm` must be measured in the same space the radius bounds —
1056 /// the *scaled* space when Jacobi scaling is on, which is Dog Leg's default.
1057 /// Feeding the un-scaled norm here would compare two different units and
1058 /// grow the radius by an arbitrary factor.
1059 fn update_trust_region(&mut self, rho: f64, scaled_step_norm: f64) -> bool {
1060 if rho > self.config.good_step_quality {
1061 // Good step: grow the region to at least `increase_factor` times the
1062 // step that just succeeded (Ceres' `max(radius, 3·‖step‖)`).
1063 let new_radius = self
1064 .trust_region_radius
1065 .max(self.config.trust_region_increase_factor * scaled_step_norm);
1066 self.trust_region_radius = new_radius.min(self.config.trust_region_max);
1067
1068 // Decrease mu on successful step (Ceres-style adaptive regularization)
1069 self.mu = (self.mu / (0.5 * self.mu_increase_factor)).max(self.min_mu);
1070
1071 // Clear reuse flag and invalidate cache on acceptance (parameters have moved)
1072 self.reuse_step_on_rejection = false;
1073 self.cached_gn_step = None;
1074 self.cached_cauchy_point = None;
1075 self.cached_gradient = None;
1076 self.cached_alpha = None;
1077 self.cache_reuse_count = 0;
1078
1079 true
1080 } else if rho < self.config.poor_step_quality {
1081 // Poor step, decrease trust region
1082 self.trust_region_radius = (self.trust_region_radius
1083 * self.config.trust_region_decrease_factor)
1084 .max(self.config.trust_region_min);
1085
1086 // Enable step reuse flag for next iteration (Ceres-style)
1087 self.reuse_step_on_rejection = self.config.enable_step_reuse;
1088
1089 false
1090 } else {
1091 // Moderate step, keep trust region unchanged
1092 // Clear reuse flag and invalidate cache on acceptance (parameters have moved)
1093 self.reuse_step_on_rejection = false;
1094 self.cached_gn_step = None;
1095 self.cached_cauchy_point = None;
1096 self.cached_gradient = None;
1097 self.cached_alpha = None;
1098 self.cache_reuse_count = 0;
1099
1100 true
1101 }
1102 }
1103
1104 /// Compute dog leg optimization step (generic over assembly mode).
1105 fn compute_optimization_step_generic<M: AssemblyBackend>(
1106 &mut self,
1107 residuals: &faer::Mat<f64>,
1108 scaled_jacobian: &M::Jacobian,
1109 linear_solver: &mut dyn LinearSolver<M>,
1110 ) -> Option<StepResult> {
1111 // Check if we can reuse cached step (Ceres-style optimization)
1112 // Safety limit: prevent excessive reuse that could lead to stale gradient/Hessian
1113 const MAX_CACHE_REUSE: usize = 5;
1114
1115 if self.reuse_step_on_rejection
1116 && self.config.enable_step_reuse
1117 && self.cache_reuse_count < MAX_CACHE_REUSE
1118 && let (Some(cached_gn), Some(cached_cauchy), Some(cached_grad), Some(_cached_a)) = (
1119 &self.cached_gn_step,
1120 &self.cached_cauchy_point,
1121 &self.cached_gradient,
1122 &self.cached_alpha,
1123 )
1124 {
1125 // Increment reuse counter
1126 self.cache_reuse_count += 1;
1127
1128 // `cached_grad` is the (possibly Jacobi-scaled) gradient the
1129 // solver cached; report `gradient_tolerance`'s convergence check
1130 // against the un-scaled one (GitHub #57).
1131 let gradient_norm = if self.config.use_jacobi_scaling {
1132 optimizer::unscaled_gradient_norm(cached_grad, self.jacobi_scaling.as_ref()?)
1133 } else {
1134 cached_grad.norm_l2()
1135 };
1136 let mut steepest_descent = faer::Mat::zeros(cached_grad.nrows(), 1);
1137 for i in 0..cached_grad.nrows() {
1138 steepest_descent[(i, 0)] = -cached_grad[(i, 0)];
1139 }
1140
1141 let (scaled_step, _step_type) = self.compute_dog_leg_step(
1142 &steepest_descent,
1143 cached_cauchy,
1144 cached_gn,
1145 self.trust_region_radius,
1146 );
1147
1148 let scaled_step_norm = scaled_step.norm_l2();
1149 let step = if self.config.use_jacobi_scaling {
1150 let scaling = self.jacobi_scaling.as_ref()?;
1151 M::apply_inverse_scaling(&scaled_step, scaling)
1152 } else {
1153 scaled_step.clone()
1154 };
1155
1156 // For cached reuse, we need the hessian for predicted reduction.
1157 // Use hessian from linear solver if available.
1158 let hessian_step = linear_solver.hessian_vec_product(&scaled_step)?;
1159 let predicted_reduction =
1160 optimizer::compute_predicted_reduction(&scaled_step, cached_grad, &hessian_step);
1161
1162 return Some(StepResult {
1163 step,
1164 gradient_norm,
1165 predicted_reduction,
1166 scaled_step_norm,
1167 });
1168 }
1169
1170 // Not reusing, compute fresh step
1171 // 1. Solve for Gauss-Newton step with adaptive mu regularization (Ceres-style)
1172 let mut scaled_gn_step = None;
1173 let mut mu_attempts = 0;
1174
1175 // Try to solve with current mu, increasing if necessary
1176 while mu_attempts < 10 && self.mu <= self.max_mu {
1177 // μ is a numerical stabiliser for the Gauss-Newton solve, not a
1178 // trust region — the region is enforced geometrically by the dog-leg
1179 // construction — so it is applied as uniform λI rather than the
1180 // Marquardt diagonal.
1181 let damping = Damping::identity(self.mu);
1182
1183 if let Ok(step) =
1184 linear_solver.solve_augmented_equation(residuals, scaled_jacobian, &damping)
1185 {
1186 scaled_gn_step = Some(step);
1187 break;
1188 }
1189
1190 // Increase mu (Ceres-style)
1191 self.mu = (self.mu * self.mu_increase_factor).min(self.max_mu);
1192 mu_attempts += 1;
1193 }
1194
1195 let scaled_gn_step = scaled_gn_step?;
1196
1197 // 2. Get gradient and Hessian (cached by solve_augmented_equation)
1198 let gradient = linear_solver.get_gradient()?;
1199 // `gradient` is the (possibly Jacobi-scaled) gradient against
1200 // `scaled_jacobian`; report `gradient_tolerance`'s convergence check
1201 // against the un-scaled one (GitHub #57). Every other use of
1202 // `gradient` below (steepest descent, Cauchy point, predicted
1203 // reduction) deliberately stays in the scaled space it was computed
1204 // in — only this reported norm changes.
1205 let gradient_norm = if self.config.use_jacobi_scaling {
1206 optimizer::unscaled_gradient_norm(gradient, self.jacobi_scaling.as_ref()?)
1207 } else {
1208 gradient.norm_l2()
1209 };
1210
1211 // 3. Compute steepest descent direction: δ_sd = -gradient
1212 let mut steepest_descent = faer::Mat::zeros(gradient.nrows(), 1);
1213 for i in 0..gradient.nrows() {
1214 steepest_descent[(i, 0)] = -gradient[(i, 0)];
1215 }
1216
1217 // 4. Compute Cauchy point and optimal step length
1218 let h_g = linear_solver.hessian_vec_product(gradient)?;
1219 let (alpha, cauchy_point) = self.compute_cauchy_point_and_alpha_generic(gradient, &h_g);
1220
1221 // 5. Compute dog leg step based on trust region radius
1222 let (scaled_step, _step_type) = self.compute_dog_leg_step(
1223 &steepest_descent,
1224 &cauchy_point,
1225 &scaled_gn_step,
1226 self.trust_region_radius,
1227 );
1228
1229 // 6. Apply inverse Jacobi scaling if enabled
1230 let scaled_step_norm = scaled_step.norm_l2();
1231 let step = if self.config.use_jacobi_scaling {
1232 let scaling = self.jacobi_scaling.as_ref()?;
1233 M::apply_inverse_scaling(&scaled_step, scaling)
1234 } else {
1235 scaled_step.clone()
1236 };
1237
1238 // 7. Compute predicted reduction
1239 let hessian_step = linear_solver.hessian_vec_product(&scaled_step)?;
1240 let predicted_reduction =
1241 optimizer::compute_predicted_reduction(&scaled_step, gradient, &hessian_step);
1242
1243 // 8. Cache step components for potential reuse (Ceres-style)
1244 self.cached_gn_step = Some(scaled_gn_step.clone());
1245 self.cached_cauchy_point = Some(cauchy_point.clone());
1246 self.cached_gradient = Some(gradient.clone());
1247 self.cached_alpha = Some(alpha);
1248
1249 Some(StepResult {
1250 step,
1251 gradient_norm,
1252 predicted_reduction,
1253 scaled_step_norm,
1254 })
1255 }
1256
1257 /// Evaluate and apply step
1258 fn evaluate_and_apply_step(
1259 &mut self,
1260 step_result: &StepResult,
1261 state: &mut optimizer::InitializedState,
1262 problem: &problem::Problem,
1263 ) -> error::ApexSolverResult<StepEvaluation> {
1264 // Apply parameter updates
1265 let _step_norm = optimizer::apply_parameter_step(
1266 &mut state.variables,
1267 step_result.step.as_ref(),
1268 &state.sorted_vars,
1269 );
1270
1271 // Compute new cost (residual only, no Jacobian needed for step evaluation).
1272 // Reuses the solve workspace instead of allocating one per iteration,
1273 // like Levenberg-Marquardt does.
1274 let (_new_residual, new_cost) = problem.compute_residual_and_cost_sparse_with_workspace(
1275 &state.variables,
1276 &mut state.workspace,
1277 )?;
1278
1279 // Compute step quality
1280 let rho = optimizer::compute_step_quality(
1281 state.current_cost,
1282 new_cost,
1283 step_result.predicted_reduction,
1284 );
1285
1286 // Accept on step quality, then resize the trust region. The two are
1287 // separate decisions: `min_relative_decrease` says whether the step was
1288 // worth keeping, `update_trust_region` says how far to trust the model
1289 // next time.
1290 let accepted = rho > self.config.min_relative_decrease;
1291 self.update_trust_region(rho, step_result.scaled_step_norm);
1292
1293 let cost_reduction = if accepted {
1294 let reduction = state.current_cost - new_cost;
1295 state.current_cost = new_cost;
1296 reduction
1297 } else {
1298 // Reject step - revert changes
1299 optimizer::apply_negative_parameter_step(
1300 &mut state.variables,
1301 step_result.step.as_ref(),
1302 &state.sorted_vars,
1303 );
1304 0.0
1305 };
1306
1307 Ok(StepEvaluation {
1308 accepted,
1309 cost_reduction,
1310 rho,
1311 })
1312 }
1313
1314 /// Run optimization using the specified assembly mode and linear solver.
1315 ///
1316 /// `state` is built once by [`Self::optimize`] and handed over here —
1317 /// see `LevenbergMarquardt::optimize_with_mode` for why: the Schur path
1318 /// needs the variable index map to size its block structure, and rebuilding
1319 /// it here would repeat the symbolic construction and cost evaluation.
1320 fn optimize_with_mode<M: AssemblyBackend>(
1321 &mut self,
1322 problem: &mut problem::Problem,
1323 linear_solver: &mut dyn LinearSolver<M>,
1324 mut state: optimizer::InitializedState,
1325 ) -> optimizer::OptimizeResult {
1326 let start_time = time::Instant::now();
1327 let mut iteration = 0;
1328 let mut cost_evaluations = 1;
1329 let mut jacobian_evaluations = 0;
1330 let mut successful_steps = 0;
1331 let mut unsuccessful_steps = 0;
1332
1333 // Seed the run state from the configuration. The radius, μ and the step
1334 // cache all evolve during the solve; resetting them here keeps
1335 // `optimize()` idempotent when the same solver instance is reused.
1336 self.trust_region_radius = self.config.trust_region_radius;
1337 self.mu = self.config.initial_mu;
1338 self.reuse_step_on_rejection = false;
1339 self.cached_gn_step = None;
1340 self.cached_cauchy_point = None;
1341 self.cached_gradient = None;
1342 self.cached_alpha = None;
1343 self.cache_reuse_count = 0;
1344 self.jacobi_scaling = None;
1345
1346 let mut max_gradient_norm: f64 = 0.0;
1347 let mut max_parameter_update_norm: f64 = 0.0;
1348 let mut total_cost_reduction = 0.0;
1349 let mut final_gradient_norm;
1350 let mut final_parameter_update_norm;
1351
1352 // Initialize iteration statistics tracking
1353 let mut iteration_stats = Vec::with_capacity(self.config.max_iterations);
1354 let mut previous_cost = state.current_cost;
1355
1356 // Print configuration and header if debug level is enabled
1357 if tracing::enabled!(tracing::Level::DEBUG) {
1358 self.config.print_configuration();
1359 IterationStats::print_header();
1360 }
1361
1362 loop {
1363 let iter_start = time::Instant::now();
1364
1365 // Shared preamble: assemble, conditioning check, Jacobi scaling
1366 let (residuals, scaled_jacobian) = match optimizer::iteration_preamble::<M>(
1367 problem,
1368 &mut state,
1369 &mut self.jacobi_scaling,
1370 self.config.use_jacobi_scaling,
1371 iteration,
1372 self.config.max_condition_number,
1373 &mut jacobian_evaluations,
1374 )? {
1375 optimizer::IterationPreamble::Proceed {
1376 residuals,
1377 scaled_jacobian,
1378 } => (residuals, scaled_jacobian),
1379 optimizer::IterationPreamble::EarlyExit(status) => {
1380 let elapsed = start_time.elapsed();
1381 self.observers.notify_complete(&state.variables, iteration);
1382 return Ok(optimizer::build_solver_result(
1383 status,
1384 iteration,
1385 state,
1386 elapsed,
1387 0.0,
1388 0.0,
1389 cost_evaluations,
1390 jacobian_evaluations,
1391 None,
1392 ));
1393 }
1394 };
1395
1396 // Compute dog leg step
1397 let step_result = match self.compute_optimization_step_generic::<M>(
1398 &residuals,
1399 &scaled_jacobian,
1400 linear_solver,
1401 ) {
1402 Some(result) => result,
1403 None => {
1404 return Err(optimizer::OptimizerError::LinearSolveFailed(
1405 "Linear solver failed to solve system".to_string(),
1406 )
1407 .into());
1408 }
1409 };
1410
1411 // Update tracking
1412 max_gradient_norm = max_gradient_norm.max(step_result.gradient_norm);
1413 final_gradient_norm = step_result.gradient_norm;
1414 let step_norm = step_result.step.norm_l2();
1415 max_parameter_update_norm = max_parameter_update_norm.max(step_norm);
1416 final_parameter_update_norm = step_norm;
1417
1418 // Evaluate and apply step
1419 let step_eval = self.evaluate_and_apply_step(&step_result, &mut state, problem)?;
1420 cost_evaluations += 1;
1421
1422 if step_eval.accepted {
1423 successful_steps += 1;
1424 total_cost_reduction += step_eval.cost_reduction;
1425 } else {
1426 unsuccessful_steps += 1;
1427 }
1428
1429 // OPTIMIZATION: Only collect iteration statistics if debug level is enabled
1430 if tracing::enabled!(tracing::Level::DEBUG) {
1431 let iter_elapsed_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
1432 let total_elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0;
1433
1434 let stats = IterationStats {
1435 iteration,
1436 cost: state.current_cost,
1437 cost_change: previous_cost - state.current_cost,
1438 gradient_norm: step_result.gradient_norm,
1439 step_norm,
1440 tr_ratio: step_eval.rho,
1441 tr_radius: self.trust_region_radius,
1442 ls_iter: 0,
1443 iter_time_ms: iter_elapsed_ms,
1444 total_time_ms: total_elapsed_ms,
1445 accepted: step_eval.accepted,
1446 };
1447
1448 iteration_stats.push(stats.clone());
1449 stats.print_line();
1450 }
1451
1452 previous_cost = state.current_cost;
1453
1454 // Notify all observers with current state
1455 optimizer::notify_observers_generic::<M>(
1456 &mut self.observers,
1457 &state.variables,
1458 iteration,
1459 state.current_cost,
1460 step_result.gradient_norm,
1461 Some(self.trust_region_radius),
1462 step_norm,
1463 Some(step_eval.rho),
1464 linear_solver,
1465 );
1466
1467 // Check convergence
1468 let elapsed = start_time.elapsed();
1469 let parameter_norm = optimizer::compute_parameter_norm(&state.variables);
1470 let new_cost = state.current_cost;
1471
1472 let cost_before_step = if step_eval.accepted {
1473 state.current_cost + step_eval.cost_reduction
1474 } else {
1475 state.current_cost
1476 };
1477
1478 if let Some(status) = optimizer::check_convergence(&optimizer::ConvergenceParams {
1479 iteration,
1480 current_cost: cost_before_step,
1481 new_cost,
1482 parameter_norm,
1483 parameter_update_norm: step_norm,
1484 gradient_norm: step_result.gradient_norm,
1485 elapsed,
1486 step_accepted: step_eval.accepted,
1487 max_iterations: self.config.max_iterations,
1488 gradient_tolerance: self.config.gradient_tolerance,
1489 parameter_tolerance: self.config.parameter_tolerance,
1490 cost_tolerance: self.config.cost_tolerance,
1491 min_cost_threshold: self.config.min_cost_threshold,
1492 timeout: self.config.timeout,
1493 trust_region_radius: Some(self.trust_region_radius),
1494 min_trust_region_radius: Some(self.config.trust_region_min),
1495 }) {
1496 // Print summary only if debug level is enabled
1497 if tracing::enabled!(tracing::Level::DEBUG) {
1498 let summary = optimizer::create_optimizer_summary(
1499 "Dog-Leg",
1500 state.initial_cost,
1501 state.current_cost,
1502 iteration + 1,
1503 Some(successful_steps),
1504 Some(unsuccessful_steps),
1505 max_gradient_norm,
1506 final_gradient_norm,
1507 max_parameter_update_norm,
1508 final_parameter_update_norm,
1509 total_cost_reduction,
1510 elapsed,
1511 iteration_stats.clone(),
1512 status.clone(),
1513 None,
1514 Some(self.trust_region_radius),
1515 None,
1516 );
1517 debug!("{}", summary);
1518 }
1519
1520 // Compute covariances if enabled
1521 let covariances = if self.config.compute_covariances {
1522 problem.compute_and_set_covariances(
1523 &mut state.variables,
1524 self.config.covariance_options,
1525 )
1526 } else {
1527 None
1528 };
1529
1530 return Ok(optimizer::build_solver_result(
1531 status,
1532 iteration + 1,
1533 state,
1534 elapsed,
1535 final_gradient_norm,
1536 final_parameter_update_norm,
1537 cost_evaluations,
1538 jacobian_evaluations,
1539 covariances,
1540 ));
1541 }
1542
1543 iteration += 1;
1544 }
1545 }
1546
1547 /// Run optimization, automatically selecting sparse or dense path based on config.
1548 ///
1549 /// Only solver/mode combinations that match are dispatched; anything else
1550 /// returns an error rather than silently substituting a different solver.
1551 /// `ExplicitSparseSchur`/`ImplicitSparseSchur` group residual rows by
1552 /// eliminated variable and initialize the Schur block structure from the
1553 /// shared initial state, exactly like Levenberg-Marquardt. The dog-leg
1554 /// construction itself is solver-agnostic: the Gauss-Newton step,
1555 /// gradient and Hessian-vector
1556 /// products all come through the [`LinearSolver`] trait, which every Schur
1557 /// variant serves.
1558 pub fn optimize(&mut self, problem: &mut problem::Problem) -> optimizer::OptimizeResult {
1559 // Chunk-wise elimination sweeps rows in chunk order, so each eliminated
1560 // variable's rows must be adjacent. Regroup before any structure is
1561 // built from the current layout — row order is a labelling, so this
1562 // leaves the problem and its cost unchanged.
1563 if matches!(
1564 self.config.linear_solver_type,
1565 LinearSolverType::ExplicitSparseSchur | LinearSolverType::ImplicitSparseSchur
1566 ) {
1567 problem.group_rows_for_elimination();
1568 }
1569
1570 // Built once here and moved into the chosen arm (see
1571 // `optimize_with_mode`).
1572 let state = optimizer::initialize_optimization_state(problem)?;
1573
1574 match problem.jacobian_mode {
1575 JacobianMode::Dense => match self.config.linear_solver_type {
1576 LinearSolverType::DenseQR => {
1577 let mut solver = DenseQRSolver::new();
1578 self.optimize_with_mode::<DenseMode>(problem, &mut solver, state)
1579 }
1580 LinearSolverType::DenseCholesky => {
1581 let mut solver = DenseCholeskySolver::new();
1582 self.optimize_with_mode::<DenseMode>(problem, &mut solver, state)
1583 }
1584 LinearSolverType::ExplicitDenseSchur => {
1585 let init = |e: linalg::LinAlgError| {
1586 optimizer::OptimizerError::LinearSolveFailed(format!(
1587 "Failed to initialize Schur solver: {e}"
1588 ))
1589 .log()
1590 };
1591 let mut solver = ExplicitDenseSchur::new();
1592 solver
1593 .initialize_structure(
1594 &state.variables,
1595 &state.variable_index_map,
1596 &problem.schur_landmark_keys,
1597 )
1598 .map_err(init)?;
1599 self.optimize_with_mode::<DenseMode>(problem, &mut solver, state)
1600 }
1601 other => Err(optimizer::OptimizerError::InvalidParameters(format!(
1602 "Dog Leg in dense Jacobian mode supports DenseCholesky, DenseQR and \
1603 ExplicitDenseSchur only; requested {other}"
1604 ))
1605 .into()),
1606 },
1607 JacobianMode::Sparse => match self.config.linear_solver_type {
1608 linalg::LinearSolverType::SparseQR => {
1609 let mut solver = SparseQRSolver::new();
1610 self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
1611 }
1612 linalg::LinearSolverType::SparseCholesky => {
1613 let mut solver = SparseCholeskySolver::new();
1614 self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
1615 }
1616 LinearSolverType::ExplicitSparseSchur => {
1617 let init = |e: linalg::LinAlgError| {
1618 optimizer::OptimizerError::LinearSolveFailed(format!(
1619 "Failed to initialize Schur solver: {e}"
1620 ))
1621 .log()
1622 };
1623 let mut solver = ExplicitSparseSchur::new()
1624 .with_variant(self.config.schur_variant)
1625 .with_preconditioner(self.config.schur_preconditioner)
1626 .with_cg_params(
1627 self.config.schur_cg_max_iterations,
1628 self.config.schur_cg_tolerance,
1629 )
1630 .with_cg_q_tolerance(self.config.schur_cg_q_tolerance);
1631 solver
1632 .initialize_structure(
1633 &state.variables,
1634 &state.variable_index_map,
1635 &problem.schur_landmark_keys,
1636 )
1637 .map_err(init)?;
1638 self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
1639 }
1640 LinearSolverType::ImplicitSparseSchur => {
1641 // Never forms S, so it is a different solver type rather
1642 // than a mode of the explicit one — this is what keeps
1643 // both the O(kept_dof²) buffer and `JᵀJ` itself out of
1644 // the picture entirely.
1645 let init = |e: linalg::LinAlgError| {
1646 optimizer::OptimizerError::LinearSolveFailed(format!(
1647 "Failed to initialize Schur solver: {e}"
1648 ))
1649 .log()
1650 };
1651 let mut solver = ImplicitSparseSchur::with_config(
1652 self.config.schur_cg_max_iterations,
1653 self.config.schur_cg_tolerance,
1654 self.config.schur_preconditioner,
1655 )
1656 .with_cg_q_tolerance(self.config.schur_cg_q_tolerance);
1657 solver
1658 .initialize_structure(
1659 &state.variables,
1660 &state.variable_index_map,
1661 &problem.schur_landmark_keys,
1662 )
1663 .map_err(init)?;
1664 self.optimize_with_mode::<SparseMode>(problem, &mut solver, state)
1665 }
1666 other => Err(optimizer::OptimizerError::InvalidParameters(format!(
1667 "Dog Leg in sparse Jacobian mode supports SparseCholesky, SparseQR, \
1668 ExplicitSparseSchur and ImplicitSparseSchur only; requested {other}"
1669 ))
1670 .into()),
1671 },
1672 }
1673 }
1674}
1675
1676impl optimizer::Optimizer for DogLeg {
1677 fn optimize(&mut self, problem: &mut problem::Problem) -> optimizer::OptimizeResult {
1678 self.optimize(problem)
1679 }
1680}
1681
1682#[cfg(test)]
1683mod tests {
1684 use super::*;
1685 use crate::factors;
1686 use apex_manifolds as manifold;
1687 use faer::prelude::ReborrowMut;
1688 use nalgebra;
1689
1690 type TestResult = Result<(), Box<dyn std::error::Error>>;
1691
1692 /// Custom Rosenbrock Factor 1: r1 = 10(x2 - x1²)
1693 /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1694 #[derive(Debug, Clone)]
1695 struct RosenbrockFactor1;
1696
1697 impl factors::Factor for RosenbrockFactor1 {
1698 fn linearize(
1699 &self,
1700 params: &[&[f64]],
1701 residual: &mut [f64],
1702 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1703 ) {
1704 let x1 = params[0][0];
1705 let x2 = params[1][0];
1706 residual[0] = 10.0 * (x2 - x1 * x1);
1707 if let Some(mut jac) = jacobian {
1708 *jac.rb_mut().get_mut(0, 0) = -20.0 * x1;
1709 *jac.rb_mut().get_mut(0, 1) = 10.0;
1710 }
1711 }
1712 fn residual_dim(&self) -> usize {
1713 1
1714 }
1715 fn jacobian_shape(&self) -> (usize, usize) {
1716 (1, 2)
1717 }
1718 }
1719
1720 /// Custom Rosenbrock Factor 2: r2 = 1 - x1
1721 /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1722 #[derive(Debug, Clone)]
1723 struct RosenbrockFactor2;
1724
1725 impl factors::Factor for RosenbrockFactor2 {
1726 fn linearize(
1727 &self,
1728 params: &[&[f64]],
1729 residual: &mut [f64],
1730 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1731 ) {
1732 residual[0] = 1.0 - params[0][0];
1733 if let Some(mut jac) = jacobian {
1734 *jac.rb_mut().get_mut(0, 0) = -1.0;
1735 }
1736 }
1737 fn residual_dim(&self) -> usize {
1738 1
1739 }
1740 fn jacobian_shape(&self) -> (usize, usize) {
1741 (1, 1)
1742 }
1743 }
1744
1745 #[test]
1746 fn test_rosenbrock_optimization() -> TestResult {
1747 // Rosenbrock function test:
1748 // Minimize: r1² + r2² where
1749 // r1 = 10(x2 - x1²)
1750 // r2 = 1 - x1
1751 // Starting point: [-1.2, 1.0]
1752 // Expected minimum: [1.0, 1.0]
1753
1754 let mut problem = problem::Problem::new(JacobianMode::Sparse);
1755 let x1 = problem.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![-1.2]);
1756 let x2 = problem.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![1.0]);
1757
1758 // Add custom factors (demonstrates extensibility!)
1759 problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1760 problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1761
1762 // Configure Dog Leg optimizer with appropriate trust region
1763 let config = DogLegConfig::new()
1764 .with_max_iterations(100)
1765 .with_cost_tolerance(1e-8)
1766 .with_parameter_tolerance(1e-8)
1767 .with_gradient_tolerance(1e-10)
1768 .with_trust_region_radius(10.0); // Start with larger trust region
1769
1770 let mut solver = DogLeg::with_config(config);
1771 let result = solver.optimize(&mut problem)?;
1772
1773 // Extract final values
1774 let x1_final = result.parameters[x1].as_param_slice()[0];
1775 let x2_final = result.parameters[x2].as_param_slice()[0];
1776
1777 // Verify convergence to [1.0, 1.0]
1778 assert!(
1779 matches!(
1780 result.status,
1781 optimizer::OptimizationStatus::Converged
1782 | optimizer::OptimizationStatus::CostToleranceReached
1783 | optimizer::OptimizationStatus::ParameterToleranceReached
1784 | optimizer::OptimizationStatus::GradientToleranceReached
1785 ),
1786 "Optimization should converge"
1787 );
1788 assert!(
1789 (x1_final - 1.0).abs() < 1e-4,
1790 "x1 should converge to 1.0, got {}",
1791 x1_final
1792 );
1793 assert!(
1794 (x2_final - 1.0).abs() < 1e-4,
1795 "x2 should converge to 1.0, got {}",
1796 x2_final
1797 );
1798 assert!(
1799 result.final_cost < 1e-6,
1800 "Final cost should be near zero, got {}",
1801 result.final_cost
1802 );
1803 Ok(())
1804 }
1805
1806 /// Trivial factor: r = x - target, J = [[1.0]]
1807 struct LinearFactor {
1808 target: f64,
1809 }
1810
1811 impl factors::Factor for LinearFactor {
1812 fn linearize(
1813 &self,
1814 params: &[&[f64]],
1815 residual: &mut [f64],
1816 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1817 ) {
1818 residual[0] = params[0][0] - self.target;
1819 if let Some(mut jac) = jacobian {
1820 *jac.rb_mut().get_mut(0, 0) = 1.0;
1821 }
1822 }
1823 fn residual_dim(&self) -> usize {
1824 1
1825 }
1826 fn jacobian_shape(&self) -> (usize, usize) {
1827 (1, 1)
1828 }
1829 }
1830
1831 fn rosenbrock_problem() -> problem::Problem {
1832 let mut prob = problem::Problem::new(JacobianMode::Sparse);
1833 let x1 = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![-1.2]);
1834 let x2 = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![1.0]);
1835 prob.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1836 prob.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1837 prob
1838 }
1839
1840 // -------------------------------------------------------------------------
1841 // Ceres-compatibility config fields
1842 // -------------------------------------------------------------------------
1843
1844 /// `min_relative_decrease` replaces the hardcoded `rho > 1e-4` gate.
1845 #[test]
1846 fn dogleg_min_relative_decrease_gates_acceptance() -> TestResult {
1847 let mut permissive_problem = rosenbrock_problem();
1848 let permissive = DogLeg::with_config(
1849 DogLegConfig::new()
1850 .with_max_iterations(100)
1851 .with_min_relative_decrease(1e-3),
1852 )
1853 .optimize(&mut permissive_problem)?;
1854
1855 let mut strict_problem = rosenbrock_problem();
1856 let strict = DogLeg::with_config(
1857 DogLegConfig::new()
1858 .with_max_iterations(100)
1859 .with_min_relative_decrease(0.999_999),
1860 )
1861 .optimize(&mut strict_problem)?;
1862
1863 assert!(
1864 strict.final_cost > permissive.final_cost,
1865 "a near-1.0 acceptance threshold should leave the cost higher: \
1866 strict {:.3e} vs permissive {:.3e}",
1867 strict.final_cost,
1868 permissive.final_cost
1869 );
1870 Ok(())
1871 }
1872
1873 /// `trust_region_increase_factor` drives the radius growth that used to be a
1874 /// hardcoded `3.0`.
1875 ///
1876 /// The initial radius has to be small for this to be observable: the growth
1877 /// rule is `radius ← max(radius, factor · ‖step‖)`, so while the radius
1878 /// still dwarfs the step — as it does from the default 1e4 on this problem —
1879 /// the factor is multiplied by a number that never wins the `max`.
1880 #[test]
1881 fn dogleg_trust_region_increase_factor_is_read() -> TestResult {
1882 let run = |factor: f64| -> Result<usize, Box<dyn std::error::Error>> {
1883 let mut problem = rosenbrock_problem();
1884 let result = DogLeg::with_config(
1885 DogLegConfig::new()
1886 .with_max_iterations(200)
1887 .with_trust_region_radius(1e-3)
1888 .with_trust_region_factors(factor, 0.5),
1889 )
1890 .optimize(&mut problem)?;
1891 Ok(result.iterations)
1892 };
1893
1894 let fast = run(50.0)?;
1895 let slow = run(1.0)?;
1896
1897 assert!(
1898 fast < slow,
1899 "a 50x radius growth factor should reach the solution in fewer \
1900 iterations than a 1x (never-grow) factor: {fast} vs {slow}"
1901 );
1902 Ok(())
1903 }
1904
1905 /// The radius is run state, so a reused solver reproduces its first result.
1906 #[test]
1907 fn dogleg_repeated_optimize_calls_are_reproducible() -> TestResult {
1908 let mut solver = DogLeg::with_config(DogLegConfig::new().with_max_iterations(100));
1909
1910 let mut first_problem = rosenbrock_problem();
1911 let first = solver.optimize(&mut first_problem)?;
1912 let mut second_problem = rosenbrock_problem();
1913 let second = solver.optimize(&mut second_problem)?;
1914
1915 assert_eq!(
1916 first.iterations, second.iterations,
1917 "reusing a solver changed the iteration count: {} then {}",
1918 first.iterations, second.iterations
1919 );
1920 assert!(
1921 (first.final_cost - second.final_cost).abs() < 1e-15,
1922 "reusing a solver changed the final cost: {:.17e} then {:.17e}",
1923 first.final_cost,
1924 second.final_cost
1925 );
1926 Ok(())
1927 }
1928
1929 /// `max_condition_number` detects a variable no residual constrains.
1930 #[test]
1931 fn dogleg_max_condition_number_detects_an_unconstrained_variable() -> TestResult {
1932 let mut problem = rosenbrock_problem();
1933 let _orphan = problem.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![0.0]);
1934
1935 let result = DogLeg::with_config(
1936 DogLegConfig::new()
1937 .with_max_iterations(10)
1938 .with_max_condition_number(1e12),
1939 )
1940 .optimize(&mut problem)?;
1941
1942 assert_eq!(
1943 result.status,
1944 optimizer::OptimizationStatus::IllConditionedJacobian
1945 );
1946 Ok(())
1947 }
1948
1949 fn linear_problem(start: f64) -> problem::Problem {
1950 let mut prob = problem::Problem::new(JacobianMode::Sparse);
1951 let x = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![start]);
1952 prob.add_residual_block(&[x], Box::new(LinearFactor { target: 0.0 }), None);
1953 prob
1954 }
1955
1956 // -------------------------------------------------------------------------
1957 // DogLegConfig builder tests
1958 // -------------------------------------------------------------------------
1959
1960 #[test]
1961 fn test_dl_config_default() {
1962 let cfg = DogLegConfig::default();
1963 assert_eq!(cfg.max_iterations, 50);
1964 assert!((cfg.cost_tolerance - 1e-6).abs() < 1e-15);
1965 assert!(cfg.use_jacobi_scaling); // DogLeg enables Jacobi scaling by default
1966 assert!(!cfg.compute_covariances);
1967 assert!(cfg.enable_step_reuse);
1968 }
1969
1970 #[test]
1971 // Asserts that the deprecated `min_step_quality` setter still stores its
1972 // value; the field is no longer read by the algorithm.
1973 #[allow(deprecated)]
1974 fn test_dl_config_builders() {
1975 use crate::linalg::LinearSolverType;
1976 let cfg = DogLegConfig::new()
1977 .with_max_iterations(20)
1978 .with_cost_tolerance(1e-5)
1979 .with_parameter_tolerance(1e-6)
1980 .with_gradient_tolerance(1e-7)
1981 .with_trust_region_radius(200.0)
1982 .with_trust_region_bounds(1e-15, 1e15)
1983 .with_trust_region_factors(4.0, 0.3)
1984 .with_step_quality_thresholds(0.01, 0.3, 0.7)
1985 .with_jacobi_scaling(false)
1986 .with_mu_params(1e-3, 1e-10, 2.0, 5.0)
1987 .with_step_reuse(false)
1988 .with_min_cost_threshold(1e-9)
1989 .with_compute_covariances(true)
1990 .with_linear_solver_type(LinearSolverType::SparseQR);
1991 assert_eq!(cfg.max_iterations, 20);
1992 assert!((cfg.trust_region_radius - 200.0).abs() < 1e-10);
1993 assert!(!cfg.use_jacobi_scaling);
1994 assert!(!cfg.enable_step_reuse);
1995 assert!(cfg.min_cost_threshold.is_some());
1996 assert!(cfg.compute_covariances);
1997 assert!((cfg.min_step_quality - 0.01).abs() < 1e-15);
1998 assert!((cfg.poor_step_quality - 0.3).abs() < 1e-15);
1999 assert!((cfg.good_step_quality - 0.7).abs() < 1e-15);
2000 assert!((cfg.initial_mu - 1e-3).abs() < 1e-15);
2001 }
2002
2003 #[test]
2004 fn test_dl_print_configuration_no_panic() {
2005 DogLegConfig::default().print_configuration();
2006 }
2007
2008 #[test]
2009 fn test_dl_default_equals_new() {
2010 let _a = DogLeg::new();
2011 let _b = DogLeg::default();
2012 }
2013
2014 #[test]
2015 fn test_dl_with_config_method() {
2016 let cfg = DogLegConfig::new().with_max_iterations(5);
2017 let _solver = DogLeg::with_config(cfg);
2018 }
2019
2020 // -------------------------------------------------------------------------
2021 // Convergence termination paths
2022 // -------------------------------------------------------------------------
2023
2024 #[test]
2025 fn test_dl_max_iterations_termination() -> TestResult {
2026 let mut problem = rosenbrock_problem();
2027 let cfg = DogLegConfig::new().with_max_iterations(2);
2028 let mut solver = DogLeg::with_config(cfg);
2029 let result = solver.optimize(&mut problem)?;
2030 assert_eq!(
2031 result.status,
2032 optimizer::OptimizationStatus::MaxIterationsReached
2033 );
2034 assert!(result.iterations <= 3, "iterations={}", result.iterations);
2035 Ok(())
2036 }
2037
2038 #[test]
2039 fn test_dl_gradient_tolerance_convergence() -> TestResult {
2040 let mut problem = linear_problem(1.0);
2041 let cfg = DogLegConfig::new()
2042 .with_gradient_tolerance(1e3)
2043 .with_cost_tolerance(1e-20)
2044 .with_parameter_tolerance(1e-20);
2045 let mut solver = DogLeg::with_config(cfg);
2046 let result = solver.optimize(&mut problem)?;
2047 assert_eq!(
2048 result.status,
2049 optimizer::OptimizationStatus::GradientToleranceReached
2050 );
2051 Ok(())
2052 }
2053
2054 #[test]
2055 fn test_dl_cost_tolerance_convergence() -> TestResult {
2056 let mut problem = rosenbrock_problem();
2057 let cfg = DogLegConfig::new()
2058 .with_cost_tolerance(1e2) // very loose
2059 .with_gradient_tolerance(1e-20)
2060 .with_parameter_tolerance(1e-20);
2061 let mut solver = DogLeg::with_config(cfg);
2062 let result = solver.optimize(&mut problem)?;
2063 assert!(matches!(
2064 result.status,
2065 optimizer::OptimizationStatus::CostToleranceReached
2066 | optimizer::OptimizationStatus::GradientToleranceReached
2067 | optimizer::OptimizationStatus::ParameterToleranceReached
2068 | optimizer::OptimizationStatus::Converged
2069 ));
2070 Ok(())
2071 }
2072
2073 #[test]
2074 fn test_dl_qr_solver() -> TestResult {
2075 use crate::linalg::LinearSolverType;
2076 let mut problem = rosenbrock_problem();
2077 let cfg = DogLegConfig::new()
2078 .with_linear_solver_type(LinearSolverType::SparseQR)
2079 .with_max_iterations(100);
2080 let mut solver = DogLeg::with_config(cfg);
2081 let result = solver.optimize(&mut problem)?;
2082 assert!(result.final_cost < 1e-6);
2083 Ok(())
2084 }
2085
2086 #[test]
2087 fn test_dl_jacobi_scaling_disabled() -> TestResult {
2088 // DogLeg has Jacobi scaling ON by default; test with it explicitly disabled
2089 let mut problem = rosenbrock_problem();
2090 let cfg = DogLegConfig::new()
2091 .with_jacobi_scaling(false)
2092 .with_max_iterations(100);
2093 let mut solver = DogLeg::with_config(cfg);
2094 let result = solver.optimize(&mut problem)?;
2095 assert!(result.final_cost < 1e-6);
2096 Ok(())
2097 }
2098
2099 /// A residual whose two columns have very different norms (100 vs 1),
2100 /// so Jacobi scaling actually changes the gradient it would report if
2101 /// left un-corrected.
2102 struct IllScaledFactor {
2103 a: f64,
2104 }
2105
2106 impl factors::Factor for IllScaledFactor {
2107 fn linearize(
2108 &self,
2109 params: &[&[f64]],
2110 residual: &mut [f64],
2111 jacobian: Option<faer::mat::MatMut<'_, f64>>,
2112 ) {
2113 let x1 = params[0][0];
2114 let x2 = params[1][0];
2115 residual[0] = self.a * x1 - 1.0;
2116 residual[1] = x2 - 1.0;
2117 if let Some(mut jac) = jacobian {
2118 *jac.rb_mut().get_mut(0, 0) = self.a;
2119 *jac.rb_mut().get_mut(0, 1) = 0.0;
2120 *jac.rb_mut().get_mut(1, 0) = 0.0;
2121 *jac.rb_mut().get_mut(1, 1) = 1.0;
2122 }
2123 }
2124 fn residual_dim(&self) -> usize {
2125 2
2126 }
2127 fn jacobian_shape(&self) -> (usize, usize) {
2128 (2, 2)
2129 }
2130 }
2131
2132 /// GitHub #57: the reported `gradient_norm` must be `‖Jᵀr‖` from the
2133 /// *un-scaled* Jacobian — matching `gradient_tolerance`'s documented
2134 /// contract — regardless of Jacobi column scaling, which DogLeg enables
2135 /// by default. `IllScaledFactor`'s columns (norm 100 vs 1) make the
2136 /// scaled and un-scaled gradients differ by roughly two orders of
2137 /// magnitude, so a stale scaled value is easy to detect.
2138 fn assert_dl_gradient_norm_matches_unscaled_jacobian(use_jacobi_scaling: bool) -> TestResult {
2139 let mut problem = problem::Problem::new(JacobianMode::Sparse);
2140 let x1 = problem.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![0.0]);
2141 let x2 = problem.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![0.0]);
2142 problem.add_residual_block(&[x1, x2], Box::new(IllScaledFactor { a: 100.0 }), None);
2143
2144 let mut state = crate::optimizer::initialize_optimization_state(&mut problem)?;
2145 let (residuals, jacobian) = SparseMode::assemble(
2146 &problem,
2147 &state.variables,
2148 &state.variable_index_map,
2149 state.symbolic_structure.as_ref(),
2150 state.total_dof,
2151 &mut state.workspace,
2152 )?;
2153
2154 // Reference: ||J^T r|| from a plain, un-scaled solve.
2155 let mut reference_solver = SparseCholeskySolver::new();
2156 reference_solver.solve_normal_equation(&residuals, &jacobian)?;
2157 let expected = reference_solver
2158 .get_gradient()
2159 .ok_or("reference solver produced no gradient")?
2160 .norm_l2();
2161
2162 let cfg = DogLegConfig::new().with_jacobi_scaling(use_jacobi_scaling);
2163 let mut solver = DogLeg::with_config(cfg);
2164
2165 let solver_jacobian = if use_jacobi_scaling {
2166 crate::optimizer::process_jacobian_generic::<SparseMode>(
2167 &jacobian,
2168 &mut solver.jacobi_scaling,
2169 0,
2170 )?
2171 } else {
2172 jacobian.clone()
2173 };
2174
2175 let mut linear_solver = SparseCholeskySolver::new();
2176 let step_result = solver
2177 .compute_optimization_step_generic::<SparseMode>(
2178 &residuals,
2179 &solver_jacobian,
2180 &mut linear_solver,
2181 )
2182 .ok_or("compute_optimization_step_generic returned None")?;
2183
2184 assert!(
2185 (step_result.gradient_norm - expected).abs() < 1e-6 * expected.max(1.0),
2186 "gradient_norm {} disagrees with the un-scaled ||J^T r|| {} \
2187 (use_jacobi_scaling = {})",
2188 step_result.gradient_norm,
2189 expected,
2190 use_jacobi_scaling,
2191 );
2192 Ok(())
2193 }
2194
2195 #[test]
2196 fn test_dl_gradient_norm_matches_unscaled_jacobian_without_scaling() -> TestResult {
2197 assert_dl_gradient_norm_matches_unscaled_jacobian(false)
2198 }
2199
2200 #[test]
2201 fn test_dl_gradient_norm_matches_unscaled_jacobian_with_scaling() -> TestResult {
2202 assert_dl_gradient_norm_matches_unscaled_jacobian(true)
2203 }
2204
2205 #[test]
2206 fn test_dl_min_cost_threshold() -> TestResult {
2207 let mut problem = rosenbrock_problem();
2208 let cfg = DogLegConfig::new()
2209 .with_min_cost_threshold(1e10)
2210 .with_cost_tolerance(1e-20)
2211 .with_gradient_tolerance(1e-20)
2212 .with_parameter_tolerance(1e-20);
2213 let mut solver = DogLeg::with_config(cfg);
2214 let result = solver.optimize(&mut problem)?;
2215 assert_eq!(
2216 result.status,
2217 optimizer::OptimizationStatus::MinCostThresholdReached
2218 );
2219 Ok(())
2220 }
2221
2222 #[test]
2223 fn test_dl_trust_region_radius_in_config() -> TestResult {
2224 let mut problem = rosenbrock_problem();
2225 let cfg = DogLegConfig::new()
2226 .with_trust_region_radius(0.1) // small initial radius
2227 .with_max_iterations(200);
2228 let mut solver = DogLeg::with_config(cfg);
2229 let result = solver.optimize(&mut problem)?;
2230 assert!(result.iterations > 0);
2231 Ok(())
2232 }
2233
2234 #[test]
2235 fn test_dl_step_reuse_config() -> TestResult {
2236 let mut problem = rosenbrock_problem();
2237 let cfg = DogLegConfig::new()
2238 .with_step_reuse(true)
2239 .with_max_iterations(100);
2240 let mut solver = DogLeg::with_config(cfg);
2241 let result = solver.optimize(&mut problem)?;
2242 assert!(result.final_cost < 1e-6);
2243 Ok(())
2244 }
2245
2246 #[test]
2247 fn test_dl_mu_params_config() {
2248 let cfg = DogLegConfig::new().with_mu_params(1e-2, 1e-9, 10.0, 20.0);
2249 assert!((cfg.initial_mu - 1e-2).abs() < 1e-15);
2250 assert!((cfg.min_mu - 1e-9).abs() < 1e-20);
2251 assert!((cfg.max_mu - 10.0).abs() < 1e-12);
2252 assert!((cfg.mu_increase_factor - 20.0).abs() < 1e-12);
2253 }
2254
2255 #[test]
2256 // Asserts that the deprecated `min_step_quality` setter still stores its
2257 // value; the field is no longer read by the algorithm.
2258 #[allow(deprecated)]
2259 fn test_dl_step_quality_thresholds() {
2260 let cfg = DogLegConfig::new().with_step_quality_thresholds(0.05, 0.25, 0.8);
2261 assert!((cfg.min_step_quality - 0.05).abs() < 1e-15);
2262 assert!((cfg.poor_step_quality - 0.25).abs() < 1e-15);
2263 assert!((cfg.good_step_quality - 0.8).abs() < 1e-15);
2264 }
2265
2266 #[test]
2267 fn test_dl_result_fields() -> TestResult {
2268 let mut problem = rosenbrock_problem();
2269 let mut solver = DogLeg::new();
2270 let result = solver.optimize(&mut problem)?;
2271 assert!(result.initial_cost > result.final_cost);
2272 assert!(result.iterations > 0);
2273 assert!(result.convergence_info.is_some());
2274 Ok(())
2275 }
2276
2277 #[test]
2278 fn test_dl_timeout_config() {
2279 let cfg = DogLegConfig::new().with_timeout(time::Duration::from_secs(60));
2280 assert!(cfg.timeout.is_some());
2281 }
2282}