Skip to main content

apex_solver/optimizer/
mod.rs

1//! Optimization solvers for nonlinear least squares problems.
2//!
3//! This module provides various optimization algorithms specifically designed
4//! for nonlinear least squares problems commonly found in computer vision:
5//! - Levenberg-Marquardt algorithm
6//! - Gauss-Newton algorithm
7//! - Dog Leg algorithm
8
9use crate::core::VarKey;
10use crate::core::problem::Problem;
11use crate::core::variable::ManifoldVariable;
12use crate::error::ErrorLogging;
13use crate::linalg::{
14    self, JacobianMode, LinearSolver, SparseCholeskySolver, SparseMode, SparseQRSolver,
15};
16use crate::linearizer::SymbolicStructure;
17use faer::sparse::{SparseColMat, Triplet};
18use faer::{Mat, MatRef};
19use slotmap::{SecondaryMap, SlotMap};
20use std::time::{self, Duration};
21use std::{
22    fmt,
23    fmt::{Display, Formatter},
24};
25use thiserror::Error;
26use tracing::debug;
27
28pub mod dog_leg;
29pub mod gauss_newton;
30pub mod levenberg_marquardt;
31
32pub use dog_leg::DogLeg;
33pub use gauss_newton::GaussNewton;
34pub use levenberg_marquardt::LevenbergMarquardt;
35
36// Re-export observer types from the observers module
37pub use crate::observers::{OptObserver, OptObserverVec};
38
39// Re-export AssemblyBackend so optimizer sub-modules can import it from optimizer::
40pub use crate::linearizer::AssemblyBackend;
41
42/// Type of optimization solver algorithm to use
43#[derive(Default, Clone, Copy, PartialEq, Eq)]
44pub enum OptimizerType {
45    /// Levenberg-Marquardt algorithm (robust, adaptive damping)
46    #[default]
47    LevenbergMarquardt,
48    /// Gauss-Newton algorithm (fast convergence, may be unstable)
49    GaussNewton,
50    /// Dog Leg algorithm (trust region method)
51    DogLeg,
52}
53
54impl Display for OptimizerType {
55    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
56        match self {
57            OptimizerType::LevenbergMarquardt => write!(f, "Levenberg-Marquardt"),
58            OptimizerType::GaussNewton => write!(f, "Gauss-Newton"),
59            OptimizerType::DogLeg => write!(f, "Dog Leg"),
60        }
61    }
62}
63
64/// Optimizer-specific error types for apex-solver
65#[derive(Debug, Clone, Error)]
66pub enum OptimizerError {
67    /// Linear system solve failed during optimization
68    #[error("Linear system solve failed: {0}")]
69    LinearSolveFailed(String),
70
71    /// Maximum iterations reached without achieving convergence
72    #[error("Maximum iterations ({max_iters}) reached without convergence")]
73    MaxIterationsReached { max_iters: usize },
74
75    /// Trust region radius became too small
76    #[error("Trust region radius became too small: {radius:.6e} < {min_radius:.6e}")]
77    TrustRegionFailure { radius: f64, min_radius: f64 },
78
79    /// Damping parameter became too large (LM-specific)
80    #[error("Damping parameter became too large: {damping:.6e} > {max_damping:.6e}")]
81    DampingFailure { damping: f64, max_damping: f64 },
82
83    /// Cost increased unexpectedly when it should decrease
84    #[error("Cost increased unexpectedly: {old_cost:.6e} -> {new_cost:.6e}")]
85    CostIncrease { old_cost: f64, new_cost: f64 },
86
87    /// Jacobian computation failed
88    #[error("Jacobian computation failed: {0}")]
89    JacobianFailed(String),
90
91    /// Invalid optimization parameters provided
92    #[error("Invalid optimization parameters: {0}")]
93    InvalidParameters(String),
94
95    /// Numerical instability detected (NaN, Inf in cost, gradient, or parameters)
96    #[error("Numerical instability detected: {0}")]
97    NumericalInstability(String),
98
99    /// Linear algebra operation failed
100    ///
101    /// **Convention**: When a `LinAlgError` occurs during optimization, it wraps
102    /// here via `?` to preserve optimizer context. This ensures the error
103    /// propagates as `ApexSolverError::Optimizer(OptimizerError::LinAlg(...))`
104    /// at the API level, not `ApexSolverError::LinearAlgebra(...)`.
105    #[error("Linear algebra error: {0}")]
106    LinAlg(#[from] linalg::LinAlgError),
107
108    /// Core module error (problem construction, factor linearization)
109    ///
110    /// Wraps errors from the core module that occur during optimization,
111    /// such as symbolic structure failures or parallel computation errors.
112    #[error("Core error: {0}")]
113    Core(#[from] crate::core::CoreError),
114
115    /// Linearizer error (Jacobian assembly, symbolic structure)
116    ///
117    /// Wraps errors from the linearizer module that occur during optimization,
118    /// such as symbolic structure failures or variable mapping errors.
119    #[error("Linearizer error: {0}")]
120    Linearizer(#[from] crate::linearizer::LinearizerError),
121
122    /// Problem has no variables to optimize
123    #[error("Problem has no variables to optimize")]
124    EmptyProblem,
125
126    /// Problem has no residual blocks
127    #[error("Problem has no residual blocks")]
128    NoResidualBlocks,
129
130    /// Jacobi scaling matrix creation failed
131    #[error("Failed to create Jacobi scaling matrix: {0}")]
132    JacobiScalingCreation(String),
133
134    /// Jacobi scaling not initialized when expected
135    #[error("Jacobi scaling not initialized")]
136    JacobiScalingNotInitialized,
137
138    /// Unknown or unsupported linear solver type
139    #[error("Unknown linear solver type: {0}")]
140    UnknownLinearSolver(String),
141}
142
143/// Result type for optimizer operations
144pub type OptimizerResult<T> = Result<T, OptimizerError>;
145
146// State information during iterative optimization.
147// #[derive(Debug, Clone)]
148// pub struct IterativeState {
149//     /// Current iteration number
150//     pub iteration: usize,
151//     /// Current cost value
152//     pub cost: f64,
153//     /// Current gradient norm
154//     pub gradient_norm: f64,
155//     /// Current parameter update norm
156//     pub parameter_update_norm: f64,
157//     /// Time elapsed since start
158//     pub elapsed_time: Duration,
159// }
160
161/// Detailed convergence information.
162#[derive(Debug, Clone)]
163pub struct ConvergenceInfo {
164    /// Final gradient norm
165    pub final_gradient_norm: f64,
166    /// Final parameter update norm
167    pub final_parameter_update_norm: f64,
168    /// Cost function evaluation count
169    pub cost_evaluations: usize,
170    /// Jacobian evaluation count
171    pub jacobian_evaluations: usize,
172}
173
174impl Display for ConvergenceInfo {
175    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
176        write!(
177            f,
178            "Final gradient norm: {:.2e}, Final parameter update norm: {:.2e}, Cost evaluations: {}, Jacobian evaluations: {}",
179            self.final_gradient_norm,
180            self.final_parameter_update_norm,
181            self.cost_evaluations,
182            self.jacobian_evaluations
183        )
184    }
185}
186
187/// Status of an optimization process
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum OptimizationStatus {
190    /// Optimization converged successfully
191    Converged,
192    /// Maximum number of iterations reached
193    MaxIterationsReached,
194    /// Cost function tolerance reached
195    CostToleranceReached,
196    /// Parameter tolerance reached
197    ParameterToleranceReached,
198    /// Gradient tolerance reached
199    GradientToleranceReached,
200    /// Optimization failed due to numerical issues
201    NumericalFailure,
202    /// User requested termination
203    UserTerminated,
204    /// Timeout reached
205    Timeout,
206    /// Trust region radius fell below minimum threshold
207    TrustRegionRadiusTooSmall,
208    /// Too many consecutive rejected steps — the solver can no longer make progress.
209    ///
210    /// Damping has grown until the step is negligible and every trial step is rejected,
211    /// so further iterations cannot change the cost. Reported instead of silently
212    /// burning the remaining iteration budget.
213    StalledNoProgress,
214    /// Objective function fell below user-specified cutoff
215    MinCostThresholdReached,
216    /// Jacobian matrix is singular or ill-conditioned
217    IllConditionedJacobian,
218    /// NaN or Inf detected in cost or parameters
219    InvalidNumericalValues,
220    /// Other failure
221    Failed(String),
222}
223
224impl Display for OptimizationStatus {
225    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
226        match self {
227            OptimizationStatus::Converged => write!(f, "Converged"),
228            OptimizationStatus::MaxIterationsReached => write!(f, "Maximum iterations reached"),
229            OptimizationStatus::CostToleranceReached => write!(f, "Cost tolerance reached"),
230            OptimizationStatus::ParameterToleranceReached => {
231                write!(f, "Parameter tolerance reached")
232            }
233            OptimizationStatus::GradientToleranceReached => write!(f, "Gradient tolerance reached"),
234            OptimizationStatus::NumericalFailure => write!(f, "Numerical failure"),
235            OptimizationStatus::UserTerminated => write!(f, "User terminated"),
236            OptimizationStatus::Timeout => write!(f, "Timeout"),
237            OptimizationStatus::TrustRegionRadiusTooSmall => {
238                write!(f, "Trust region radius too small")
239            }
240            OptimizationStatus::StalledNoProgress => {
241                write!(f, "Stalled (no further progress possible)")
242            }
243            OptimizationStatus::MinCostThresholdReached => {
244                write!(f, "Minimum cost threshold reached")
245            }
246            OptimizationStatus::IllConditionedJacobian => {
247                write!(f, "Ill-conditioned Jacobian matrix")
248            }
249            OptimizationStatus::InvalidNumericalValues => {
250                write!(f, "Invalid numerical values (NaN/Inf) detected")
251            }
252            OptimizationStatus::Failed(msg) => write!(f, "Failed: {msg}"),
253        }
254    }
255}
256
257/// Result of a solver execution.
258#[derive(Clone)]
259pub struct SolverResult<T> {
260    /// Final parameters
261    pub parameters: T,
262    /// Final optimization status
263    pub status: OptimizationStatus,
264    /// Initial cost value
265    pub initial_cost: f64,
266    /// Final cost value
267    pub final_cost: f64,
268    /// Number of iterations performed
269    pub iterations: usize,
270    /// Total time elapsed
271    pub elapsed_time: time::Duration,
272    /// Convergence statistics
273    pub convergence_info: Option<ConvergenceInfo>,
274    /// Per-variable covariance matrices (uncertainty estimation)
275    ///
276    /// This is `None` if covariance computation was not enabled in the solver configuration.
277    /// When present, it contains a mapping from variable keys to their covariance matrices
278    /// in tangent space. For example, for SE3 variables this would be 6×6 matrices.
279    ///
280    /// Enable covariance computation by setting `compute_covariances: true` in the optimizer config.
281    pub covariances: Option<SecondaryMap<VarKey, Mat<f64>>>,
282}
283
284/// Type alias for the result returned by all optimizer `optimize()` calls.
285pub type OptimizeResult =
286    Result<SolverResult<SlotMap<VarKey, Box<dyn ManifoldVariable>>>, crate::error::ApexSolverError>;
287
288/// Unified optimizer interface. Object-safe — `Box<dyn Optimizer>` is valid.
289///
290/// All three optimizers ([`LevenbergMarquardt`],
291/// [`GaussNewton`],
292/// [`DogLeg`]) implement this trait.
293/// Each optimizer also provides inherent `new()`, `with_config()`, and `optimize()` methods
294/// for direct (non-polymorphic) usage.
295pub trait Optimizer {
296    /// Optimize the problem to minimize the cost function.
297    fn optimize(&mut self, problem: &mut Problem) -> OptimizeResult;
298}
299
300/// Apply parameter update step to all variables.
301///
302/// This is a common operation used by all optimizers (Levenberg-Marquardt, Gauss-Newton, Dog Leg).
303/// It applies a tangent space perturbation to each variable using the proper manifold plus operation.
304///
305/// # Arguments
306/// * `variables` - Mutable map of variables to update
307/// * `step` - Full step vector in tangent space (faer matrix view)
308/// * `variable_order` - Ordered list of variable names (defines indexing into step vector)
309///
310/// # Returns
311/// * Step norm (L2 norm) for convergence checking
312///
313/// # Implementation Notes
314/// The step vector contains concatenated tangent vectors for all variables in the order
315/// specified by `variable_order`. Each variable's tangent space dimension determines
316/// how many elements it occupies in the step vector.
317///
318pub fn apply_parameter_step(
319    variables: &mut SlotMap<VarKey, Box<dyn ManifoldVariable>>,
320    step: MatRef<f64>,
321    variable_order: &[VarKey],
322) -> f64 {
323    let mut step_offset = 0;
324
325    // SmallVec-backed buffer: inline for the common case (DOF ≤ 16, covering
326    // SE3/SO3/SE2/SO2 and small RN), spills to heap only for large RN
327    // variables. Mirrors the `[0f64; 16]` buffer pattern used inside
328    // `Variable::apply_tangent_step`.
329    let mut step_buf: smallvec::SmallVec<[f64; 16]> = smallvec::SmallVec::new();
330    for &var_key in variable_order {
331        if let Some(var) = variables.get_mut(var_key) {
332            let var_size = var.dof();
333            let var_step = step.subrows(step_offset, var_size);
334            step_buf.clear();
335            step_buf.resize(var_size, 0.0);
336            for i in 0..var_size {
337                step_buf[i] = var_step[(i, 0)];
338            }
339            var.apply_tangent_step(&step_buf);
340            step_offset += var_size;
341        }
342    }
343
344    step.norm_l2()
345}
346
347/// Apply negative parameter step to rollback variables.
348///
349/// This is used when an optimization step is rejected (e.g., in trust region methods).
350/// It applies the negative of a tangent space perturbation to revert the previous update.
351///
352/// # Arguments
353/// * `variables` - Mutable map of variables to revert
354/// * `step` - Full step vector in tangent space (faer matrix view) to negate
355/// * `variable_order` - Ordered list of variable names (defines indexing into step vector)
356///
357pub fn apply_negative_parameter_step(
358    variables: &mut SlotMap<VarKey, Box<dyn ManifoldVariable>>,
359    step: MatRef<f64>,
360    variable_order: &[VarKey],
361) {
362    let mut negative_step = Mat::zeros(step.nrows(), 1);
363    for i in 0..step.nrows() {
364        negative_step[(i, 0)] = -step[(i, 0)];
365    }
366    apply_parameter_step(variables, negative_step.as_ref(), variable_order);
367}
368
369pub fn compute_cost(residual: &Mat<f64>) -> f64 {
370    let cost = residual.norm_l2();
371    0.5 * cost * cost
372}
373
374// ============================================================================
375// Shared optimizer utilities
376// ============================================================================
377// The following types and functions are shared across all three optimizer
378// implementations (Levenberg-Marquardt, Gauss-Newton, Dog Leg) to eliminate
379// code duplication.
380
381/// Per-iteration statistics for detailed logging (Ceres-style output).
382///
383/// Captures all relevant metrics for each optimization iteration, enabling
384/// detailed analysis and debugging of the optimization process.
385#[derive(Debug, Clone)]
386pub struct IterationStats {
387    /// Iteration number (0-indexed)
388    pub iteration: usize,
389    /// Cost function value at this iteration
390    pub cost: f64,
391    /// Change in cost from previous iteration
392    pub cost_change: f64,
393    /// L2 norm of the gradient (||J^T·r||)
394    pub gradient_norm: f64,
395    /// L2 norm of the parameter update step (||Δx||)
396    pub step_norm: f64,
397    /// Trust region ratio (ρ = actual_reduction / predicted_reduction)
398    pub tr_ratio: f64,
399    /// Trust region radius (damping parameter λ for LM, Δ for Dog Leg)
400    pub tr_radius: f64,
401    /// Linear solver iterations (0 for direct solvers like Cholesky)
402    pub ls_iter: usize,
403    /// Time taken for this iteration in milliseconds
404    pub iter_time_ms: f64,
405    /// Total elapsed time since optimization started in milliseconds
406    pub total_time_ms: f64,
407    /// Whether the step was accepted (true) or rejected (false)
408    pub accepted: bool,
409}
410
411impl IterationStats {
412    /// Print table header in Ceres-style format
413    pub fn print_header() {
414        debug!(
415            "{:>4}  {:>13}  {:>13}  {:>13}  {:>13}  {:>11}  {:>11}  {:>7}  {:>11}  {:>13}  {:>6}",
416            "iter",
417            "cost",
418            "cost_change",
419            "|gradient|",
420            "|step|",
421            "tr_ratio",
422            "tr_radius",
423            "ls_iter",
424            "iter_time",
425            "total_time",
426            "status"
427        );
428    }
429
430    /// Print single iteration line in Ceres-style format with scientific notation
431    pub fn print_line(&self) {
432        let status = if self.iteration == 0 {
433            "-"
434        } else if self.accepted {
435            "✓"
436        } else {
437            "✗"
438        };
439
440        debug!(
441            "{:>4}  {:>13.6e}  {:>13.2e}  {:>13.2e}  {:>13.2e}  {:>11.2e}  {:>11.2e}  {:>7}  {:>9.2}ms  {:>11.2}ms  {:>6}",
442            self.iteration,
443            self.cost,
444            self.cost_change,
445            self.gradient_norm,
446            self.step_norm,
447            self.tr_ratio,
448            self.tr_radius,
449            self.ls_iter,
450            self.iter_time_ms,
451            self.total_time_ms,
452            status
453        );
454    }
455}
456
457/// Result of optimization state initialization, shared by all optimizers.
458pub struct InitializedState {
459    pub variables: SlotMap<VarKey, Box<dyn ManifoldVariable>>,
460    pub variable_index_map: SecondaryMap<VarKey, usize>,
461    pub sorted_vars: Vec<VarKey>,
462    pub symbolic_structure: Option<SymbolicStructure>,
463    pub total_dof: usize,
464    pub current_cost: f64,
465    pub initial_cost: f64,
466}
467
468/// Compute total parameter vector norm ||x|| across all variables.
469pub fn compute_parameter_norm(variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>) -> f64 {
470    variables
471        .values()
472        .map(|v| v.as_param_slice().iter().map(|x| x * x).sum::<f64>())
473        .sum::<f64>()
474        .sqrt()
475}
476
477/// Create Jacobi scaling diagonal matrix from Jacobian column norms.
478///
479/// The scaling factor for each column is `1 / (1 + ||col||)`, which normalizes
480/// the columns to improve conditioning of the linear system.
481pub fn create_jacobi_scaling(
482    jacobian: &SparseColMat<usize, f64>,
483) -> Result<SparseColMat<usize, f64>, OptimizerError> {
484    let cols = jacobian.ncols();
485    let jacobi_scaling_vec: Vec<Triplet<usize, usize, f64>> = (0..cols)
486        .map(|c| {
487            let col_norm_squared: f64 = jacobian
488                .triplet_iter()
489                .filter(|t| t.col == c)
490                .map(|t| t.val * t.val)
491                .sum();
492            let col_norm = col_norm_squared.sqrt();
493            let scaling = 1.0 / (1.0 + col_norm);
494            Triplet::new(c, c, scaling)
495        })
496        .collect();
497
498    SparseColMat::try_new_from_triplets(cols, cols, &jacobi_scaling_vec)
499        .map_err(|e| OptimizerError::JacobiScalingCreation(e.to_string()).log_with_source(e))
500}
501
502/// Process Jacobian by applying Jacobi scaling (created on first iteration).
503///
504/// On `iteration == 0`, creates the scaling matrix and stores it. On subsequent
505/// iterations, reuses the cached scaling.
506pub fn process_jacobian(
507    jacobian: &SparseColMat<usize, f64>,
508    jacobi_scaling: &mut Option<SparseColMat<usize, f64>>,
509    iteration: usize,
510) -> Result<SparseColMat<usize, f64>, OptimizerError> {
511    if iteration == 0 {
512        let scaling = create_jacobi_scaling(jacobian)?;
513        *jacobi_scaling = Some(scaling);
514    }
515    let scaling = jacobi_scaling
516        .as_ref()
517        .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
518    Ok(jacobian * scaling)
519}
520
521/// Initialize optimization state from problem and initial parameters.
522///
523/// This is the common initialization sequence used by all optimizers:
524/// 1. Create variables from initial values
525/// 2. Build variable-to-column index mapping
526/// 3. Build symbolic sparsity structure for Jacobian (sparse mode only)
527/// 4. Compute initial cost
528///
529/// The assembly mode is determined by `problem.jacobian_mode`.
530pub fn initialize_optimization_state(problem: &mut Problem) -> OptimizerResult<InitializedState> {
531    let mut variables = problem.variables.clone();
532    problem.apply_constraints_to_variables(&mut variables);
533
534    let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
535    let mut col_offset = 0;
536    let mut sorted_vars: Vec<VarKey> = variables.keys().collect();
537    // Sort by current column offset for deterministic ordering
538    sorted_vars.sort_by_key(|k| variable_index_map.get(*k).copied().unwrap_or(usize::MAX));
539
540    for &var_key in &sorted_vars {
541        variable_index_map.insert(var_key, col_offset);
542        col_offset += variables[var_key].dof();
543    }
544
545    // Rebuild sorted_vars in correct column order now that index map is built
546    let mut sorted_vars: Vec<VarKey> = variables.keys().collect();
547    sorted_vars.sort_by_key(|k| variable_index_map[*k]);
548
549    let total_dof = col_offset;
550
551    let symbolic_structure = match problem.jacobian_mode {
552        JacobianMode::Sparse => Some(crate::linearizer::cpu::sparse::build_symbolic_structure(
553            problem,
554            &variables,
555            &variable_index_map,
556            total_dof,
557        )?),
558        JacobianMode::Dense => None,
559    };
560
561    let residual = problem.compute_residual_sparse(&variables)?;
562    let current_cost = compute_cost(&residual);
563    let initial_cost = current_cost;
564
565    Ok(InitializedState {
566        variables,
567        variable_index_map,
568        sorted_vars,
569        symbolic_structure,
570        total_dof,
571        current_cost,
572        initial_cost,
573    })
574}
575
576/// Parameters for convergence checking, shared across optimizers.
577pub struct ConvergenceParams {
578    pub iteration: usize,
579    pub current_cost: f64,
580    pub new_cost: f64,
581    pub parameter_norm: f64,
582    pub parameter_update_norm: f64,
583    pub gradient_norm: f64,
584    pub elapsed: Duration,
585    pub step_accepted: bool,
586    // Config values
587    pub max_iterations: usize,
588    pub gradient_tolerance: f64,
589    pub parameter_tolerance: f64,
590    pub cost_tolerance: f64,
591    pub min_cost_threshold: Option<f64>,
592    pub timeout: Option<Duration>,
593    /// Trust region radius (LM damping or DogLeg radius). None for GN.
594    pub trust_region_radius: Option<f64>,
595    /// Minimum trust region radius threshold. None for GN.
596    pub min_trust_region_radius: Option<f64>,
597}
598
599/// Check convergence criteria common to all optimizers.
600///
601/// Returns `Some(status)` if a termination criterion is met, `None` otherwise.
602pub fn check_convergence(params: &ConvergenceParams) -> Option<OptimizationStatus> {
603    // CRITICAL SAFETY CHECKS (perform first)
604
605    // Invalid Numerical Values (NaN/Inf)
606    if !params.new_cost.is_finite()
607        || !params.parameter_update_norm.is_finite()
608        || !params.gradient_norm.is_finite()
609    {
610        return Some(OptimizationStatus::InvalidNumericalValues);
611    }
612
613    // Timeout
614    if let Some(timeout) = params.timeout {
615        if params.elapsed >= timeout {
616            return Some(OptimizationStatus::Timeout);
617        }
618    }
619
620    // Maximum Iterations
621    if params.iteration >= params.max_iterations {
622        return Some(OptimizationStatus::MaxIterationsReached);
623    }
624
625    // CONVERGENCE CRITERIA (only check after accepted steps)
626    if !params.step_accepted {
627        return None;
628    }
629
630    // Gradient Norm (First-Order Optimality)
631    if params.gradient_norm < params.gradient_tolerance {
632        return Some(OptimizationStatus::GradientToleranceReached);
633    }
634
635    // Parameter and cost criteria (only after first iteration)
636    if params.iteration > 0 {
637        // Parameter Change Tolerance (xtol)
638        let relative_step_tolerance =
639            params.parameter_tolerance * (params.parameter_norm + params.parameter_tolerance);
640        if params.parameter_update_norm <= relative_step_tolerance {
641            return Some(OptimizationStatus::ParameterToleranceReached);
642        }
643
644        // Function Value Change Tolerance (ftol)
645        let cost_change = (params.current_cost - params.new_cost).abs();
646        let relative_cost_change = cost_change / params.current_cost.max(1e-10);
647        if relative_cost_change < params.cost_tolerance {
648            return Some(OptimizationStatus::CostToleranceReached);
649        }
650    }
651
652    // Objective Function Cutoff (optional early stopping)
653    if let Some(min_cost) = params.min_cost_threshold {
654        if params.new_cost < min_cost {
655            return Some(OptimizationStatus::MinCostThresholdReached);
656        }
657    }
658
659    // Trust Region Radius (LM and DogLeg only)
660    if let (Some(radius), Some(min_radius)) =
661        (params.trust_region_radius, params.min_trust_region_radius)
662    {
663        if radius < min_radius {
664            return Some(OptimizationStatus::TrustRegionRadiusTooSmall);
665        }
666    }
667
668    None
669}
670
671/// Compute step quality ratio (actual vs predicted reduction).
672///
673/// Used by Levenberg-Marquardt and Dog Leg optimizers to evaluate
674/// whether a proposed step improved the objective function as predicted
675/// by the local quadratic model.
676///
677/// Returns `ρ = actual_reduction / predicted_reduction`, handling
678/// near-zero predicted reduction gracefully.
679pub fn compute_step_quality(current_cost: f64, new_cost: f64, predicted_reduction: f64) -> f64 {
680    let actual_reduction = current_cost - new_cost;
681    if predicted_reduction.abs() < 1e-15 {
682        if actual_reduction > 0.0 { 1.0 } else { 0.0 }
683    } else {
684        actual_reduction / predicted_reduction
685    }
686}
687
688/// Create the appropriate linear solver based on configuration.
689///
690/// Used by Gauss-Newton and Dog Leg optimizers. Levenberg-Marquardt has its own
691/// solver creation logic due to special Schur complement adapter requirements.
692pub fn create_linear_solver(
693    solver_type: &linalg::LinearSolverType,
694) -> Box<dyn LinearSolver<SparseMode>> {
695    match solver_type {
696        linalg::LinearSolverType::SparseCholesky => Box::new(SparseCholeskySolver::new()),
697        linalg::LinearSolverType::SparseQR => Box::new(SparseQRSolver::new()),
698        _ => {
699            // SparseSchurComplement requires special handling; DenseCholesky/DenseQR are
700            // dispatched via the dense path in each optimizer — all fall back to Cholesky here.
701            Box::new(SparseCholeskySolver::new())
702        }
703    }
704}
705
706/// Notify observers with current optimization state (sparse path).
707///
708/// This is the common observer notification pattern used by all three optimizers.
709#[allow(clippy::too_many_arguments)]
710pub fn notify_observers(
711    observers: &mut OptObserverVec,
712    variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
713    iteration: usize,
714    cost: f64,
715    gradient_norm: f64,
716    damping: Option<f64>,
717    step_norm: f64,
718    step_quality: Option<f64>,
719    linear_solver: &dyn LinearSolver<SparseMode>,
720) {
721    observers.set_iteration_metrics(cost, gradient_norm, damping, step_norm, step_quality);
722
723    if !observers.is_empty() {
724        if let (Some(hessian), Some(gradient)) =
725            (linear_solver.get_hessian(), linear_solver.get_gradient())
726        {
727            observers.set_matrix_data(Some(hessian.clone()), Some(gradient.clone()));
728        }
729    }
730
731    observers.notify(variables, iteration);
732}
733
734/// Notify observers with current optimization state (generic path).
735///
736/// For dense mode, the Hessian is converted to sparse for observer compatibility.
737/// This is acceptable since observers are for visualization/debugging, not the hot path.
738#[allow(clippy::too_many_arguments)]
739pub fn notify_observers_generic<M: AssemblyBackend>(
740    observers: &mut OptObserverVec,
741    variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
742    iteration: usize,
743    cost: f64,
744    gradient_norm: f64,
745    damping: Option<f64>,
746    step_norm: f64,
747    step_quality: Option<f64>,
748    _linear_solver: &dyn LinearSolver<M>,
749) {
750    observers.set_iteration_metrics(cost, gradient_norm, damping, step_norm, step_quality);
751    // Skip matrix data for generic path since observers expect sparse Hessian.
752    // Observer matrix visualization is optional and only used for debugging.
753    observers.notify(variables, iteration);
754}
755
756/// Process Jacobian with Jacobi scaling (generic over assembly mode).
757///
758/// On `iteration == 0`, computes the scaling factors and stores them.
759/// On subsequent iterations, reuses the cached scaling.
760pub fn process_jacobian_generic<M: AssemblyBackend>(
761    jacobian: &M::Jacobian,
762    jacobi_scaling: &mut Option<Vec<f64>>,
763    iteration: usize,
764) -> Result<M::Jacobian, OptimizerError> {
765    if iteration == 0 {
766        let norms = M::compute_column_norms(jacobian);
767        let scaling: Vec<f64> = norms.iter().map(|n| 1.0 / (1.0 + n)).collect();
768        *jacobi_scaling = Some(scaling);
769    }
770    let scaling = jacobi_scaling
771        .as_ref()
772        .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
773    Ok(M::apply_column_scaling(jacobian, scaling))
774}
775
776/// Build a SolverResult from common optimization loop outputs.
777///
778/// All three optimizers construct SolverResult identically at convergence.
779#[allow(clippy::too_many_arguments)]
780pub fn build_solver_result(
781    status: OptimizationStatus,
782    iterations: usize,
783    state: InitializedState,
784    elapsed: Duration,
785    final_gradient_norm: f64,
786    final_parameter_update_norm: f64,
787    cost_evaluations: usize,
788    jacobian_evaluations: usize,
789    covariances: Option<SecondaryMap<VarKey, Mat<f64>>>,
790) -> SolverResult<SlotMap<VarKey, Box<dyn ManifoldVariable>>> {
791    SolverResult {
792        status,
793        iterations,
794        initial_cost: state.initial_cost,
795        final_cost: state.current_cost,
796        parameters: state.variables,
797        elapsed_time: elapsed,
798        convergence_info: Some(ConvergenceInfo {
799            final_gradient_norm,
800            final_parameter_update_norm,
801            cost_evaluations,
802            jacobian_evaluations,
803        }),
804        covariances,
805    }
806}
807
808/// Unified summary statistics for all optimizer types.
809///
810/// Replaces the separate `LevenbergMarquardtSummary`, `GaussNewtonSummary`,
811/// and `DogLegSummary` structs with a single type that handles algorithm-specific
812/// fields via `Option`.
813#[derive(Debug, Clone)]
814pub struct OptimizerSummary {
815    /// Name of the optimizer algorithm
816    pub optimizer_name: &'static str,
817    /// Initial cost value
818    pub initial_cost: f64,
819    /// Final cost value
820    pub final_cost: f64,
821    /// Total number of iterations performed
822    pub iterations: usize,
823    /// Number of successful steps (None for GN which always accepts)
824    pub successful_steps: Option<usize>,
825    /// Number of unsuccessful steps (None for GN which always accepts)
826    pub unsuccessful_steps: Option<usize>,
827    /// Average cost reduction per iteration
828    pub average_cost_reduction: f64,
829    /// Maximum gradient norm encountered
830    pub max_gradient_norm: f64,
831    /// Final gradient norm
832    pub final_gradient_norm: f64,
833    /// Maximum parameter update norm
834    pub max_parameter_update_norm: f64,
835    /// Final parameter update norm
836    pub final_parameter_update_norm: f64,
837    /// Total time elapsed
838    pub total_time: Duration,
839    /// Average time per iteration
840    pub average_time_per_iteration: Duration,
841    /// Detailed per-iteration statistics history
842    pub iteration_history: Vec<IterationStats>,
843    /// Convergence status
844    pub convergence_status: OptimizationStatus,
845    /// Final damping parameter (LM only)
846    pub final_damping: Option<f64>,
847    /// Final trust region radius (DL only)
848    pub final_trust_region_radius: Option<f64>,
849    /// Step quality ratio (LM only)
850    pub rho: Option<f64>,
851}
852
853impl Display for OptimizerSummary {
854    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
855        let converged = matches!(
856            self.convergence_status,
857            OptimizationStatus::Converged
858                | OptimizationStatus::CostToleranceReached
859                | OptimizationStatus::GradientToleranceReached
860                | OptimizationStatus::ParameterToleranceReached
861        );
862
863        writeln!(f, "{} Final Result", self.optimizer_name)?;
864
865        if converged {
866            writeln!(f, "CONVERGED ({:?})", self.convergence_status)?;
867        } else {
868            writeln!(f, "DIVERGED ({:?})", self.convergence_status)?;
869        }
870
871        writeln!(f)?;
872        writeln!(f, "Cost:")?;
873        writeln!(f, "  Initial:   {:.6e}", self.initial_cost)?;
874        writeln!(f, "  Final:     {:.6e}", self.final_cost)?;
875        writeln!(
876            f,
877            "  Reduction: {:.6e} ({:.2}%)",
878            self.initial_cost - self.final_cost,
879            100.0 * (self.initial_cost - self.final_cost) / self.initial_cost.max(1e-12)
880        )?;
881        writeln!(f)?;
882        writeln!(f, "Iterations:")?;
883        writeln!(f, "  Total:              {}", self.iterations)?;
884        if let (Some(successful), Some(unsuccessful)) =
885            (self.successful_steps, self.unsuccessful_steps)
886        {
887            writeln!(
888                f,
889                "  Successful steps:   {} ({:.1}%)",
890                successful,
891                100.0 * successful as f64 / self.iterations.max(1) as f64
892            )?;
893            writeln!(
894                f,
895                "  Unsuccessful steps: {} ({:.1}%)",
896                unsuccessful,
897                100.0 * unsuccessful as f64 / self.iterations.max(1) as f64
898            )?;
899        }
900        if let Some(radius) = self.final_trust_region_radius {
901            writeln!(f)?;
902            writeln!(f, "Trust Region:")?;
903            writeln!(f, "  Final radius: {:.6e}", radius)?;
904        }
905        writeln!(f)?;
906        writeln!(f, "Gradient:")?;
907        writeln!(f, "  Max norm:   {:.2e}", self.max_gradient_norm)?;
908        writeln!(f, "  Final norm: {:.2e}", self.final_gradient_norm)?;
909        writeln!(f)?;
910        writeln!(f, "Parameter Update:")?;
911        writeln!(f, "  Max norm:   {:.2e}", self.max_parameter_update_norm)?;
912        writeln!(f, "  Final norm: {:.2e}", self.final_parameter_update_norm)?;
913        writeln!(f)?;
914        writeln!(f, "Performance:")?;
915        writeln!(
916            f,
917            "  Total time:             {:.2}ms",
918            self.total_time.as_secs_f64() * 1000.0
919        )?;
920        writeln!(
921            f,
922            "  Average per iteration:  {:.2}ms",
923            self.average_time_per_iteration.as_secs_f64() * 1000.0
924        )?;
925
926        Ok(())
927    }
928}
929
930/// Create an OptimizerSummary from common optimization loop outputs.
931#[allow(clippy::too_many_arguments)]
932pub fn create_optimizer_summary(
933    optimizer_name: &'static str,
934    initial_cost: f64,
935    final_cost: f64,
936    iterations: usize,
937    successful_steps: Option<usize>,
938    unsuccessful_steps: Option<usize>,
939    max_gradient_norm: f64,
940    final_gradient_norm: f64,
941    max_parameter_update_norm: f64,
942    final_parameter_update_norm: f64,
943    total_cost_reduction: f64,
944    total_time: Duration,
945    iteration_history: Vec<IterationStats>,
946    convergence_status: OptimizationStatus,
947    final_damping: Option<f64>,
948    final_trust_region_radius: Option<f64>,
949    rho: Option<f64>,
950) -> OptimizerSummary {
951    OptimizerSummary {
952        optimizer_name,
953        initial_cost,
954        final_cost,
955        iterations,
956        successful_steps,
957        unsuccessful_steps,
958        average_cost_reduction: if iterations > 0 {
959            total_cost_reduction / iterations as f64
960        } else {
961            0.0
962        },
963        max_gradient_norm,
964        final_gradient_norm,
965        max_parameter_update_norm,
966        final_parameter_update_norm,
967        total_time,
968        average_time_per_iteration: if iterations > 0 {
969            total_time / iterations as u32
970        } else {
971            Duration::from_secs(0)
972        },
973        iteration_history,
974        convergence_status,
975        final_damping,
976        final_trust_region_radius,
977        rho,
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984    use crate::core::VarKey;
985    use crate::core::variable::{ManifoldVariable, Variable};
986    use crate::factors::Factor;
987    use crate::linalg::JacobianMode;
988    use apex_manifolds::ManifoldType;
989    use apex_manifolds::rn::Rn;
990    use faer::Mat;
991    use faer::prelude::ReborrowMut;
992    use faer::sparse::{SparseColMat, Triplet};
993    use nalgebra::dvector;
994    use slotmap::{SecondaryMap, SlotMap};
995    use std::time::Duration;
996
997    type TestResult = Result<(), Box<dyn std::error::Error>>;
998
999    // -------------------------------------------------------------------------
1000    // compute_cost
1001    // -------------------------------------------------------------------------
1002
1003    #[test]
1004    fn test_compute_cost_known_value() {
1005        // ||[1, 2]||² * 0.5 = (1 + 4) * 0.5 = 2.5
1006        let r = Mat::from_fn(2, 1, |i, _| (i + 1) as f64);
1007        let cost = compute_cost(&r);
1008        assert!((cost - 2.5).abs() < 1e-12, "expected 2.5, got {cost}");
1009    }
1010
1011    #[test]
1012    fn test_compute_cost_zero_residual() {
1013        let r = Mat::zeros(3, 1);
1014        assert_eq!(compute_cost(&r), 0.0);
1015    }
1016
1017    // -------------------------------------------------------------------------
1018    // compute_step_quality
1019    // -------------------------------------------------------------------------
1020
1021    #[test]
1022    fn test_compute_step_quality_normal() {
1023        // actual = 1.0-0.0 = 1.0, predicted = 2.0 → rho = 0.5
1024        let rho = compute_step_quality(1.0, 0.0, 2.0);
1025        assert!((rho - 0.5).abs() < 1e-12);
1026    }
1027
1028    #[test]
1029    fn test_compute_step_quality_zero_predicted_positive_actual() {
1030        // predicted ≈ 0, actual > 0 → 1.0
1031        let rho = compute_step_quality(2.0, 1.0, 0.0);
1032        assert_eq!(rho, 1.0);
1033    }
1034
1035    #[test]
1036    fn test_compute_step_quality_zero_predicted_nonpositive_actual() {
1037        // predicted ≈ 0, actual ≤ 0 → 0.0
1038        let rho = compute_step_quality(1.0, 2.0, 0.0); // actual = -1.0
1039        assert_eq!(rho, 0.0);
1040    }
1041
1042    #[test]
1043    fn test_compute_step_quality_negative_reduction() {
1044        // cost increased: actual = 1.0 - 2.0 = -1.0, predicted = 1.0 → -1.0
1045        let rho = compute_step_quality(1.0, 2.0, 1.0);
1046        assert!((rho - (-1.0)).abs() < 1e-12);
1047    }
1048
1049    // -------------------------------------------------------------------------
1050    // create_jacobi_scaling
1051    // -------------------------------------------------------------------------
1052
1053    fn make_identity_jacobian(n: usize) -> SparseColMat<usize, f64> {
1054        let triplets: Vec<Triplet<usize, usize, f64>> =
1055            (0..n).map(|i| Triplet::new(i, i, 1.0)).collect();
1056        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap_or_else(|_| {
1057            let empty: Vec<Triplet<usize, usize, f64>> = vec![];
1058            SparseColMat::try_new_from_triplets(0, 0, &empty)
1059                .unwrap_or_else(|_| panic!("failed to create empty matrix"))
1060        })
1061    }
1062
1063    #[test]
1064    fn test_create_jacobi_scaling_identity_jacobian() -> TestResult {
1065        // For identity Jacobian each column has norm 1.0 → scaling = 1/(1+1) = 0.5
1066        let jac = make_identity_jacobian(3);
1067        let scaling = create_jacobi_scaling(&jac)?;
1068        for i in 0..3 {
1069            let val = scaling.get(i, i).copied().unwrap_or(0.0);
1070            assert!(
1071                (val - 0.5).abs() < 1e-12,
1072                "col {i}: expected 0.5, got {val}"
1073            );
1074        }
1075        Ok(())
1076    }
1077
1078    #[test]
1079    fn test_create_jacobi_scaling_zero_column() -> TestResult {
1080        // A zero column has norm 0 → scaling = 1/(1+0) = 1.0
1081        let triplets = vec![Triplet::new(0_usize, 0_usize, 1.0_f64)];
1082        let jac = SparseColMat::try_new_from_triplets(2, 2, &triplets)?;
1083        let scaling = create_jacobi_scaling(&jac)?;
1084        // col 0: norm=1 → 0.5; col 1: norm=0 → 1.0
1085        let s0 = scaling.get(0, 0).copied().unwrap_or(0.0);
1086        let s1 = scaling.get(1, 1).copied().unwrap_or(0.0);
1087        assert!((s0 - 0.5).abs() < 1e-12);
1088        assert!((s1 - 1.0).abs() < 1e-12);
1089        Ok(())
1090    }
1091
1092    // -------------------------------------------------------------------------
1093    // process_jacobian
1094    // -------------------------------------------------------------------------
1095
1096    #[test]
1097    fn test_process_jacobian_creates_at_iter0() -> TestResult {
1098        let jac = make_identity_jacobian(2);
1099        let mut cache: Option<SparseColMat<usize, f64>> = None;
1100        let scaled = process_jacobian(&jac, &mut cache, 0)?;
1101        assert!(cache.is_some(), "scaling should be cached after iter 0");
1102        // Each diagonal entry should be scaled by 0.5
1103        let s = scaled.get(0, 0).copied().unwrap_or(0.0);
1104        assert!((s - 0.5).abs() < 1e-12);
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn test_process_jacobian_reuses_at_iter1() -> TestResult {
1110        let jac = make_identity_jacobian(2);
1111        let mut cache: Option<SparseColMat<usize, f64>> = None;
1112        // build cache at iter 0
1113        process_jacobian(&jac, &mut cache, 0)?;
1114        // now use cached at iter 1
1115        let scaled = process_jacobian(&jac, &mut cache, 1)?;
1116        let s = scaled.get(0, 0).copied().unwrap_or(0.0);
1117        assert!((s - 0.5).abs() < 1e-12);
1118        Ok(())
1119    }
1120
1121    #[test]
1122    fn test_process_jacobian_error_at_iter1_without_init() {
1123        let jac = make_identity_jacobian(2);
1124        let mut cache: Option<SparseColMat<usize, f64>> = None;
1125        // skip iter 0 — should error
1126        let result = process_jacobian(&jac, &mut cache, 1);
1127        assert!(result.is_err(), "should fail without prior iter=0 call");
1128    }
1129
1130    // -------------------------------------------------------------------------
1131    // compute_parameter_norm
1132    // -------------------------------------------------------------------------
1133
1134    #[test]
1135    fn test_compute_parameter_norm_two_variables() {
1136        let mut vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1137        vars.insert(Box::new(Variable::new(Rn::new(dvector![3.0]))));
1138        vars.insert(Box::new(Variable::new(Rn::new(dvector![4.0]))));
1139        let norm = compute_parameter_norm(&vars);
1140        // sqrt(3² + 4²) = 5.0
1141        assert!((norm - 5.0).abs() < 1e-12, "expected 5.0, got {norm}");
1142    }
1143
1144    #[test]
1145    fn test_compute_parameter_norm_empty() {
1146        let vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1147        assert_eq!(compute_parameter_norm(&vars), 0.0);
1148    }
1149
1150    // -------------------------------------------------------------------------
1151    // apply_parameter_step / apply_negative_parameter_step
1152    // -------------------------------------------------------------------------
1153
1154    #[test]
1155    fn test_apply_parameter_step_advances_variable() {
1156        let mut vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1157        let k = vars.insert(Box::new(Variable::new(Rn::new(dvector![0.0]))));
1158        let order = vec![k];
1159        let step = Mat::from_fn(1, 1, |_, _| 3.0);
1160        let norm = apply_parameter_step(&mut vars, step.as_ref(), &order);
1161        assert!((norm - 3.0).abs() < 1e-12);
1162        let val = vars[k].as_param_slice()[0];
1163        assert!((val - 3.0).abs() < 1e-12, "expected 3.0, got {val}");
1164    }
1165
1166    #[test]
1167    fn test_apply_negative_parameter_step_reverts() {
1168        let mut vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1169        let k = vars.insert(Box::new(Variable::new(Rn::new(dvector![5.0]))));
1170        let order = vec![k];
1171        let step = Mat::from_fn(1, 1, |_, _| 2.0);
1172        apply_parameter_step(&mut vars, step.as_ref(), &order);
1173        assert!((vars[k].as_param_slice()[0] - 7.0).abs() < 1e-12);
1174        apply_negative_parameter_step(&mut vars, step.as_ref(), &order);
1175        assert!((vars[k].as_param_slice()[0] - 5.0).abs() < 1e-12);
1176    }
1177
1178    // -------------------------------------------------------------------------
1179    // check_convergence — all branches
1180    // -------------------------------------------------------------------------
1181
1182    fn base_params() -> ConvergenceParams {
1183        ConvergenceParams {
1184            iteration: 1,
1185            current_cost: 1.0,
1186            new_cost: 0.9,
1187            parameter_norm: 1.0,
1188            parameter_update_norm: 1e-3,
1189            gradient_norm: 1e-3,
1190            elapsed: Duration::from_millis(10),
1191            step_accepted: true,
1192            max_iterations: 100,
1193            gradient_tolerance: 1e-10,
1194            parameter_tolerance: 1e-10,
1195            cost_tolerance: 1e-10,
1196            min_cost_threshold: None,
1197            timeout: None,
1198            trust_region_radius: None,
1199            min_trust_region_radius: None,
1200        }
1201    }
1202
1203    #[test]
1204    fn test_check_convergence_no_trigger() {
1205        assert!(check_convergence(&base_params()).is_none());
1206    }
1207
1208    #[test]
1209    fn test_check_convergence_nan_cost() {
1210        let mut p = base_params();
1211        p.new_cost = f64::NAN;
1212        assert_eq!(
1213            check_convergence(&p),
1214            Some(OptimizationStatus::InvalidNumericalValues)
1215        );
1216    }
1217
1218    #[test]
1219    fn test_check_convergence_inf_gradient() {
1220        let mut p = base_params();
1221        p.gradient_norm = f64::INFINITY;
1222        assert_eq!(
1223            check_convergence(&p),
1224            Some(OptimizationStatus::InvalidNumericalValues)
1225        );
1226    }
1227
1228    #[test]
1229    fn test_check_convergence_timeout() {
1230        let mut p = base_params();
1231        p.timeout = Some(Duration::from_millis(5));
1232        p.elapsed = Duration::from_millis(10);
1233        assert_eq!(check_convergence(&p), Some(OptimizationStatus::Timeout));
1234    }
1235
1236    #[test]
1237    fn test_check_convergence_max_iterations() {
1238        let mut p = base_params();
1239        p.iteration = 100;
1240        p.max_iterations = 100;
1241        assert_eq!(
1242            check_convergence(&p),
1243            Some(OptimizationStatus::MaxIterationsReached)
1244        );
1245    }
1246
1247    #[test]
1248    fn test_check_convergence_gradient_tolerance() {
1249        let mut p = base_params();
1250        p.step_accepted = true;
1251        p.gradient_norm = 1e-12;
1252        p.gradient_tolerance = 1e-10;
1253        assert_eq!(
1254            check_convergence(&p),
1255            Some(OptimizationStatus::GradientToleranceReached)
1256        );
1257    }
1258
1259    #[test]
1260    fn test_check_convergence_parameter_tolerance() {
1261        let mut p = base_params();
1262        p.step_accepted = true;
1263        p.gradient_norm = 1.0; // above tolerance
1264        p.parameter_update_norm = 1e-20;
1265        p.parameter_tolerance = 1e-8;
1266        p.parameter_norm = 1.0;
1267        assert_eq!(
1268            check_convergence(&p),
1269            Some(OptimizationStatus::ParameterToleranceReached)
1270        );
1271    }
1272
1273    #[test]
1274    fn test_check_convergence_cost_tolerance() {
1275        let mut p = base_params();
1276        p.step_accepted = true;
1277        p.gradient_norm = 1.0; // above
1278        p.parameter_update_norm = 1.0; // above
1279        p.current_cost = 1.0;
1280        p.new_cost = 1.0 - 1e-15; // nearly no change
1281        p.cost_tolerance = 1e-10;
1282        assert_eq!(
1283            check_convergence(&p),
1284            Some(OptimizationStatus::CostToleranceReached)
1285        );
1286    }
1287
1288    #[test]
1289    fn test_check_convergence_min_cost_threshold() {
1290        let mut p = base_params();
1291        p.step_accepted = true;
1292        p.gradient_norm = 1.0;
1293        p.parameter_update_norm = 1.0;
1294        p.current_cost = 1.0;
1295        p.new_cost = 1.0 - 0.5; // big change — cost tol not triggered
1296        p.cost_tolerance = 1e-10;
1297        p.min_cost_threshold = Some(1.0); // new_cost=0.5 < 1.0 → trigger
1298        assert_eq!(
1299            check_convergence(&p),
1300            Some(OptimizationStatus::MinCostThresholdReached)
1301        );
1302    }
1303
1304    #[test]
1305    fn test_check_convergence_trust_region_too_small() {
1306        let mut p = base_params();
1307        p.step_accepted = true;
1308        p.gradient_norm = 1.0;
1309        p.parameter_update_norm = 1.0;
1310        p.current_cost = 1.0;
1311        p.new_cost = 0.5;
1312        p.cost_tolerance = 1e-10;
1313        p.trust_region_radius = Some(1e-40);
1314        p.min_trust_region_radius = Some(1e-32);
1315        assert_eq!(
1316            check_convergence(&p),
1317            Some(OptimizationStatus::TrustRegionRadiusTooSmall)
1318        );
1319    }
1320
1321    #[test]
1322    fn test_check_convergence_step_not_accepted_skips_criteria() {
1323        // With step_accepted=false, gradient/parameter/cost tol should NOT fire
1324        let mut p = base_params();
1325        p.step_accepted = false;
1326        p.gradient_norm = 0.0; // would trigger gradient tol if accepted
1327        p.parameter_update_norm = 0.0;
1328        p.new_cost = 0.0;
1329        assert!(check_convergence(&p).is_none());
1330    }
1331
1332    // -------------------------------------------------------------------------
1333    // create_linear_solver
1334    // -------------------------------------------------------------------------
1335
1336    #[test]
1337    fn test_create_linear_solver_cholesky() {
1338        let solver = create_linear_solver(&crate::linalg::LinearSolverType::SparseCholesky);
1339        // just verify it's constructable and callable without panic
1340        let _ = solver.get_hessian();
1341    }
1342
1343    #[test]
1344    fn test_create_linear_solver_qr() {
1345        let solver = create_linear_solver(&crate::linalg::LinearSolverType::SparseQR);
1346        let _ = solver.get_hessian();
1347    }
1348
1349    #[test]
1350    fn test_create_linear_solver_fallback_for_schur() {
1351        // SparseSchurComplement is special; falls back to Cholesky in create_linear_solver
1352        let solver = create_linear_solver(&crate::linalg::LinearSolverType::SparseSchurComplement);
1353        let _ = solver.get_hessian();
1354    }
1355
1356    // -------------------------------------------------------------------------
1357    // Display impls
1358    // -------------------------------------------------------------------------
1359
1360    #[test]
1361    fn test_optimizer_type_display() {
1362        assert_eq!(
1363            format!("{}", OptimizerType::LevenbergMarquardt),
1364            "Levenberg-Marquardt"
1365        );
1366        assert_eq!(format!("{}", OptimizerType::GaussNewton), "Gauss-Newton");
1367        assert_eq!(format!("{}", OptimizerType::DogLeg), "Dog Leg");
1368    }
1369
1370    #[test]
1371    fn test_optimization_status_display() {
1372        assert_eq!(format!("{}", OptimizationStatus::Converged), "Converged");
1373        assert_eq!(
1374            format!("{}", OptimizationStatus::MaxIterationsReached),
1375            "Maximum iterations reached"
1376        );
1377        assert_eq!(format!("{}", OptimizationStatus::Timeout), "Timeout");
1378        assert_eq!(
1379            format!("{}", OptimizationStatus::InvalidNumericalValues),
1380            "Invalid numerical values (NaN/Inf) detected"
1381        );
1382        assert!(format!("{}", OptimizationStatus::Failed("oops".into())).contains("oops"));
1383    }
1384
1385    #[test]
1386    fn test_optimizer_error_variants() {
1387        let e1 = OptimizerError::TrustRegionFailure {
1388            radius: 1e-40,
1389            min_radius: 1e-32,
1390        };
1391        assert!(e1.to_string().contains("Trust region radius"));
1392
1393        let e2 = OptimizerError::DampingFailure {
1394            damping: 1e13,
1395            max_damping: 1e12,
1396        };
1397        assert!(e2.to_string().contains("Damping parameter"));
1398
1399        let e3 = OptimizerError::CostIncrease {
1400            old_cost: 1.0,
1401            new_cost: 2.0,
1402        };
1403        assert!(e3.to_string().contains("Cost increased"));
1404
1405        let e4 = OptimizerError::LinearSolveFailed("singular".into());
1406        assert!(e4.to_string().contains("singular"));
1407
1408        let e5 = OptimizerError::EmptyProblem;
1409        assert!(e5.to_string().contains("no variables"));
1410
1411        let e6 = OptimizerError::NoResidualBlocks;
1412        assert!(e6.to_string().contains("residual blocks"));
1413    }
1414
1415    // -------------------------------------------------------------------------
1416    // IterationStats print (smoke tests — no panic)
1417    // -------------------------------------------------------------------------
1418
1419    #[test]
1420    fn test_iteration_stats_print_header_no_panic() {
1421        IterationStats::print_header();
1422    }
1423
1424    #[test]
1425    fn test_iteration_stats_print_line_no_panic() {
1426        let stats = IterationStats {
1427            iteration: 1,
1428            cost: 1.5,
1429            cost_change: -0.5,
1430            gradient_norm: 1e-3,
1431            step_norm: 1e-4,
1432            tr_ratio: 0.8,
1433            tr_radius: 1e3,
1434            ls_iter: 0,
1435            iter_time_ms: 2.5,
1436            total_time_ms: 10.0,
1437            accepted: true,
1438        };
1439        stats.print_line();
1440    }
1441
1442    // -------------------------------------------------------------------------
1443    // build_solver_result
1444    // -------------------------------------------------------------------------
1445
1446    #[test]
1447    fn test_build_solver_result_fields() -> TestResult {
1448        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1449        let k = variables.insert(Box::new(Variable::new(Rn::new(dvector![3.0]))));
1450        let state = InitializedState {
1451            variables,
1452            variable_index_map: SecondaryMap::new(),
1453            sorted_vars: vec![k],
1454            symbolic_structure: None,
1455            total_dof: 1,
1456            current_cost: 0.1,
1457            initial_cost: 5.0,
1458        };
1459        let result = build_solver_result(
1460            OptimizationStatus::CostToleranceReached,
1461            10,
1462            state,
1463            Duration::from_millis(50),
1464            1e-5,
1465            1e-6,
1466            15,
1467            10,
1468            None,
1469        );
1470        assert_eq!(result.status, OptimizationStatus::CostToleranceReached);
1471        assert_eq!(result.iterations, 10);
1472        assert!((result.initial_cost - 5.0).abs() < 1e-12);
1473        assert!((result.final_cost - 0.1).abs() < 1e-12);
1474        let ci = result
1475            .convergence_info
1476            .as_ref()
1477            .ok_or("convergence_info is None")?;
1478        assert_eq!(ci.cost_evaluations, 15);
1479        assert_eq!(ci.jacobian_evaluations, 10);
1480        Ok(())
1481    }
1482
1483    // -------------------------------------------------------------------------
1484    // create_optimizer_summary
1485    // -------------------------------------------------------------------------
1486
1487    #[test]
1488    fn test_create_optimizer_summary_averages() {
1489        let summary = create_optimizer_summary(
1490            "TestOptimizer",
1491            10.0,
1492            1.0,
1493            4,
1494            Some(3),
1495            Some(1),
1496            2.0,
1497            0.1,
1498            3.0,
1499            0.05,
1500            9.0, // total_cost_reduction
1501            Duration::from_millis(400),
1502            vec![],
1503            OptimizationStatus::CostToleranceReached,
1504            Some(1e-3),
1505            None,
1506            Some(0.8),
1507        );
1508        // average_cost_reduction = 9.0 / 4 = 2.25
1509        assert!((summary.average_cost_reduction - 2.25).abs() < 1e-10);
1510        // average_time_per_iteration = 400ms / 4 = 100ms
1511        assert_eq!(
1512            summary.average_time_per_iteration,
1513            Duration::from_millis(100)
1514        );
1515        assert_eq!(summary.optimizer_name, "TestOptimizer");
1516        assert_eq!(summary.successful_steps, Some(3));
1517    }
1518
1519    #[test]
1520    fn test_create_optimizer_summary_zero_iterations() {
1521        let summary = create_optimizer_summary(
1522            "Test",
1523            1.0,
1524            1.0,
1525            0, // zero iterations
1526            None,
1527            None,
1528            0.0,
1529            0.0,
1530            0.0,
1531            0.0,
1532            0.0,
1533            Duration::from_secs(0),
1534            vec![],
1535            OptimizationStatus::MaxIterationsReached,
1536            None,
1537            None,
1538            None,
1539        );
1540        assert_eq!(summary.average_cost_reduction, 0.0);
1541        assert_eq!(summary.average_time_per_iteration, Duration::from_secs(0));
1542    }
1543
1544    // -------------------------------------------------------------------------
1545    // Simple Factor for integration tests in mod.rs
1546    // -------------------------------------------------------------------------
1547
1548    /// Linear factor: r = x - target, J = [[1.0]]
1549    struct LinearFactor {
1550        target: f64,
1551    }
1552
1553    impl Factor for LinearFactor {
1554        fn linearize(
1555            &self,
1556            params: &[&[f64]],
1557            residual: &mut [f64],
1558            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1559        ) {
1560            residual[0] = params[0][0] - self.target;
1561            if let Some(mut jac) = jacobian {
1562                *jac.rb_mut().get_mut(0, 0) = 1.0;
1563            }
1564        }
1565        fn residual_dim(&self) -> usize {
1566            1
1567        }
1568        fn jacobian_shape(&self) -> (usize, usize) {
1569            (1, 1)
1570        }
1571    }
1572
1573    // -------------------------------------------------------------------------
1574    // initialize_optimization_state (smoke test)
1575    // -------------------------------------------------------------------------
1576
1577    #[test]
1578    fn test_initialize_optimization_state() -> TestResult {
1579        use crate::core::problem::Problem;
1580
1581        let mut problem = Problem::new(JacobianMode::Sparse);
1582        let k = problem.add_variable(ManifoldType::RN, dvector![5.0]);
1583        problem.add_residual_block(&[k], Box::new(LinearFactor { target: 0.0 }), None);
1584
1585        let state = initialize_optimization_state(&mut problem)?;
1586        assert_eq!(state.total_dof, 1);
1587        assert!(state.initial_cost > 0.0);
1588        assert!(state.sorted_vars.contains(&k));
1589        Ok(())
1590    }
1591}