Skip to main content

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}