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)]
163pub struct ConvergenceInfo {
164 pub final_gradient_norm: f64,
166 pub final_parameter_update_norm: f64,
168 pub cost_evaluations: usize,
170 pub jacobian_evaluations: usize,
172}
173
174impl Display for ConvergenceInfo {
175 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
176 write!(
177 f,
178 "Final gradient norm: {:.2e}, Final parameter update norm: {:.2e}, Cost evaluations: {}, Jacobian evaluations: {}",
179 self.final_gradient_norm,
180 self.final_parameter_update_norm,
181 self.cost_evaluations,
182 self.jacobian_evaluations
183 )
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum OptimizationStatus {
190 Converged,
192 MaxIterationsReached,
194 CostToleranceReached,
196 ParameterToleranceReached,
198 GradientToleranceReached,
200 NumericalFailure,
202 UserTerminated,
204 Timeout,
206 TrustRegionRadiusTooSmall,
208 StalledNoProgress,
214 MinCostThresholdReached,
216 IllConditionedJacobian,
218 InvalidNumericalValues,
220 Failed(String),
222}
223
224impl Display for OptimizationStatus {
225 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
226 match self {
227 OptimizationStatus::Converged => write!(f, "Converged"),
228 OptimizationStatus::MaxIterationsReached => write!(f, "Maximum iterations reached"),
229 OptimizationStatus::CostToleranceReached => write!(f, "Cost tolerance reached"),
230 OptimizationStatus::ParameterToleranceReached => {
231 write!(f, "Parameter tolerance reached")
232 }
233 OptimizationStatus::GradientToleranceReached => write!(f, "Gradient tolerance reached"),
234 OptimizationStatus::NumericalFailure => write!(f, "Numerical failure"),
235 OptimizationStatus::UserTerminated => write!(f, "User terminated"),
236 OptimizationStatus::Timeout => write!(f, "Timeout"),
237 OptimizationStatus::TrustRegionRadiusTooSmall => {
238 write!(f, "Trust region radius too small")
239 }
240 OptimizationStatus::StalledNoProgress => {
241 write!(f, "Stalled (no further progress possible)")
242 }
243 OptimizationStatus::MinCostThresholdReached => {
244 write!(f, "Minimum cost threshold reached")
245 }
246 OptimizationStatus::IllConditionedJacobian => {
247 write!(f, "Ill-conditioned Jacobian matrix")
248 }
249 OptimizationStatus::InvalidNumericalValues => {
250 write!(f, "Invalid numerical values (NaN/Inf) detected")
251 }
252 OptimizationStatus::Failed(msg) => write!(f, "Failed: {msg}"),
253 }
254 }
255}
256
257#[derive(Clone)]
259pub struct SolverResult<T> {
260 pub parameters: T,
262 pub status: OptimizationStatus,
264 pub initial_cost: f64,
266 pub final_cost: f64,
268 pub iterations: usize,
270 pub elapsed_time: time::Duration,
272 pub convergence_info: Option<ConvergenceInfo>,
274 pub covariances: Option<SecondaryMap<VarKey, Mat<f64>>>,
282}
283
284pub type OptimizeResult =
286 Result<SolverResult<SlotMap<VarKey, Box<dyn ManifoldVariable>>>, crate::error::ApexSolverError>;
287
288pub trait Optimizer {
296 fn optimize(&mut self, problem: &mut Problem) -> OptimizeResult;
298}
299
300pub fn apply_parameter_step(
319 variables: &mut SlotMap<VarKey, Box<dyn ManifoldVariable>>,
320 step: MatRef<f64>,
321 variable_order: &[VarKey],
322) -> f64 {
323 let mut step_offset = 0;
324
325 let mut step_buf: smallvec::SmallVec<[f64; 16]> = smallvec::SmallVec::new();
330 for &var_key in variable_order {
331 if let Some(var) = variables.get_mut(var_key) {
332 let var_size = var.dof();
333 let var_step = step.subrows(step_offset, var_size);
334 step_buf.clear();
335 step_buf.resize(var_size, 0.0);
336 for i in 0..var_size {
337 step_buf[i] = var_step[(i, 0)];
338 }
339 var.apply_tangent_step(&step_buf);
340 step_offset += var_size;
341 }
342 }
343
344 step.norm_l2()
345}
346
347pub fn apply_negative_parameter_step(
358 variables: &mut SlotMap<VarKey, Box<dyn ManifoldVariable>>,
359 step: MatRef<f64>,
360 variable_order: &[VarKey],
361) {
362 let mut negative_step = Mat::zeros(step.nrows(), 1);
363 for i in 0..step.nrows() {
364 negative_step[(i, 0)] = -step[(i, 0)];
365 }
366 apply_parameter_step(variables, negative_step.as_ref(), variable_order);
367}
368
369pub fn compute_cost(residual: &Mat<f64>) -> f64 {
370 let cost = residual.norm_l2();
371 0.5 * cost * cost
372}
373
374#[derive(Debug, Clone)]
386pub struct IterationStats {
387 pub iteration: usize,
389 pub cost: f64,
391 pub cost_change: f64,
393 pub gradient_norm: f64,
395 pub step_norm: f64,
397 pub tr_ratio: f64,
399 pub tr_radius: f64,
401 pub ls_iter: usize,
403 pub iter_time_ms: f64,
405 pub total_time_ms: f64,
407 pub accepted: bool,
409}
410
411impl IterationStats {
412 pub fn print_header() {
414 debug!(
415 "{:>4} {:>13} {:>13} {:>13} {:>13} {:>11} {:>11} {:>7} {:>11} {:>13} {:>6}",
416 "iter",
417 "cost",
418 "cost_change",
419 "|gradient|",
420 "|step|",
421 "tr_ratio",
422 "tr_radius",
423 "ls_iter",
424 "iter_time",
425 "total_time",
426 "status"
427 );
428 }
429
430 pub fn print_line(&self) {
432 let status = if self.iteration == 0 {
433 "-"
434 } else if self.accepted {
435 "✓"
436 } else {
437 "✗"
438 };
439
440 debug!(
441 "{:>4} {:>13.6e} {:>13.2e} {:>13.2e} {:>13.2e} {:>11.2e} {:>11.2e} {:>7} {:>9.2}ms {:>11.2}ms {:>6}",
442 self.iteration,
443 self.cost,
444 self.cost_change,
445 self.gradient_norm,
446 self.step_norm,
447 self.tr_ratio,
448 self.tr_radius,
449 self.ls_iter,
450 self.iter_time_ms,
451 self.total_time_ms,
452 status
453 );
454 }
455}
456
457pub struct InitializedState {
459 pub variables: SlotMap<VarKey, Box<dyn ManifoldVariable>>,
460 pub variable_index_map: SecondaryMap<VarKey, usize>,
461 pub sorted_vars: Vec<VarKey>,
462 pub symbolic_structure: Option<SymbolicStructure>,
463 pub total_dof: usize,
464 pub current_cost: f64,
465 pub initial_cost: f64,
466}
467
468pub fn compute_parameter_norm(variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>) -> f64 {
470 variables
471 .values()
472 .map(|v| v.as_param_slice().iter().map(|x| x * x).sum::<f64>())
473 .sum::<f64>()
474 .sqrt()
475}
476
477pub fn create_jacobi_scaling(
482 jacobian: &SparseColMat<usize, f64>,
483) -> Result<SparseColMat<usize, f64>, OptimizerError> {
484 let cols = jacobian.ncols();
485 let jacobi_scaling_vec: Vec<Triplet<usize, usize, f64>> = (0..cols)
486 .map(|c| {
487 let col_norm_squared: f64 = jacobian
488 .triplet_iter()
489 .filter(|t| t.col == c)
490 .map(|t| t.val * t.val)
491 .sum();
492 let col_norm = col_norm_squared.sqrt();
493 let scaling = 1.0 / (1.0 + col_norm);
494 Triplet::new(c, c, scaling)
495 })
496 .collect();
497
498 SparseColMat::try_new_from_triplets(cols, cols, &jacobi_scaling_vec)
499 .map_err(|e| OptimizerError::JacobiScalingCreation(e.to_string()).log_with_source(e))
500}
501
502pub fn process_jacobian(
507 jacobian: &SparseColMat<usize, f64>,
508 jacobi_scaling: &mut Option<SparseColMat<usize, f64>>,
509 iteration: usize,
510) -> Result<SparseColMat<usize, f64>, OptimizerError> {
511 if iteration == 0 {
512 let scaling = create_jacobi_scaling(jacobian)?;
513 *jacobi_scaling = Some(scaling);
514 }
515 let scaling = jacobi_scaling
516 .as_ref()
517 .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
518 Ok(jacobian * scaling)
519}
520
521pub fn initialize_optimization_state(problem: &mut Problem) -> OptimizerResult<InitializedState> {
531 let mut variables = problem.variables.clone();
532 problem.apply_constraints_to_variables(&mut variables);
533
534 let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
535 let mut col_offset = 0;
536 let mut sorted_vars: Vec<VarKey> = variables.keys().collect();
537 sorted_vars.sort_by_key(|k| variable_index_map.get(*k).copied().unwrap_or(usize::MAX));
539
540 for &var_key in &sorted_vars {
541 variable_index_map.insert(var_key, col_offset);
542 col_offset += variables[var_key].dof();
543 }
544
545 let mut sorted_vars: Vec<VarKey> = variables.keys().collect();
547 sorted_vars.sort_by_key(|k| variable_index_map[*k]);
548
549 let total_dof = col_offset;
550
551 let symbolic_structure = match problem.jacobian_mode {
552 JacobianMode::Sparse => Some(crate::linearizer::cpu::sparse::build_symbolic_structure(
553 problem,
554 &variables,
555 &variable_index_map,
556 total_dof,
557 )?),
558 JacobianMode::Dense => None,
559 };
560
561 let residual = problem.compute_residual_sparse(&variables)?;
562 let current_cost = compute_cost(&residual);
563 let initial_cost = current_cost;
564
565 Ok(InitializedState {
566 variables,
567 variable_index_map,
568 sorted_vars,
569 symbolic_structure,
570 total_dof,
571 current_cost,
572 initial_cost,
573 })
574}
575
576pub struct ConvergenceParams {
578 pub iteration: usize,
579 pub current_cost: f64,
580 pub new_cost: f64,
581 pub parameter_norm: f64,
582 pub parameter_update_norm: f64,
583 pub gradient_norm: f64,
584 pub elapsed: Duration,
585 pub step_accepted: bool,
586 pub max_iterations: usize,
588 pub gradient_tolerance: f64,
589 pub parameter_tolerance: f64,
590 pub cost_tolerance: f64,
591 pub min_cost_threshold: Option<f64>,
592 pub timeout: Option<Duration>,
593 pub trust_region_radius: Option<f64>,
595 pub min_trust_region_radius: Option<f64>,
597}
598
599pub fn check_convergence(params: &ConvergenceParams) -> Option<OptimizationStatus> {
603 if !params.new_cost.is_finite()
607 || !params.parameter_update_norm.is_finite()
608 || !params.gradient_norm.is_finite()
609 {
610 return Some(OptimizationStatus::InvalidNumericalValues);
611 }
612
613 if let Some(timeout) = params.timeout {
615 if params.elapsed >= timeout {
616 return Some(OptimizationStatus::Timeout);
617 }
618 }
619
620 if params.iteration >= params.max_iterations {
622 return Some(OptimizationStatus::MaxIterationsReached);
623 }
624
625 if !params.step_accepted {
627 return None;
628 }
629
630 if params.gradient_norm < params.gradient_tolerance {
632 return Some(OptimizationStatus::GradientToleranceReached);
633 }
634
635 if params.iteration > 0 {
637 let relative_step_tolerance =
639 params.parameter_tolerance * (params.parameter_norm + params.parameter_tolerance);
640 if params.parameter_update_norm <= relative_step_tolerance {
641 return Some(OptimizationStatus::ParameterToleranceReached);
642 }
643
644 let cost_change = (params.current_cost - params.new_cost).abs();
646 let relative_cost_change = cost_change / params.current_cost.max(1e-10);
647 if relative_cost_change < params.cost_tolerance {
648 return Some(OptimizationStatus::CostToleranceReached);
649 }
650 }
651
652 if let Some(min_cost) = params.min_cost_threshold {
654 if params.new_cost < min_cost {
655 return Some(OptimizationStatus::MinCostThresholdReached);
656 }
657 }
658
659 if let (Some(radius), Some(min_radius)) =
661 (params.trust_region_radius, params.min_trust_region_radius)
662 {
663 if radius < min_radius {
664 return Some(OptimizationStatus::TrustRegionRadiusTooSmall);
665 }
666 }
667
668 None
669}
670
671pub fn compute_step_quality(current_cost: f64, new_cost: f64, predicted_reduction: f64) -> f64 {
680 let actual_reduction = current_cost - new_cost;
681 if predicted_reduction.abs() < 1e-15 {
682 if actual_reduction > 0.0 { 1.0 } else { 0.0 }
683 } else {
684 actual_reduction / predicted_reduction
685 }
686}
687
688pub fn create_linear_solver(
693 solver_type: &linalg::LinearSolverType,
694) -> Box<dyn LinearSolver<SparseMode>> {
695 match solver_type {
696 linalg::LinearSolverType::SparseCholesky => Box::new(SparseCholeskySolver::new()),
697 linalg::LinearSolverType::SparseQR => Box::new(SparseQRSolver::new()),
698 _ => {
699 Box::new(SparseCholeskySolver::new())
702 }
703 }
704}
705
706#[allow(clippy::too_many_arguments)]
710pub fn notify_observers(
711 observers: &mut OptObserverVec,
712 variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
713 iteration: usize,
714 cost: f64,
715 gradient_norm: f64,
716 damping: Option<f64>,
717 step_norm: f64,
718 step_quality: Option<f64>,
719 linear_solver: &dyn LinearSolver<SparseMode>,
720) {
721 observers.set_iteration_metrics(cost, gradient_norm, damping, step_norm, step_quality);
722
723 if !observers.is_empty() {
724 if let (Some(hessian), Some(gradient)) =
725 (linear_solver.get_hessian(), linear_solver.get_gradient())
726 {
727 observers.set_matrix_data(Some(hessian.clone()), Some(gradient.clone()));
728 }
729 }
730
731 observers.notify(variables, iteration);
732}
733
734#[allow(clippy::too_many_arguments)]
739pub fn notify_observers_generic<M: AssemblyBackend>(
740 observers: &mut OptObserverVec,
741 variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
742 iteration: usize,
743 cost: f64,
744 gradient_norm: f64,
745 damping: Option<f64>,
746 step_norm: f64,
747 step_quality: Option<f64>,
748 _linear_solver: &dyn LinearSolver<M>,
749) {
750 observers.set_iteration_metrics(cost, gradient_norm, damping, step_norm, step_quality);
751 observers.notify(variables, iteration);
754}
755
756pub fn process_jacobian_generic<M: AssemblyBackend>(
761 jacobian: &M::Jacobian,
762 jacobi_scaling: &mut Option<Vec<f64>>,
763 iteration: usize,
764) -> Result<M::Jacobian, OptimizerError> {
765 if iteration == 0 {
766 let norms = M::compute_column_norms(jacobian);
767 let scaling: Vec<f64> = norms.iter().map(|n| 1.0 / (1.0 + n)).collect();
768 *jacobi_scaling = Some(scaling);
769 }
770 let scaling = jacobi_scaling
771 .as_ref()
772 .ok_or_else(|| OptimizerError::JacobiScalingNotInitialized.log())?;
773 Ok(M::apply_column_scaling(jacobian, scaling))
774}
775
776#[allow(clippy::too_many_arguments)]
780pub fn build_solver_result(
781 status: OptimizationStatus,
782 iterations: usize,
783 state: InitializedState,
784 elapsed: Duration,
785 final_gradient_norm: f64,
786 final_parameter_update_norm: f64,
787 cost_evaluations: usize,
788 jacobian_evaluations: usize,
789 covariances: Option<SecondaryMap<VarKey, Mat<f64>>>,
790) -> SolverResult<SlotMap<VarKey, Box<dyn ManifoldVariable>>> {
791 SolverResult {
792 status,
793 iterations,
794 initial_cost: state.initial_cost,
795 final_cost: state.current_cost,
796 parameters: state.variables,
797 elapsed_time: elapsed,
798 convergence_info: Some(ConvergenceInfo {
799 final_gradient_norm,
800 final_parameter_update_norm,
801 cost_evaluations,
802 jacobian_evaluations,
803 }),
804 covariances,
805 }
806}
807
808#[derive(Debug, Clone)]
814pub struct OptimizerSummary {
815 pub optimizer_name: &'static str,
817 pub initial_cost: f64,
819 pub final_cost: f64,
821 pub iterations: usize,
823 pub successful_steps: Option<usize>,
825 pub unsuccessful_steps: Option<usize>,
827 pub average_cost_reduction: f64,
829 pub max_gradient_norm: f64,
831 pub final_gradient_norm: f64,
833 pub max_parameter_update_norm: f64,
835 pub final_parameter_update_norm: f64,
837 pub total_time: Duration,
839 pub average_time_per_iteration: Duration,
841 pub iteration_history: Vec<IterationStats>,
843 pub convergence_status: OptimizationStatus,
845 pub final_damping: Option<f64>,
847 pub final_trust_region_radius: Option<f64>,
849 pub rho: Option<f64>,
851}
852
853impl Display for OptimizerSummary {
854 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
855 let converged = matches!(
856 self.convergence_status,
857 OptimizationStatus::Converged
858 | OptimizationStatus::CostToleranceReached
859 | OptimizationStatus::GradientToleranceReached
860 | OptimizationStatus::ParameterToleranceReached
861 );
862
863 writeln!(f, "{} Final Result", self.optimizer_name)?;
864
865 if converged {
866 writeln!(f, "CONVERGED ({:?})", self.convergence_status)?;
867 } else {
868 writeln!(f, "DIVERGED ({:?})", self.convergence_status)?;
869 }
870
871 writeln!(f)?;
872 writeln!(f, "Cost:")?;
873 writeln!(f, " Initial: {:.6e}", self.initial_cost)?;
874 writeln!(f, " Final: {:.6e}", self.final_cost)?;
875 writeln!(
876 f,
877 " Reduction: {:.6e} ({:.2}%)",
878 self.initial_cost - self.final_cost,
879 100.0 * (self.initial_cost - self.final_cost) / self.initial_cost.max(1e-12)
880 )?;
881 writeln!(f)?;
882 writeln!(f, "Iterations:")?;
883 writeln!(f, " Total: {}", self.iterations)?;
884 if let (Some(successful), Some(unsuccessful)) =
885 (self.successful_steps, self.unsuccessful_steps)
886 {
887 writeln!(
888 f,
889 " Successful steps: {} ({:.1}%)",
890 successful,
891 100.0 * successful as f64 / self.iterations.max(1) as f64
892 )?;
893 writeln!(
894 f,
895 " Unsuccessful steps: {} ({:.1}%)",
896 unsuccessful,
897 100.0 * unsuccessful as f64 / self.iterations.max(1) as f64
898 )?;
899 }
900 if let Some(radius) = self.final_trust_region_radius {
901 writeln!(f)?;
902 writeln!(f, "Trust Region:")?;
903 writeln!(f, " Final radius: {:.6e}", radius)?;
904 }
905 writeln!(f)?;
906 writeln!(f, "Gradient:")?;
907 writeln!(f, " Max norm: {:.2e}", self.max_gradient_norm)?;
908 writeln!(f, " Final norm: {:.2e}", self.final_gradient_norm)?;
909 writeln!(f)?;
910 writeln!(f, "Parameter Update:")?;
911 writeln!(f, " Max norm: {:.2e}", self.max_parameter_update_norm)?;
912 writeln!(f, " Final norm: {:.2e}", self.final_parameter_update_norm)?;
913 writeln!(f)?;
914 writeln!(f, "Performance:")?;
915 writeln!(
916 f,
917 " Total time: {:.2}ms",
918 self.total_time.as_secs_f64() * 1000.0
919 )?;
920 writeln!(
921 f,
922 " Average per iteration: {:.2}ms",
923 self.average_time_per_iteration.as_secs_f64() * 1000.0
924 )?;
925
926 Ok(())
927 }
928}
929
930#[allow(clippy::too_many_arguments)]
932pub fn create_optimizer_summary(
933 optimizer_name: &'static str,
934 initial_cost: f64,
935 final_cost: f64,
936 iterations: usize,
937 successful_steps: Option<usize>,
938 unsuccessful_steps: Option<usize>,
939 max_gradient_norm: f64,
940 final_gradient_norm: f64,
941 max_parameter_update_norm: f64,
942 final_parameter_update_norm: f64,
943 total_cost_reduction: f64,
944 total_time: Duration,
945 iteration_history: Vec<IterationStats>,
946 convergence_status: OptimizationStatus,
947 final_damping: Option<f64>,
948 final_trust_region_radius: Option<f64>,
949 rho: Option<f64>,
950) -> OptimizerSummary {
951 OptimizerSummary {
952 optimizer_name,
953 initial_cost,
954 final_cost,
955 iterations,
956 successful_steps,
957 unsuccessful_steps,
958 average_cost_reduction: if iterations > 0 {
959 total_cost_reduction / iterations as f64
960 } else {
961 0.0
962 },
963 max_gradient_norm,
964 final_gradient_norm,
965 max_parameter_update_norm,
966 final_parameter_update_norm,
967 total_time,
968 average_time_per_iteration: if iterations > 0 {
969 total_time / iterations as u32
970 } else {
971 Duration::from_secs(0)
972 },
973 iteration_history,
974 convergence_status,
975 final_damping,
976 final_trust_region_radius,
977 rho,
978 }
979}
980
981#[cfg(test)]
982mod tests {
983 use super::*;
984 use crate::core::VarKey;
985 use crate::core::variable::{ManifoldVariable, Variable};
986 use crate::factors::Factor;
987 use crate::linalg::JacobianMode;
988 use apex_manifolds::ManifoldType;
989 use apex_manifolds::rn::Rn;
990 use faer::Mat;
991 use faer::prelude::ReborrowMut;
992 use faer::sparse::{SparseColMat, Triplet};
993 use nalgebra::dvector;
994 use slotmap::{SecondaryMap, SlotMap};
995 use std::time::Duration;
996
997 type TestResult = Result<(), Box<dyn std::error::Error>>;
998
999 #[test]
1004 fn test_compute_cost_known_value() {
1005 let r = Mat::from_fn(2, 1, |i, _| (i + 1) as f64);
1007 let cost = compute_cost(&r);
1008 assert!((cost - 2.5).abs() < 1e-12, "expected 2.5, got {cost}");
1009 }
1010
1011 #[test]
1012 fn test_compute_cost_zero_residual() {
1013 let r = Mat::zeros(3, 1);
1014 assert_eq!(compute_cost(&r), 0.0);
1015 }
1016
1017 #[test]
1022 fn test_compute_step_quality_normal() {
1023 let rho = compute_step_quality(1.0, 0.0, 2.0);
1025 assert!((rho - 0.5).abs() < 1e-12);
1026 }
1027
1028 #[test]
1029 fn test_compute_step_quality_zero_predicted_positive_actual() {
1030 let rho = compute_step_quality(2.0, 1.0, 0.0);
1032 assert_eq!(rho, 1.0);
1033 }
1034
1035 #[test]
1036 fn test_compute_step_quality_zero_predicted_nonpositive_actual() {
1037 let rho = compute_step_quality(1.0, 2.0, 0.0); assert_eq!(rho, 0.0);
1040 }
1041
1042 #[test]
1043 fn test_compute_step_quality_negative_reduction() {
1044 let rho = compute_step_quality(1.0, 2.0, 1.0);
1046 assert!((rho - (-1.0)).abs() < 1e-12);
1047 }
1048
1049 fn make_identity_jacobian(n: usize) -> SparseColMat<usize, f64> {
1054 let triplets: Vec<Triplet<usize, usize, f64>> =
1055 (0..n).map(|i| Triplet::new(i, i, 1.0)).collect();
1056 SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap_or_else(|_| {
1057 let empty: Vec<Triplet<usize, usize, f64>> = vec![];
1058 SparseColMat::try_new_from_triplets(0, 0, &empty)
1059 .unwrap_or_else(|_| panic!("failed to create empty matrix"))
1060 })
1061 }
1062
1063 #[test]
1064 fn test_create_jacobi_scaling_identity_jacobian() -> TestResult {
1065 let jac = make_identity_jacobian(3);
1067 let scaling = create_jacobi_scaling(&jac)?;
1068 for i in 0..3 {
1069 let val = scaling.get(i, i).copied().unwrap_or(0.0);
1070 assert!(
1071 (val - 0.5).abs() < 1e-12,
1072 "col {i}: expected 0.5, got {val}"
1073 );
1074 }
1075 Ok(())
1076 }
1077
1078 #[test]
1079 fn test_create_jacobi_scaling_zero_column() -> TestResult {
1080 let triplets = vec![Triplet::new(0_usize, 0_usize, 1.0_f64)];
1082 let jac = SparseColMat::try_new_from_triplets(2, 2, &triplets)?;
1083 let scaling = create_jacobi_scaling(&jac)?;
1084 let s0 = scaling.get(0, 0).copied().unwrap_or(0.0);
1086 let s1 = scaling.get(1, 1).copied().unwrap_or(0.0);
1087 assert!((s0 - 0.5).abs() < 1e-12);
1088 assert!((s1 - 1.0).abs() < 1e-12);
1089 Ok(())
1090 }
1091
1092 #[test]
1097 fn test_process_jacobian_creates_at_iter0() -> TestResult {
1098 let jac = make_identity_jacobian(2);
1099 let mut cache: Option<SparseColMat<usize, f64>> = None;
1100 let scaled = process_jacobian(&jac, &mut cache, 0)?;
1101 assert!(cache.is_some(), "scaling should be cached after iter 0");
1102 let s = scaled.get(0, 0).copied().unwrap_or(0.0);
1104 assert!((s - 0.5).abs() < 1e-12);
1105 Ok(())
1106 }
1107
1108 #[test]
1109 fn test_process_jacobian_reuses_at_iter1() -> TestResult {
1110 let jac = make_identity_jacobian(2);
1111 let mut cache: Option<SparseColMat<usize, f64>> = None;
1112 process_jacobian(&jac, &mut cache, 0)?;
1114 let scaled = process_jacobian(&jac, &mut cache, 1)?;
1116 let s = scaled.get(0, 0).copied().unwrap_or(0.0);
1117 assert!((s - 0.5).abs() < 1e-12);
1118 Ok(())
1119 }
1120
1121 #[test]
1122 fn test_process_jacobian_error_at_iter1_without_init() {
1123 let jac = make_identity_jacobian(2);
1124 let mut cache: Option<SparseColMat<usize, f64>> = None;
1125 let result = process_jacobian(&jac, &mut cache, 1);
1127 assert!(result.is_err(), "should fail without prior iter=0 call");
1128 }
1129
1130 #[test]
1135 fn test_compute_parameter_norm_two_variables() {
1136 let mut vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1137 vars.insert(Box::new(Variable::new(Rn::new(dvector![3.0]))));
1138 vars.insert(Box::new(Variable::new(Rn::new(dvector![4.0]))));
1139 let norm = compute_parameter_norm(&vars);
1140 assert!((norm - 5.0).abs() < 1e-12, "expected 5.0, got {norm}");
1142 }
1143
1144 #[test]
1145 fn test_compute_parameter_norm_empty() {
1146 let vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1147 assert_eq!(compute_parameter_norm(&vars), 0.0);
1148 }
1149
1150 #[test]
1155 fn test_apply_parameter_step_advances_variable() {
1156 let mut vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1157 let k = vars.insert(Box::new(Variable::new(Rn::new(dvector![0.0]))));
1158 let order = vec![k];
1159 let step = Mat::from_fn(1, 1, |_, _| 3.0);
1160 let norm = apply_parameter_step(&mut vars, step.as_ref(), &order);
1161 assert!((norm - 3.0).abs() < 1e-12);
1162 let val = vars[k].as_param_slice()[0];
1163 assert!((val - 3.0).abs() < 1e-12, "expected 3.0, got {val}");
1164 }
1165
1166 #[test]
1167 fn test_apply_negative_parameter_step_reverts() {
1168 let mut vars: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1169 let k = vars.insert(Box::new(Variable::new(Rn::new(dvector![5.0]))));
1170 let order = vec![k];
1171 let step = Mat::from_fn(1, 1, |_, _| 2.0);
1172 apply_parameter_step(&mut vars, step.as_ref(), &order);
1173 assert!((vars[k].as_param_slice()[0] - 7.0).abs() < 1e-12);
1174 apply_negative_parameter_step(&mut vars, step.as_ref(), &order);
1175 assert!((vars[k].as_param_slice()[0] - 5.0).abs() < 1e-12);
1176 }
1177
1178 fn base_params() -> ConvergenceParams {
1183 ConvergenceParams {
1184 iteration: 1,
1185 current_cost: 1.0,
1186 new_cost: 0.9,
1187 parameter_norm: 1.0,
1188 parameter_update_norm: 1e-3,
1189 gradient_norm: 1e-3,
1190 elapsed: Duration::from_millis(10),
1191 step_accepted: true,
1192 max_iterations: 100,
1193 gradient_tolerance: 1e-10,
1194 parameter_tolerance: 1e-10,
1195 cost_tolerance: 1e-10,
1196 min_cost_threshold: None,
1197 timeout: None,
1198 trust_region_radius: None,
1199 min_trust_region_radius: None,
1200 }
1201 }
1202
1203 #[test]
1204 fn test_check_convergence_no_trigger() {
1205 assert!(check_convergence(&base_params()).is_none());
1206 }
1207
1208 #[test]
1209 fn test_check_convergence_nan_cost() {
1210 let mut p = base_params();
1211 p.new_cost = f64::NAN;
1212 assert_eq!(
1213 check_convergence(&p),
1214 Some(OptimizationStatus::InvalidNumericalValues)
1215 );
1216 }
1217
1218 #[test]
1219 fn test_check_convergence_inf_gradient() {
1220 let mut p = base_params();
1221 p.gradient_norm = f64::INFINITY;
1222 assert_eq!(
1223 check_convergence(&p),
1224 Some(OptimizationStatus::InvalidNumericalValues)
1225 );
1226 }
1227
1228 #[test]
1229 fn test_check_convergence_timeout() {
1230 let mut p = base_params();
1231 p.timeout = Some(Duration::from_millis(5));
1232 p.elapsed = Duration::from_millis(10);
1233 assert_eq!(check_convergence(&p), Some(OptimizationStatus::Timeout));
1234 }
1235
1236 #[test]
1237 fn test_check_convergence_max_iterations() {
1238 let mut p = base_params();
1239 p.iteration = 100;
1240 p.max_iterations = 100;
1241 assert_eq!(
1242 check_convergence(&p),
1243 Some(OptimizationStatus::MaxIterationsReached)
1244 );
1245 }
1246
1247 #[test]
1248 fn test_check_convergence_gradient_tolerance() {
1249 let mut p = base_params();
1250 p.step_accepted = true;
1251 p.gradient_norm = 1e-12;
1252 p.gradient_tolerance = 1e-10;
1253 assert_eq!(
1254 check_convergence(&p),
1255 Some(OptimizationStatus::GradientToleranceReached)
1256 );
1257 }
1258
1259 #[test]
1260 fn test_check_convergence_parameter_tolerance() {
1261 let mut p = base_params();
1262 p.step_accepted = true;
1263 p.gradient_norm = 1.0; p.parameter_update_norm = 1e-20;
1265 p.parameter_tolerance = 1e-8;
1266 p.parameter_norm = 1.0;
1267 assert_eq!(
1268 check_convergence(&p),
1269 Some(OptimizationStatus::ParameterToleranceReached)
1270 );
1271 }
1272
1273 #[test]
1274 fn test_check_convergence_cost_tolerance() {
1275 let mut p = base_params();
1276 p.step_accepted = true;
1277 p.gradient_norm = 1.0; p.parameter_update_norm = 1.0; p.current_cost = 1.0;
1280 p.new_cost = 1.0 - 1e-15; p.cost_tolerance = 1e-10;
1282 assert_eq!(
1283 check_convergence(&p),
1284 Some(OptimizationStatus::CostToleranceReached)
1285 );
1286 }
1287
1288 #[test]
1289 fn test_check_convergence_min_cost_threshold() {
1290 let mut p = base_params();
1291 p.step_accepted = true;
1292 p.gradient_norm = 1.0;
1293 p.parameter_update_norm = 1.0;
1294 p.current_cost = 1.0;
1295 p.new_cost = 1.0 - 0.5; p.cost_tolerance = 1e-10;
1297 p.min_cost_threshold = Some(1.0); assert_eq!(
1299 check_convergence(&p),
1300 Some(OptimizationStatus::MinCostThresholdReached)
1301 );
1302 }
1303
1304 #[test]
1305 fn test_check_convergence_trust_region_too_small() {
1306 let mut p = base_params();
1307 p.step_accepted = true;
1308 p.gradient_norm = 1.0;
1309 p.parameter_update_norm = 1.0;
1310 p.current_cost = 1.0;
1311 p.new_cost = 0.5;
1312 p.cost_tolerance = 1e-10;
1313 p.trust_region_radius = Some(1e-40);
1314 p.min_trust_region_radius = Some(1e-32);
1315 assert_eq!(
1316 check_convergence(&p),
1317 Some(OptimizationStatus::TrustRegionRadiusTooSmall)
1318 );
1319 }
1320
1321 #[test]
1322 fn test_check_convergence_step_not_accepted_skips_criteria() {
1323 let mut p = base_params();
1325 p.step_accepted = false;
1326 p.gradient_norm = 0.0; p.parameter_update_norm = 0.0;
1328 p.new_cost = 0.0;
1329 assert!(check_convergence(&p).is_none());
1330 }
1331
1332 #[test]
1337 fn test_create_linear_solver_cholesky() {
1338 let solver = create_linear_solver(&crate::linalg::LinearSolverType::SparseCholesky);
1339 let _ = solver.get_hessian();
1341 }
1342
1343 #[test]
1344 fn test_create_linear_solver_qr() {
1345 let solver = create_linear_solver(&crate::linalg::LinearSolverType::SparseQR);
1346 let _ = solver.get_hessian();
1347 }
1348
1349 #[test]
1350 fn test_create_linear_solver_fallback_for_schur() {
1351 let solver = create_linear_solver(&crate::linalg::LinearSolverType::SparseSchurComplement);
1353 let _ = solver.get_hessian();
1354 }
1355
1356 #[test]
1361 fn test_optimizer_type_display() {
1362 assert_eq!(
1363 format!("{}", OptimizerType::LevenbergMarquardt),
1364 "Levenberg-Marquardt"
1365 );
1366 assert_eq!(format!("{}", OptimizerType::GaussNewton), "Gauss-Newton");
1367 assert_eq!(format!("{}", OptimizerType::DogLeg), "Dog Leg");
1368 }
1369
1370 #[test]
1371 fn test_optimization_status_display() {
1372 assert_eq!(format!("{}", OptimizationStatus::Converged), "Converged");
1373 assert_eq!(
1374 format!("{}", OptimizationStatus::MaxIterationsReached),
1375 "Maximum iterations reached"
1376 );
1377 assert_eq!(format!("{}", OptimizationStatus::Timeout), "Timeout");
1378 assert_eq!(
1379 format!("{}", OptimizationStatus::InvalidNumericalValues),
1380 "Invalid numerical values (NaN/Inf) detected"
1381 );
1382 assert!(format!("{}", OptimizationStatus::Failed("oops".into())).contains("oops"));
1383 }
1384
1385 #[test]
1386 fn test_optimizer_error_variants() {
1387 let e1 = OptimizerError::TrustRegionFailure {
1388 radius: 1e-40,
1389 min_radius: 1e-32,
1390 };
1391 assert!(e1.to_string().contains("Trust region radius"));
1392
1393 let e2 = OptimizerError::DampingFailure {
1394 damping: 1e13,
1395 max_damping: 1e12,
1396 };
1397 assert!(e2.to_string().contains("Damping parameter"));
1398
1399 let e3 = OptimizerError::CostIncrease {
1400 old_cost: 1.0,
1401 new_cost: 2.0,
1402 };
1403 assert!(e3.to_string().contains("Cost increased"));
1404
1405 let e4 = OptimizerError::LinearSolveFailed("singular".into());
1406 assert!(e4.to_string().contains("singular"));
1407
1408 let e5 = OptimizerError::EmptyProblem;
1409 assert!(e5.to_string().contains("no variables"));
1410
1411 let e6 = OptimizerError::NoResidualBlocks;
1412 assert!(e6.to_string().contains("residual blocks"));
1413 }
1414
1415 #[test]
1420 fn test_iteration_stats_print_header_no_panic() {
1421 IterationStats::print_header();
1422 }
1423
1424 #[test]
1425 fn test_iteration_stats_print_line_no_panic() {
1426 let stats = IterationStats {
1427 iteration: 1,
1428 cost: 1.5,
1429 cost_change: -0.5,
1430 gradient_norm: 1e-3,
1431 step_norm: 1e-4,
1432 tr_ratio: 0.8,
1433 tr_radius: 1e3,
1434 ls_iter: 0,
1435 iter_time_ms: 2.5,
1436 total_time_ms: 10.0,
1437 accepted: true,
1438 };
1439 stats.print_line();
1440 }
1441
1442 #[test]
1447 fn test_build_solver_result_fields() -> TestResult {
1448 let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1449 let k = variables.insert(Box::new(Variable::new(Rn::new(dvector![3.0]))));
1450 let state = InitializedState {
1451 variables,
1452 variable_index_map: SecondaryMap::new(),
1453 sorted_vars: vec![k],
1454 symbolic_structure: None,
1455 total_dof: 1,
1456 current_cost: 0.1,
1457 initial_cost: 5.0,
1458 };
1459 let result = build_solver_result(
1460 OptimizationStatus::CostToleranceReached,
1461 10,
1462 state,
1463 Duration::from_millis(50),
1464 1e-5,
1465 1e-6,
1466 15,
1467 10,
1468 None,
1469 );
1470 assert_eq!(result.status, OptimizationStatus::CostToleranceReached);
1471 assert_eq!(result.iterations, 10);
1472 assert!((result.initial_cost - 5.0).abs() < 1e-12);
1473 assert!((result.final_cost - 0.1).abs() < 1e-12);
1474 let ci = result
1475 .convergence_info
1476 .as_ref()
1477 .ok_or("convergence_info is None")?;
1478 assert_eq!(ci.cost_evaluations, 15);
1479 assert_eq!(ci.jacobian_evaluations, 10);
1480 Ok(())
1481 }
1482
1483 #[test]
1488 fn test_create_optimizer_summary_averages() {
1489 let summary = create_optimizer_summary(
1490 "TestOptimizer",
1491 10.0,
1492 1.0,
1493 4,
1494 Some(3),
1495 Some(1),
1496 2.0,
1497 0.1,
1498 3.0,
1499 0.05,
1500 9.0, Duration::from_millis(400),
1502 vec![],
1503 OptimizationStatus::CostToleranceReached,
1504 Some(1e-3),
1505 None,
1506 Some(0.8),
1507 );
1508 assert!((summary.average_cost_reduction - 2.25).abs() < 1e-10);
1510 assert_eq!(
1512 summary.average_time_per_iteration,
1513 Duration::from_millis(100)
1514 );
1515 assert_eq!(summary.optimizer_name, "TestOptimizer");
1516 assert_eq!(summary.successful_steps, Some(3));
1517 }
1518
1519 #[test]
1520 fn test_create_optimizer_summary_zero_iterations() {
1521 let summary = create_optimizer_summary(
1522 "Test",
1523 1.0,
1524 1.0,
1525 0, None,
1527 None,
1528 0.0,
1529 0.0,
1530 0.0,
1531 0.0,
1532 0.0,
1533 Duration::from_secs(0),
1534 vec![],
1535 OptimizationStatus::MaxIterationsReached,
1536 None,
1537 None,
1538 None,
1539 );
1540 assert_eq!(summary.average_cost_reduction, 0.0);
1541 assert_eq!(summary.average_time_per_iteration, Duration::from_secs(0));
1542 }
1543
1544 struct LinearFactor {
1550 target: f64,
1551 }
1552
1553 impl Factor for LinearFactor {
1554 fn linearize(
1555 &self,
1556 params: &[&[f64]],
1557 residual: &mut [f64],
1558 jacobian: Option<faer::mat::MatMut<'_, f64>>,
1559 ) {
1560 residual[0] = params[0][0] - self.target;
1561 if let Some(mut jac) = jacobian {
1562 *jac.rb_mut().get_mut(0, 0) = 1.0;
1563 }
1564 }
1565 fn residual_dim(&self) -> usize {
1566 1
1567 }
1568 fn jacobian_shape(&self) -> (usize, usize) {
1569 (1, 1)
1570 }
1571 }
1572
1573 #[test]
1578 fn test_initialize_optimization_state() -> TestResult {
1579 use crate::core::problem::Problem;
1580
1581 let mut problem = Problem::new(JacobianMode::Sparse);
1582 let k = problem.add_variable(ManifoldType::RN, dvector![5.0]);
1583 problem.add_residual_block(&[k], Box::new(LinearFactor { target: 0.0 }), None);
1584
1585 let state = initialize_optimization_state(&mut problem)?;
1586 assert_eq!(state.total_dof, 1);
1587 assert!(state.initial_cost > 0.0);
1588 assert!(state.sorted_vars.contains(&k));
1589 Ok(())
1590 }
1591}