1use 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
36pub use crate::observers::{OptObserver, OptObserverVec};
38
39pub use crate::linearizer::AssemblyBackend;
41
42#[derive(Default, Clone, Copy, PartialEq, Eq)]
44pub enum OptimizerType {
45 #[default]
47 LevenbergMarquardt,
48 GaussNewton,
50 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#[derive(Debug, Clone, Error)]
66pub enum OptimizerError {
67 #[error("Linear system solve failed: {0}")]
69 LinearSolveFailed(String),
70
71 #[error("Maximum iterations ({max_iters}) reached without convergence")]
73 MaxIterationsReached { max_iters: usize },
74
75 #[error("Trust region radius became too small: {radius:.6e} < {min_radius:.6e}")]
77 TrustRegionFailure { radius: f64, min_radius: f64 },
78
79 #[error("Damping parameter became too large: {damping:.6e} > {max_damping:.6e}")]
81 DampingFailure { damping: f64, max_damping: f64 },
82
83 #[error("Cost increased unexpectedly: {old_cost:.6e} -> {new_cost:.6e}")]
85 CostIncrease { old_cost: f64, new_cost: f64 },
86
87 #[error("Jacobian computation failed: {0}")]
89 JacobianFailed(String),
90
91 #[error("Invalid optimization parameters: {0}")]
93 InvalidParameters(String),
94
95 #[error("Numerical instability detected: {0}")]
97 NumericalInstability(String),
98
99 #[error("Linear algebra error: {0}")]
106 LinAlg(#[from] linalg::LinAlgError),
107
108 #[error("Core error: {0}")]
113 Core(#[from] crate::core::CoreError),
114
115 #[error("Linearizer error: {0}")]
120 Linearizer(#[from] crate::linearizer::LinearizerError),
121
122 #[error("Problem has no variables to optimize")]
124 EmptyProblem,
125
126 #[error("Problem has no residual blocks")]
128 NoResidualBlocks,
129
130 #[error("Failed to create Jacobi scaling matrix: {0}")]
132 JacobiScalingCreation(String),
133
134 #[error("Jacobi scaling not initialized")]
136 JacobiScalingNotInitialized,
137
138 #[error("Unknown linear solver type: {0}")]
140 UnknownLinearSolver(String),
141}
142
143pub type OptimizerResult<T> = Result<T, OptimizerError>;
145
146#[derive(Debug, Clone)]
148pub struct ConvergenceInfo {
149 pub final_gradient_norm: f64,
151 pub final_parameter_update_norm: f64,
153 pub cost_evaluations: usize,
155 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#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum OptimizationStatus {
175 Converged,
177 MaxIterationsReached,
179 CostToleranceReached,
181 ParameterToleranceReached,
183 GradientToleranceReached,
185 NumericalFailure,
187 UserTerminated,
189 Timeout,
191 TrustRegionRadiusTooSmall,
193 StalledNoProgress,
199 MinCostThresholdReached,
201 IllConditionedJacobian,
203 InvalidNumericalValues,
205 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#[derive(Clone)]
244pub struct SolverResult<T> {
245 pub parameters: T,
247 pub status: OptimizationStatus,
249 pub initial_cost: f64,
251 pub final_cost: f64,
253 pub iterations: usize,
255 pub elapsed_time: time::Duration,
257 pub convergence_info: Option<ConvergenceInfo>,
259 pub covariances: Option<SecondaryMap<VarKey, Mat<f64>>>,
267}
268
269pub type OptimizeResult =
271 Result<SolverResult<SlotMap<VarKey, Box<dyn ManifoldVariable>>>, crate::error::ApexSolverError>;
272
273pub trait Optimizer {
281 fn optimize(&mut self, problem: &mut Problem) -> OptimizeResult;
283}
284
285pub 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
311fn 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 let mut step_offset = 0;
326
327 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
354pub 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#[derive(Debug, Clone)]
389pub struct IterationStats {
390 pub iteration: usize,
392 pub cost: f64,
394 pub cost_change: f64,
396 pub gradient_norm: f64,
398 pub step_norm: f64,
400 pub tr_ratio: f64,
402 pub tr_radius: f64,
404 pub ls_iter: usize,
406 pub iter_time_ms: f64,
408 pub total_time_ms: f64,
410 pub accepted: bool,
412}
413
414impl IterationStats {
415 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 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
460pub 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 pub workspace: crate::linearizer::AssemblyWorkspace,
469 pub total_dof: usize,
470 pub current_cost: f64,
471 pub initial_cost: f64,
472}
473
474pub 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#[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#[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
539pub(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
570pub 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 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
639pub 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 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 pub trust_region_radius: Option<f64>,
658 pub min_trust_region_radius: Option<f64>,
660}
661
662pub fn check_convergence(params: &ConvergenceParams) -> Option<OptimizationStatus> {
666 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 if let Some(timeout) = params.timeout
678 && params.elapsed >= timeout
679 {
680 return Some(OptimizationStatus::Timeout);
681 }
682
683 if params.iteration >= params.max_iterations {
685 return Some(OptimizationStatus::MaxIterationsReached);
686 }
687
688 if !params.step_accepted {
690 return None;
691 }
692
693 if params.gradient_norm < params.gradient_tolerance {
695 return Some(OptimizationStatus::GradientToleranceReached);
696 }
697
698 if params.iteration > 0 {
700 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 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 if let Some(min_cost) = params.min_cost_threshold
717 && params.new_cost < min_cost
718 {
719 return Some(OptimizationStatus::MinCostThresholdReached);
720 }
721
722 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
733pub 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
774pub 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
801pub(crate) enum IterationPreamble<M: AssemblyBackend> {
804 Proceed {
806 residuals: Mat<f64>,
807 scaled_jacobian: M::Jacobian,
808 },
809 EarlyExit(OptimizationStatus),
812}
813
814pub(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 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 if let Some(status) = check_jacobian_conditioning::<M>(&jacobian, max_condition_number) {
838 return Ok(IterationPreamble::EarlyExit(status));
839 }
840
841 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
854pub 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
887pub(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
909pub 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#[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 Box::new(SparseCholeskySolver::new())
962 }
963 }
964}
965
966#[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 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#[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 observers.notify(variables, iteration);
1014}
1015
1016pub 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#[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#[derive(Debug, Clone)]
1074pub struct OptimizerSummary {
1075 pub optimizer_name: &'static str,
1077 pub initial_cost: f64,
1079 pub final_cost: f64,
1081 pub iterations: usize,
1083 pub successful_steps: Option<usize>,
1085 pub unsuccessful_steps: Option<usize>,
1087 pub average_cost_reduction: f64,
1089 pub max_gradient_norm: f64,
1091 pub final_gradient_norm: f64,
1093 pub max_parameter_update_norm: f64,
1095 pub final_parameter_update_norm: f64,
1097 pub total_time: Duration,
1099 pub average_time_per_iteration: Duration,
1101 pub iteration_history: Vec<IterationStats>,
1103 pub convergence_status: OptimizationStatus,
1105 pub final_damping: Option<f64>,
1107 pub final_trust_region_radius: Option<f64>,
1109 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#[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)] mod 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 #[test]
1271 fn step_quality_rejects_when_the_model_predicts_an_increase() {
1272 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 assert!(
1280 rho < 1e-3,
1281 "ρ = {rho} would pass the default min_relative_decrease"
1282 );
1283 }
1284
1285 #[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 #[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 #[test]
1314 fn condition_bound_squares_the_column_norm_ratio() {
1315 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 #[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 #[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 #[test]
1341 fn condition_bound_never_exceeds_the_true_condition_number() {
1342 let norms = [1.0, 1.0];
1345 let bound = condition_number_lower_bound(&norms);
1346 assert!(
1348 bound <= 1.0 + 1e-12,
1349 "bound {bound} must not exceed the true condition number"
1350 );
1351 }
1352
1353 #[test]
1358 fn test_compute_cost_known_value() {
1359 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 #[test]
1376 fn test_compute_step_quality_normal() {
1377 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 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 let rho = compute_step_quality(1.0, 2.0, 0.0); assert_eq!(rho, 0.0);
1394 }
1395
1396 #[test]
1397 fn test_compute_step_quality_negative_reduction() {
1398 let rho = compute_step_quality(1.0, 2.0, 1.0);
1400 assert!((rho - (-1.0)).abs() < 1e-12);
1401 }
1402
1403 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 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 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 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 #[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 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 process_jacobian(&jac, &mut cache, 0)?;
1468 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 let result = process_jacobian(&jac, &mut cache, 1);
1481 assert!(result.is_err(), "should fail without prior iter=0 call");
1482 }
1483
1484 #[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 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 #[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 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; 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; p.parameter_update_norm = 1.0; p.current_cost = 1.0;
1634 p.new_cost = 1.0 - 1e-15; 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; p.cost_tolerance = 1e-10;
1651 p.min_cost_threshold = Some(1.0); 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 let mut p = base_params();
1679 p.step_accepted = false;
1680 p.gradient_norm = 0.0; p.parameter_update_norm = 0.0;
1682 p.new_cost = 0.0;
1683 assert!(check_convergence(&p).is_none());
1684 }
1685
1686 #[allow(deprecated)]
1691 #[test]
1692 fn test_create_linear_solver_cholesky() {
1693 let solver = create_linear_solver(&crate::linalg::LinearSolverType::SparseCholesky);
1694 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 let solver = create_linear_solver(&crate::linalg::LinearSolverType::ExplicitSparseSchur);
1710 let _ = solver.get_hessian();
1711 }
1712
1713 #[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 #[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 #[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 #[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, Duration::from_millis(400),
1860 vec![],
1861 OptimizationStatus::CostToleranceReached,
1862 Some(1e-3),
1863 None,
1864 Some(0.8),
1865 );
1866 assert!((summary.average_cost_reduction - 2.25).abs() < 1e-10);
1868 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, 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 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 #[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 #[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 #[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 #[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}