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::problem::{Problem, SymbolicStructure, VariableEnum};
10use crate::error;
11use crate::linalg::{self, SparseCholeskySolver, SparseLinearSolver, SparseQRSolver};
12use apex_manifolds::ManifoldType;
13use faer::sparse::{SparseColMat, Triplet};
14use faer::{Mat, MatRef};
15use nalgebra::DVector;
16use std::collections::HashMap;
17use std::time::{self, Duration};
18use std::{
19    fmt,
20    fmt::{Display, Formatter},
21};
22use thiserror::Error;
23use tracing::{debug, error};
24
25pub mod dog_leg;
26pub mod gauss_newton;
27pub mod levenberg_marquardt;
28
29pub use dog_leg::DogLeg;
30pub use gauss_newton::GaussNewton;
31pub use levenberg_marquardt::LevenbergMarquardt;
32
33// Re-export observer types from the observers module
34pub use crate::observers::{OptObserver, OptObserverVec};
35
36/// Type of optimization solver algorithm to use
37#[derive(Default, Clone, Copy, PartialEq, Eq)]
38pub enum OptimizerType {
39    /// Levenberg-Marquardt algorithm (robust, adaptive damping)
40    #[default]
41    LevenbergMarquardt,
42    /// Gauss-Newton algorithm (fast convergence, may be unstable)
43    GaussNewton,
44    /// Dog Leg algorithm (trust region method)
45    DogLeg,
46}
47
48impl Display for OptimizerType {
49    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
50        match self {
51            OptimizerType::LevenbergMarquardt => write!(f, "Levenberg-Marquardt"),
52            OptimizerType::GaussNewton => write!(f, "Gauss-Newton"),
53            OptimizerType::DogLeg => write!(f, "Dog Leg"),
54        }
55    }
56}
57
58/// Optimizer-specific error types for apex-solver
59#[derive(Debug, Clone, Error)]
60pub enum OptimizerError {
61    /// Linear system solve failed during optimization
62    #[error("Linear system solve failed: {0}")]
63    LinearSolveFailed(String),
64
65    /// Maximum iterations reached without achieving convergence
66    #[error("Maximum iterations ({max_iters}) reached without convergence")]
67    MaxIterationsReached { max_iters: usize },
68
69    /// Trust region radius became too small
70    #[error("Trust region radius became too small: {radius:.6e} < {min_radius:.6e}")]
71    TrustRegionFailure { radius: f64, min_radius: f64 },
72
73    /// Damping parameter became too large (LM-specific)
74    #[error("Damping parameter became too large: {damping:.6e} > {max_damping:.6e}")]
75    DampingFailure { damping: f64, max_damping: f64 },
76
77    /// Cost increased unexpectedly when it should decrease
78    #[error("Cost increased unexpectedly: {old_cost:.6e} -> {new_cost:.6e}")]
79    CostIncrease { old_cost: f64, new_cost: f64 },
80
81    /// Jacobian computation failed
82    #[error("Jacobian computation failed: {0}")]
83    JacobianFailed(String),
84
85    /// Invalid optimization parameters provided
86    #[error("Invalid optimization parameters: {0}")]
87    InvalidParameters(String),
88
89    /// Numerical instability detected (NaN, Inf in cost, gradient, or parameters)
90    #[error("Numerical instability detected: {0}")]
91    NumericalInstability(String),
92
93    /// Linear algebra operation failed
94    #[error("Linear algebra error: {0}")]
95    LinAlg(#[from] linalg::LinAlgError),
96
97    /// Problem has no variables to optimize
98    #[error("Problem has no variables to optimize")]
99    EmptyProblem,
100
101    /// Problem has no residual blocks
102    #[error("Problem has no residual blocks")]
103    NoResidualBlocks,
104
105    /// Jacobi scaling matrix creation failed
106    #[error("Failed to create Jacobi scaling matrix: {0}")]
107    JacobiScalingCreation(String),
108
109    /// Jacobi scaling not initialized when expected
110    #[error("Jacobi scaling not initialized")]
111    JacobiScalingNotInitialized,
112
113    /// Unknown or unsupported linear solver type
114    #[error("Unknown linear solver type: {0}")]
115    UnknownLinearSolver(String),
116}
117
118impl OptimizerError {
119    /// Log the error with tracing::error and return self for chaining
120    ///
121    /// This method allows for a consistent error logging pattern throughout
122    /// the optimizer module, ensuring all errors are properly recorded.
123    ///
124    /// # Example
125    /// ```
126    /// # use apex_solver::optimizer::OptimizerError;
127    /// # fn operation() -> Result<(), OptimizerError> { Ok(()) }
128    /// # fn example() -> Result<(), OptimizerError> {
129    /// operation()
130    ///     .map_err(|e| e.log())?;
131    /// # Ok(())
132    /// # }
133    /// ```
134    #[must_use]
135    pub fn log(self) -> Self {
136        error!("{}", self);
137        self
138    }
139
140    /// Log the error with the original source error from a third-party library
141    ///
142    /// This method logs both the OptimizerError and the underlying error
143    /// from external libraries. This provides full debugging context when
144    /// errors occur in third-party code.
145    ///
146    /// # Arguments
147    /// * `source_error` - The original error from the third-party library (must implement Debug)
148    ///
149    /// # Example
150    /// ```
151    /// # use apex_solver::optimizer::OptimizerError;
152    /// # fn sparse_matrix_op() -> Result<(), std::io::Error> { Ok(()) }
153    /// # fn example() -> Result<(), OptimizerError> {
154    /// sparse_matrix_op()
155    ///     .map_err(|e| {
156    ///         OptimizerError::JacobiScalingCreation(e.to_string())
157    ///             .log_with_source(e)
158    ///     })?;
159    /// # Ok(())
160    /// # }
161    /// ```
162    #[must_use]
163    pub fn log_with_source<E: std::fmt::Debug>(self, source_error: E) -> Self {
164        error!("{} | Source: {:?}", self, source_error);
165        self
166    }
167}
168
169/// Result type for optimizer operations
170pub type OptimizerResult<T> = Result<T, OptimizerError>;
171
172// State information during iterative optimization.
173// #[derive(Debug, Clone)]
174// pub struct IterativeState {
175//     /// Current iteration number
176//     pub iteration: usize,
177//     /// Current cost value
178//     pub cost: f64,
179//     /// Current gradient norm
180//     pub gradient_norm: f64,
181//     /// Current parameter update norm
182//     pub parameter_update_norm: f64,
183//     /// Time elapsed since start
184//     pub elapsed_time: Duration,
185// }
186
187/// Detailed convergence information.
188#[derive(Debug, Clone)]
189pub struct ConvergenceInfo {
190    /// Final gradient norm
191    pub final_gradient_norm: f64,
192    /// Final parameter update norm
193    pub final_parameter_update_norm: f64,
194    /// Cost function evaluation count
195    pub cost_evaluations: usize,
196    /// Jacobian evaluation count
197    pub jacobian_evaluations: usize,
198}
199
200impl Display for ConvergenceInfo {
201    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
202        write!(
203            f,
204            "Final gradient norm: {:.2e}, Final parameter update norm: {:.2e}, Cost evaluations: {}, Jacobian evaluations: {}",
205            self.final_gradient_norm,
206            self.final_parameter_update_norm,
207            self.cost_evaluations,
208            self.jacobian_evaluations
209        )
210    }
211}
212
213/// Status of an optimization process
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum OptimizationStatus {
216    /// Optimization converged successfully
217    Converged,
218    /// Maximum number of iterations reached
219    MaxIterationsReached,
220    /// Cost function tolerance reached
221    CostToleranceReached,
222    /// Parameter tolerance reached
223    ParameterToleranceReached,
224    /// Gradient tolerance reached
225    GradientToleranceReached,
226    /// Optimization failed due to numerical issues
227    NumericalFailure,
228    /// User requested termination
229    UserTerminated,
230    /// Timeout reached
231    Timeout,
232    /// Trust region radius fell below minimum threshold
233    TrustRegionRadiusTooSmall,
234    /// Objective function fell below user-specified cutoff
235    MinCostThresholdReached,
236    /// Jacobian matrix is singular or ill-conditioned
237    IllConditionedJacobian,
238    /// NaN or Inf detected in cost or parameters
239    InvalidNumericalValues,
240    /// Other failure
241    Failed(String),
242}
243
244impl Display for OptimizationStatus {
245    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
246        match self {
247            OptimizationStatus::Converged => write!(f, "Converged"),
248            OptimizationStatus::MaxIterationsReached => write!(f, "Maximum iterations reached"),
249            OptimizationStatus::CostToleranceReached => write!(f, "Cost tolerance reached"),
250            OptimizationStatus::ParameterToleranceReached => {
251                write!(f, "Parameter tolerance reached")
252            }
253            OptimizationStatus::GradientToleranceReached => write!(f, "Gradient tolerance reached"),
254            OptimizationStatus::NumericalFailure => write!(f, "Numerical failure"),
255            OptimizationStatus::UserTerminated => write!(f, "User terminated"),
256            OptimizationStatus::Timeout => write!(f, "Timeout"),
257            OptimizationStatus::TrustRegionRadiusTooSmall => {
258                write!(f, "Trust region radius too small")
259            }
260            OptimizationStatus::MinCostThresholdReached => {
261                write!(f, "Minimum cost threshold reached")
262            }
263            OptimizationStatus::IllConditionedJacobian => {
264                write!(f, "Ill-conditioned Jacobian matrix")
265            }
266            OptimizationStatus::InvalidNumericalValues => {
267                write!(f, "Invalid numerical values (NaN/Inf) detected")
268            }
269            OptimizationStatus::Failed(msg) => write!(f, "Failed: {msg}"),
270        }
271    }
272}
273
274/// Result of a solver execution.
275#[derive(Clone)]
276pub struct SolverResult<T> {
277    /// Final parameters
278    pub parameters: T,
279    /// Final optimization status
280    pub status: OptimizationStatus,
281    /// Initial cost value
282    pub initial_cost: f64,
283    /// Final cost value
284    pub final_cost: f64,
285    /// Number of iterations performed
286    pub iterations: usize,
287    /// Total time elapsed
288    pub elapsed_time: time::Duration,
289    /// Convergence statistics
290    pub convergence_info: Option<ConvergenceInfo>,
291    /// Per-variable covariance matrices (uncertainty estimation)
292    ///
293    /// This is `None` if covariance computation was not enabled in the solver configuration.
294    /// When present, it contains a mapping from variable names to their covariance matrices
295    /// in tangent space. For example, for SE3 variables this would be 6×6 matrices.
296    ///
297    /// Enable covariance computation by setting `compute_covariances: true` in the optimizer config.
298    pub covariances: Option<HashMap<String, Mat<f64>>>,
299}
300
301/// Core trait for optimization solvers.
302pub trait Solver {
303    /// Configuration type for this solver
304    type Config;
305    /// Error type
306    type Error;
307
308    /// Create a new solver with the given configuration
309    fn new() -> Self;
310
311    /// Optimize the problem to minimize the cost function
312    fn optimize(
313        &mut self,
314        problem: &Problem,
315        initial_params: &HashMap<String, (ManifoldType, DVector<f64>)>,
316    ) -> Result<SolverResult<HashMap<String, VariableEnum>>, Self::Error>;
317}
318
319/// Apply parameter update step to all variables.
320///
321/// This is a common operation used by all optimizers (Levenberg-Marquardt, Gauss-Newton, Dog Leg).
322/// It applies a tangent space perturbation to each variable using the proper manifold plus operation.
323///
324/// # Arguments
325/// * `variables` - Mutable map of variables to update
326/// * `step` - Full step vector in tangent space (faer matrix view)
327/// * `variable_order` - Ordered list of variable names (defines indexing into step vector)
328///
329/// # Returns
330/// * Step norm (L2 norm) for convergence checking
331///
332/// # Implementation Notes
333/// The step vector contains concatenated tangent vectors for all variables in the order
334/// specified by `variable_order`. Each variable's tangent space dimension determines
335/// how many elements it occupies in the step vector.
336///
337pub fn apply_parameter_step(
338    variables: &mut HashMap<String, VariableEnum>,
339    step: MatRef<f64>,
340    variable_order: &[String],
341) -> f64 {
342    let mut step_offset = 0;
343
344    for var_name in variable_order {
345        if let Some(var) = variables.get_mut(var_name) {
346            let var_size = var.get_size();
347            let var_step = step.subrows(step_offset, var_size);
348
349            // Delegate to VariableEnum's apply_tangent_step method
350            // This handles all manifold types (SE2, SE3, SO2, SO3, Rn)
351            var.apply_tangent_step(var_step);
352
353            step_offset += var_size;
354        }
355    }
356
357    // Compute and return step norm for convergence checking
358    step.norm_l2()
359}
360
361/// Apply negative parameter step to rollback variables.
362///
363/// This is used when an optimization step is rejected (e.g., in trust region methods).
364/// It applies the negative of a tangent space perturbation to revert the previous update.
365///
366/// # Arguments
367/// * `variables` - Mutable map of variables to revert
368/// * `step` - Full step vector in tangent space (faer matrix view) to negate
369/// * `variable_order` - Ordered list of variable names (defines indexing into step vector)
370///
371pub fn apply_negative_parameter_step(
372    variables: &mut HashMap<String, VariableEnum>,
373    step: MatRef<f64>,
374    variable_order: &[String],
375) {
376    // Create a negated version of the step vector
377    let mut negative_step = Mat::zeros(step.nrows(), 1);
378    for i in 0..step.nrows() {
379        negative_step[(i, 0)] = -step[(i, 0)];
380    }
381
382    // Apply the negative step using the standard apply_parameter_step function
383    apply_parameter_step(variables, negative_step.as_ref(), variable_order);
384}
385
386pub fn compute_cost(residual: &Mat<f64>) -> f64 {
387    let cost = residual.norm_l2();
388    0.5 * cost * cost
389}
390
391// ============================================================================
392// Shared optimizer utilities
393// ============================================================================
394// The following types and functions are shared across all three optimizer
395// implementations (Levenberg-Marquardt, Gauss-Newton, Dog Leg) to eliminate
396// code duplication.
397
398/// Per-iteration statistics for detailed logging (Ceres-style output).
399///
400/// Captures all relevant metrics for each optimization iteration, enabling
401/// detailed analysis and debugging of the optimization process.
402#[derive(Debug, Clone)]
403pub struct IterationStats {
404    /// Iteration number (0-indexed)
405    pub iteration: usize,
406    /// Cost function value at this iteration
407    pub cost: f64,
408    /// Change in cost from previous iteration
409    pub cost_change: f64,
410    /// L2 norm of the gradient (||J^T·r||)
411    pub gradient_norm: f64,
412    /// L2 norm of the parameter update step (||Δx||)
413    pub step_norm: f64,
414    /// Trust region ratio (ρ = actual_reduction / predicted_reduction)
415    pub tr_ratio: f64,
416    /// Trust region radius (damping parameter λ for LM, Δ for Dog Leg)
417    pub tr_radius: f64,
418    /// Linear solver iterations (0 for direct solvers like Cholesky)
419    pub ls_iter: usize,
420    /// Time taken for this iteration in milliseconds
421    pub iter_time_ms: f64,
422    /// Total elapsed time since optimization started in milliseconds
423    pub total_time_ms: f64,
424    /// Whether the step was accepted (true) or rejected (false)
425    pub accepted: bool,
426}
427
428impl IterationStats {
429    /// Print table header in Ceres-style format
430    pub fn print_header() {
431        debug!(
432            "{:>4}  {:>13}  {:>13}  {:>13}  {:>13}  {:>11}  {:>11}  {:>7}  {:>11}  {:>13}  {:>6}",
433            "iter",
434            "cost",
435            "cost_change",
436            "|gradient|",
437            "|step|",
438            "tr_ratio",
439            "tr_radius",
440            "ls_iter",
441            "iter_time",
442            "total_time",
443            "status"
444        );
445    }
446
447    /// Print single iteration line in Ceres-style format with scientific notation
448    pub fn print_line(&self) {
449        let status = if self.iteration == 0 {
450            "-"
451        } else if self.accepted {
452            "✓"
453        } else {
454            "✗"
455        };
456
457        debug!(
458            "{:>4}  {:>13.6e}  {:>13.2e}  {:>13.2e}  {:>13.2e}  {:>11.2e}  {:>11.2e}  {:>7}  {:>9.2}ms  {:>11.2}ms  {:>6}",
459            self.iteration,
460            self.cost,
461            self.cost_change,
462            self.gradient_norm,
463            self.step_norm,
464            self.tr_ratio,
465            self.tr_radius,
466            self.ls_iter,
467            self.iter_time_ms,
468            self.total_time_ms,
469            status
470        );
471    }
472}
473
474/// Result of optimization state initialization, shared by all optimizers.
475pub struct InitializedState {
476    pub variables: HashMap<String, VariableEnum>,
477    pub variable_index_map: HashMap<String, usize>,
478    pub sorted_vars: Vec<String>,
479    pub symbolic_structure: SymbolicStructure,
480    pub current_cost: f64,
481    pub initial_cost: f64,
482}
483
484/// Compute total parameter vector norm ||x|| across all variables.
485pub fn compute_parameter_norm(variables: &HashMap<String, VariableEnum>) -> f64 {
486    variables
487        .values()
488        .map(|v| {
489            let vec = v.to_vector();
490            vec.norm_squared()
491        })
492        .sum::<f64>()
493        .sqrt()
494}
495
496/// Create Jacobi scaling diagonal matrix from Jacobian column norms.
497///
498/// The scaling factor for each column is `1 / (1 + ||col||)`, which normalizes
499/// the columns to improve conditioning of the linear system.
500pub fn create_jacobi_scaling(
501    jacobian: &SparseColMat<usize, f64>,
502) -> Result<SparseColMat<usize, f64>, OptimizerError> {
503    let cols = jacobian.ncols();
504    let jacobi_scaling_vec: Vec<Triplet<usize, usize, f64>> = (0..cols)
505        .map(|c| {
506            let col_norm_squared: f64 = jacobian
507                .triplet_iter()
508                .filter(|t| t.col == c)
509                .map(|t| t.val * t.val)
510                .sum();
511            let col_norm = col_norm_squared.sqrt();
512            let scaling = 1.0 / (1.0 + col_norm);
513            Triplet::new(c, c, scaling)
514        })
515        .collect();
516
517    SparseColMat::try_new_from_triplets(cols, cols, &jacobi_scaling_vec)
518        .map_err(|e| OptimizerError::JacobiScalingCreation(e.to_string()).log_with_source(e))
519}
520
521/// Process Jacobian by applying Jacobi scaling (created on first iteration).
522///
523/// On `iteration == 0`, creates the scaling matrix and stores it. On subsequent
524/// iterations, reuses the cached scaling.
525pub fn process_jacobian(
526    jacobian: &SparseColMat<usize, f64>,
527    jacobi_scaling: &mut Option<SparseColMat<usize, f64>>,
528    iteration: usize,
529) -> Result<SparseColMat<usize, f64>, OptimizerError> {
530    if iteration == 0 {
531        let scaling = create_jacobi_scaling(jacobian)?;
532        *jacobi_scaling = Some(scaling);
533    }
534    let scaling = jacobi_scaling
535        .as_ref()
536        .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
537    Ok(jacobian * scaling)
538}
539
540/// Initialize optimization state from problem and initial parameters.
541///
542/// This is the common initialization sequence used by all optimizers:
543/// 1. Create variables from initial values
544/// 2. Build variable-to-column index mapping
545/// 3. Build symbolic sparsity structure for Jacobian
546/// 4. Compute initial cost
547pub fn initialize_optimization_state(
548    problem: &Problem,
549    initial_params: &HashMap<String, (ManifoldType, DVector<f64>)>,
550) -> Result<InitializedState, error::ApexSolverError> {
551    let variables = problem.initialize_variables(initial_params);
552
553    let mut variable_index_map = HashMap::new();
554    let mut col_offset = 0;
555    let mut sorted_vars: Vec<String> = variables.keys().cloned().collect();
556    sorted_vars.sort();
557
558    for var_name in &sorted_vars {
559        variable_index_map.insert(var_name.clone(), col_offset);
560        col_offset += variables[var_name].get_size();
561    }
562
563    let symbolic_structure =
564        problem.build_symbolic_structure(&variables, &variable_index_map, col_offset)?;
565
566    let residual = problem.compute_residual_sparse(&variables)?;
567    let current_cost = compute_cost(&residual);
568    let initial_cost = current_cost;
569
570    Ok(InitializedState {
571        variables,
572        variable_index_map,
573        sorted_vars,
574        symbolic_structure,
575        current_cost,
576        initial_cost,
577    })
578}
579
580/// Parameters for convergence checking, shared across optimizers.
581pub struct ConvergenceParams {
582    pub iteration: usize,
583    pub current_cost: f64,
584    pub new_cost: f64,
585    pub parameter_norm: f64,
586    pub parameter_update_norm: f64,
587    pub gradient_norm: f64,
588    pub elapsed: Duration,
589    pub step_accepted: bool,
590    // Config values
591    pub max_iterations: usize,
592    pub gradient_tolerance: f64,
593    pub parameter_tolerance: f64,
594    pub cost_tolerance: f64,
595    pub min_cost_threshold: Option<f64>,
596    pub timeout: Option<Duration>,
597    /// Trust region radius (LM damping or DogLeg radius). None for GN.
598    pub trust_region_radius: Option<f64>,
599    /// Minimum trust region radius threshold. None for GN.
600    pub min_trust_region_radius: Option<f64>,
601}
602
603/// Check convergence criteria common to all optimizers.
604///
605/// Returns `Some(status)` if a termination criterion is met, `None` otherwise.
606pub fn check_convergence(params: &ConvergenceParams) -> Option<OptimizationStatus> {
607    // CRITICAL SAFETY CHECKS (perform first)
608
609    // Invalid Numerical Values (NaN/Inf)
610    if !params.new_cost.is_finite()
611        || !params.parameter_update_norm.is_finite()
612        || !params.gradient_norm.is_finite()
613    {
614        return Some(OptimizationStatus::InvalidNumericalValues);
615    }
616
617    // Timeout
618    if let Some(timeout) = params.timeout {
619        if params.elapsed >= timeout {
620            return Some(OptimizationStatus::Timeout);
621        }
622    }
623
624    // Maximum Iterations
625    if params.iteration >= params.max_iterations {
626        return Some(OptimizationStatus::MaxIterationsReached);
627    }
628
629    // CONVERGENCE CRITERIA (only check after accepted steps)
630    if !params.step_accepted {
631        return None;
632    }
633
634    // Gradient Norm (First-Order Optimality)
635    if params.gradient_norm < params.gradient_tolerance {
636        return Some(OptimizationStatus::GradientToleranceReached);
637    }
638
639    // Parameter and cost criteria (only after first iteration)
640    if params.iteration > 0 {
641        // Parameter Change Tolerance (xtol)
642        let relative_step_tolerance =
643            params.parameter_tolerance * (params.parameter_norm + params.parameter_tolerance);
644        if params.parameter_update_norm <= relative_step_tolerance {
645            return Some(OptimizationStatus::ParameterToleranceReached);
646        }
647
648        // Function Value Change Tolerance (ftol)
649        let cost_change = (params.current_cost - params.new_cost).abs();
650        let relative_cost_change = cost_change / params.current_cost.max(1e-10);
651        if relative_cost_change < params.cost_tolerance {
652            return Some(OptimizationStatus::CostToleranceReached);
653        }
654    }
655
656    // Objective Function Cutoff (optional early stopping)
657    if let Some(min_cost) = params.min_cost_threshold {
658        if params.new_cost < min_cost {
659            return Some(OptimizationStatus::MinCostThresholdReached);
660        }
661    }
662
663    // Trust Region Radius (LM and DogLeg only)
664    if let (Some(radius), Some(min_radius)) =
665        (params.trust_region_radius, params.min_trust_region_radius)
666    {
667        if radius < min_radius {
668            return Some(OptimizationStatus::TrustRegionRadiusTooSmall);
669        }
670    }
671
672    None
673}
674
675/// Compute step quality ratio (actual vs predicted reduction).
676///
677/// Used by Levenberg-Marquardt and Dog Leg optimizers to evaluate
678/// whether a proposed step improved the objective function as predicted
679/// by the local quadratic model.
680///
681/// Returns `ρ = actual_reduction / predicted_reduction`, handling
682/// near-zero predicted reduction gracefully.
683pub fn compute_step_quality(current_cost: f64, new_cost: f64, predicted_reduction: f64) -> f64 {
684    let actual_reduction = current_cost - new_cost;
685    if predicted_reduction.abs() < 1e-15 {
686        if actual_reduction > 0.0 { 1.0 } else { 0.0 }
687    } else {
688        actual_reduction / predicted_reduction
689    }
690}
691
692/// Create the appropriate linear solver based on configuration.
693///
694/// Used by Gauss-Newton and Dog Leg optimizers. Levenberg-Marquardt has its own
695/// solver creation logic due to special Schur complement adapter requirements.
696pub fn create_linear_solver(solver_type: &linalg::LinearSolverType) -> Box<dyn SparseLinearSolver> {
697    match solver_type {
698        linalg::LinearSolverType::SparseCholesky => Box::new(SparseCholeskySolver::new()),
699        linalg::LinearSolverType::SparseQR => Box::new(SparseQRSolver::new()),
700        linalg::LinearSolverType::SparseSchurComplement => {
701            // Schur complement solver requires special handling - fallback to Cholesky
702            Box::new(SparseCholeskySolver::new())
703        }
704    }
705}
706
707/// Notify observers with current optimization state.
708///
709/// This is the common observer notification pattern used by all three optimizers.
710#[allow(clippy::too_many_arguments)]
711pub fn notify_observers(
712    observers: &mut OptObserverVec,
713    variables: &HashMap<String, VariableEnum>,
714    iteration: usize,
715    cost: f64,
716    gradient_norm: f64,
717    damping: Option<f64>,
718    step_norm: f64,
719    step_quality: Option<f64>,
720    linear_solver: &dyn SparseLinearSolver,
721) {
722    observers.set_iteration_metrics(cost, gradient_norm, damping, step_norm, step_quality);
723
724    if !observers.is_empty() {
725        if let (Some(hessian), Some(gradient)) =
726            (linear_solver.get_hessian(), linear_solver.get_gradient())
727        {
728            observers.set_matrix_data(Some(hessian.clone()), Some(gradient.clone()));
729        }
730    }
731
732    observers.notify(variables, iteration);
733}
734
735/// Build a SolverResult from common optimization loop outputs.
736///
737/// All three optimizers construct SolverResult identically at convergence.
738#[allow(clippy::too_many_arguments)]
739pub fn build_solver_result(
740    status: OptimizationStatus,
741    iterations: usize,
742    state: InitializedState,
743    elapsed: Duration,
744    final_gradient_norm: f64,
745    final_parameter_update_norm: f64,
746    cost_evaluations: usize,
747    jacobian_evaluations: usize,
748    covariances: Option<HashMap<String, Mat<f64>>>,
749) -> SolverResult<HashMap<String, VariableEnum>> {
750    SolverResult {
751        status,
752        iterations,
753        initial_cost: state.initial_cost,
754        final_cost: state.current_cost,
755        parameters: state.variables.into_iter().collect(),
756        elapsed_time: elapsed,
757        convergence_info: Some(ConvergenceInfo {
758            final_gradient_norm,
759            final_parameter_update_norm,
760            cost_evaluations,
761            jacobian_evaluations,
762        }),
763        covariances,
764    }
765}
766
767/// Unified summary statistics for all optimizer types.
768///
769/// Replaces the separate `LevenbergMarquardtSummary`, `GaussNewtonSummary`,
770/// and `DogLegSummary` structs with a single type that handles algorithm-specific
771/// fields via `Option`.
772#[derive(Debug, Clone)]
773pub struct OptimizerSummary {
774    /// Name of the optimizer algorithm
775    pub optimizer_name: &'static str,
776    /// Initial cost value
777    pub initial_cost: f64,
778    /// Final cost value
779    pub final_cost: f64,
780    /// Total number of iterations performed
781    pub iterations: usize,
782    /// Number of successful steps (None for GN which always accepts)
783    pub successful_steps: Option<usize>,
784    /// Number of unsuccessful steps (None for GN which always accepts)
785    pub unsuccessful_steps: Option<usize>,
786    /// Average cost reduction per iteration
787    pub average_cost_reduction: f64,
788    /// Maximum gradient norm encountered
789    pub max_gradient_norm: f64,
790    /// Final gradient norm
791    pub final_gradient_norm: f64,
792    /// Maximum parameter update norm
793    pub max_parameter_update_norm: f64,
794    /// Final parameter update norm
795    pub final_parameter_update_norm: f64,
796    /// Total time elapsed
797    pub total_time: Duration,
798    /// Average time per iteration
799    pub average_time_per_iteration: Duration,
800    /// Detailed per-iteration statistics history
801    pub iteration_history: Vec<IterationStats>,
802    /// Convergence status
803    pub convergence_status: OptimizationStatus,
804    /// Final damping parameter (LM only)
805    pub final_damping: Option<f64>,
806    /// Final trust region radius (DL only)
807    pub final_trust_region_radius: Option<f64>,
808    /// Step quality ratio (LM only)
809    pub rho: Option<f64>,
810}
811
812impl Display for OptimizerSummary {
813    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
814        let converged = matches!(
815            self.convergence_status,
816            OptimizationStatus::Converged
817                | OptimizationStatus::CostToleranceReached
818                | OptimizationStatus::GradientToleranceReached
819                | OptimizationStatus::ParameterToleranceReached
820        );
821
822        writeln!(f, "{} Final Result", self.optimizer_name)?;
823
824        if converged {
825            writeln!(f, "CONVERGED ({:?})", self.convergence_status)?;
826        } else {
827            writeln!(f, "DIVERGED ({:?})", self.convergence_status)?;
828        }
829
830        writeln!(f)?;
831        writeln!(f, "Cost:")?;
832        writeln!(f, "  Initial:   {:.6e}", self.initial_cost)?;
833        writeln!(f, "  Final:     {:.6e}", self.final_cost)?;
834        writeln!(
835            f,
836            "  Reduction: {:.6e} ({:.2}%)",
837            self.initial_cost - self.final_cost,
838            100.0 * (self.initial_cost - self.final_cost) / self.initial_cost.max(1e-12)
839        )?;
840        writeln!(f)?;
841        writeln!(f, "Iterations:")?;
842        writeln!(f, "  Total:              {}", self.iterations)?;
843        if let (Some(successful), Some(unsuccessful)) =
844            (self.successful_steps, self.unsuccessful_steps)
845        {
846            writeln!(
847                f,
848                "  Successful steps:   {} ({:.1}%)",
849                successful,
850                100.0 * successful as f64 / self.iterations.max(1) as f64
851            )?;
852            writeln!(
853                f,
854                "  Unsuccessful steps: {} ({:.1}%)",
855                unsuccessful,
856                100.0 * unsuccessful as f64 / self.iterations.max(1) as f64
857            )?;
858        }
859        if let Some(radius) = self.final_trust_region_radius {
860            writeln!(f)?;
861            writeln!(f, "Trust Region:")?;
862            writeln!(f, "  Final radius: {:.6e}", radius)?;
863        }
864        writeln!(f)?;
865        writeln!(f, "Gradient:")?;
866        writeln!(f, "  Max norm:   {:.2e}", self.max_gradient_norm)?;
867        writeln!(f, "  Final norm: {:.2e}", self.final_gradient_norm)?;
868        writeln!(f)?;
869        writeln!(f, "Parameter Update:")?;
870        writeln!(f, "  Max norm:   {:.2e}", self.max_parameter_update_norm)?;
871        writeln!(f, "  Final norm: {:.2e}", self.final_parameter_update_norm)?;
872        writeln!(f)?;
873        writeln!(f, "Performance:")?;
874        writeln!(
875            f,
876            "  Total time:             {:.2}ms",
877            self.total_time.as_secs_f64() * 1000.0
878        )?;
879        writeln!(
880            f,
881            "  Average per iteration:  {:.2}ms",
882            self.average_time_per_iteration.as_secs_f64() * 1000.0
883        )?;
884
885        Ok(())
886    }
887}
888
889/// Create an OptimizerSummary from common optimization loop outputs.
890#[allow(clippy::too_many_arguments)]
891pub fn create_optimizer_summary(
892    optimizer_name: &'static str,
893    initial_cost: f64,
894    final_cost: f64,
895    iterations: usize,
896    successful_steps: Option<usize>,
897    unsuccessful_steps: Option<usize>,
898    max_gradient_norm: f64,
899    final_gradient_norm: f64,
900    max_parameter_update_norm: f64,
901    final_parameter_update_norm: f64,
902    total_cost_reduction: f64,
903    total_time: Duration,
904    iteration_history: Vec<IterationStats>,
905    convergence_status: OptimizationStatus,
906    final_damping: Option<f64>,
907    final_trust_region_radius: Option<f64>,
908    rho: Option<f64>,
909) -> OptimizerSummary {
910    OptimizerSummary {
911        optimizer_name,
912        initial_cost,
913        final_cost,
914        iterations,
915        successful_steps,
916        unsuccessful_steps,
917        average_cost_reduction: if iterations > 0 {
918            total_cost_reduction / iterations as f64
919        } else {
920            0.0
921        },
922        max_gradient_norm,
923        final_gradient_norm,
924        max_parameter_update_norm,
925        final_parameter_update_norm,
926        total_time,
927        average_time_per_iteration: if iterations > 0 {
928            total_time / iterations as u32
929        } else {
930            Duration::from_secs(0)
931        },
932        iteration_history,
933        convergence_status,
934        final_damping,
935        final_trust_region_radius,
936        rho,
937    }
938}