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/// Detailed convergence information.
147#[derive(Debug, Clone)]
148pub struct ConvergenceInfo {
149    /// Final gradient norm
150    pub final_gradient_norm: f64,
151    /// Final parameter update norm
152    pub final_parameter_update_norm: f64,
153    /// Cost function evaluation count
154    pub cost_evaluations: usize,
155    /// Jacobian evaluation count
156    pub jacobian_evaluations: usize,
157}
158
159impl Display for ConvergenceInfo {
160    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
161        write!(
162            f,
163            "Final gradient norm: {:.2e}, Final parameter update norm: {:.2e}, Cost evaluations: {}, Jacobian evaluations: {}",
164            self.final_gradient_norm,
165            self.final_parameter_update_norm,
166            self.cost_evaluations,
167            self.jacobian_evaluations
168        )
169    }
170}
171
172/// Status of an optimization process
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum OptimizationStatus {
175    /// Optimization converged successfully
176    Converged,
177    /// Maximum number of iterations reached
178    MaxIterationsReached,
179    /// Cost function tolerance reached
180    CostToleranceReached,
181    /// Parameter tolerance reached
182    ParameterToleranceReached,
183    /// Gradient tolerance reached
184    GradientToleranceReached,
185    /// Optimization failed due to numerical issues
186    NumericalFailure,
187    /// User requested termination
188    UserTerminated,
189    /// Timeout reached
190    Timeout,
191    /// Trust region radius fell below minimum threshold
192    TrustRegionRadiusTooSmall,
193    /// Too many consecutive rejected steps — the solver can no longer make progress.
194    ///
195    /// Damping has grown until the step is negligible and every trial step is rejected,
196    /// so further iterations cannot change the cost. Reported instead of silently
197    /// burning the remaining iteration budget.
198    StalledNoProgress,
199    /// Objective function fell below user-specified cutoff
200    MinCostThresholdReached,
201    /// Jacobian matrix is singular or ill-conditioned
202    IllConditionedJacobian,
203    /// NaN or Inf detected in cost or parameters
204    InvalidNumericalValues,
205    /// Other failure
206    Failed(String),
207}
208
209impl Display for OptimizationStatus {
210    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
211        match self {
212            OptimizationStatus::Converged => write!(f, "Converged"),
213            OptimizationStatus::MaxIterationsReached => write!(f, "Maximum iterations reached"),
214            OptimizationStatus::CostToleranceReached => write!(f, "Cost tolerance reached"),
215            OptimizationStatus::ParameterToleranceReached => {
216                write!(f, "Parameter tolerance reached")
217            }
218            OptimizationStatus::GradientToleranceReached => write!(f, "Gradient tolerance reached"),
219            OptimizationStatus::NumericalFailure => write!(f, "Numerical failure"),
220            OptimizationStatus::UserTerminated => write!(f, "User terminated"),
221            OptimizationStatus::Timeout => write!(f, "Timeout"),
222            OptimizationStatus::TrustRegionRadiusTooSmall => {
223                write!(f, "Trust region radius too small")
224            }
225            OptimizationStatus::StalledNoProgress => {
226                write!(f, "Stalled (no further progress possible)")
227            }
228            OptimizationStatus::MinCostThresholdReached => {
229                write!(f, "Minimum cost threshold reached")
230            }
231            OptimizationStatus::IllConditionedJacobian => {
232                write!(f, "Ill-conditioned Jacobian matrix")
233            }
234            OptimizationStatus::InvalidNumericalValues => {
235                write!(f, "Invalid numerical values (NaN/Inf) detected")
236            }
237            OptimizationStatus::Failed(msg) => write!(f, "Failed: {msg}"),
238        }
239    }
240}
241
242/// Result of a solver execution.
243#[derive(Clone)]
244pub struct SolverResult<T> {
245    /// Final parameters
246    pub parameters: T,
247    /// Final optimization status
248    pub status: OptimizationStatus,
249    /// Initial cost value
250    pub initial_cost: f64,
251    /// Final cost value
252    pub final_cost: f64,
253    /// Number of iterations performed
254    pub iterations: usize,
255    /// Total time elapsed
256    pub elapsed_time: time::Duration,
257    /// Convergence statistics
258    pub convergence_info: Option<ConvergenceInfo>,
259    /// Per-variable covariance matrices (uncertainty estimation)
260    ///
261    /// This is `None` if covariance computation was not enabled in the solver configuration.
262    /// When present, it contains a mapping from variable keys to their covariance matrices
263    /// in tangent space. For example, for SE3 variables this would be 6×6 matrices.
264    ///
265    /// Enable covariance computation by setting `compute_covariances: true` in the optimizer config.
266    pub covariances: Option<SecondaryMap<VarKey, Mat<f64>>>,
267}
268
269/// Type alias for the result returned by all optimizer `optimize()` calls.
270pub type OptimizeResult =
271    Result<SolverResult<SlotMap<VarKey, Box<dyn ManifoldVariable>>>, crate::error::ApexSolverError>;
272
273/// Unified optimizer interface. Object-safe — `Box<dyn Optimizer>` is valid.
274///
275/// All three optimizers ([`LevenbergMarquardt`],
276/// [`GaussNewton`],
277/// [`DogLeg`]) implement this trait.
278/// Each optimizer also provides inherent `new()`, `with_config()`, and `optimize()` methods
279/// for direct (non-polymorphic) usage.
280pub trait Optimizer {
281    /// Optimize the problem to minimize the cost function.
282    fn optimize(&mut self, problem: &mut Problem) -> OptimizeResult;
283}
284
285/// Apply parameter update step to all variables.
286///
287/// This is a common operation used by all optimizers (Levenberg-Marquardt, Gauss-Newton, Dog Leg).
288/// It applies a tangent space perturbation to each variable using the proper manifold plus operation.
289///
290/// # Arguments
291/// * `variables` - Mutable map of variables to update
292/// * `step` - Full step vector in tangent space (faer matrix view)
293/// * `variable_order` - Ordered list of variable names (defines indexing into step vector)
294///
295/// # Returns
296/// * Step norm (L2 norm) for convergence checking
297///
298/// # Implementation Notes
299/// The step vector contains concatenated tangent vectors for all variables in the order
300/// specified by `variable_order`. Each variable's tangent space dimension determines
301/// how many elements it occupies in the step vector.
302///
303pub fn apply_parameter_step(
304    variables: &mut SlotMap<VarKey, Box<dyn ManifoldVariable>>,
305    step: MatRef<f64>,
306    variable_order: &[VarKey],
307) -> f64 {
308    apply_signed_parameter_step(variables, step, variable_order, 1.0)
309}
310
311/// Apply `sign · step`, the shared body of the forward and rollback paths.
312///
313/// The sign is folded into the per-variable copy that has to happen anyway, so
314/// a rollback does not allocate and fill a negated copy of the full step vector.
315fn apply_signed_parameter_step(
316    variables: &mut SlotMap<VarKey, Box<dyn ManifoldVariable>>,
317    step: MatRef<f64>,
318    variable_order: &[VarKey],
319    sign: f64,
320) -> f64 {
321    // `step` is sized to `total_dof` from `build_variable_index_map`, i.e.
322    // free columns only (see ISSUE-0003) — its norm below is already the
323    // norm of the step actually applied, with no fixed-coordinate entries
324    // inflating it.
325    let mut step_offset = 0;
326
327    // SmallVec-backed buffers: inline for the common case (DOF ≤ 16, covering
328    // SE3/SO3/SE2/SO2 and small RN), spills to heap only for large RN
329    // variables. Mirrors the `[0f64; 16]` buffer pattern used inside
330    // `Variable::apply_tangent_step`.
331    let mut free_buf: smallvec::SmallVec<[f64; 16]> = smallvec::SmallVec::new();
332    let mut full_buf: smallvec::SmallVec<[f64; 16]> = smallvec::SmallVec::new();
333    for &var_key in variable_order {
334        if let Some(var) = variables.get_mut(var_key) {
335            let free_size = var.free_dof();
336            let dof = var.dof();
337            let var_step = step.subrows(step_offset, free_size);
338            free_buf.clear();
339            free_buf.resize(free_size, 0.0);
340            for i in 0..free_size {
341                free_buf[i] = sign * var_step[(i, 0)];
342            }
343            full_buf.clear();
344            full_buf.resize(dof, 0.0);
345            var.expand_free_step(&free_buf, &mut full_buf);
346            var.apply_tangent_step(&full_buf);
347            step_offset += free_size;
348        }
349    }
350
351    step.norm_l2()
352}
353
354/// Apply negative parameter step to rollback variables.
355///
356/// This is used when an optimization step is rejected (e.g., in trust region methods).
357/// It applies the negative of a tangent space perturbation to revert the previous update.
358///
359/// # Arguments
360/// * `variables` - Mutable map of variables to revert
361/// * `step` - Full step vector in tangent space (faer matrix view) to negate
362/// * `variable_order` - Ordered list of variable names (defines indexing into step vector)
363///
364pub fn apply_negative_parameter_step(
365    variables: &mut SlotMap<VarKey, Box<dyn ManifoldVariable>>,
366    step: MatRef<f64>,
367    variable_order: &[VarKey],
368) {
369    apply_signed_parameter_step(variables, step, variable_order, -1.0);
370}
371
372pub fn compute_cost(residual: &Mat<f64>) -> f64 {
373    let cost = residual.norm_l2();
374    0.5 * cost * cost
375}
376
377// ============================================================================
378// Shared optimizer utilities
379// ============================================================================
380// The following types and functions are shared across all three optimizer
381// implementations (Levenberg-Marquardt, Gauss-Newton, Dog Leg) to eliminate
382// code duplication.
383
384/// Per-iteration statistics for detailed logging (Ceres-style output).
385///
386/// Captures all relevant metrics for each optimization iteration, enabling
387/// detailed analysis and debugging of the optimization process.
388#[derive(Debug, Clone)]
389pub struct IterationStats {
390    /// Iteration number (0-indexed)
391    pub iteration: usize,
392    /// Cost function value at this iteration
393    pub cost: f64,
394    /// Change in cost from previous iteration
395    pub cost_change: f64,
396    /// L2 norm of the gradient (||J^T·r||)
397    pub gradient_norm: f64,
398    /// L2 norm of the parameter update step (||Δx||)
399    pub step_norm: f64,
400    /// Trust region ratio (ρ = actual_reduction / predicted_reduction)
401    pub tr_ratio: f64,
402    /// Trust region radius (damping parameter λ for LM, Δ for Dog Leg)
403    pub tr_radius: f64,
404    /// Linear solver iterations (0 for direct solvers like Cholesky)
405    pub ls_iter: usize,
406    /// Time taken for this iteration in milliseconds
407    pub iter_time_ms: f64,
408    /// Total elapsed time since optimization started in milliseconds
409    pub total_time_ms: f64,
410    /// Whether the step was accepted (true) or rejected (false)
411    pub accepted: bool,
412}
413
414impl IterationStats {
415    /// Print table header in Ceres-style format
416    pub fn print_header() {
417        debug!(
418            "{:>4}  {:>13}  {:>13}  {:>13}  {:>13}  {:>11}  {:>11}  {:>7}  {:>11}  {:>13}  {:>6}",
419            "iter",
420            "cost",
421            "cost_change",
422            "|gradient|",
423            "|step|",
424            "tr_ratio",
425            "tr_radius",
426            "ls_iter",
427            "iter_time",
428            "total_time",
429            "status"
430        );
431    }
432
433    /// Print single iteration line in Ceres-style format with scientific notation
434    pub fn print_line(&self) {
435        let status = if self.iteration == 0 {
436            "-"
437        } else if self.accepted {
438            "✓"
439        } else {
440            "✗"
441        };
442
443        debug!(
444            "{:>4}  {:>13.6e}  {:>13.2e}  {:>13.2e}  {:>13.2e}  {:>11.2e}  {:>11.2e}  {:>7}  {:>9.2}ms  {:>11.2}ms  {:>6}",
445            self.iteration,
446            self.cost,
447            self.cost_change,
448            self.gradient_norm,
449            self.step_norm,
450            self.tr_ratio,
451            self.tr_radius,
452            self.ls_iter,
453            self.iter_time_ms,
454            self.total_time_ms,
455            status
456        );
457    }
458}
459
460/// Result of optimization state initialization, shared by all optimizers.
461pub struct InitializedState {
462    pub variables: SlotMap<VarKey, Box<dyn ManifoldVariable>>,
463    pub variable_index_map: SecondaryMap<VarKey, usize>,
464    pub sorted_vars: Vec<VarKey>,
465    pub symbolic_structure: Option<SymbolicStructure>,
466    /// Static per-solve assembly scratch (block order, offsets, buffers),
467    /// built once here and reused by every iteration.
468    pub workspace: crate::linearizer::AssemblyWorkspace,
469    pub total_dof: usize,
470    pub current_cost: f64,
471    pub initial_cost: f64,
472}
473
474/// Compute total parameter vector norm ||x|| across all variables.
475pub fn compute_parameter_norm(variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>) -> f64 {
476    variables
477        .values()
478        .map(|v| v.as_param_slice().iter().map(|x| x * x).sum::<f64>())
479        .sum::<f64>()
480        .sqrt()
481}
482
483/// Create Jacobi scaling diagonal matrix from Jacobian column norms.
484///
485/// The scaling factor for each column is `1 / (1 + ||col||)`, which normalizes
486/// the columns to improve conditioning of the linear system.
487#[deprecated(
488    since = "1.6.0",
489    note = "quadratic in the number of columns; the optimizers use \
490            `AssemblyBackend::compute_column_norms` + `apply_column_scaling`, which are \
491            linear in nnz and parallel"
492)]
493pub fn create_jacobi_scaling(
494    jacobian: &SparseColMat<usize, f64>,
495) -> Result<SparseColMat<usize, f64>, OptimizerError> {
496    let cols = jacobian.ncols();
497    let jacobi_scaling_vec: Vec<Triplet<usize, usize, f64>> = (0..cols)
498        .map(|c| {
499            let col_norm_squared: f64 = jacobian
500                .triplet_iter()
501                .filter(|t| t.col == c)
502                .map(|t| t.val * t.val)
503                .sum();
504            let col_norm = col_norm_squared.sqrt();
505            let scaling = 1.0 / (1.0 + col_norm);
506            Triplet::new(c, c, scaling)
507        })
508        .collect();
509
510    SparseColMat::try_new_from_triplets(cols, cols, &jacobi_scaling_vec)
511        .map_err(|e| OptimizerError::JacobiScalingCreation(e.to_string()).log_with_source(e))
512}
513
514/// Process Jacobian by applying Jacobi scaling (created on first iteration).
515///
516/// On `iteration == 0`, creates the scaling matrix and stores it. On subsequent
517/// iterations, reuses the cached scaling.
518#[deprecated(
519    since = "1.6.0",
520    note = "builds a diagonal scaling matrix and multiplies; the optimizers scale the \
521            CSC values in place via `AssemblyBackend::apply_column_scaling`"
522)]
523#[allow(deprecated)]
524pub fn process_jacobian(
525    jacobian: &SparseColMat<usize, f64>,
526    jacobi_scaling: &mut Option<SparseColMat<usize, f64>>,
527    iteration: usize,
528) -> Result<SparseColMat<usize, f64>, OptimizerError> {
529    if iteration == 0 {
530        let scaling = create_jacobi_scaling(jacobian)?;
531        *jacobi_scaling = Some(scaling);
532    }
533    let scaling = jacobi_scaling
534        .as_ref()
535        .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
536    Ok(jacobian * scaling)
537}
538
539/// Assign each variable a contiguous range of tangent-space **free** columns.
540///
541/// Ordering follows `variables.keys()` — slotmap insertion order, which is already
542/// deterministic — and each variable is given `free_dof()` (its tangent
543/// dimension minus any fixed local indices) consecutive columns starting at
544/// the running offset. Fixed tangent coordinates never occupy a column: they
545/// must be eliminated before the linear solve rather than solved for and
546/// zeroed afterward, since free and fixed columns generally couple through
547/// `JᵀJ` (see `codex/issues/ISSUE-0003-fixed-dofs-solved-as-free.md`).
548///
549/// Returns `(free-column offset per variable, variable keys in column order,
550/// total free DOF)`.
551///
552/// This is shared by [`initialize_optimization_state`] and by covariance
553/// estimation ([`crate::linalg::covariance`]). Both must agree on the column
554/// layout, or extracted covariance blocks land on the wrong variables.
555pub(crate) fn build_variable_index_map(
556    variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
557) -> (SecondaryMap<VarKey, usize>, Vec<VarKey>, usize) {
558    let sorted_vars: Vec<VarKey> = variables.keys().collect();
559
560    let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
561    let mut col_offset = 0;
562    for &var_key in &sorted_vars {
563        variable_index_map.insert(var_key, col_offset);
564        col_offset += variables[var_key].free_dof();
565    }
566
567    (variable_index_map, sorted_vars, col_offset)
568}
569
570/// Initialize optimization state from problem and initial parameters.
571///
572/// This is the common initialization sequence used by all optimizers:
573/// 1. Create variables from initial values
574/// 2. Build variable-to-column index mapping
575/// 3. Build symbolic sparsity structure for Jacobian (sparse mode only)
576/// 4. Compute initial cost
577///
578/// The assembly mode is determined by `problem.jacobian_mode`.
579///
580/// # Unsupported: variable bounds
581///
582/// `Problem::set_variable_bounds` is validated and stored, but no optimizer
583/// step here enforces it — solving anyway would silently return the
584/// unconstrained answer (see `codex/issues/ISSUE-0004-variable-bounds-ignored.md`).
585/// Until bounded optimization is implemented, any variable carrying a
586/// non-trivial bound (`lower > -inf` or `upper < inf`) is rejected here with
587/// a typed error rather than silently ignored.
588pub fn initialize_optimization_state(problem: &mut Problem) -> OptimizerResult<InitializedState> {
589    if let Some((var_key, idx, lower, upper)) = problem.variable_bounds.iter().find_map(|(k, m)| {
590        m.iter()
591            .find(|&(_, &(lower, upper))| lower > f64::NEG_INFINITY || upper < f64::INFINITY)
592            .map(|(&idx, &(lower, upper))| (k, idx, lower, upper))
593    }) {
594        let core_err = crate::core::CoreError::InvalidConstraint(format!(
595            "variable {var_key:?} component {idx} has bound [{lower}, {upper}], but no \
596             optimizer currently enforces variable bounds during the solve — set_variable_bounds \
597             is accepted and stored but silently ignored, which would return the unconstrained \
598             answer; remove the bound (Problem::remove_variable_bounds) or wait for constrained \
599             optimization support"
600        ));
601        return Err(OptimizerError::from(core_err));
602    }
603
604    let mut variables = problem.variables.clone();
605    problem.apply_constraints_to_variables(&mut variables);
606
607    let (variable_index_map, sorted_vars, total_dof) = build_variable_index_map(&variables);
608
609    let symbolic_structure = match problem.jacobian_mode {
610        JacobianMode::Sparse => Some(crate::linearizer::cpu::sparse::build_symbolic_structure(
611            problem,
612            &variables,
613            &variable_index_map,
614            total_dof,
615        )?),
616        JacobianMode::Dense => None,
617    };
618
619    // Static per-solve assembly data: block order, slice offsets and scratch
620    // buffers, computed once and reused by every linearization below.
621    let mut workspace = crate::linearizer::AssemblyWorkspace::build(problem);
622
623    let (_residual, current_cost) =
624        problem.compute_residual_and_cost_sparse_with_workspace(&variables, &mut workspace)?;
625    let initial_cost = current_cost;
626
627    Ok(InitializedState {
628        variables,
629        variable_index_map,
630        sorted_vars,
631        symbolic_structure,
632        workspace,
633        total_dof,
634        current_cost,
635        initial_cost,
636    })
637}
638
639/// Parameters for convergence checking, shared across optimizers.
640pub struct ConvergenceParams {
641    pub iteration: usize,
642    pub current_cost: f64,
643    pub new_cost: f64,
644    pub parameter_norm: f64,
645    pub parameter_update_norm: f64,
646    pub gradient_norm: f64,
647    pub elapsed: Duration,
648    pub step_accepted: bool,
649    // Config values
650    pub max_iterations: usize,
651    pub gradient_tolerance: f64,
652    pub parameter_tolerance: f64,
653    pub cost_tolerance: f64,
654    pub min_cost_threshold: Option<f64>,
655    pub timeout: Option<Duration>,
656    /// Trust region radius (LM damping or DogLeg radius). None for GN.
657    pub trust_region_radius: Option<f64>,
658    /// Minimum trust region radius threshold. None for GN.
659    pub min_trust_region_radius: Option<f64>,
660}
661
662/// Check convergence criteria common to all optimizers.
663///
664/// Returns `Some(status)` if a termination criterion is met, `None` otherwise.
665pub fn check_convergence(params: &ConvergenceParams) -> Option<OptimizationStatus> {
666    // CRITICAL SAFETY CHECKS (perform first)
667
668    // Invalid Numerical Values (NaN/Inf)
669    if !params.new_cost.is_finite()
670        || !params.parameter_update_norm.is_finite()
671        || !params.gradient_norm.is_finite()
672    {
673        return Some(OptimizationStatus::InvalidNumericalValues);
674    }
675
676    // Timeout
677    if let Some(timeout) = params.timeout
678        && params.elapsed >= timeout
679    {
680        return Some(OptimizationStatus::Timeout);
681    }
682
683    // Maximum Iterations
684    if params.iteration >= params.max_iterations {
685        return Some(OptimizationStatus::MaxIterationsReached);
686    }
687
688    // CONVERGENCE CRITERIA (only check after accepted steps)
689    if !params.step_accepted {
690        return None;
691    }
692
693    // Gradient Norm (First-Order Optimality)
694    if params.gradient_norm < params.gradient_tolerance {
695        return Some(OptimizationStatus::GradientToleranceReached);
696    }
697
698    // Parameter and cost criteria (only after first iteration)
699    if params.iteration > 0 {
700        // Parameter Change Tolerance (xtol)
701        let relative_step_tolerance =
702            params.parameter_tolerance * (params.parameter_norm + params.parameter_tolerance);
703        if params.parameter_update_norm <= relative_step_tolerance {
704            return Some(OptimizationStatus::ParameterToleranceReached);
705        }
706
707        // Function Value Change Tolerance (ftol)
708        let cost_change = (params.current_cost - params.new_cost).abs();
709        let relative_cost_change = cost_change / params.current_cost.max(1e-10);
710        if relative_cost_change < params.cost_tolerance {
711            return Some(OptimizationStatus::CostToleranceReached);
712        }
713    }
714
715    // Objective Function Cutoff (optional early stopping)
716    if let Some(min_cost) = params.min_cost_threshold
717        && params.new_cost < min_cost
718    {
719        return Some(OptimizationStatus::MinCostThresholdReached);
720    }
721
722    // Trust Region Radius (LM and DogLeg only)
723    if let (Some(radius), Some(min_radius)) =
724        (params.trust_region_radius, params.min_trust_region_radius)
725        && radius < min_radius
726    {
727        return Some(OptimizationStatus::TrustRegionRadiusTooSmall);
728    }
729
730    None
731}
732
733/// A rigorous lower bound on the 2-norm condition number of `H = JᵀJ`.
734///
735/// For a symmetric positive semi-definite `H`, the Rayleigh quotient evaluated
736/// at the basis vector `e_j` gives `λ_max(H) ≥ H_jj` and `λ_min(H) ≤ H_jj` for
737/// every `j`, hence
738///
739/// ```text
740/// κ₂(H) = λ_max/λ_min ≥ max_j H_jj / min_j H_jj = (max_j ‖c_j‖ / min_j ‖c_j‖)²
741/// ```
742///
743/// where `c_j` is column `j` of `J`, since `H_jj = ‖J e_j‖²`.
744///
745/// The one-sidedness matters and is deliberate: a returned value above a
746/// threshold **proves** the system is worse conditioned than that threshold, so
747/// the check never fires spuriously. The converse does not hold — a small bound
748/// is not evidence of good conditioning, because ill-conditioning arising from
749/// near-linear-dependence between columns of comparable norm is invisible to
750/// the diagonal. What it does catch reliably is the case worth catching: a
751/// variable that no residual constrains has a zero column, so the bound is
752/// infinite.
753///
754/// Pass the column norms of the **un-scaled** Jacobian — Jacobi scaling exists
755/// precisely to equalise them, so measuring the scaled matrix would report the
756/// conditioning of the preconditioned system rather than the user's problem.
757pub fn condition_number_lower_bound(column_norms: &[f64]) -> f64 {
758    if column_norms.is_empty() {
759        return 1.0;
760    }
761    let mut min_norm = f64::INFINITY;
762    let mut max_norm: f64 = 0.0;
763    for &norm in column_norms {
764        min_norm = min_norm.min(norm);
765        max_norm = max_norm.max(norm);
766    }
767    if min_norm <= 0.0 {
768        return f64::INFINITY;
769    }
770    let ratio = max_norm / min_norm;
771    ratio * ratio
772}
773
774/// Check the Jacobian against a user-supplied `max_condition_number`, if set.
775///
776/// Returns `Some(OptimizationStatus::IllConditionedJacobian)` when the bound
777/// from [`condition_number_lower_bound`] exceeds the threshold, having logged
778/// the offending value. `None` means "no threshold configured, or the bound did
779/// not prove the problem ill-conditioned" — see that function on why the second
780/// case is not a certificate of good conditioning.
781///
782/// Costs one `O(nnz)` pass over `J` and only runs when the option is `Some`.
783pub fn check_jacobian_conditioning<M: AssemblyBackend>(
784    jacobian: &M::Jacobian,
785    max_condition_number: Option<f64>,
786) -> Option<OptimizationStatus> {
787    let threshold = max_condition_number?;
788    let column_norms = M::compute_column_norms(jacobian);
789    let bound = condition_number_lower_bound(&column_norms);
790    if bound > threshold {
791        tracing::error!(
792            "Jacobian is ill-conditioned: cond(JᵀJ) >= {bound:.3e} exceeds the configured \
793             max_condition_number {threshold:.3e}. An infinite bound means at least one \
794             variable is unconstrained by every residual."
795        );
796        return Some(OptimizationStatus::IllConditionedJacobian);
797    }
798    None
799}
800
801/// The per-iteration preamble shared by LM, Gauss-Newton and Dog-Leg:
802/// assemble → conditioning check → Jacobi scaling.
803pub(crate) enum IterationPreamble<M: AssemblyBackend> {
804    /// Normal equations are ready for the optimizer's step computation.
805    Proceed {
806        residuals: Mat<f64>,
807        scaled_jacobian: M::Jacobian,
808    },
809    /// The conditioning check terminated the solve; the caller notifies its
810    /// observers and builds the final [`SolverResult`].
811    EarlyExit(OptimizationStatus),
812}
813
814/// Run the shared iteration preamble for one optimizer iteration.
815pub(crate) fn iteration_preamble<M: AssemblyBackend>(
816    problem: &Problem,
817    state: &mut InitializedState,
818    jacobi_scaling: &mut Option<Vec<f64>>,
819    use_jacobi_scaling: bool,
820    iteration: usize,
821    max_condition_number: Option<f64>,
822    jacobian_evaluations: &mut usize,
823) -> Result<IterationPreamble<M>, crate::error::ApexSolverError> {
824    // Evaluate residuals and Jacobian using the assembly mode
825    let (residuals, jacobian) = M::assemble(
826        problem,
827        &state.variables,
828        &state.variable_index_map,
829        state.symbolic_structure.as_ref(),
830        state.total_dof,
831        &mut state.workspace,
832    )?;
833    *jacobian_evaluations += 1;
834
835    // Conditioning check on the *un-scaled* Jacobian, before Jacobi
836    // scaling equalises the column norms this bound is read from.
837    if let Some(status) = check_jacobian_conditioning::<M>(&jacobian, max_condition_number) {
838        return Ok(IterationPreamble::EarlyExit(status));
839    }
840
841    // Process Jacobian (apply scaling if enabled)
842    let scaled_jacobian = if use_jacobi_scaling {
843        process_jacobian_generic::<M>(&jacobian, jacobi_scaling, iteration)?
844    } else {
845        jacobian
846    };
847
848    Ok(IterationPreamble::Proceed {
849        residuals,
850        scaled_jacobian,
851    })
852}
853
854/// Predicted decrease of the local quadratic model for a trial step.
855///
856/// ```text
857/// pred = −δᵀg − ½·δᵀHδ
858/// ```
859///
860/// where `g = Jᵀr` and `H = JᵀJ` are the *un-damped* normal equations, i.e.
861/// exactly what [`LinearSolver::get_gradient`] and
862/// [`LinearSolver::get_hessian`] publish.
863///
864/// Written this way the formula is independent of how the step was produced.
865/// The shorter Levenberg-Marquardt identity `pred = ½·δᵀ(λδ − g)` is obtained
866/// by substituting `(H + λI)δ = −g` into the above, so it silently assumes
867/// *uniform* `λI` damping and is wrong for the Marquardt diagonal
868/// `λ·D` (see [`Damping`](crate::linalg::Damping)) — and wrong for Dog Leg's
869/// steps, which do not solve that system at all. One extra sparse mat-vec per
870/// iteration buys correctness under every damping policy.
871///
872/// The step, gradient and Hessian must all live in the same space: when Jacobi
873/// column scaling is enabled, pass the *scaled* step, since the solver's cached
874/// gradient and Hessian are the scaled ones. The predicted reduction is a value
875/// of the quadratic model and is invariant under that change of variables, so
876/// the result is equally the predicted reduction of the un-scaled step.
877pub fn compute_predicted_reduction(
878    step: &Mat<f64>,
879    gradient: &Mat<f64>,
880    hessian_step: &Mat<f64>,
881) -> f64 {
882    let linear_term = (step.transpose() * gradient)[(0, 0)];
883    let quadratic_term = (step.transpose() * hessian_step)[(0, 0)];
884    -linear_term - 0.5 * quadratic_term
885}
886
887/// `‖g‖` for the *unscaled* gradient, from a gradient computed against a
888/// Jacobi-column-scaled Jacobian (GitHub #57).
889///
890/// `gradient_tolerance`'s contract (documented on every optimizer's
891/// `optimize`) is `‖Jᵀr‖ < gradient_tolerance` — the plain, un-scaled
892/// gradient, matching Ceres' own convention. But `LinearSolver::get_gradient`
893/// reflects whatever Jacobian it was last handed: under Jacobi scaling
894/// (`J̃ = J·diag(scaling)`), that is `g̃ = J̃ᵀr = diag(scaling)·g`, not `g`.
895/// Column scaling and gradient transform in *opposite* directions — compare
896/// [`AssemblyBackend::apply_inverse_scaling`](crate::linearizer::AssemblyBackend::apply_inverse_scaling),
897/// which un-scales a *step* by multiplying by `scaling` — so this divides
898/// instead: `g = g̃ ⊘ scaling`. Most impactful for Dog Leg, which enables
899/// Jacobi scaling by default.
900pub(crate) fn unscaled_gradient_norm(gradient: &Mat<f64>, scaling: &[f64]) -> f64 {
901    let mut sum_sq = 0.0;
902    for i in 0..gradient.nrows() {
903        let g = gradient[(i, 0)] / scaling[i];
904        sum_sq += g * g;
905    }
906    sum_sq.sqrt()
907}
908
909/// Compute step quality ratio (actual vs predicted reduction).
910///
911/// Used by Levenberg-Marquardt and Dog Leg optimizers to evaluate
912/// whether a proposed step improved the objective function as predicted
913/// by the local quadratic model.
914///
915/// Returns `ρ = actual_reduction / predicted_reduction`, handling
916/// degenerate predicted reductions.
917///
918/// # Non-positive predicted reduction
919///
920/// A *negative* predicted reduction means the trial step increases the local
921/// quadratic model — the model is not a descent direction and the ratio is
922/// meaningless. Worse, it is actively misleading: a negative actual reduction
923/// divided by a negative predicted reduction yields `ρ > 0`, so a step that
924/// raised the cost would be accepted. Ceres treats a non-positive
925/// `model_cost_change` as an invalid step; `-1.0` is returned here so every
926/// acceptance threshold rejects it.
927///
928/// A predicted reduction that is merely *near zero* is different: at the
929/// solution both reductions vanish legitimately, so the historic behaviour
930/// (accept when the cost actually fell) is kept — rejecting there would inflate
931/// the damping just as the solver converges.
932pub fn compute_step_quality(current_cost: f64, new_cost: f64, predicted_reduction: f64) -> f64 {
933    let actual_reduction = current_cost - new_cost;
934    if predicted_reduction < -1e-15 {
935        -1.0
936    } else if predicted_reduction.abs() <= 1e-15 {
937        if actual_reduction > 0.0 { 1.0 } else { 0.0 }
938    } else {
939        actual_reduction / predicted_reduction
940    }
941}
942
943/// Create a sparse linear solver for the given solver type.
944///
945/// Never referenced by the optimizers — each dispatches its own supported
946/// solver/mode combinations and rejects the rest. Superseded; scheduled for
947/// removal.
948#[deprecated(
949    since = "1.5.0",
950    note = "no optimizer calls this; construct the desired solver directly"
951)]
952pub fn create_linear_solver(
953    solver_type: &linalg::LinearSolverType,
954) -> Box<dyn LinearSolver<SparseMode>> {
955    match solver_type {
956        linalg::LinearSolverType::SparseCholesky => Box::new(SparseCholeskySolver::new()),
957        linalg::LinearSolverType::SparseQR => Box::new(SparseQRSolver::new()),
958        _ => {
959            // Schur requires StructureAware initialization; dense solvers live
960            // behind the dense dispatch. Callers get plain Cholesky here.
961            Box::new(SparseCholeskySolver::new())
962        }
963    }
964}
965
966/// Notify observers with current optimization state (sparse path).
967///
968/// This is the common observer notification pattern used by all three optimizers.
969#[allow(clippy::too_many_arguments)]
970pub fn notify_observers(
971    observers: &mut OptObserverVec,
972    variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
973    iteration: usize,
974    cost: f64,
975    gradient_norm: f64,
976    damping: Option<f64>,
977    step_norm: f64,
978    step_quality: Option<f64>,
979    linear_solver: &dyn LinearSolver<SparseMode>,
980) {
981    observers.set_iteration_metrics(cost, gradient_norm, damping, step_norm, step_quality);
982
983    // Backends that eliminate straight from J never materialize JᵀJ, so the
984    // matrix view is best-effort; the gradient is always available.
985    if !observers.is_empty()
986        && let Some(gradient) = linear_solver.get_gradient()
987    {
988        observers.set_matrix_data(linear_solver.get_hessian().cloned(), Some(gradient.clone()));
989    }
990
991    observers.notify(variables, iteration);
992}
993
994/// Notify observers with current optimization state (generic path).
995///
996/// For dense mode, the Hessian is converted to sparse for observer compatibility.
997/// This is acceptable since observers are for visualization/debugging, not the hot path.
998#[allow(clippy::too_many_arguments)]
999pub fn notify_observers_generic<M: AssemblyBackend>(
1000    observers: &mut OptObserverVec,
1001    variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1002    iteration: usize,
1003    cost: f64,
1004    gradient_norm: f64,
1005    damping: Option<f64>,
1006    step_norm: f64,
1007    step_quality: Option<f64>,
1008    _linear_solver: &dyn LinearSolver<M>,
1009) {
1010    observers.set_iteration_metrics(cost, gradient_norm, damping, step_norm, step_quality);
1011    // Skip matrix data for generic path since observers expect sparse Hessian.
1012    // Observer matrix visualization is optional and only used for debugging.
1013    observers.notify(variables, iteration);
1014}
1015
1016/// Process Jacobian with Jacobi scaling (generic over assembly mode).
1017///
1018/// On `iteration == 0`, computes the scaling factors and stores them.
1019/// On subsequent iterations, reuses the cached scaling.
1020pub fn process_jacobian_generic<M: AssemblyBackend>(
1021    jacobian: &M::Jacobian,
1022    jacobi_scaling: &mut Option<Vec<f64>>,
1023    iteration: usize,
1024) -> Result<M::Jacobian, OptimizerError> {
1025    if iteration == 0 {
1026        let norms = M::compute_column_norms(jacobian);
1027        let scaling: Vec<f64> = norms.iter().map(|n| 1.0 / (1.0 + n)).collect();
1028        *jacobi_scaling = Some(scaling);
1029    }
1030    let scaling = jacobi_scaling
1031        .as_ref()
1032        .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
1033    Ok(M::apply_column_scaling(jacobian, scaling))
1034}
1035
1036/// Build a SolverResult from common optimization loop outputs.
1037///
1038/// All three optimizers construct SolverResult identically at convergence.
1039#[allow(clippy::too_many_arguments)]
1040pub fn build_solver_result(
1041    status: OptimizationStatus,
1042    iterations: usize,
1043    state: InitializedState,
1044    elapsed: Duration,
1045    final_gradient_norm: f64,
1046    final_parameter_update_norm: f64,
1047    cost_evaluations: usize,
1048    jacobian_evaluations: usize,
1049    covariances: Option<SecondaryMap<VarKey, Mat<f64>>>,
1050) -> SolverResult<SlotMap<VarKey, Box<dyn ManifoldVariable>>> {
1051    SolverResult {
1052        status,
1053        iterations,
1054        initial_cost: state.initial_cost,
1055        final_cost: state.current_cost,
1056        parameters: state.variables,
1057        elapsed_time: elapsed,
1058        convergence_info: Some(ConvergenceInfo {
1059            final_gradient_norm,
1060            final_parameter_update_norm,
1061            cost_evaluations,
1062            jacobian_evaluations,
1063        }),
1064        covariances,
1065    }
1066}
1067
1068/// Unified summary statistics for all optimizer types.
1069///
1070/// Replaces the separate `LevenbergMarquardtSummary`, `GaussNewtonSummary`,
1071/// and `DogLegSummary` structs with a single type that handles algorithm-specific
1072/// fields via `Option`.
1073#[derive(Debug, Clone)]
1074pub struct OptimizerSummary {
1075    /// Name of the optimizer algorithm
1076    pub optimizer_name: &'static str,
1077    /// Initial cost value
1078    pub initial_cost: f64,
1079    /// Final cost value
1080    pub final_cost: f64,
1081    /// Total number of iterations performed
1082    pub iterations: usize,
1083    /// Number of successful steps (None for GN which always accepts)
1084    pub successful_steps: Option<usize>,
1085    /// Number of unsuccessful steps (None for GN which always accepts)
1086    pub unsuccessful_steps: Option<usize>,
1087    /// Average cost reduction per iteration
1088    pub average_cost_reduction: f64,
1089    /// Maximum gradient norm encountered
1090    pub max_gradient_norm: f64,
1091    /// Final gradient norm
1092    pub final_gradient_norm: f64,
1093    /// Maximum parameter update norm
1094    pub max_parameter_update_norm: f64,
1095    /// Final parameter update norm
1096    pub final_parameter_update_norm: f64,
1097    /// Total time elapsed
1098    pub total_time: Duration,
1099    /// Average time per iteration
1100    pub average_time_per_iteration: Duration,
1101    /// Detailed per-iteration statistics history
1102    pub iteration_history: Vec<IterationStats>,
1103    /// Convergence status
1104    pub convergence_status: OptimizationStatus,
1105    /// Final damping parameter (LM only)
1106    pub final_damping: Option<f64>,
1107    /// Final trust region radius (DL only)
1108    pub final_trust_region_radius: Option<f64>,
1109    /// Step quality ratio (LM only)
1110    pub rho: Option<f64>,
1111}
1112
1113impl Display for OptimizerSummary {
1114    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1115        let converged = matches!(
1116            self.convergence_status,
1117            OptimizationStatus::Converged
1118                | OptimizationStatus::CostToleranceReached
1119                | OptimizationStatus::GradientToleranceReached
1120                | OptimizationStatus::ParameterToleranceReached
1121        );
1122
1123        writeln!(f, "{} Final Result", self.optimizer_name)?;
1124
1125        if converged {
1126            writeln!(f, "CONVERGED ({:?})", self.convergence_status)?;
1127        } else {
1128            writeln!(f, "DIVERGED ({:?})", self.convergence_status)?;
1129        }
1130
1131        writeln!(f)?;
1132        writeln!(f, "Cost:")?;
1133        writeln!(f, "  Initial:   {:.6e}", self.initial_cost)?;
1134        writeln!(f, "  Final:     {:.6e}", self.final_cost)?;
1135        writeln!(
1136            f,
1137            "  Reduction: {:.6e} ({:.2}%)",
1138            self.initial_cost - self.final_cost,
1139            100.0 * (self.initial_cost - self.final_cost) / self.initial_cost.max(1e-12)
1140        )?;
1141        writeln!(f)?;
1142        writeln!(f, "Iterations:")?;
1143        writeln!(f, "  Total:              {}", self.iterations)?;
1144        if let (Some(successful), Some(unsuccessful)) =
1145            (self.successful_steps, self.unsuccessful_steps)
1146        {
1147            writeln!(
1148                f,
1149                "  Successful steps:   {} ({:.1}%)",
1150                successful,
1151                100.0 * successful as f64 / self.iterations.max(1) as f64
1152            )?;
1153            writeln!(
1154                f,
1155                "  Unsuccessful steps: {} ({:.1}%)",
1156                unsuccessful,
1157                100.0 * unsuccessful as f64 / self.iterations.max(1) as f64
1158            )?;
1159        }
1160        if let Some(radius) = self.final_trust_region_radius {
1161            writeln!(f)?;
1162            writeln!(f, "Trust Region:")?;
1163            writeln!(f, "  Final radius: {:.6e}", radius)?;
1164        }
1165        writeln!(f)?;
1166        writeln!(f, "Gradient:")?;
1167        writeln!(f, "  Max norm:   {:.2e}", self.max_gradient_norm)?;
1168        writeln!(f, "  Final norm: {:.2e}", self.final_gradient_norm)?;
1169        writeln!(f)?;
1170        writeln!(f, "Parameter Update:")?;
1171        writeln!(f, "  Max norm:   {:.2e}", self.max_parameter_update_norm)?;
1172        writeln!(f, "  Final norm: {:.2e}", self.final_parameter_update_norm)?;
1173        writeln!(f)?;
1174        writeln!(f, "Performance:")?;
1175        writeln!(
1176            f,
1177            "  Total time:             {:.2}ms",
1178            self.total_time.as_secs_f64() * 1000.0
1179        )?;
1180        writeln!(
1181            f,
1182            "  Average per iteration:  {:.2}ms",
1183            self.average_time_per_iteration.as_secs_f64() * 1000.0
1184        )?;
1185
1186        Ok(())
1187    }
1188}
1189
1190/// Create an OptimizerSummary from common optimization loop outputs.
1191#[allow(clippy::too_many_arguments)]
1192pub fn create_optimizer_summary(
1193    optimizer_name: &'static str,
1194    initial_cost: f64,
1195    final_cost: f64,
1196    iterations: usize,
1197    successful_steps: Option<usize>,
1198    unsuccessful_steps: Option<usize>,
1199    max_gradient_norm: f64,
1200    final_gradient_norm: f64,
1201    max_parameter_update_norm: f64,
1202    final_parameter_update_norm: f64,
1203    total_cost_reduction: f64,
1204    total_time: Duration,
1205    iteration_history: Vec<IterationStats>,
1206    convergence_status: OptimizationStatus,
1207    final_damping: Option<f64>,
1208    final_trust_region_radius: Option<f64>,
1209    rho: Option<f64>,
1210) -> OptimizerSummary {
1211    OptimizerSummary {
1212        optimizer_name,
1213        initial_cost,
1214        final_cost,
1215        iterations,
1216        successful_steps,
1217        unsuccessful_steps,
1218        average_cost_reduction: if iterations > 0 {
1219            total_cost_reduction / iterations as f64
1220        } else {
1221            0.0
1222        },
1223        max_gradient_norm,
1224        final_gradient_norm,
1225        max_parameter_update_norm,
1226        final_parameter_update_norm,
1227        total_time,
1228        average_time_per_iteration: if iterations > 0 {
1229            total_time / iterations as u32
1230        } else {
1231            Duration::from_secs(0)
1232        },
1233        iteration_history,
1234        convergence_status,
1235        final_damping,
1236        final_trust_region_radius,
1237        rho,
1238    }
1239}
1240
1241#[cfg(test)]
1242#[allow(deprecated)] // these tests exist to cover the deprecated helpers until removal
1243mod tests {
1244    use super::*;
1245    use crate::core::VarKey;
1246    use crate::core::variable::{ManifoldVariable, Variable};
1247    use crate::factors::Factor;
1248    use crate::linalg::JacobianMode;
1249    use apex_manifolds::ManifoldType;
1250    use apex_manifolds::rn::Rn;
1251    use faer::Mat;
1252    use faer::prelude::ReborrowMut;
1253    use faer::sparse::{SparseColMat, Triplet};
1254    use nalgebra::dvector;
1255    use slotmap::{SecondaryMap, SlotMap};
1256    use std::time::Duration;
1257
1258    type TestResult = Result<(), Box<dyn std::error::Error>>;
1259
1260    // -------------------------------------------------------------------------
1261    // compute_step_quality — the non-positive predicted-reduction guard
1262    // -------------------------------------------------------------------------
1263
1264    /// A negative predicted reduction must reject, not accept.
1265    ///
1266    /// The trap: a cost that *rose* gives a negative actual reduction, and
1267    /// dividing two negatives yields ρ > 0. Without the guard the solver accepts
1268    /// a step that made the objective worse, every time the quadratic model
1269    /// points uphill.
1270    #[test]
1271    fn step_quality_rejects_when_the_model_predicts_an_increase() {
1272        // cost rose 10 → 12, and the model predicted a rise too.
1273        let rho = compute_step_quality(10.0, 12.0, -1.0);
1274        assert!(
1275            rho <= 0.0,
1276            "a negative predicted reduction must not produce a positive ρ, got {rho}"
1277        );
1278        // Under every acceptance threshold in the crate this rejects.
1279        assert!(
1280            rho < 1e-3,
1281            "ρ = {rho} would pass the default min_relative_decrease"
1282        );
1283    }
1284
1285    /// A vanishing predicted reduction still accepts a genuine improvement.
1286    ///
1287    /// At the solution both reductions go to zero legitimately; rejecting there
1288    /// would inflate the damping exactly as the solver converges.
1289    #[test]
1290    fn step_quality_accepts_a_real_decrease_when_prediction_underflows() {
1291        let rho = compute_step_quality(10.0, 9.999_999_999, 1e-20);
1292        assert!((rho - 1.0).abs() < 1e-12, "expected ρ = 1.0, got {rho}");
1293
1294        let rho_no_gain = compute_step_quality(10.0, 10.5, 1e-20);
1295        assert!(
1296            (rho_no_gain).abs() < 1e-12,
1297            "expected ρ = 0.0, got {rho_no_gain}"
1298        );
1299    }
1300
1301    /// The ordinary case is untouched.
1302    #[test]
1303    fn step_quality_is_the_plain_ratio_for_a_positive_prediction() {
1304        let rho = compute_step_quality(10.0, 6.0, 8.0);
1305        assert!((rho - 0.5).abs() < 1e-12, "expected ρ = 0.5, got {rho}");
1306    }
1307
1308    // -------------------------------------------------------------------------
1309    // condition_number_lower_bound
1310    // -------------------------------------------------------------------------
1311
1312    /// The bound is `(max‖c‖ / min‖c‖)²` and is exactly attained on a diagonal J.
1313    #[test]
1314    fn condition_bound_squares_the_column_norm_ratio() {
1315        // For J = diag(1, 10), JᵀJ = diag(1, 100), so κ₂ = 100 exactly.
1316        let bound = condition_number_lower_bound(&[1.0, 10.0]);
1317        assert!((bound - 100.0).abs() < 1e-9, "expected 100, got {bound}");
1318    }
1319
1320    /// A structurally unconstrained variable gives a zero column, hence an
1321    /// infinite bound. This is the case the check earns its keep on.
1322    #[test]
1323    fn condition_bound_is_infinite_for_an_unconstrained_column() {
1324        assert!(condition_number_lower_bound(&[3.0, 0.0, 1.0]).is_infinite());
1325    }
1326
1327    /// Equal column norms give the minimum possible bound of 1.
1328    #[test]
1329    fn condition_bound_is_one_for_equal_column_norms() {
1330        let bound = condition_number_lower_bound(&[2.5, 2.5, 2.5]);
1331        assert!((bound - 1.0).abs() < 1e-12, "expected 1, got {bound}");
1332        assert!((condition_number_lower_bound(&[]) - 1.0).abs() < 1e-12);
1333    }
1334
1335    /// The bound must never exceed the true condition number — it is a lower
1336    /// bound, so exceeding a threshold is proof of ill-conditioning.
1337    ///
1338    /// Checked against `κ₂(JᵀJ)` computed from the singular values of a J whose
1339    /// columns are deliberately non-orthogonal, so the diagonal underestimates.
1340    #[test]
1341    fn condition_bound_never_exceeds_the_true_condition_number() {
1342        // Two nearly parallel unit columns: the true κ is enormous, the column
1343        // norms are identical, so the bound is 1 — a valid, very loose, bound.
1344        let norms = [1.0, 1.0];
1345        let bound = condition_number_lower_bound(&norms);
1346        // True κ₂ for columns at angle θ is cot²(θ/2); at θ → 0 this diverges.
1347        assert!(
1348            bound <= 1.0 + 1e-12,
1349            "bound {bound} must not exceed the true condition number"
1350        );
1351    }
1352
1353    // -------------------------------------------------------------------------
1354    // compute_cost
1355    // -------------------------------------------------------------------------
1356
1357    #[test]
1358    fn test_compute_cost_known_value() {
1359        // ||[1, 2]||² * 0.5 = (1 + 4) * 0.5 = 2.5
1360        let r = Mat::from_fn(2, 1, |i, _| (i + 1) as f64);
1361        let cost = compute_cost(&r);
1362        assert!((cost - 2.5).abs() < 1e-12, "expected 2.5, got {cost}");
1363    }
1364
1365    #[test]
1366    fn test_compute_cost_zero_residual() {
1367        let r = Mat::zeros(3, 1);
1368        assert_eq!(compute_cost(&r), 0.0);
1369    }
1370
1371    // -------------------------------------------------------------------------
1372    // compute_step_quality
1373    // -------------------------------------------------------------------------
1374
1375    #[test]
1376    fn test_compute_step_quality_normal() {
1377        // actual = 1.0-0.0 = 1.0, predicted = 2.0 → rho = 0.5
1378        let rho = compute_step_quality(1.0, 0.0, 2.0);
1379        assert!((rho - 0.5).abs() < 1e-12);
1380    }
1381
1382    #[test]
1383    fn test_compute_step_quality_zero_predicted_positive_actual() {
1384        // predicted ≈ 0, actual > 0 → 1.0
1385        let rho = compute_step_quality(2.0, 1.0, 0.0);
1386        assert_eq!(rho, 1.0);
1387    }
1388
1389    #[test]
1390    fn test_compute_step_quality_zero_predicted_nonpositive_actual() {
1391        // predicted ≈ 0, actual ≤ 0 → 0.0
1392        let rho = compute_step_quality(1.0, 2.0, 0.0); // actual = -1.0
1393        assert_eq!(rho, 0.0);
1394    }
1395
1396    #[test]
1397    fn test_compute_step_quality_negative_reduction() {
1398        // cost increased: actual = 1.0 - 2.0 = -1.0, predicted = 1.0 → -1.0
1399        let rho = compute_step_quality(1.0, 2.0, 1.0);
1400        assert!((rho - (-1.0)).abs() < 1e-12);
1401    }
1402
1403    // -------------------------------------------------------------------------
1404    // create_jacobi_scaling
1405    // -------------------------------------------------------------------------
1406
1407    fn make_identity_jacobian(n: usize) -> SparseColMat<usize, f64> {
1408        let triplets: Vec<Triplet<usize, usize, f64>> =
1409            (0..n).map(|i| Triplet::new(i, i, 1.0)).collect();
1410        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap_or_else(|_| {
1411            let empty: Vec<Triplet<usize, usize, f64>> = vec![];
1412            SparseColMat::try_new_from_triplets(0, 0, &empty)
1413                .unwrap_or_else(|_| panic!("failed to create empty matrix"))
1414        })
1415    }
1416
1417    #[test]
1418    fn test_create_jacobi_scaling_identity_jacobian() -> TestResult {
1419        // For identity Jacobian each column has norm 1.0 → scaling = 1/(1+1) = 0.5
1420        let jac = make_identity_jacobian(3);
1421        let scaling = create_jacobi_scaling(&jac)?;
1422        for i in 0..3 {
1423            let val = scaling.get(i, i).copied().unwrap_or(0.0);
1424            assert!(
1425                (val - 0.5).abs() < 1e-12,
1426                "col {i}: expected 0.5, got {val}"
1427            );
1428        }
1429        Ok(())
1430    }
1431
1432    #[test]
1433    fn test_create_jacobi_scaling_zero_column() -> TestResult {
1434        // A zero column has norm 0 → scaling = 1/(1+0) = 1.0
1435        let triplets = vec![Triplet::new(0_usize, 0_usize, 1.0_f64)];
1436        let jac = SparseColMat::try_new_from_triplets(2, 2, &triplets)?;
1437        let scaling = create_jacobi_scaling(&jac)?;
1438        // col 0: norm=1 → 0.5; col 1: norm=0 → 1.0
1439        let s0 = scaling.get(0, 0).copied().unwrap_or(0.0);
1440        let s1 = scaling.get(1, 1).copied().unwrap_or(0.0);
1441        assert!((s0 - 0.5).abs() < 1e-12);
1442        assert!((s1 - 1.0).abs() < 1e-12);
1443        Ok(())
1444    }
1445
1446    // -------------------------------------------------------------------------
1447    // process_jacobian
1448    // -------------------------------------------------------------------------
1449
1450    #[test]
1451    fn test_process_jacobian_creates_at_iter0() -> TestResult {
1452        let jac = make_identity_jacobian(2);
1453        let mut cache: Option<SparseColMat<usize, f64>> = None;
1454        let scaled = process_jacobian(&jac, &mut cache, 0)?;
1455        assert!(cache.is_some(), "scaling should be cached after iter 0");
1456        // Each diagonal entry should be scaled by 0.5
1457        let s = scaled.get(0, 0).copied().unwrap_or(0.0);
1458        assert!((s - 0.5).abs() < 1e-12);
1459        Ok(())
1460    }
1461
1462    #[test]
1463    fn test_process_jacobian_reuses_at_iter1() -> TestResult {
1464        let jac = make_identity_jacobian(2);
1465        let mut cache: Option<SparseColMat<usize, f64>> = None;
1466        // build cache at iter 0
1467        process_jacobian(&jac, &mut cache, 0)?;
1468        // now use cached at iter 1
1469        let scaled = process_jacobian(&jac, &mut cache, 1)?;
1470        let s = scaled.get(0, 0).copied().unwrap_or(0.0);
1471        assert!((s - 0.5).abs() < 1e-12);
1472        Ok(())
1473    }
1474
1475    #[test]
1476    fn test_process_jacobian_error_at_iter1_without_init() {
1477        let jac = make_identity_jacobian(2);
1478        let mut cache: Option<SparseColMat<usize, f64>> = None;
1479        // skip iter 0 — should error
1480        let result = process_jacobian(&jac, &mut cache, 1);
1481        assert!(result.is_err(), "should fail without prior iter=0 call");
1482    }
1483
1484    // -------------------------------------------------------------------------
1485    // compute_parameter_norm
1486    // -------------------------------------------------------------------------
1487
1488    #[test]
1489    fn test_compute_parameter_norm_two_variables() {
1490        let mut vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1491        vars.insert(Box::new(Variable::new(Rn::new(dvector![3.0]))));
1492        vars.insert(Box::new(Variable::new(Rn::new(dvector![4.0]))));
1493        let norm = compute_parameter_norm(&vars);
1494        // sqrt(3² + 4²) = 5.0
1495        assert!((norm - 5.0).abs() < 1e-12, "expected 5.0, got {norm}");
1496    }
1497
1498    #[test]
1499    fn test_compute_parameter_norm_empty() {
1500        let vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1501        assert_eq!(compute_parameter_norm(&vars), 0.0);
1502    }
1503
1504    // -------------------------------------------------------------------------
1505    // apply_parameter_step / apply_negative_parameter_step
1506    // -------------------------------------------------------------------------
1507
1508    #[test]
1509    fn test_apply_parameter_step_advances_variable() {
1510        let mut vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1511        let k = vars.insert(Box::new(Variable::new(Rn::new(dvector![0.0]))));
1512        let order = vec![k];
1513        let step = Mat::from_fn(1, 1, |_, _| 3.0);
1514        let norm = apply_parameter_step(&mut vars, step.as_ref(), &order);
1515        assert!((norm - 3.0).abs() < 1e-12);
1516        let val = vars[k].as_param_slice()[0];
1517        assert!((val - 3.0).abs() < 1e-12, "expected 3.0, got {val}");
1518    }
1519
1520    #[test]
1521    fn test_apply_negative_parameter_step_reverts() {
1522        let mut vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1523        let k = vars.insert(Box::new(Variable::new(Rn::new(dvector![5.0]))));
1524        let order = vec![k];
1525        let step = Mat::from_fn(1, 1, |_, _| 2.0);
1526        apply_parameter_step(&mut vars, step.as_ref(), &order);
1527        assert!((vars[k].as_param_slice()[0] - 7.0).abs() < 1e-12);
1528        apply_negative_parameter_step(&mut vars, step.as_ref(), &order);
1529        assert!((vars[k].as_param_slice()[0] - 5.0).abs() < 1e-12);
1530    }
1531
1532    // -------------------------------------------------------------------------
1533    // check_convergence — all branches
1534    // -------------------------------------------------------------------------
1535
1536    fn base_params() -> ConvergenceParams {
1537        ConvergenceParams {
1538            iteration: 1,
1539            current_cost: 1.0,
1540            new_cost: 0.9,
1541            parameter_norm: 1.0,
1542            parameter_update_norm: 1e-3,
1543            gradient_norm: 1e-3,
1544            elapsed: Duration::from_millis(10),
1545            step_accepted: true,
1546            max_iterations: 100,
1547            gradient_tolerance: 1e-10,
1548            parameter_tolerance: 1e-10,
1549            cost_tolerance: 1e-10,
1550            min_cost_threshold: None,
1551            timeout: None,
1552            trust_region_radius: None,
1553            min_trust_region_radius: None,
1554        }
1555    }
1556
1557    #[test]
1558    fn test_check_convergence_no_trigger() {
1559        assert!(check_convergence(&base_params()).is_none());
1560    }
1561
1562    #[test]
1563    fn test_check_convergence_nan_cost() {
1564        let mut p = base_params();
1565        p.new_cost = f64::NAN;
1566        assert_eq!(
1567            check_convergence(&p),
1568            Some(OptimizationStatus::InvalidNumericalValues)
1569        );
1570    }
1571
1572    #[test]
1573    fn test_check_convergence_inf_gradient() {
1574        let mut p = base_params();
1575        p.gradient_norm = f64::INFINITY;
1576        assert_eq!(
1577            check_convergence(&p),
1578            Some(OptimizationStatus::InvalidNumericalValues)
1579        );
1580    }
1581
1582    #[test]
1583    fn test_check_convergence_timeout() {
1584        let mut p = base_params();
1585        p.timeout = Some(Duration::from_millis(5));
1586        p.elapsed = Duration::from_millis(10);
1587        assert_eq!(check_convergence(&p), Some(OptimizationStatus::Timeout));
1588    }
1589
1590    #[test]
1591    fn test_check_convergence_max_iterations() {
1592        let mut p = base_params();
1593        p.iteration = 100;
1594        p.max_iterations = 100;
1595        assert_eq!(
1596            check_convergence(&p),
1597            Some(OptimizationStatus::MaxIterationsReached)
1598        );
1599    }
1600
1601    #[test]
1602    fn test_check_convergence_gradient_tolerance() {
1603        let mut p = base_params();
1604        p.step_accepted = true;
1605        p.gradient_norm = 1e-12;
1606        p.gradient_tolerance = 1e-10;
1607        assert_eq!(
1608            check_convergence(&p),
1609            Some(OptimizationStatus::GradientToleranceReached)
1610        );
1611    }
1612
1613    #[test]
1614    fn test_check_convergence_parameter_tolerance() {
1615        let mut p = base_params();
1616        p.step_accepted = true;
1617        p.gradient_norm = 1.0; // above tolerance
1618        p.parameter_update_norm = 1e-20;
1619        p.parameter_tolerance = 1e-8;
1620        p.parameter_norm = 1.0;
1621        assert_eq!(
1622            check_convergence(&p),
1623            Some(OptimizationStatus::ParameterToleranceReached)
1624        );
1625    }
1626
1627    #[test]
1628    fn test_check_convergence_cost_tolerance() {
1629        let mut p = base_params();
1630        p.step_accepted = true;
1631        p.gradient_norm = 1.0; // above
1632        p.parameter_update_norm = 1.0; // above
1633        p.current_cost = 1.0;
1634        p.new_cost = 1.0 - 1e-15; // nearly no change
1635        p.cost_tolerance = 1e-10;
1636        assert_eq!(
1637            check_convergence(&p),
1638            Some(OptimizationStatus::CostToleranceReached)
1639        );
1640    }
1641
1642    #[test]
1643    fn test_check_convergence_min_cost_threshold() {
1644        let mut p = base_params();
1645        p.step_accepted = true;
1646        p.gradient_norm = 1.0;
1647        p.parameter_update_norm = 1.0;
1648        p.current_cost = 1.0;
1649        p.new_cost = 1.0 - 0.5; // big change — cost tol not triggered
1650        p.cost_tolerance = 1e-10;
1651        p.min_cost_threshold = Some(1.0); // new_cost=0.5 < 1.0 → trigger
1652        assert_eq!(
1653            check_convergence(&p),
1654            Some(OptimizationStatus::MinCostThresholdReached)
1655        );
1656    }
1657
1658    #[test]
1659    fn test_check_convergence_trust_region_too_small() {
1660        let mut p = base_params();
1661        p.step_accepted = true;
1662        p.gradient_norm = 1.0;
1663        p.parameter_update_norm = 1.0;
1664        p.current_cost = 1.0;
1665        p.new_cost = 0.5;
1666        p.cost_tolerance = 1e-10;
1667        p.trust_region_radius = Some(1e-40);
1668        p.min_trust_region_radius = Some(1e-32);
1669        assert_eq!(
1670            check_convergence(&p),
1671            Some(OptimizationStatus::TrustRegionRadiusTooSmall)
1672        );
1673    }
1674
1675    #[test]
1676    fn test_check_convergence_step_not_accepted_skips_criteria() {
1677        // With step_accepted=false, gradient/parameter/cost tol should NOT fire
1678        let mut p = base_params();
1679        p.step_accepted = false;
1680        p.gradient_norm = 0.0; // would trigger gradient tol if accepted
1681        p.parameter_update_norm = 0.0;
1682        p.new_cost = 0.0;
1683        assert!(check_convergence(&p).is_none());
1684    }
1685
1686    // -------------------------------------------------------------------------
1687    // create_linear_solver (deprecated)
1688    // -------------------------------------------------------------------------
1689
1690    #[allow(deprecated)]
1691    #[test]
1692    fn test_create_linear_solver_cholesky() {
1693        let solver = create_linear_solver(&crate::linalg::LinearSolverType::SparseCholesky);
1694        // just verify it's constructable and callable without panic
1695        let _ = solver.get_hessian();
1696    }
1697
1698    #[allow(deprecated)]
1699    #[test]
1700    fn test_create_linear_solver_qr() {
1701        let solver = create_linear_solver(&crate::linalg::LinearSolverType::SparseQR);
1702        let _ = solver.get_hessian();
1703    }
1704
1705    #[allow(deprecated)]
1706    #[test]
1707    fn test_create_linear_solver_fallback_for_schur() {
1708        // ExplicitSparseSchur is special; falls back to Cholesky in create_linear_solver
1709        let solver = create_linear_solver(&crate::linalg::LinearSolverType::ExplicitSparseSchur);
1710        let _ = solver.get_hessian();
1711    }
1712
1713    // -------------------------------------------------------------------------
1714    // Display impls
1715    // -------------------------------------------------------------------------
1716
1717    #[test]
1718    fn test_optimizer_type_display() {
1719        assert_eq!(
1720            format!("{}", OptimizerType::LevenbergMarquardt),
1721            "Levenberg-Marquardt"
1722        );
1723        assert_eq!(format!("{}", OptimizerType::GaussNewton), "Gauss-Newton");
1724        assert_eq!(format!("{}", OptimizerType::DogLeg), "Dog Leg");
1725    }
1726
1727    #[test]
1728    fn test_optimization_status_display() {
1729        assert_eq!(format!("{}", OptimizationStatus::Converged), "Converged");
1730        assert_eq!(
1731            format!("{}", OptimizationStatus::MaxIterationsReached),
1732            "Maximum iterations reached"
1733        );
1734        assert_eq!(format!("{}", OptimizationStatus::Timeout), "Timeout");
1735        assert_eq!(
1736            format!("{}", OptimizationStatus::InvalidNumericalValues),
1737            "Invalid numerical values (NaN/Inf) detected"
1738        );
1739        assert!(format!("{}", OptimizationStatus::Failed("oops".into())).contains("oops"));
1740    }
1741
1742    #[test]
1743    fn test_optimizer_error_variants() {
1744        let e1 = OptimizerError::TrustRegionFailure {
1745            radius: 1e-40,
1746            min_radius: 1e-32,
1747        };
1748        assert!(e1.to_string().contains("Trust region radius"));
1749
1750        let e2 = OptimizerError::DampingFailure {
1751            damping: 1e13,
1752            max_damping: 1e12,
1753        };
1754        assert!(e2.to_string().contains("Damping parameter"));
1755
1756        let e3 = OptimizerError::CostIncrease {
1757            old_cost: 1.0,
1758            new_cost: 2.0,
1759        };
1760        assert!(e3.to_string().contains("Cost increased"));
1761
1762        let e4 = OptimizerError::LinearSolveFailed("singular".into());
1763        assert!(e4.to_string().contains("singular"));
1764
1765        let e5 = OptimizerError::EmptyProblem;
1766        assert!(e5.to_string().contains("no variables"));
1767
1768        let e6 = OptimizerError::NoResidualBlocks;
1769        assert!(e6.to_string().contains("residual blocks"));
1770    }
1771
1772    // -------------------------------------------------------------------------
1773    // IterationStats print (smoke tests — no panic)
1774    // -------------------------------------------------------------------------
1775
1776    #[test]
1777    fn test_iteration_stats_print_header_no_panic() {
1778        IterationStats::print_header();
1779    }
1780
1781    #[test]
1782    fn test_iteration_stats_print_line_no_panic() {
1783        let stats = IterationStats {
1784            iteration: 1,
1785            cost: 1.5,
1786            cost_change: -0.5,
1787            gradient_norm: 1e-3,
1788            step_norm: 1e-4,
1789            tr_ratio: 0.8,
1790            tr_radius: 1e3,
1791            ls_iter: 0,
1792            iter_time_ms: 2.5,
1793            total_time_ms: 10.0,
1794            accepted: true,
1795        };
1796        stats.print_line();
1797    }
1798
1799    // -------------------------------------------------------------------------
1800    // build_solver_result
1801    // -------------------------------------------------------------------------
1802
1803    #[test]
1804    fn test_build_solver_result_fields() -> TestResult {
1805        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1806        let k = variables.insert(Box::new(Variable::new(Rn::new(dvector![3.0]))));
1807        let state = InitializedState {
1808            variables,
1809            variable_index_map: SecondaryMap::new(),
1810            sorted_vars: vec![k],
1811            symbolic_structure: None,
1812            workspace: crate::linearizer::AssemblyWorkspace::empty(),
1813            total_dof: 1,
1814            current_cost: 0.1,
1815            initial_cost: 5.0,
1816        };
1817        let result = build_solver_result(
1818            OptimizationStatus::CostToleranceReached,
1819            10,
1820            state,
1821            Duration::from_millis(50),
1822            1e-5,
1823            1e-6,
1824            15,
1825            10,
1826            None,
1827        );
1828        assert_eq!(result.status, OptimizationStatus::CostToleranceReached);
1829        assert_eq!(result.iterations, 10);
1830        assert!((result.initial_cost - 5.0).abs() < 1e-12);
1831        assert!((result.final_cost - 0.1).abs() < 1e-12);
1832        let ci = result
1833            .convergence_info
1834            .as_ref()
1835            .ok_or("convergence_info is None")?;
1836        assert_eq!(ci.cost_evaluations, 15);
1837        assert_eq!(ci.jacobian_evaluations, 10);
1838        Ok(())
1839    }
1840
1841    // -------------------------------------------------------------------------
1842    // create_optimizer_summary
1843    // -------------------------------------------------------------------------
1844
1845    #[test]
1846    fn test_create_optimizer_summary_averages() {
1847        let summary = create_optimizer_summary(
1848            "TestOptimizer",
1849            10.0,
1850            1.0,
1851            4,
1852            Some(3),
1853            Some(1),
1854            2.0,
1855            0.1,
1856            3.0,
1857            0.05,
1858            9.0, // total_cost_reduction
1859            Duration::from_millis(400),
1860            vec![],
1861            OptimizationStatus::CostToleranceReached,
1862            Some(1e-3),
1863            None,
1864            Some(0.8),
1865        );
1866        // average_cost_reduction = 9.0 / 4 = 2.25
1867        assert!((summary.average_cost_reduction - 2.25).abs() < 1e-10);
1868        // average_time_per_iteration = 400ms / 4 = 100ms
1869        assert_eq!(
1870            summary.average_time_per_iteration,
1871            Duration::from_millis(100)
1872        );
1873        assert_eq!(summary.optimizer_name, "TestOptimizer");
1874        assert_eq!(summary.successful_steps, Some(3));
1875    }
1876
1877    #[test]
1878    fn test_create_optimizer_summary_zero_iterations() {
1879        let summary = create_optimizer_summary(
1880            "Test",
1881            1.0,
1882            1.0,
1883            0, // zero iterations
1884            None,
1885            None,
1886            0.0,
1887            0.0,
1888            0.0,
1889            0.0,
1890            0.0,
1891            Duration::from_secs(0),
1892            vec![],
1893            OptimizationStatus::MaxIterationsReached,
1894            None,
1895            None,
1896            None,
1897        );
1898        assert_eq!(summary.average_cost_reduction, 0.0);
1899        assert_eq!(summary.average_time_per_iteration, Duration::from_secs(0));
1900    }
1901
1902    // -------------------------------------------------------------------------
1903    // Simple Factor for integration tests in mod.rs
1904    // -------------------------------------------------------------------------
1905
1906    /// Linear factor: r = x - target, J = [[1.0]]
1907    struct LinearFactor {
1908        target: f64,
1909    }
1910
1911    impl Factor for LinearFactor {
1912        fn linearize(
1913            &self,
1914            params: &[&[f64]],
1915            residual: &mut [f64],
1916            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1917        ) {
1918            residual[0] = params[0][0] - self.target;
1919            if let Some(mut jac) = jacobian {
1920                *jac.rb_mut().get_mut(0, 0) = 1.0;
1921            }
1922        }
1923        fn residual_dim(&self) -> usize {
1924            1
1925        }
1926        fn jacobian_shape(&self) -> (usize, usize) {
1927            (1, 1)
1928        }
1929    }
1930
1931    // -------------------------------------------------------------------------
1932    // initialize_optimization_state (smoke test)
1933    // -------------------------------------------------------------------------
1934
1935    #[test]
1936    fn test_initialize_optimization_state() -> TestResult {
1937        use crate::core::problem::Problem;
1938
1939        let mut problem = Problem::new(JacobianMode::Sparse);
1940        let k = problem.add_variable(ManifoldType::RN, dvector![5.0]);
1941        problem.add_residual_block(&[k], Box::new(LinearFactor { target: 0.0 }), None);
1942
1943        let state = initialize_optimization_state(&mut problem)?;
1944        assert_eq!(state.total_dof, 1);
1945        assert!(state.initial_cost > 0.0);
1946        assert!(state.sorted_vars.contains(&k));
1947        Ok(())
1948    }
1949
1950    /// ISSUE-0004 regression: a variable bound is validated and stored, but no
1951    /// optimizer currently enforces it — `initialize_optimization_state` must
1952    /// reject with a typed error rather than silently solving the
1953    /// unconstrained problem, for zero-width, one-sided, and interior bounds.
1954    #[test]
1955    fn initialize_optimization_state_rejects_active_variable_bounds() {
1956        use crate::core::problem::Problem;
1957
1958        for (lower, upper) in [(0.0, 0.0), (f64::NEG_INFINITY, 5.0), (-5.0, f64::INFINITY)] {
1959            let mut problem = Problem::new(JacobianMode::Sparse);
1960            let k = problem.add_variable(ManifoldType::RN, dvector![10.0]);
1961            problem.add_residual_block(&[k], Box::new(LinearFactor { target: 0.0 }), None);
1962            problem.set_variable_bounds(k, 0, lower, upper);
1963
1964            let Err(err) = initialize_optimization_state(&mut problem) else {
1965                panic!("a non-trivial bound must be rejected, not silently ignored");
1966            };
1967            let message = err.to_string();
1968            assert!(
1969                message.contains("bound") && message.contains("component"),
1970                "error must reference the offending bound, got: {message}"
1971            );
1972        }
1973    }
1974
1975    /// A bound of exactly `(-inf, inf)` is a documented no-op (equivalent to
1976    /// no bound at all) and must not trip the rejection.
1977    #[test]
1978    fn initialize_optimization_state_allows_unbounded_interval() -> TestResult {
1979        use crate::core::problem::Problem;
1980
1981        let mut problem = Problem::new(JacobianMode::Sparse);
1982        let k = problem.add_variable(ManifoldType::RN, dvector![5.0]);
1983        problem.add_residual_block(&[k], Box::new(LinearFactor { target: 0.0 }), None);
1984        problem.set_variable_bounds(k, 0, f64::NEG_INFINITY, f64::INFINITY);
1985
1986        initialize_optimization_state(&mut problem)?;
1987        Ok(())
1988    }
1989
1990    /// Problems that never set a bound at all are completely unaffected.
1991    #[test]
1992    fn initialize_optimization_state_unaffected_without_bounds() -> TestResult {
1993        use crate::core::problem::Problem;
1994
1995        let mut problem = Problem::new(JacobianMode::Sparse);
1996        let k = problem.add_variable(ManifoldType::RN, dvector![5.0]);
1997        problem.add_residual_block(&[k], Box::new(LinearFactor { target: 0.0 }), None);
1998
1999        let state = initialize_optimization_state(&mut problem)?;
2000        assert_eq!(state.total_dof, 1);
2001        Ok(())
2002    }
2003}