Skip to main content

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}