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::{core::problem, error, linalg, optimizer};
195use std::{fmt, time};
196use tracing::debug;
197
198use crate::linalg::{
199 DenseCholeskySolver, DenseMode, DenseQRSolver, JacobianMode, LinearSolver, LinearSolverType,
200 SparseCholeskySolver, SparseMode, SparseQRSolver,
201};
202use crate::optimizer::{AssemblyBackend, IterationStats};
203
204/// Configuration parameters for the Dog Leg trust region optimizer.
205///
206/// Controls trust region management, convergence criteria, adaptive regularization,
207/// and Ceres Solver enhancements for the Dog Leg algorithm.
208///
209/// # Builder Pattern
210///
211/// All configuration options can be set using the builder pattern:
212///
213/// ```
214/// use apex_solver::optimizer::dog_leg::DogLegConfig;
215///
216/// let config = DogLegConfig::new()
217/// .with_max_iterations(100)
218/// .with_trust_region_radius(1e4)
219/// .with_mu_params(1e-4, 1e-8, 1.0, 10.0)
220/// .with_jacobi_scaling(true)
221/// .with_step_reuse(true);
222/// ```
223///
224/// # Trust Region Behavior
225///
226/// The trust region radius Δ controls the maximum allowed step size:
227///
228/// - **Initial radius** (`trust_region_radius`): Starting value (default: 1e4)
229/// - **Bounds** (`trust_region_min`, `trust_region_max`): Valid range (default: 1e-3 to 1e6)
230/// - **Adaptation**: Increases for good steps, decreases for poor steps
231///
232/// # Adaptive μ Regularization (Ceres Enhancement)
233///
234/// Controls the regularization parameter in `(J^T·J + μI)·h = -J^T·r`:
235///
236/// - `initial_mu`: Starting value (default: 1e-4 for numerical stability)
237/// - `min_mu`, `max_mu`: Bounds (default: 1e-8 to 1.0)
238/// - `mu_increase_factor`: Multiplier when solve fails (default: 10.0)
239///
240/// # Convergence Criteria
241///
242/// The optimizer terminates when ANY of the following conditions is met:
243///
244/// - **Cost tolerance**: `|cost_k - cost_{k-1}| < cost_tolerance`
245/// - **Parameter tolerance**: `||step|| < parameter_tolerance`
246/// - **Gradient tolerance**: `||J^T·r|| < gradient_tolerance`
247/// - **Maximum iterations**: `iteration >= max_iterations`
248/// - **Timeout**: `elapsed_time >= timeout`
249///
250/// # See Also
251///
252/// - [`DogLeg`] - The solver that uses this configuration
253/// - [`LevenbergMarquardtConfig`](crate::optimizer::levenberg_marquardt::LevenbergMarquardtConfig) - Alternative damping approach
254/// - [`GaussNewtonConfig`](crate::optimizer::gauss_newton::GaussNewtonConfig) - Undamped variant
255#[derive(Clone)]
256pub struct DogLegConfig {
257 /// Type of linear solver for the linear systems
258 pub linear_solver_type: linalg::LinearSolverType,
259 /// Maximum number of iterations
260 pub max_iterations: usize,
261 /// Convergence tolerance for cost function
262 pub cost_tolerance: f64,
263 /// Convergence tolerance for parameter updates
264 pub parameter_tolerance: f64,
265 /// Convergence tolerance for gradient norm
266 pub gradient_tolerance: f64,
267 /// Timeout duration
268 pub timeout: Option<time::Duration>,
269 /// Initial trust region radius
270 pub trust_region_radius: f64,
271 /// Minimum trust region radius
272 pub trust_region_min: f64,
273 /// Maximum trust region radius
274 pub trust_region_max: f64,
275 /// Trust region increase factor (for good steps, rho > 0.75)
276 pub trust_region_increase_factor: f64,
277 /// Trust region decrease factor (for poor steps, rho < 0.25)
278 pub trust_region_decrease_factor: f64,
279 /// Minimum step quality for acceptance (typically 0.0)
280 pub min_step_quality: f64,
281 /// Good step quality threshold (typically 0.75)
282 pub good_step_quality: f64,
283 /// Poor step quality threshold (typically 0.25)
284 pub poor_step_quality: f64,
285 /// Use Jacobi column scaling (preconditioning)
286 pub use_jacobi_scaling: bool,
287
288 // Ceres-style adaptive mu regularization parameters
289 /// Initial mu regularization parameter for Gauss-Newton step
290 pub initial_mu: f64,
291 /// Minimum mu regularization parameter
292 pub min_mu: f64,
293 /// Maximum mu regularization parameter
294 pub max_mu: f64,
295 /// Factor to increase mu when linear solver fails
296 pub mu_increase_factor: f64,
297
298 // Ceres-style step reuse optimization
299 /// Enable step reuse after rejection (Ceres-style efficiency optimization)
300 pub enable_step_reuse: bool,
301
302 /// Minimum objective function cutoff (optional early termination)
303 ///
304 /// If set, optimization terminates when cost falls below this threshold.
305 /// Useful for early stopping when a "good enough" solution is acceptable.
306 ///
307 /// Default: None (disabled)
308 pub min_cost_threshold: Option<f64>,
309
310 /// Maximum condition number for Jacobian matrix (optional check)
311 ///
312 /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
313 /// threshold and terminates with IllConditionedJacobian status.
314 /// Note: Computing condition number is expensive, so this is disabled by default.
315 ///
316 /// Default: None (disabled)
317 pub max_condition_number: Option<f64>,
318
319 /// Minimum relative cost decrease for step acceptance
320 ///
321 /// Used in computing step quality (rho = actual_reduction / predicted_reduction).
322 /// Steps with rho < min_relative_decrease are rejected. Matches Ceres Solver's
323 /// min_relative_decrease parameter.
324 ///
325 /// Default: 1e-3 (Ceres-compatible)
326 pub min_relative_decrease: f64,
327
328 /// Compute per-variable covariance matrices (uncertainty estimation)
329 ///
330 /// When enabled, computes covariance by inverting the Hessian matrix after
331 /// convergence. The full covariance matrix is extracted into per-variable
332 /// blocks stored in both Variable structs and optimizer::SolverResult.
333 ///
334 /// Default: false (to avoid performance overhead)
335 pub compute_covariances: bool,
336
337 /// Enable real-time visualization (graphical debugging).
338 ///
339 /// When enabled, optimization progress is logged to a Rerun viewer.
340 /// **Note:** Requires the `visualization` feature to be enabled in `Cargo.toml`.
341 ///
342 /// Default: false
343 #[cfg(feature = "visualization")]
344 pub enable_visualization: bool,
345}
346
347impl Default for DogLegConfig {
348 fn default() -> Self {
349 Self {
350 linear_solver_type: linalg::LinearSolverType::default(),
351 // Ceres Solver default: 50 (changed from 100 for compatibility)
352 max_iterations: 50,
353 // Ceres Solver default: 1e-6 (changed from 1e-8 for compatibility)
354 cost_tolerance: 1e-6,
355 // Ceres Solver default: 1e-8 (unchanged)
356 parameter_tolerance: 1e-8,
357 // Ceres Solver default: 1e-10 (changed from 1e-8 for compatibility)
358 gradient_tolerance: 1e-10,
359 timeout: None,
360 // Ceres-style: larger initial radius for better global convergence
361 trust_region_radius: 1e4,
362 trust_region_min: 1e-12,
363 trust_region_max: 1e12,
364 // Ceres uses adaptive increase (max(radius, 3*step_norm)),
365 // but we keep factor for simpler config
366 trust_region_increase_factor: 3.0,
367 trust_region_decrease_factor: 0.5,
368 min_step_quality: 0.0,
369 good_step_quality: 0.75,
370 poor_step_quality: 0.25,
371 // Ceres-style: Enable diagonal scaling by default for elliptical trust region
372 use_jacobi_scaling: true,
373
374 // Ceres-style adaptive mu regularization defaults
375 // Start with more conservative regularization to avoid singular Hessian
376 initial_mu: 1e-4,
377 min_mu: 1e-8,
378 max_mu: 1.0,
379 mu_increase_factor: 10.0,
380
381 // Ceres-style step reuse optimization
382 enable_step_reuse: true,
383
384 // New Ceres-compatible termination parameters
385 min_cost_threshold: None,
386 max_condition_number: None,
387 min_relative_decrease: 1e-3,
388
389 compute_covariances: false,
390 #[cfg(feature = "visualization")]
391 enable_visualization: false,
392 }
393 }
394}
395
396impl DogLegConfig {
397 /// Create a new Dog Leg configuration with default values.
398 pub fn new() -> Self {
399 Self::default()
400 }
401
402 /// Set the linear solver type
403 pub fn with_linear_solver_type(mut self, linear_solver_type: linalg::LinearSolverType) -> Self {
404 self.linear_solver_type = linear_solver_type;
405 self
406 }
407
408 /// Set the maximum number of iterations
409 pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
410 self.max_iterations = max_iterations;
411 self
412 }
413
414 /// Set the cost tolerance
415 pub fn with_cost_tolerance(mut self, cost_tolerance: f64) -> Self {
416 self.cost_tolerance = cost_tolerance;
417 self
418 }
419
420 /// Set the parameter tolerance
421 pub fn with_parameter_tolerance(mut self, parameter_tolerance: f64) -> Self {
422 self.parameter_tolerance = parameter_tolerance;
423 self
424 }
425
426 /// Set the gradient tolerance
427 pub fn with_gradient_tolerance(mut self, gradient_tolerance: f64) -> Self {
428 self.gradient_tolerance = gradient_tolerance;
429 self
430 }
431
432 /// Set the timeout duration
433 pub fn with_timeout(mut self, timeout: time::Duration) -> Self {
434 self.timeout = Some(timeout);
435 self
436 }
437
438 /// Set the initial trust region radius
439 pub fn with_trust_region_radius(mut self, radius: f64) -> Self {
440 self.trust_region_radius = radius;
441 self
442 }
443
444 /// Set the trust region radius bounds
445 pub fn with_trust_region_bounds(mut self, min: f64, max: f64) -> Self {
446 self.trust_region_min = min;
447 self.trust_region_max = max;
448 self
449 }
450
451 /// Set the trust region adjustment factors
452 pub fn with_trust_region_factors(mut self, increase: f64, decrease: f64) -> Self {
453 self.trust_region_increase_factor = increase;
454 self.trust_region_decrease_factor = decrease;
455 self
456 }
457
458 /// Set the trust region quality thresholds
459 pub fn with_step_quality_thresholds(
460 mut self,
461 min_quality: f64,
462 poor_quality: f64,
463 good_quality: f64,
464 ) -> Self {
465 self.min_step_quality = min_quality;
466 self.poor_step_quality = poor_quality;
467 self.good_step_quality = good_quality;
468 self
469 }
470
471 /// Enable or disable Jacobi column scaling (preconditioning)
472 pub fn with_jacobi_scaling(mut self, use_jacobi_scaling: bool) -> Self {
473 self.use_jacobi_scaling = use_jacobi_scaling;
474 self
475 }
476
477 /// Set adaptive mu regularization parameters (Ceres-style)
478 pub fn with_mu_params(
479 mut self,
480 initial_mu: f64,
481 min_mu: f64,
482 max_mu: f64,
483 increase_factor: f64,
484 ) -> Self {
485 self.initial_mu = initial_mu;
486 self.min_mu = min_mu;
487 self.max_mu = max_mu;
488 self.mu_increase_factor = increase_factor;
489 self
490 }
491
492 /// Enable or disable step reuse optimization (Ceres-style)
493 pub fn with_step_reuse(mut self, enable_step_reuse: bool) -> Self {
494 self.enable_step_reuse = enable_step_reuse;
495 self
496 }
497
498 /// Set minimum objective function cutoff for early termination.
499 ///
500 /// When set, optimization terminates with MinCostThresholdReached status
501 /// if the cost falls below this threshold. Useful for early stopping when
502 /// a "good enough" solution is acceptable.
503 pub fn with_min_cost_threshold(mut self, min_cost: f64) -> Self {
504 self.min_cost_threshold = Some(min_cost);
505 self
506 }
507
508 /// Set maximum condition number for Jacobian matrix.
509 ///
510 /// If set, the optimizer checks if condition_number(J^T*J) exceeds this
511 /// threshold and terminates with IllConditionedJacobian status.
512 /// Note: Computing condition number is expensive, disabled by default.
513 pub fn with_max_condition_number(mut self, max_cond: f64) -> Self {
514 self.max_condition_number = Some(max_cond);
515 self
516 }
517
518 /// Set minimum relative cost decrease for step acceptance.
519 ///
520 /// Steps with rho = (actual_reduction / predicted_reduction) below this
521 /// threshold are rejected. Default: 1e-3 (Ceres-compatible)
522 pub fn with_min_relative_decrease(mut self, min_decrease: f64) -> Self {
523 self.min_relative_decrease = min_decrease;
524 self
525 }
526
527 /// Enable or disable covariance computation (uncertainty estimation).
528 ///
529 /// When enabled, computes the full covariance matrix by inverting the Hessian
530 /// after convergence, then extracts per-variable covariance blocks.
531 pub fn with_compute_covariances(mut self, compute_covariances: bool) -> Self {
532 self.compute_covariances = compute_covariances;
533 self
534 }
535
536 /// Enable real-time visualization.
537 ///
538 /// **Note:** Requires the `visualization` feature to be enabled in `Cargo.toml`.
539 ///
540 /// # Arguments
541 ///
542 /// * `enable` - Whether to enable visualization
543 #[cfg(feature = "visualization")]
544 pub fn with_visualization(mut self, enable: bool) -> Self {
545 self.enable_visualization = enable;
546 self
547 }
548
549 /// Print configuration parameters (info level logging)
550 pub fn print_configuration(&self) {
551 debug!(
552 "Configuration:\n Solver: Dog-Leg\n Linear solver: {:?}\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: {}",
553 self.linear_solver_type,
554 self.max_iterations,
555 self.cost_tolerance,
556 self.parameter_tolerance,
557 self.gradient_tolerance,
558 self.timeout,
559 self.trust_region_radius,
560 self.trust_region_min,
561 self.trust_region_max,
562 self.min_step_quality,
563 self.good_step_quality,
564 self.poor_step_quality,
565 self.initial_mu,
566 self.min_mu,
567 self.max_mu,
568 self.mu_increase_factor,
569 if self.use_jacobi_scaling {
570 "enabled"
571 } else {
572 "disabled"
573 },
574 if self.enable_step_reuse {
575 "enabled"
576 } else {
577 "disabled"
578 },
579 if self.compute_covariances {
580 "enabled"
581 } else {
582 "disabled"
583 }
584 );
585 }
586}
587
588/// Result from step computation
589struct StepResult {
590 step: faer::Mat<f64>,
591 gradient_norm: f64,
592 predicted_reduction: f64,
593}
594
595/// Type of step taken
596#[derive(Debug, Clone, Copy)]
597enum StepType {
598 /// Full Gauss-Newton step
599 GaussNewton,
600 /// Scaled steepest descent (Cauchy point)
601 SteepestDescent,
602 /// Dog leg interpolation
603 DogLeg,
604}
605
606impl fmt::Display for StepType {
607 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
608 match self {
609 StepType::GaussNewton => write!(f, "GN"),
610 StepType::SteepestDescent => write!(f, "SD"),
611 StepType::DogLeg => write!(f, "DL"),
612 }
613 }
614}
615
616/// Result from step evaluation
617struct StepEvaluation {
618 accepted: bool,
619 cost_reduction: f64,
620 rho: f64,
621}
622
623/// Dog Leg trust region solver for nonlinear least squares optimization.
624///
625/// Implements Powell's Dog Leg algorithm with Ceres Solver enhancements including
626/// adaptive μ regularization, numerically robust beta computation, and step reuse caching.
627///
628/// # Algorithm
629///
630/// At each iteration k:
631/// 1. Compute residual `r(xₖ)` and Jacobian `J(xₖ)`
632/// 2. Solve for Gauss-Newton step: `(J^T·J + μI)·h_gn = -J^T·r`
633/// 3. Compute steepest descent direction: `-g` where `g = J^T·r`
634/// 4. Compute Cauchy point: `p_c = -α·g` (optimal along steepest descent)
635/// 5. Construct dog leg step based on trust region radius Δ:
636/// - If `||h_gn|| ≤ Δ`: Take full GN step
637/// - Else if `||p_c|| ≥ Δ`: Take scaled SD to boundary
638/// - Else: Interpolate `h = p_c + β·(h_gn - p_c)` where `||h|| = Δ`
639/// 6. Evaluate gain ratio: `ρ = (actual reduction) / (predicted reduction)`
640/// 7. Update trust region radius based on ρ
641/// 8. Accept/reject step and update parameters
642///
643/// # Ceres Solver Enhancements
644///
645/// This implementation includes four major improvements from Google's Ceres Solver:
646///
647/// **1. Adaptive μ Regularization:** Dynamically adjusts regularization parameter
648/// to handle ill-conditioned Hessians (increases on failure, decreases on success).
649///
650/// **2. Numerically Robust Beta:** Uses two formulas for computing dog leg
651/// interpolation parameter β to avoid catastrophic cancellation.
652///
653/// **3. Step Reuse Mechanism:** Caches GN step, Cauchy point, and gradient when
654/// steps are rejected. Limited to 5 consecutive reuses to prevent staleness.
655///
656/// **4. Jacobi Scaling:** Optional diagonal preconditioning creates elliptical
657/// trust regions for better handling of mixed-scale problems.
658///
659/// # Examples
660///
661/// ```no_run
662/// use apex_solver::optimizer::DogLeg;
663/// use apex_solver::core::problem::Problem;
664/// use apex_solver::JacobianMode;
665///
666/// # type TestResult = Result<(), Box<dyn std::error::Error>>;
667/// # fn main() -> TestResult {
668/// let mut problem = Problem::new(JacobianMode::Sparse);
669/// // ... add factors to problem ...
670///
671/// let mut solver = DogLeg::new();
672/// let result = solver.optimize(&mut problem)?;
673/// # Ok(())
674/// # }
675/// ```
676///
677/// # See Also
678///
679/// - [`DogLegConfig`] - Configuration options
680/// - [`LevenbergMarquardt`](crate::optimizer::LevenbergMarquardt) - Alternative adaptive damping
681/// - [`GaussNewton`](crate::optimizer::GaussNewton) - Undamped variant
682pub struct DogLeg {
683 config: DogLegConfig,
684 jacobi_scaling: Option<Vec<f64>>,
685 observers: optimizer::OptObserverVec,
686
687 // Adaptive mu regularization (Ceres-style)
688 mu: f64,
689 min_mu: f64,
690 max_mu: f64,
691 mu_increase_factor: f64,
692
693 // Step reuse mechanism (Ceres-style efficiency optimization)
694 reuse_step_on_rejection: bool,
695 cached_gn_step: Option<faer::Mat<f64>>,
696 cached_cauchy_point: Option<faer::Mat<f64>>,
697 cached_gradient: Option<faer::Mat<f64>>,
698 cached_alpha: Option<f64>,
699 cache_reuse_count: usize, // Track consecutive reuses to prevent staleness
700}
701
702impl Default for DogLeg {
703 fn default() -> Self {
704 Self::new()
705 }
706}
707
708impl DogLeg {
709 /// Create a new Dog Leg solver with default configuration.
710 pub fn new() -> Self {
711 Self::with_config(DogLegConfig::default())
712 }
713
714 /// Create a new Dog Leg solver with the given configuration.
715 pub fn with_config(config: DogLegConfig) -> Self {
716 Self {
717 // Initialize adaptive mu from config
718 mu: config.initial_mu,
719 min_mu: config.min_mu,
720 max_mu: config.max_mu,
721 mu_increase_factor: config.mu_increase_factor,
722
723 // Initialize step reuse mechanism (disabled initially, enabled after first rejection)
724 reuse_step_on_rejection: false,
725 cached_gn_step: None,
726 cached_cauchy_point: None,
727 cached_gradient: None,
728 cached_alpha: None,
729 cache_reuse_count: 0,
730
731 config,
732 jacobi_scaling: None,
733 observers: optimizer::OptObserverVec::new(),
734 }
735 }
736
737 /// Add an observer to the solver.
738 ///
739 /// Observers are notified at each iteration with the current variable values.
740 /// This enables real-time visualization, logging, metrics collection, etc.
741 ///
742 /// # Examples
743 ///
744 /// ```no_run
745 /// use apex_solver::optimizer::DogLeg;
746 /// # use apex_solver::optimizer::OptObserver;
747 /// # use apex_solver::core::VarKey;
748 /// # use apex_solver::core::variable::ManifoldVariable;
749 /// # use slotmap::SlotMap;
750 ///
751 /// # struct MyObserver;
752 /// # impl OptObserver for MyObserver {
753 /// # fn on_step(&self, _: &SlotMap<VarKey, Box<dyn ManifoldVariable>>, _: usize) {}
754 /// # }
755 /// let mut solver = DogLeg::new();
756 /// solver.add_observer(MyObserver);
757 /// ```
758 pub fn add_observer(&mut self, observer: impl optimizer::OptObserver + 'static) {
759 self.observers.add(observer);
760 }
761
762 /// Compute Cauchy point and optimal step length for steepest descent (generic).
763 ///
764 /// Returns (alpha, cauchy_point) where:
765 /// - alpha: optimal step length α = ||g||² / (g^T H g)
766 /// - cauchy_point: p_c = -α * g (the Cauchy point)
767 fn compute_cauchy_point_and_alpha_generic<M: AssemblyBackend>(
768 &self,
769 gradient: &faer::Mat<f64>,
770 hessian: &M::Hessian,
771 ) -> (f64, faer::Mat<f64>) {
772 // Optimal step size along steepest descent: α = (g^T*g) / (g^T*H*g)
773 let g_norm_sq_mat = gradient.transpose() * gradient;
774 let g_norm_sq = g_norm_sq_mat[(0, 0)];
775
776 let h_g = M::hessian_vec_product(hessian, gradient);
777 let g_h_g_mat = gradient.transpose() * &h_g;
778 let g_h_g = g_h_g_mat[(0, 0)];
779
780 // Avoid division by zero
781 let alpha = if g_h_g.abs() > 1e-15 {
782 g_norm_sq / g_h_g
783 } else {
784 1.0
785 };
786
787 // Compute Cauchy point: p_c = -α * gradient
788 let mut cauchy_point = faer::Mat::zeros(gradient.nrows(), 1);
789 for i in 0..gradient.nrows() {
790 cauchy_point[(i, 0)] = -alpha * gradient[(i, 0)];
791 }
792
793 (alpha, cauchy_point)
794 }
795
796 /// Compute the dog leg step using Powell's Dog Leg method
797 ///
798 /// The dog leg path consists of two segments:
799 /// 1. From origin to Cauchy point (optimal along steepest descent)
800 /// 2. From Cauchy point to Gauss-Newton step
801 ///
802 /// Arguments:
803 /// - steepest_descent_dir: -gradient (steepest descent direction, not scaled)
804 /// - cauchy_point: p_c = α * (-gradient), the optimal steepest descent step
805 /// - h_gn: Gauss-Newton step
806 /// - delta: Trust region radius
807 ///
808 /// Returns: (step, step_type)
809 fn compute_dog_leg_step(
810 &self,
811 steepest_descent_dir: &faer::Mat<f64>,
812 cauchy_point: &faer::Mat<f64>,
813 h_gn: &faer::Mat<f64>,
814 delta: f64,
815 ) -> (faer::Mat<f64>, StepType) {
816 let gn_norm = h_gn.norm_l2();
817 let cauchy_norm = cauchy_point.norm_l2();
818 let sd_norm = steepest_descent_dir.norm_l2();
819
820 // Case 1: Full Gauss-Newton step fits in trust region
821 if gn_norm <= delta {
822 return (h_gn.clone(), StepType::GaussNewton);
823 }
824
825 // Case 2: Even Cauchy point is outside trust region
826 // Scale steepest descent direction to boundary: (delta / ||δ_sd||) * δ_sd
827 if cauchy_norm >= delta {
828 let mut scaled_sd = faer::Mat::zeros(steepest_descent_dir.nrows(), 1);
829 let scale = delta / sd_norm;
830 for i in 0..steepest_descent_dir.nrows() {
831 scaled_sd[(i, 0)] = steepest_descent_dir[(i, 0)] * scale;
832 }
833 return (scaled_sd, StepType::SteepestDescent);
834 }
835
836 // Case 3: Dog leg interpolation between Cauchy point and GN step
837 // Use Ceres-style numerically robust formula
838 //
839 // Following Ceres solver implementation for numerical stability:
840 // Compute intersection of trust region boundary with line from Cauchy point to GN step
841 //
842 // Let v = δ_gn - p_c
843 // Solve: ||p_c + β*v||² = delta²
844 // This gives: a*β² + 2*b*β + c = 0
845 // where:
846 // a = v^T·v = ||v||²
847 // b = p_c^T·v
848 // c = p_c^T·p_c - delta² = ||p_c||² - delta²
849
850 let mut v = faer::Mat::zeros(cauchy_point.nrows(), 1);
851 for i in 0..cauchy_point.nrows() {
852 v[(i, 0)] = h_gn[(i, 0)] - cauchy_point[(i, 0)];
853 }
854
855 // Compute coefficients
856 let v_squared_norm = v.transpose() * &v;
857 let a = v_squared_norm[(0, 0)];
858
859 let pc_dot_v = cauchy_point.transpose() * &v;
860 let b = pc_dot_v[(0, 0)];
861
862 let c = cauchy_norm * cauchy_norm - delta * delta;
863
864 // Ceres-style numerically robust beta computation
865 // Uses two different formulas based on sign of b to avoid catastrophic cancellation
866 let d_squared = b * b - a * c;
867
868 let beta = if d_squared < 0.0 {
869 // Should not happen geometrically, but handle gracefully
870 1.0
871 } else if a.abs() < 1e-15 {
872 // Degenerate case: v is nearly zero
873 1.0
874 } else {
875 let d = d_squared.sqrt();
876
877 // Ceres formula: choose formula based on sign of b to avoid cancellation
878 // If b <= 0: beta = (-b + d) / a (standard formula, no cancellation)
879 // If b > 0: beta = -c / (b + d) (alternative formula, avoids cancellation)
880 if b <= 0.0 { (-b + d) / a } else { -c / (b + d) }
881 };
882
883 // Clamp beta to [0, 1] for safety
884 let beta = beta.clamp(0.0, 1.0);
885
886 // Compute dog leg step: p_dl = p_c + β*(δ_gn - p_c)
887 let mut dog_leg = faer::Mat::zeros(cauchy_point.nrows(), 1);
888 for i in 0..cauchy_point.nrows() {
889 dog_leg[(i, 0)] = cauchy_point[(i, 0)] + beta * v[(i, 0)];
890 }
891
892 (dog_leg, StepType::DogLeg)
893 }
894
895 /// Update trust region radius based on step quality (Ceres-style)
896 fn update_trust_region(&mut self, rho: f64, step_norm: f64) -> bool {
897 if rho > self.config.good_step_quality {
898 // Good step, increase trust region (Ceres-style: max(radius, 3*step_norm))
899 let new_radius = self.config.trust_region_radius.max(3.0 * step_norm);
900 self.config.trust_region_radius = new_radius.min(self.config.trust_region_max);
901
902 // Decrease mu on successful step (Ceres-style adaptive regularization)
903 self.mu = (self.mu / (0.5 * self.mu_increase_factor)).max(self.min_mu);
904
905 // Clear reuse flag and invalidate cache on acceptance (parameters have moved)
906 self.reuse_step_on_rejection = false;
907 self.cached_gn_step = None;
908 self.cached_cauchy_point = None;
909 self.cached_gradient = None;
910 self.cached_alpha = None;
911 self.cache_reuse_count = 0;
912
913 true
914 } else if rho < self.config.poor_step_quality {
915 // Poor step, decrease trust region
916 self.config.trust_region_radius = (self.config.trust_region_radius
917 * self.config.trust_region_decrease_factor)
918 .max(self.config.trust_region_min);
919
920 // Enable step reuse flag for next iteration (Ceres-style)
921 self.reuse_step_on_rejection = self.config.enable_step_reuse;
922
923 false
924 } else {
925 // Moderate step, keep trust region unchanged
926 // Clear reuse flag and invalidate cache on acceptance (parameters have moved)
927 self.reuse_step_on_rejection = false;
928 self.cached_gn_step = None;
929 self.cached_cauchy_point = None;
930 self.cached_gradient = None;
931 self.cached_alpha = None;
932 self.cache_reuse_count = 0;
933
934 true
935 }
936 }
937
938 /// Compute predicted cost reduction from linear model (generic over assembly mode).
939 fn compute_predicted_reduction_generic<M: AssemblyBackend>(
940 &self,
941 step: &faer::Mat<f64>,
942 gradient: &faer::Mat<f64>,
943 hessian: &M::Hessian,
944 ) -> f64 {
945 // Dog Leg predicted reduction: -step^T * gradient - 0.5 * step^T * H * step
946 let linear_term = step.transpose() * gradient;
947 let hessian_step = M::hessian_vec_product(hessian, step);
948 let quadratic_term = step.transpose() * &hessian_step;
949
950 -linear_term[(0, 0)] - 0.5 * quadratic_term[(0, 0)]
951 }
952
953 /// Compute dog leg optimization step (generic over assembly mode).
954 fn compute_optimization_step_generic<M: AssemblyBackend>(
955 &mut self,
956 residuals: &faer::Mat<f64>,
957 scaled_jacobian: &M::Jacobian,
958 linear_solver: &mut dyn LinearSolver<M>,
959 ) -> Option<StepResult> {
960 // Check if we can reuse cached step (Ceres-style optimization)
961 // Safety limit: prevent excessive reuse that could lead to stale gradient/Hessian
962 const MAX_CACHE_REUSE: usize = 5;
963
964 if self.reuse_step_on_rejection
965 && self.config.enable_step_reuse
966 && self.cache_reuse_count < MAX_CACHE_REUSE
967 && let (Some(cached_gn), Some(cached_cauchy), Some(cached_grad), Some(_cached_a)) = (
968 &self.cached_gn_step,
969 &self.cached_cauchy_point,
970 &self.cached_gradient,
971 &self.cached_alpha,
972 )
973 {
974 // Increment reuse counter
975 self.cache_reuse_count += 1;
976
977 let gradient_norm = cached_grad.norm_l2();
978 let mut steepest_descent = faer::Mat::zeros(cached_grad.nrows(), 1);
979 for i in 0..cached_grad.nrows() {
980 steepest_descent[(i, 0)] = -cached_grad[(i, 0)];
981 }
982
983 let (scaled_step, _step_type) = self.compute_dog_leg_step(
984 &steepest_descent,
985 cached_cauchy,
986 cached_gn,
987 self.config.trust_region_radius,
988 );
989
990 let step = if self.config.use_jacobi_scaling {
991 let scaling = self.jacobi_scaling.as_ref()?;
992 M::apply_inverse_scaling(&scaled_step, scaling)
993 } else {
994 scaled_step.clone()
995 };
996
997 // For cached reuse, we need the hessian for predicted reduction.
998 // Use hessian from linear solver if available.
999 let hessian = linear_solver.get_hessian()?;
1000 let predicted_reduction =
1001 self.compute_predicted_reduction_generic::<M>(&scaled_step, cached_grad, hessian);
1002
1003 return Some(StepResult {
1004 step,
1005 gradient_norm,
1006 predicted_reduction,
1007 });
1008 }
1009
1010 // Not reusing, compute fresh step
1011 // 1. Solve for Gauss-Newton step with adaptive mu regularization (Ceres-style)
1012 let residuals_owned = residuals.as_ref().to_owned();
1013 let mut scaled_gn_step = None;
1014 let mut mu_attempts = 0;
1015
1016 // Try to solve with current mu, increasing if necessary
1017 while mu_attempts < 10 && self.mu <= self.max_mu {
1018 let damping = self.mu;
1019
1020 if let Ok(step) =
1021 linear_solver.solve_augmented_equation(&residuals_owned, scaled_jacobian, damping)
1022 {
1023 scaled_gn_step = Some(step);
1024 break;
1025 }
1026
1027 // Increase mu (Ceres-style)
1028 self.mu = (self.mu * self.mu_increase_factor).min(self.max_mu);
1029 mu_attempts += 1;
1030 }
1031
1032 let scaled_gn_step = scaled_gn_step?;
1033
1034 // 2. Get gradient and Hessian (cached by solve_augmented_equation)
1035 let gradient = linear_solver.get_gradient()?;
1036 let hessian = linear_solver.get_hessian()?;
1037 let gradient_norm = gradient.norm_l2();
1038
1039 // 3. Compute steepest descent direction: δ_sd = -gradient
1040 let mut steepest_descent = faer::Mat::zeros(gradient.nrows(), 1);
1041 for i in 0..gradient.nrows() {
1042 steepest_descent[(i, 0)] = -gradient[(i, 0)];
1043 }
1044
1045 // 4. Compute Cauchy point and optimal step length
1046 let (alpha, cauchy_point) =
1047 self.compute_cauchy_point_and_alpha_generic::<M>(gradient, hessian);
1048
1049 // 5. Compute dog leg step based on trust region radius
1050 let (scaled_step, _step_type) = self.compute_dog_leg_step(
1051 &steepest_descent,
1052 &cauchy_point,
1053 &scaled_gn_step,
1054 self.config.trust_region_radius,
1055 );
1056
1057 // 6. Apply inverse Jacobi scaling if enabled
1058 let step = if self.config.use_jacobi_scaling {
1059 let scaling = self.jacobi_scaling.as_ref()?;
1060 M::apply_inverse_scaling(&scaled_step, scaling)
1061 } else {
1062 scaled_step.clone()
1063 };
1064
1065 // 7. Compute predicted reduction
1066 let predicted_reduction =
1067 self.compute_predicted_reduction_generic::<M>(&scaled_step, gradient, hessian);
1068
1069 // 8. Cache step components for potential reuse (Ceres-style)
1070 self.cached_gn_step = Some(scaled_gn_step.clone());
1071 self.cached_cauchy_point = Some(cauchy_point.clone());
1072 self.cached_gradient = Some(gradient.clone());
1073 self.cached_alpha = Some(alpha);
1074
1075 Some(StepResult {
1076 step,
1077 gradient_norm,
1078 predicted_reduction,
1079 })
1080 }
1081
1082 /// Evaluate and apply step
1083 fn evaluate_and_apply_step(
1084 &mut self,
1085 step_result: &StepResult,
1086 state: &mut optimizer::InitializedState,
1087 problem: &problem::Problem,
1088 ) -> error::ApexSolverResult<StepEvaluation> {
1089 // Apply parameter updates
1090 let step_norm = optimizer::apply_parameter_step(
1091 &mut state.variables,
1092 step_result.step.as_ref(),
1093 &state.sorted_vars,
1094 );
1095
1096 // Compute new cost (residual only, no Jacobian needed for step evaluation)
1097 let new_residual = problem.compute_residual_sparse(&state.variables)?;
1098 let new_cost = optimizer::compute_cost(&new_residual);
1099
1100 // Compute step quality
1101 let rho = optimizer::compute_step_quality(
1102 state.current_cost,
1103 new_cost,
1104 step_result.predicted_reduction,
1105 );
1106
1107 // Update trust region and decide acceptance
1108 // Filter out numerical noise with small threshold
1109 let accepted = rho > 1e-4;
1110 let _trust_region_updated = self.update_trust_region(rho, step_norm);
1111
1112 let cost_reduction = if accepted {
1113 let reduction = state.current_cost - new_cost;
1114 state.current_cost = new_cost;
1115 reduction
1116 } else {
1117 // Reject step - revert changes
1118 optimizer::apply_negative_parameter_step(
1119 &mut state.variables,
1120 step_result.step.as_ref(),
1121 &state.sorted_vars,
1122 );
1123 0.0
1124 };
1125
1126 Ok(StepEvaluation {
1127 accepted,
1128 cost_reduction,
1129 rho,
1130 })
1131 }
1132
1133 /// Run optimization using the specified assembly mode and linear solver.
1134 fn optimize_with_mode<M: AssemblyBackend>(
1135 &mut self,
1136 problem: &mut problem::Problem,
1137 linear_solver: &mut dyn LinearSolver<M>,
1138 ) -> optimizer::OptimizeResult {
1139 let start_time = time::Instant::now();
1140 let mut iteration = 0;
1141 let mut cost_evaluations = 1;
1142 let mut jacobian_evaluations = 0;
1143 let mut successful_steps = 0;
1144 let mut unsuccessful_steps = 0;
1145
1146 let mut state = optimizer::initialize_optimization_state(problem)?;
1147
1148 let mut max_gradient_norm: f64 = 0.0;
1149 let mut max_parameter_update_norm: f64 = 0.0;
1150 let mut total_cost_reduction = 0.0;
1151 let mut final_gradient_norm;
1152 let mut final_parameter_update_norm;
1153
1154 // Initialize iteration statistics tracking
1155 let mut iteration_stats = Vec::with_capacity(self.config.max_iterations);
1156 let mut previous_cost = state.current_cost;
1157
1158 // Print configuration and header if debug level is enabled
1159 if tracing::enabled!(tracing::Level::DEBUG) {
1160 self.config.print_configuration();
1161 IterationStats::print_header();
1162 }
1163
1164 loop {
1165 let iter_start = time::Instant::now();
1166
1167 // Evaluate residuals and Jacobian using the assembly mode
1168 let (residuals, jacobian) = M::assemble(
1169 problem,
1170 &state.variables,
1171 &state.variable_index_map,
1172 state.symbolic_structure.as_ref(),
1173 state.total_dof,
1174 )?;
1175 jacobian_evaluations += 1;
1176
1177 // Process Jacobian (apply scaling if enabled)
1178 let scaled_jacobian = if self.config.use_jacobi_scaling {
1179 optimizer::process_jacobian_generic::<M>(
1180 &jacobian,
1181 &mut self.jacobi_scaling,
1182 iteration,
1183 )?
1184 } else {
1185 jacobian
1186 };
1187
1188 // Compute dog leg step
1189 let step_result = match self.compute_optimization_step_generic::<M>(
1190 &residuals,
1191 &scaled_jacobian,
1192 linear_solver,
1193 ) {
1194 Some(result) => result,
1195 None => {
1196 return Err(optimizer::OptimizerError::LinearSolveFailed(
1197 "Linear solver failed to solve system".to_string(),
1198 )
1199 .into());
1200 }
1201 };
1202
1203 // Update tracking
1204 max_gradient_norm = max_gradient_norm.max(step_result.gradient_norm);
1205 final_gradient_norm = step_result.gradient_norm;
1206 let step_norm = step_result.step.norm_l2();
1207 max_parameter_update_norm = max_parameter_update_norm.max(step_norm);
1208 final_parameter_update_norm = step_norm;
1209
1210 // Evaluate and apply step
1211 let step_eval = self.evaluate_and_apply_step(&step_result, &mut state, problem)?;
1212 cost_evaluations += 1;
1213
1214 if step_eval.accepted {
1215 successful_steps += 1;
1216 total_cost_reduction += step_eval.cost_reduction;
1217 } else {
1218 unsuccessful_steps += 1;
1219 }
1220
1221 // OPTIMIZATION: Only collect iteration statistics if debug level is enabled
1222 if tracing::enabled!(tracing::Level::DEBUG) {
1223 let iter_elapsed_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
1224 let total_elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0;
1225
1226 let stats = IterationStats {
1227 iteration,
1228 cost: state.current_cost,
1229 cost_change: previous_cost - state.current_cost,
1230 gradient_norm: step_result.gradient_norm,
1231 step_norm,
1232 tr_ratio: step_eval.rho,
1233 tr_radius: self.config.trust_region_radius,
1234 ls_iter: 0,
1235 iter_time_ms: iter_elapsed_ms,
1236 total_time_ms: total_elapsed_ms,
1237 accepted: step_eval.accepted,
1238 };
1239
1240 iteration_stats.push(stats.clone());
1241 stats.print_line();
1242 }
1243
1244 previous_cost = state.current_cost;
1245
1246 // Notify all observers with current state
1247 optimizer::notify_observers_generic::<M>(
1248 &mut self.observers,
1249 &state.variables,
1250 iteration,
1251 state.current_cost,
1252 step_result.gradient_norm,
1253 Some(self.config.trust_region_radius),
1254 step_norm,
1255 Some(step_eval.rho),
1256 linear_solver,
1257 );
1258
1259 // Check convergence
1260 let elapsed = start_time.elapsed();
1261 let parameter_norm = optimizer::compute_parameter_norm(&state.variables);
1262 let new_cost = state.current_cost;
1263
1264 let cost_before_step = if step_eval.accepted {
1265 state.current_cost + step_eval.cost_reduction
1266 } else {
1267 state.current_cost
1268 };
1269
1270 if let Some(status) = optimizer::check_convergence(&optimizer::ConvergenceParams {
1271 iteration,
1272 current_cost: cost_before_step,
1273 new_cost,
1274 parameter_norm,
1275 parameter_update_norm: step_norm,
1276 gradient_norm: step_result.gradient_norm,
1277 elapsed,
1278 step_accepted: step_eval.accepted,
1279 max_iterations: self.config.max_iterations,
1280 gradient_tolerance: self.config.gradient_tolerance,
1281 parameter_tolerance: self.config.parameter_tolerance,
1282 cost_tolerance: self.config.cost_tolerance,
1283 min_cost_threshold: self.config.min_cost_threshold,
1284 timeout: self.config.timeout,
1285 trust_region_radius: Some(self.config.trust_region_radius),
1286 min_trust_region_radius: Some(self.config.trust_region_min),
1287 }) {
1288 // Print summary only if debug level is enabled
1289 if tracing::enabled!(tracing::Level::DEBUG) {
1290 let summary = optimizer::create_optimizer_summary(
1291 "Dog-Leg",
1292 state.initial_cost,
1293 state.current_cost,
1294 iteration + 1,
1295 Some(successful_steps),
1296 Some(unsuccessful_steps),
1297 max_gradient_norm,
1298 final_gradient_norm,
1299 max_parameter_update_norm,
1300 final_parameter_update_norm,
1301 total_cost_reduction,
1302 elapsed,
1303 iteration_stats.clone(),
1304 status.clone(),
1305 None,
1306 Some(self.config.trust_region_radius),
1307 None,
1308 );
1309 debug!("{}", summary);
1310 }
1311
1312 // Compute covariances if enabled
1313 let covariances = if self.config.compute_covariances {
1314 problem.compute_and_set_covariances_generic::<M>(
1315 linear_solver,
1316 &mut state.variables,
1317 &state.variable_index_map,
1318 )
1319 } else {
1320 None
1321 };
1322
1323 return Ok(optimizer::build_solver_result(
1324 status,
1325 iteration + 1,
1326 state,
1327 elapsed,
1328 final_gradient_norm,
1329 final_parameter_update_norm,
1330 cost_evaluations,
1331 jacobian_evaluations,
1332 covariances,
1333 ));
1334 }
1335
1336 iteration += 1;
1337 }
1338 }
1339
1340 /// Run optimization, automatically selecting sparse or dense path based on config.
1341 pub fn optimize(&mut self, problem: &mut problem::Problem) -> optimizer::OptimizeResult {
1342 match problem.jacobian_mode {
1343 JacobianMode::Dense => match self.config.linear_solver_type {
1344 LinearSolverType::DenseQR => {
1345 let mut solver = DenseQRSolver::new();
1346 self.optimize_with_mode::<DenseMode>(problem, &mut solver)
1347 }
1348 _ => {
1349 let mut solver = DenseCholeskySolver::new();
1350 self.optimize_with_mode::<DenseMode>(problem, &mut solver)
1351 }
1352 },
1353 JacobianMode::Sparse => match self.config.linear_solver_type {
1354 linalg::LinearSolverType::SparseQR => {
1355 let mut solver = SparseQRSolver::new();
1356 self.optimize_with_mode::<SparseMode>(problem, &mut solver)
1357 }
1358 _ => {
1359 // SparseCholesky (default), SparseSchurComplement or DenseCholesky with
1360 // sparse mode → SparseCholeskySolver
1361 let mut solver = SparseCholeskySolver::new();
1362 self.optimize_with_mode::<SparseMode>(problem, &mut solver)
1363 }
1364 },
1365 }
1366 }
1367}
1368
1369impl optimizer::Optimizer for DogLeg {
1370 fn optimize(&mut self, problem: &mut problem::Problem) -> optimizer::OptimizeResult {
1371 self.optimize(problem)
1372 }
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377 use super::*;
1378 use crate::factors;
1379 use apex_manifolds as manifold;
1380 use faer::prelude::ReborrowMut;
1381 use nalgebra;
1382
1383 type TestResult = Result<(), Box<dyn std::error::Error>>;
1384
1385 /// Custom Rosenbrock Factor 1: r1 = 10(x2 - x1²)
1386 /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1387 #[derive(Debug, Clone)]
1388 struct RosenbrockFactor1;
1389
1390 impl factors::Factor for RosenbrockFactor1 {
1391 fn linearize(
1392 &self,
1393 params: &[&[f64]],
1394 residual: &mut [f64],
1395 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1396 ) {
1397 let x1 = params[0][0];
1398 let x2 = params[1][0];
1399 residual[0] = 10.0 * (x2 - x1 * x1);
1400 if let Some(mut jac) = jacobian {
1401 *jac.rb_mut().get_mut(0, 0) = -20.0 * x1;
1402 *jac.rb_mut().get_mut(0, 1) = 10.0;
1403 }
1404 }
1405 fn residual_dim(&self) -> usize {
1406 1
1407 }
1408 fn jacobian_shape(&self) -> (usize, usize) {
1409 (1, 2)
1410 }
1411 }
1412
1413 /// Custom Rosenbrock Factor 2: r2 = 1 - x1
1414 /// Demonstrates extensibility - custom factors can be defined outside of factors.rs
1415 #[derive(Debug, Clone)]
1416 struct RosenbrockFactor2;
1417
1418 impl factors::Factor for RosenbrockFactor2 {
1419 fn linearize(
1420 &self,
1421 params: &[&[f64]],
1422 residual: &mut [f64],
1423 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1424 ) {
1425 residual[0] = 1.0 - params[0][0];
1426 if let Some(mut jac) = jacobian {
1427 *jac.rb_mut().get_mut(0, 0) = -1.0;
1428 }
1429 }
1430 fn residual_dim(&self) -> usize {
1431 1
1432 }
1433 fn jacobian_shape(&self) -> (usize, usize) {
1434 (1, 1)
1435 }
1436 }
1437
1438 #[test]
1439 fn test_rosenbrock_optimization() -> TestResult {
1440 // Rosenbrock function test:
1441 // Minimize: r1² + r2² where
1442 // r1 = 10(x2 - x1²)
1443 // r2 = 1 - x1
1444 // Starting point: [-1.2, 1.0]
1445 // Expected minimum: [1.0, 1.0]
1446
1447 let mut problem = problem::Problem::new(JacobianMode::Sparse);
1448 let x1 = problem.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![-1.2]);
1449 let x2 = problem.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![1.0]);
1450
1451 // Add custom factors (demonstrates extensibility!)
1452 problem.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1453 problem.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1454
1455 // Configure Dog Leg optimizer with appropriate trust region
1456 let config = DogLegConfig::new()
1457 .with_max_iterations(100)
1458 .with_cost_tolerance(1e-8)
1459 .with_parameter_tolerance(1e-8)
1460 .with_gradient_tolerance(1e-10)
1461 .with_trust_region_radius(10.0); // Start with larger trust region
1462
1463 let mut solver = DogLeg::with_config(config);
1464 let result = solver.optimize(&mut problem)?;
1465
1466 // Extract final values
1467 let x1_final = result.parameters[x1].as_param_slice()[0];
1468 let x2_final = result.parameters[x2].as_param_slice()[0];
1469
1470 // Verify convergence to [1.0, 1.0]
1471 assert!(
1472 matches!(
1473 result.status,
1474 optimizer::OptimizationStatus::Converged
1475 | optimizer::OptimizationStatus::CostToleranceReached
1476 | optimizer::OptimizationStatus::ParameterToleranceReached
1477 | optimizer::OptimizationStatus::GradientToleranceReached
1478 ),
1479 "Optimization should converge"
1480 );
1481 assert!(
1482 (x1_final - 1.0).abs() < 1e-4,
1483 "x1 should converge to 1.0, got {}",
1484 x1_final
1485 );
1486 assert!(
1487 (x2_final - 1.0).abs() < 1e-4,
1488 "x2 should converge to 1.0, got {}",
1489 x2_final
1490 );
1491 assert!(
1492 result.final_cost < 1e-6,
1493 "Final cost should be near zero, got {}",
1494 result.final_cost
1495 );
1496 Ok(())
1497 }
1498
1499 /// Trivial factor: r = x - target, J = [[1.0]]
1500 struct LinearFactor {
1501 target: f64,
1502 }
1503
1504 impl factors::Factor for LinearFactor {
1505 fn linearize(
1506 &self,
1507 params: &[&[f64]],
1508 residual: &mut [f64],
1509 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1510 ) {
1511 residual[0] = params[0][0] - self.target;
1512 if let Some(mut jac) = jacobian {
1513 *jac.rb_mut().get_mut(0, 0) = 1.0;
1514 }
1515 }
1516 fn residual_dim(&self) -> usize {
1517 1
1518 }
1519 fn jacobian_shape(&self) -> (usize, usize) {
1520 (1, 1)
1521 }
1522 }
1523
1524 fn rosenbrock_problem() -> problem::Problem {
1525 let mut prob = problem::Problem::new(JacobianMode::Sparse);
1526 let x1 = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![-1.2]);
1527 let x2 = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![1.0]);
1528 prob.add_residual_block(&[x1, x2], Box::new(RosenbrockFactor1), None);
1529 prob.add_residual_block(&[x1], Box::new(RosenbrockFactor2), None);
1530 prob
1531 }
1532
1533 fn linear_problem(start: f64) -> problem::Problem {
1534 let mut prob = problem::Problem::new(JacobianMode::Sparse);
1535 let x = prob.add_variable(manifold::ManifoldType::RN, nalgebra::dvector![start]);
1536 prob.add_residual_block(&[x], Box::new(LinearFactor { target: 0.0 }), None);
1537 prob
1538 }
1539
1540 // -------------------------------------------------------------------------
1541 // DogLegConfig builder tests
1542 // -------------------------------------------------------------------------
1543
1544 #[test]
1545 fn test_dl_config_default() {
1546 let cfg = DogLegConfig::default();
1547 assert_eq!(cfg.max_iterations, 50);
1548 assert!((cfg.cost_tolerance - 1e-6).abs() < 1e-15);
1549 assert!(cfg.use_jacobi_scaling); // DogLeg enables Jacobi scaling by default
1550 assert!(!cfg.compute_covariances);
1551 assert!(cfg.enable_step_reuse);
1552 }
1553
1554 #[test]
1555 fn test_dl_config_builders() {
1556 use crate::linalg::LinearSolverType;
1557 let cfg = DogLegConfig::new()
1558 .with_max_iterations(20)
1559 .with_cost_tolerance(1e-5)
1560 .with_parameter_tolerance(1e-6)
1561 .with_gradient_tolerance(1e-7)
1562 .with_trust_region_radius(200.0)
1563 .with_trust_region_bounds(1e-15, 1e15)
1564 .with_trust_region_factors(4.0, 0.3)
1565 .with_step_quality_thresholds(0.01, 0.3, 0.7)
1566 .with_jacobi_scaling(false)
1567 .with_mu_params(1e-3, 1e-10, 2.0, 5.0)
1568 .with_step_reuse(false)
1569 .with_min_cost_threshold(1e-9)
1570 .with_compute_covariances(true)
1571 .with_linear_solver_type(LinearSolverType::SparseQR);
1572 assert_eq!(cfg.max_iterations, 20);
1573 assert!((cfg.trust_region_radius - 200.0).abs() < 1e-10);
1574 assert!(!cfg.use_jacobi_scaling);
1575 assert!(!cfg.enable_step_reuse);
1576 assert!(cfg.min_cost_threshold.is_some());
1577 assert!(cfg.compute_covariances);
1578 assert!((cfg.min_step_quality - 0.01).abs() < 1e-15);
1579 assert!((cfg.poor_step_quality - 0.3).abs() < 1e-15);
1580 assert!((cfg.good_step_quality - 0.7).abs() < 1e-15);
1581 assert!((cfg.initial_mu - 1e-3).abs() < 1e-15);
1582 }
1583
1584 #[test]
1585 fn test_dl_print_configuration_no_panic() {
1586 DogLegConfig::default().print_configuration();
1587 }
1588
1589 #[test]
1590 fn test_dl_default_equals_new() {
1591 let _a = DogLeg::new();
1592 let _b = DogLeg::default();
1593 }
1594
1595 #[test]
1596 fn test_dl_with_config_method() {
1597 let cfg = DogLegConfig::new().with_max_iterations(5);
1598 let _solver = DogLeg::with_config(cfg);
1599 }
1600
1601 // -------------------------------------------------------------------------
1602 // Convergence termination paths
1603 // -------------------------------------------------------------------------
1604
1605 #[test]
1606 fn test_dl_max_iterations_termination() -> TestResult {
1607 let mut problem = rosenbrock_problem();
1608 let cfg = DogLegConfig::new().with_max_iterations(2);
1609 let mut solver = DogLeg::with_config(cfg);
1610 let result = solver.optimize(&mut problem)?;
1611 assert_eq!(
1612 result.status,
1613 optimizer::OptimizationStatus::MaxIterationsReached
1614 );
1615 assert!(result.iterations <= 3, "iterations={}", result.iterations);
1616 Ok(())
1617 }
1618
1619 #[test]
1620 fn test_dl_gradient_tolerance_convergence() -> TestResult {
1621 let mut problem = linear_problem(1.0);
1622 let cfg = DogLegConfig::new()
1623 .with_gradient_tolerance(1e3)
1624 .with_cost_tolerance(1e-20)
1625 .with_parameter_tolerance(1e-20);
1626 let mut solver = DogLeg::with_config(cfg);
1627 let result = solver.optimize(&mut problem)?;
1628 assert_eq!(
1629 result.status,
1630 optimizer::OptimizationStatus::GradientToleranceReached
1631 );
1632 Ok(())
1633 }
1634
1635 #[test]
1636 fn test_dl_cost_tolerance_convergence() -> TestResult {
1637 let mut problem = rosenbrock_problem();
1638 let cfg = DogLegConfig::new()
1639 .with_cost_tolerance(1e2) // very loose
1640 .with_gradient_tolerance(1e-20)
1641 .with_parameter_tolerance(1e-20);
1642 let mut solver = DogLeg::with_config(cfg);
1643 let result = solver.optimize(&mut problem)?;
1644 assert!(matches!(
1645 result.status,
1646 optimizer::OptimizationStatus::CostToleranceReached
1647 | optimizer::OptimizationStatus::GradientToleranceReached
1648 | optimizer::OptimizationStatus::ParameterToleranceReached
1649 | optimizer::OptimizationStatus::Converged
1650 ));
1651 Ok(())
1652 }
1653
1654 #[test]
1655 fn test_dl_qr_solver() -> TestResult {
1656 use crate::linalg::LinearSolverType;
1657 let mut problem = rosenbrock_problem();
1658 let cfg = DogLegConfig::new()
1659 .with_linear_solver_type(LinearSolverType::SparseQR)
1660 .with_max_iterations(100);
1661 let mut solver = DogLeg::with_config(cfg);
1662 let result = solver.optimize(&mut problem)?;
1663 assert!(result.final_cost < 1e-6);
1664 Ok(())
1665 }
1666
1667 #[test]
1668 fn test_dl_jacobi_scaling_disabled() -> TestResult {
1669 // DogLeg has Jacobi scaling ON by default; test with it explicitly disabled
1670 let mut problem = rosenbrock_problem();
1671 let cfg = DogLegConfig::new()
1672 .with_jacobi_scaling(false)
1673 .with_max_iterations(100);
1674 let mut solver = DogLeg::with_config(cfg);
1675 let result = solver.optimize(&mut problem)?;
1676 assert!(result.final_cost < 1e-6);
1677 Ok(())
1678 }
1679
1680 #[test]
1681 fn test_dl_min_cost_threshold() -> TestResult {
1682 let mut problem = rosenbrock_problem();
1683 let cfg = DogLegConfig::new()
1684 .with_min_cost_threshold(1e10)
1685 .with_cost_tolerance(1e-20)
1686 .with_gradient_tolerance(1e-20)
1687 .with_parameter_tolerance(1e-20);
1688 let mut solver = DogLeg::with_config(cfg);
1689 let result = solver.optimize(&mut problem)?;
1690 assert_eq!(
1691 result.status,
1692 optimizer::OptimizationStatus::MinCostThresholdReached
1693 );
1694 Ok(())
1695 }
1696
1697 #[test]
1698 fn test_dl_trust_region_radius_in_config() -> TestResult {
1699 let mut problem = rosenbrock_problem();
1700 let cfg = DogLegConfig::new()
1701 .with_trust_region_radius(0.1) // small initial radius
1702 .with_max_iterations(200);
1703 let mut solver = DogLeg::with_config(cfg);
1704 let result = solver.optimize(&mut problem)?;
1705 assert!(result.iterations > 0);
1706 Ok(())
1707 }
1708
1709 #[test]
1710 fn test_dl_step_reuse_config() -> TestResult {
1711 let mut problem = rosenbrock_problem();
1712 let cfg = DogLegConfig::new()
1713 .with_step_reuse(true)
1714 .with_max_iterations(100);
1715 let mut solver = DogLeg::with_config(cfg);
1716 let result = solver.optimize(&mut problem)?;
1717 assert!(result.final_cost < 1e-6);
1718 Ok(())
1719 }
1720
1721 #[test]
1722 fn test_dl_mu_params_config() {
1723 let cfg = DogLegConfig::new().with_mu_params(1e-2, 1e-9, 10.0, 20.0);
1724 assert!((cfg.initial_mu - 1e-2).abs() < 1e-15);
1725 assert!((cfg.min_mu - 1e-9).abs() < 1e-20);
1726 assert!((cfg.max_mu - 10.0).abs() < 1e-12);
1727 assert!((cfg.mu_increase_factor - 20.0).abs() < 1e-12);
1728 }
1729
1730 #[test]
1731 fn test_dl_step_quality_thresholds() {
1732 let cfg = DogLegConfig::new().with_step_quality_thresholds(0.05, 0.25, 0.8);
1733 assert!((cfg.min_step_quality - 0.05).abs() < 1e-15);
1734 assert!((cfg.poor_step_quality - 0.25).abs() < 1e-15);
1735 assert!((cfg.good_step_quality - 0.8).abs() < 1e-15);
1736 }
1737
1738 #[test]
1739 fn test_dl_result_fields() -> TestResult {
1740 let mut problem = rosenbrock_problem();
1741 let mut solver = DogLeg::new();
1742 let result = solver.optimize(&mut problem)?;
1743 assert!(result.initial_cost > result.final_cost);
1744 assert!(result.iterations > 0);
1745 assert!(result.convergence_info.is_some());
1746 Ok(())
1747 }
1748
1749 #[test]
1750 fn test_dl_timeout_config() {
1751 let cfg = DogLegConfig::new().with_timeout(time::Duration::from_secs(60));
1752 assert!(cfg.timeout.is_some());
1753 }
1754}