apex_solver/linalg/mod.rs
1pub mod covariance;
2pub mod dense;
3pub(crate) mod regularization;
4pub mod schur;
5pub mod sparse;
6pub mod utils;
7
8use crate::core::{VarKey, variable::ManifoldVariable};
9use crate::error::ErrorLogging;
10use faer::Mat;
11use slotmap::{SecondaryMap, SlotMap};
12use std::collections::HashSet;
13use std::fmt::{self, Debug, Display, Formatter};
14use thiserror::Error;
15
16pub use schur::{
17 BlockSpan, ColSlot, EliminatedBlocks, SchurOrdering, SchurPartition, SchurPreconditioner,
18};
19pub use sparse::{
20 ChunkLayout, ChunkedSchurEliminator, ExplicitSchurVariant, ExplicitSparseSchur,
21 ImplicitSparseSchur, PatternFingerprint, ReducedSystem, SparseCholeskySolver, SparseQRSolver,
22};
23
24pub use dense::{DenseCholeskySolver, DenseQRSolver, ExplicitDenseSchur};
25
26pub use covariance::{Covariance, CovarianceAlgorithm, CovarianceError, CovarianceOptions};
27
28pub use crate::linearizer::cpu::{DenseMode, LinearizationMode, SparseMode};
29
30// ============================================================================
31// Jacobian mode selection
32// ============================================================================
33
34/// Controls which Jacobian assembly strategy the Problem uses.
35///
36/// Set this when constructing a [`Problem`](crate::core::problem::Problem):
37/// - `Problem::new(JacobianMode::Sparse)` — sparse (default, best for large-scale problems)
38/// - `Problem::new(JacobianMode::Dense)` — dense (best for small-to-medium problems < ~500 DOF)
39/// - `Problem::default()` — equivalent to `JacobianMode::Sparse`
40///
41/// The optimizer reads this field and dispatches to the appropriate assembly path.
42/// `LinearSolverType` selects the specific algorithm within the sparse path.
43#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
44pub enum JacobianMode {
45 /// Sparse Jacobian using symbolic structure and `SparseColMat`. Best for large problems.
46 #[default]
47 Sparse,
48 /// Dense Jacobian using `Mat<f64>`. Best for small-to-medium problems (< ~500 DOF).
49 Dense,
50}
51
52// ============================================================================
53// Linear solver type selection
54// ============================================================================
55
56#[non_exhaustive]
57#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
58pub enum LinearSolverType {
59 #[default]
60 SparseCholesky,
61 SparseQR,
62 /// Explicit Schur complement over a sparse Hessian: forms `S` and
63 /// factorizes (or PCG-solves) it. Equivalent to Ceres's `SPARSE_SCHUR`.
64 /// Sub-configured by [`ExplicitSchurVariant`] and, for its `Iterative`
65 /// sub-variant, [`SchurPreconditioner`].
66 ExplicitSparseSchur,
67 /// Implicit (matrix-free) Schur complement, solved with PCG. Neither `S`
68 /// nor `JᵀJ` is ever materialized: the operator reads `J` directly, so its
69 /// cost scales with `nnz(J)`. Equivalent to Ceres's `ITERATIVE_SCHUR`.
70 /// Sub-configured by [`SchurPreconditioner`].
71 ImplicitSparseSchur,
72 DenseCholesky,
73 DenseQR,
74 /// Explicit Schur complement over a dense Hessian: forms `S` densely and
75 /// factorizes it. Equivalent to Ceres's `DENSE_SCHUR`.
76 ExplicitDenseSchur,
77}
78
79impl Display for LinearSolverType {
80 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
81 match self {
82 LinearSolverType::SparseCholesky => write!(f, "Sparse Cholesky"),
83 LinearSolverType::SparseQR => write!(f, "Sparse QR"),
84 LinearSolverType::ExplicitSparseSchur => write!(f, "Explicit Sparse Schur Complement"),
85 LinearSolverType::ImplicitSparseSchur => write!(f, "Implicit Sparse Schur Complement"),
86 LinearSolverType::DenseCholesky => write!(f, "Dense Cholesky"),
87 LinearSolverType::DenseQR => write!(f, "Dense QR"),
88 LinearSolverType::ExplicitDenseSchur => write!(f, "Explicit Dense Schur Complement"),
89 }
90 }
91}
92
93// ============================================================================
94// Damping
95// ============================================================================
96
97/// Damping applied to the normal equations by a trust-region optimizer.
98///
99/// The augmented system solved by [`LinearSolver::solve_augmented_equation`] is
100///
101/// ```text
102/// (JᵀJ + λ·D) · dx = −Jᵀr, D_jj = clamp(JᵀJ_jj, min_diagonal, max_diagonal)
103/// ```
104///
105/// This is Ceres' `LevenbergMarquardtStrategy`: damping each column in
106/// proportion to that column's own curvature makes the damped step invariant to
107/// a rescaling of the parameters, which uniform `λI` damping is not. `D` is
108/// formed from the Hessian the solver actually receives — so when Jacobi column
109/// scaling is enabled upstream, `D` is the diagonal of the *scaled* `J̃ᵀJ̃`,
110/// matching Ceres.
111///
112/// [`Damping::identity`] sets `min_diagonal == max_diagonal == 1.0`, giving
113/// `D = I` and recovering plain `λI` damping exactly.
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub struct Damping {
116 /// The damping parameter λ. Must be finite and non-negative.
117 pub lambda: f64,
118 /// Lower clamp on the damping diagonal (Ceres' `min_lm_diagonal`, 1e-6).
119 ///
120 /// Bounds the damping away from zero for columns with little curvature —
121 /// without it, an unconstrained direction would receive no damping at all.
122 pub min_diagonal: f64,
123 /// Upper clamp on the damping diagonal (Ceres' `max_lm_diagonal`, 1e32).
124 ///
125 /// Keeps a single very stiff column from dominating the damped system.
126 pub max_diagonal: f64,
127}
128
129impl Damping {
130 /// Marquardt damping with an explicit clamp range.
131 ///
132 /// # Errors
133 ///
134 /// Returns [`LinAlgError::InvalidInput`] if `lambda` is negative or not
135 /// finite, if either bound is not finite or non-positive, or if
136 /// `min_diagonal > max_diagonal` — `f64::clamp` panics on an inverted range,
137 /// so the check has to happen at construction.
138 pub fn new(lambda: f64, min_diagonal: f64, max_diagonal: f64) -> LinAlgResult<Self> {
139 if !lambda.is_finite() || lambda < 0.0 {
140 return Err(LinAlgError::InvalidInput(format!(
141 "damping lambda must be finite and non-negative, got {lambda}"
142 ))
143 .log());
144 }
145 if !min_diagonal.is_finite() || min_diagonal <= 0.0 {
146 return Err(LinAlgError::InvalidInput(format!(
147 "min_diagonal must be finite and positive, got {min_diagonal}"
148 ))
149 .log());
150 }
151 if max_diagonal.is_nan() || max_diagonal <= 0.0 {
152 return Err(LinAlgError::InvalidInput(format!(
153 "max_diagonal must be positive, got {max_diagonal}"
154 ))
155 .log());
156 }
157 if min_diagonal > max_diagonal {
158 return Err(LinAlgError::InvalidInput(format!(
159 "min_diagonal ({min_diagonal}) must not exceed max_diagonal ({max_diagonal})"
160 ))
161 .log());
162 }
163 Ok(Self {
164 lambda,
165 min_diagonal,
166 max_diagonal,
167 })
168 }
169
170 /// Uniform `λI` damping — the classic Levenberg form.
171 ///
172 /// Used where λ is a numerical stabiliser rather than a trust region: Dog
173 /// Leg's μ regularisation of the Gauss-Newton step, and Gauss-Newton's own
174 /// `min_diagonal` guard against an exactly singular `JᵀJ`.
175 pub fn identity(lambda: f64) -> Self {
176 Self {
177 lambda,
178 min_diagonal: 1.0,
179 max_diagonal: 1.0,
180 }
181 }
182
183 /// The damping to add to diagonal entry `(j, j)`, given `JᵀJ_jj`.
184 ///
185 /// `JᵀJ_jj = ‖J e_j‖² ≥ 0`, so the clamp is well defined for every input a
186 /// Gauss-Newton Hessian can produce.
187 #[inline]
188 pub fn diagonal_term(&self, hessian_diagonal: f64) -> f64 {
189 self.lambda * hessian_diagonal.clamp(self.min_diagonal, self.max_diagonal)
190 }
191}
192
193// ============================================================================
194// Error types
195// ============================================================================
196
197/// Linear algebra specific error types for apex-solver
198#[derive(Debug, Clone, Error)]
199pub enum LinAlgError {
200 /// Matrix factorization failed (Cholesky, QR, etc.)
201 #[error("Matrix factorization failed: {0}")]
202 FactorizationFailed(String),
203
204 /// Singular or near-singular matrix detected
205 #[error("Singular matrix detected: {0}")]
206 SingularMatrix(String),
207
208 /// Failed to create sparse matrix from triplets
209 #[error("Failed to create sparse matrix: {0}")]
210 SparseMatrixCreation(String),
211
212 /// Matrix format conversion failed
213 #[error("Matrix conversion failed: {0}")]
214 MatrixConversion(String),
215
216 /// Invalid input provided to linear solver
217 #[error("Invalid input: {0}")]
218 InvalidInput(String),
219
220 /// Solver in invalid state (e.g., initialized incorrectly)
221 #[error("Invalid solver state: {0}")]
222 InvalidState(String),
223}
224
225/// Result type for linear algebra operations
226pub type LinAlgResult<T> = Result<T, LinAlgError>;
227
228// ============================================================================
229// StructureAware
230// ============================================================================
231
232/// For solvers that need variable structure information before solving.
233///
234/// Implemented by Schur complement solvers, which must partition variables
235/// into camera and landmark blocks before performing any linear solves.
236/// Call [`initialize_structure`](StructureAware::initialize_structure) once
237/// during solver setup, before passing the solver to an optimizer.
238pub trait StructureAware {
239 /// Initialize the solver's block structure from problem variables.
240 fn initialize_structure(
241 &mut self,
242 variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
243 variable_index_map: &SecondaryMap<VarKey, usize>,
244 schur_landmark_keys: &HashSet<VarKey>,
245 ) -> LinAlgResult<()>;
246}
247
248// ============================================================================
249// LinearizationMode — re-exported from linearizer/cpu where it is defined
250// ============================================================================
251
252// ============================================================================
253// LinearSolver trait (unified solver interface, generic over LinearizationMode)
254// ============================================================================
255
256/// Unified linear solver interface parameterized by [`LinearizationMode`].
257///
258/// This is the single trait implemented by all linear solvers. When `M` is
259/// a concrete type (e.g., `SparseMode`), this trait is object-safe and can
260/// be used as `dyn LinearSolver<SparseMode>` or `dyn LinearSolver<DenseMode>`.
261///
262/// - Sparse solvers (`SparseCholeskySolver`, `SparseQRSolver`, `SchurSolverAdapter`)
263/// implement `LinearSolver<SparseMode>`.
264/// - Dense solvers (`DenseCholeskySolver`, `DenseQRSolver`)
265/// implement `LinearSolver<DenseMode>`.
266pub trait LinearSolver<M: LinearizationMode> {
267 /// Solve the normal equations: (J^T · J) · dx = −J^T · r
268 fn solve_normal_equation(
269 &mut self,
270 residuals: &Mat<f64>,
271 jacobian: &M::Jacobian,
272 ) -> LinAlgResult<Mat<f64>>;
273
274 /// Solve the augmented equations: `(JᵀJ + λ·D) · dx = −Jᵀr`.
275 ///
276 /// See [`Damping`] for the definition of `D`. Pass
277 /// [`Damping::identity`] for classic uniform `λI` damping.
278 fn solve_augmented_equation(
279 &mut self,
280 residuals: &Mat<f64>,
281 jacobian: &M::Jacobian,
282 damping: &Damping,
283 ) -> LinAlgResult<Mat<f64>>;
284
285 /// `H·v` for the **un-damped** `H = JᵀJ` of the last solve.
286 ///
287 /// The optimizers evaluate the true quadratic model through this — Dog
288 /// Leg's Cauchy point and every predicted cost reduction — so it must never
289 /// carry the damping term `λ·D`, which would corrupt the step-quality
290 /// ratio ρ.
291 ///
292 /// This is the *action* of the Hessian rather than the matrix, because that
293 /// is all any consumer needs. A backend holding `JᵀJ` multiplies it; a
294 /// backend that never forms it can evaluate `Jᵀ(J·v)` from the Jacobian
295 /// alone, which is what makes chunk-wise Schur elimination expressible.
296 fn hessian_vec_product(&self, v: &Mat<f64>) -> Option<Mat<f64>>;
297
298 /// The **un-damped** `JᵀJ`, when this backend happens to hold one.
299 ///
300 /// Diagnostics and visualisation only. `None` is a valid answer — a backend
301 /// that eliminates straight from `J` never materializes `JᵀJ` — so callers
302 /// must degrade rather than fail.
303 ///
304 /// Freshness contract: these accessors always describe the last
305 /// *successful* solve. A failed solve must leave the previously published
306 /// values untouched, so an optimizer retrying after an error never reads a
307 /// half-published linearization mixed across two iterations.
308 fn get_hessian(&self) -> Option<&M::Hessian> {
309 None
310 }
311
312 /// The gradient `+Jᵀr` from the last solve.
313 ///
314 /// Note the sign: this is `Jᵀr`, **not** the right-hand side `−Jᵀr`.
315 /// Predicted-reduction formulas depend on it, so a backend that publishes
316 /// the negated vector silently inverts every ρ computed from it.
317 fn get_gradient(&self) -> Option<&Mat<f64>>;
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::error::ErrorLogging;
324
325 // -------------------------------------------------------------------------
326 // JacobianMode
327 // -------------------------------------------------------------------------
328
329 #[test]
330 fn test_jacobian_mode_default_is_sparse() {
331 assert_eq!(JacobianMode::default(), JacobianMode::Sparse);
332 }
333
334 #[test]
335 fn test_jacobian_mode_equality() {
336 assert_eq!(JacobianMode::Sparse, JacobianMode::Sparse);
337 assert_eq!(JacobianMode::Dense, JacobianMode::Dense);
338 assert_ne!(JacobianMode::Sparse, JacobianMode::Dense);
339 }
340
341 // -------------------------------------------------------------------------
342 // LinearSolverType Display + Default
343 // -------------------------------------------------------------------------
344
345 #[test]
346 fn test_linear_solver_type_default_is_cholesky() {
347 assert_eq!(
348 LinearSolverType::default(),
349 LinearSolverType::SparseCholesky
350 );
351 }
352
353 #[test]
354 fn test_linear_solver_type_display_all_variants() {
355 assert_eq!(
356 format!("{}", LinearSolverType::SparseCholesky),
357 "Sparse Cholesky"
358 );
359 assert_eq!(format!("{}", LinearSolverType::SparseQR), "Sparse QR");
360 assert_eq!(
361 format!("{}", LinearSolverType::ExplicitSparseSchur),
362 "Explicit Sparse Schur Complement"
363 );
364 assert_eq!(
365 format!("{}", LinearSolverType::ImplicitSparseSchur),
366 "Implicit Sparse Schur Complement"
367 );
368 assert_eq!(
369 format!("{}", LinearSolverType::DenseCholesky),
370 "Dense Cholesky"
371 );
372 assert_eq!(format!("{}", LinearSolverType::DenseQR), "Dense QR");
373 assert_eq!(
374 format!("{}", LinearSolverType::ExplicitDenseSchur),
375 "Explicit Dense Schur Complement"
376 );
377 }
378
379 // -------------------------------------------------------------------------
380 // LinAlgError Display — one per variant
381 // -------------------------------------------------------------------------
382
383 #[test]
384 fn test_lin_alg_error_factorization_failed_display() {
385 let e = LinAlgError::FactorizationFailed("non-positive definite".into());
386 assert!(e.to_string().contains("non-positive definite"));
387 }
388
389 #[test]
390 fn test_lin_alg_error_singular_matrix_display() {
391 let e = LinAlgError::SingularMatrix("rank deficient".into());
392 assert!(e.to_string().contains("rank deficient"));
393 }
394
395 #[test]
396 fn test_lin_alg_error_sparse_matrix_creation_display() {
397 let e = LinAlgError::SparseMatrixCreation("bad triplets".into());
398 assert!(e.to_string().contains("bad triplets"));
399 }
400
401 #[test]
402 fn test_lin_alg_error_matrix_conversion_display() {
403 let e = LinAlgError::MatrixConversion("size mismatch".into());
404 assert!(e.to_string().contains("size mismatch"));
405 }
406
407 #[test]
408 fn test_lin_alg_error_invalid_input_display() {
409 let e = LinAlgError::InvalidInput("null jacobian".into());
410 assert!(e.to_string().contains("null jacobian"));
411 }
412
413 #[test]
414 fn test_lin_alg_error_invalid_state_display() {
415 let e = LinAlgError::InvalidState("not initialized".into());
416 assert!(e.to_string().contains("not initialized"));
417 }
418
419 // -------------------------------------------------------------------------
420 // log() / log_with_source() return self
421 // -------------------------------------------------------------------------
422
423 #[test]
424 fn test_lin_alg_error_log_returns_self() {
425 let e = LinAlgError::InvalidInput("log_test".into());
426 let returned = e.log();
427 assert!(returned.to_string().contains("log_test"));
428 }
429
430 #[test]
431 fn test_lin_alg_error_log_with_source_returns_self() {
432 let e = LinAlgError::SingularMatrix("source_test".into());
433 let source = std::io::Error::other("src");
434 let returned = e.log_with_source(source);
435 assert!(returned.to_string().contains("source_test"));
436 }
437
438 // -------------------------------------------------------------------------
439 // LinAlgResult type alias
440 // -------------------------------------------------------------------------
441
442 #[test]
443 fn test_lin_alg_result_ok() {
444 let r: LinAlgResult<i32> = Ok(7);
445 assert!(matches!(r, Ok(7)));
446 }
447
448 #[test]
449 fn test_lin_alg_result_err() {
450 let r: LinAlgResult<i32> = Err(LinAlgError::InvalidInput("oops".into()));
451 assert!(r.is_err());
452 }
453}