Skip to main content

apex_solver/linearizer/
mod.rs

1//! Jacobian linearization — the bridge between the nonlinear factor graph
2//! and the linear system solved each iteration.
3//!
4//! This is the central module for all linearization concerns:
5//! - `linearize_block()`: Shared factor evaluation (loss correction, residual accumulation)
6//! - [`cpu::sparse`]: Sparse Jacobian assembly using `SparseColMat` and symbolic structure
7//! - [`cpu::dense`]: Dense Jacobian assembly using `Mat<f64>`
8//! - [`AssemblyBackend`]: Trait bridging linearization with the optimizer's solver types
9//!
10//! # Architecture
11//!
12//! ```text
13//! Problem (factor graph)
14//!     │  AssemblyBackend::assemble()
15//!     ▼
16//! (r: Mat<f64>, J: M::Jacobian)   ← M: LinearizationMode
17//!     │
18//!     ▼
19//! LinearSolver<M>   (linalg/)
20//!     │
21//!     ▼
22//! dx: Mat<f64>  → manifold update
23//! ```
24
25pub mod cpu;
26pub mod gpu;
27
28use slotmap::{SecondaryMap, SlotMap};
29
30use faer::Mat;
31use faer::sparse::{SparseColMat, Triplet};
32use smallvec::SmallVec;
33use thiserror::Error;
34
35use crate::core::VarKey;
36use crate::core::problem::Problem;
37use crate::core::variable::ManifoldVariable;
38use crate::{
39    core::{corrector::Corrector, residual_block::ResidualBlock},
40    linearizer::cpu::{DenseMode, LinearizationMode, SparseMode},
41};
42
43pub use cpu::sparse::SymbolicStructure;
44
45// ============================================================================
46// Linearizer error types
47// ============================================================================
48
49/// Linearizer-specific error types for Jacobian assembly and symbolic structure operations.
50///
51/// These errors occur during the linearization phase of optimization, where
52/// the nonlinear factor graph is converted into a linear system (residual vector
53/// and Jacobian matrix).
54///
55/// # Error Hierarchy
56///
57/// `LinearizerError` is a Layer C (deep/module) error. It propagates up through:
58/// - `CoreError::Linearizer(LinearizerError)` → for core module callers
59/// - `ApexSolverError::Linearizer(LinearizerError)` → for direct API callers
60///
61
62#[derive(Debug, Clone, Error)]
63pub enum LinearizerError {
64    /// Symbolic structure construction or usage failed
65    #[error("Symbolic structure error: {0}")]
66    SymbolicStructure(String),
67
68    /// Parallel computation error (thread/mutex failures during assembly)
69    #[error("Parallel computation error: {0}")]
70    ParallelComputation(String),
71
72    /// Variable key missing in index mapping during Jacobian scatter
73    #[error("Variable error: {0}")]
74    Variable(String),
75
76    /// Factor linearization returned no Jacobian when expected
77    #[error("Factor linearization failed: {0}")]
78    FactorLinearization(String),
79
80    /// Invalid input (e.g., SparseMode requires symbolic structure)
81    #[error("Invalid input: {0}")]
82    InvalidInput(String),
83}
84
85/// Result type for linearizer module operations
86pub type LinearizerResult<T> = Result<T, LinearizerError>;
87
88// ============================================================================
89// Block linearization (shared by sparse and dense paths)
90// ============================================================================
91
92/// Metadata produced by evaluating a single residual block.
93///
94/// The corrected Jacobian itself lives in a caller-provided buffer (see
95/// [`compute_block_into`]) — this struct only describes how to scatter it
96/// into the global Jacobian.
97pub(crate) struct BlockLinearization {
98    /// Maps each variable to (local_col_offset, dof_size) within the block Jacobian.
99    /// SmallVec inline storage covers all current factor types (≤ 4 variables per factor).
100    pub variable_local_idx_size_list: SmallVec<[(usize, usize); 8]>,
101    /// Starting row index in the global residual/Jacobian
102    pub residual_row_start_idx: usize,
103    /// Residual dimension for this block
104    pub residual_dim: usize,
105}
106
107/// Split a mutable buffer into non-overlapping slices at the given (start, len) offsets.
108///
109/// `sorted_offsets_lens` must be sorted ascending by `start` and non-overlapping.
110/// Uses chained `split_at_mut` calls — no unsafe code needed.
111pub(crate) fn split_by_row_offsets_mut<'a>(
112    buf: &'a mut [f64],
113    sorted_offsets_lens: &[(usize, usize)],
114) -> Vec<&'a mut [f64]> {
115    let mut remaining = buf;
116    let mut result = Vec::with_capacity(sorted_offsets_lens.len());
117    let mut current = 0usize;
118    for &(start, len) in sorted_offsets_lens {
119        let gap = start - current;
120        let (_, rest) = remaining.split_at_mut(gap);
121        let (slice, rest2) = rest.split_at_mut(len);
122        result.push(slice);
123        remaining = rest2;
124        current = start + len;
125    }
126    result
127}
128
129/// Evaluate a single residual block: call `factor.linearize()`, apply loss correction,
130/// write the corrected residual into the provided slice, return the Jacobian buffer.
131///
132/// This is the shared core used by both sparse and dense assembly. It:
133/// 1. Gathers `&[f64]` parameter slices for each variable (zero copy from manifold storage)
134/// 2. Calls `factor.linearize()` — writes into `residual_slice` and a local Jacobian buffer
135/// 3. Applies the robust loss function correction (if any)
136/// 4. Returns the corrected Jacobian buffer and metadata for the caller to scatter
137pub(crate) fn compute_block_into(
138    residual_block: &ResidualBlock,
139    variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
140    residual_slice: &mut [f64],
141    jacobian_buf: &mut [f64],
142) -> LinearizerResult<BlockLinearization> {
143    let mut param_slices: SmallVec<[&[f64]; 8]> = SmallVec::new();
144    let mut variable_local_idx_size_list: SmallVec<[(usize, usize); 8]> = SmallVec::new();
145    let mut count_variable_local_idx: usize = 0;
146
147    for &var_key in &residual_block.variable_keys {
148        if let Some(variable) = variables.get(var_key) {
149            param_slices.push(variable.as_param_slice());
150            let var_size = variable.dof();
151            variable_local_idx_size_list.push((count_variable_local_idx, var_size));
152            count_variable_local_idx += var_size;
153        }
154    }
155
156    let (rows, cols) = residual_block.factor.jacobian_shape();
157    debug_assert_eq!(jacobian_buf.len(), rows * cols);
158    {
159        let jac_mut = faer::mat::MatMut::from_column_major_slice_mut(jacobian_buf, rows, cols);
160        residual_block
161            .factor
162            .linearize(&param_slices, residual_slice, Some(jac_mut));
163    }
164
165    // Apply robust-loss correction in-place: no heap allocation, math identical to
166    // `Corrector::correct_jacobian` / `correct_residuals` on `DVector`/`DMatrix`.
167    if let Some(loss_func) = &residual_block.loss_func {
168        let squared_norm: f64 = residual_slice.iter().map(|x| x * x).sum();
169        let corrector = Corrector::new(loss_func.as_ref(), squared_norm);
170        // Jacobian correction must read the original (un-scaled) residual.
171        corrector.correct_jacobian_in_place(residual_slice, jacobian_buf, rows, cols);
172        corrector.correct_residual_in_place(residual_slice);
173    }
174
175    Ok(BlockLinearization {
176        variable_local_idx_size_list,
177        residual_row_start_idx: residual_block.residual_row_start_idx,
178        residual_dim: rows,
179    })
180}
181
182// ============================================================================
183// AssemblyBackend trait (bridges linearizer output with optimizer solver types)
184// ============================================================================
185
186/// Type-level backend for assembling (residuals, Jacobian) and performing
187/// matrix operations. Implemented by [`SparseMode`] and [`DenseMode`].
188///
189/// All methods are static — this trait is used as a compile-time strategy
190/// selector, not as an object interface. Extends [`LinearizationMode`] with
191/// the five operations an optimizer needs each iteration: building `(r, J)`,
192/// scaling `J`, unscaling `dx`, and `H·v`.
193///
194/// All three optimizers (LM, GN, DogLeg) are generic over `M: AssemblyBackend`,
195/// giving zero-cost static dispatch through the entire pipeline.
196pub trait AssemblyBackend: LinearizationMode {
197    /// Assemble residuals and Jacobian from the problem.
198    fn assemble(
199        problem: &Problem,
200        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
201        variable_index_map: &SecondaryMap<VarKey, usize>,
202        symbolic_structure: Option<&SymbolicStructure>,
203        total_dof: usize,
204    ) -> LinearizerResult<(Mat<f64>, Self::Jacobian)>;
205
206    /// Compute column norms of the Jacobian (for Jacobi scaling).
207    fn compute_column_norms(jacobian: &Self::Jacobian) -> Vec<f64>;
208
209    /// Apply diagonal column scaling to the Jacobian.
210    /// Returns a new Jacobian with columns scaled by `1 / (1 + norm)`.
211    fn apply_column_scaling(jacobian: &Self::Jacobian, scaling: &[f64]) -> Self::Jacobian;
212
213    /// Apply inverse scaling to a step vector: step_i *= scaling_i
214    fn apply_inverse_scaling(step: &Mat<f64>, scaling: &[f64]) -> Mat<f64>;
215
216    /// Hessian-vector product: H * v (needed by DogLeg for Cauchy point)
217    fn hessian_vec_product(hessian: &Self::Hessian, vec: &Mat<f64>) -> Mat<f64>;
218}
219
220impl AssemblyBackend for SparseMode {
221    fn assemble(
222        problem: &Problem,
223        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
224        variable_index_map: &SecondaryMap<VarKey, usize>,
225        symbolic_structure: Option<&SymbolicStructure>,
226        _total_dof: usize,
227    ) -> LinearizerResult<(Mat<f64>, SparseColMat<usize, f64>)> {
228        let sym = symbolic_structure.ok_or_else(|| {
229            LinearizerError::InvalidInput("SparseMode requires symbolic structure".to_string())
230        })?;
231        crate::linearizer::cpu::sparse::assemble_sparse(problem, variables, variable_index_map, sym)
232    }
233
234    fn compute_column_norms(jacobian: &SparseColMat<usize, f64>) -> Vec<f64> {
235        let ncols = jacobian.ncols();
236        let sparse_ref = jacobian.as_ref();
237        (0..ncols)
238            .map(|c| {
239                let col_norm_squared: f64 =
240                    sparse_ref.val_of_col(c).iter().map(|&val| val * val).sum();
241                col_norm_squared.sqrt()
242            })
243            .collect()
244    }
245
246    fn apply_column_scaling(
247        jacobian: &SparseColMat<usize, f64>,
248        scaling: &[f64],
249    ) -> SparseColMat<usize, f64> {
250        let ncols = jacobian.ncols();
251        let triplets: Vec<Triplet<usize, usize, f64>> =
252            (0..ncols).map(|c| Triplet::new(c, c, scaling[c])).collect();
253        let scaling_mat = match SparseColMat::try_new_from_triplets(ncols, ncols, &triplets) {
254            Ok(mat) => mat,
255            Err(_) => return jacobian.clone(),
256        };
257        jacobian * &scaling_mat
258    }
259
260    fn apply_inverse_scaling(step: &Mat<f64>, scaling: &[f64]) -> Mat<f64> {
261        let mut result = step.clone();
262        for i in 0..step.nrows() {
263            result[(i, 0)] *= scaling[i];
264        }
265        result
266    }
267
268    fn hessian_vec_product(hessian: &SparseColMat<usize, f64>, vec: &Mat<f64>) -> Mat<f64> {
269        use std::ops::Mul;
270        hessian.as_ref().mul(vec)
271    }
272}
273
274impl AssemblyBackend for DenseMode {
275    fn assemble(
276        problem: &Problem,
277        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
278        variable_index_map: &SecondaryMap<VarKey, usize>,
279        _symbolic_structure: Option<&SymbolicStructure>,
280        total_dof: usize,
281    ) -> LinearizerResult<(Mat<f64>, Mat<f64>)> {
282        crate::linearizer::cpu::dense::assemble_dense(
283            problem,
284            variables,
285            variable_index_map,
286            total_dof,
287        )
288    }
289
290    fn compute_column_norms(jacobian: &Mat<f64>) -> Vec<f64> {
291        let ncols = jacobian.ncols();
292        (0..ncols)
293            .map(|c| {
294                let mut norm_sq = 0.0;
295                for r in 0..jacobian.nrows() {
296                    let v = jacobian[(r, c)];
297                    norm_sq += v * v;
298                }
299                norm_sq.sqrt()
300            })
301            .collect()
302    }
303
304    fn apply_column_scaling(jacobian: &Mat<f64>, scaling: &[f64]) -> Mat<f64> {
305        let mut result = jacobian.clone();
306        for c in 0..jacobian.ncols() {
307            for r in 0..jacobian.nrows() {
308                result[(r, c)] *= scaling[c];
309            }
310        }
311        result
312    }
313
314    fn apply_inverse_scaling(step: &Mat<f64>, scaling: &[f64]) -> Mat<f64> {
315        let mut result = step.clone();
316        for i in 0..step.nrows() {
317            result[(i, 0)] *= scaling[i];
318        }
319        result
320    }
321
322    fn hessian_vec_product(hessian: &Mat<f64>, vec: &Mat<f64>) -> Mat<f64> {
323        hessian * vec
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::{
331        core::{VarKey, problem::Problem},
332        factors,
333        linalg::JacobianMode,
334    };
335    use apex_manifolds::ManifoldType;
336    use faer::prelude::ReborrowMut;
337    use nalgebra::dvector;
338    use slotmap::{SecondaryMap, SlotMap};
339
340    type TestResult = Result<(), Box<dyn std::error::Error>>;
341
342    struct LinearFactor {
343        target: f64,
344    }
345
346    impl factors::Factor for LinearFactor {
347        fn linearize(
348            &self,
349            params: &[&[f64]],
350            residual: &mut [f64],
351            jacobian: Option<faer::mat::MatMut<'_, f64>>,
352        ) {
353            residual[0] = params[0][0] - self.target;
354            if let Some(mut jac) = jacobian {
355                *jac.rb_mut().get_mut(0, 0) = 1.0;
356            }
357        }
358        fn residual_dim(&self) -> usize {
359            1
360        }
361        fn jacobian_shape(&self) -> (usize, usize) {
362            (1, 1)
363        }
364    }
365
366    fn one_var_problem() -> (Problem, VarKey) {
367        let mut problem = Problem::new(JacobianMode::Sparse);
368        let k = problem.add_variable(ManifoldType::RN, dvector![5.0]);
369        problem.add_residual_block(&[k], Box::new(LinearFactor { target: 0.0 }), None);
370        (problem, k)
371    }
372
373    #[allow(clippy::type_complexity)]
374    fn make_index_map(
375        problem: &Problem,
376    ) -> (
377        SlotMap<VarKey, Box<dyn crate::core::variable::ManifoldVariable>>,
378        SecondaryMap<VarKey, usize>,
379        usize,
380    ) {
381        let variables = problem.variables.clone();
382        let mut index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
383        let mut offset = 0;
384        for (k, v) in &variables {
385            index_map.insert(k, offset);
386            offset += v.dof();
387        }
388        (variables, index_map, offset)
389    }
390
391    // -------------------------------------------------------------------------
392    // compute_block_into
393    // -------------------------------------------------------------------------
394
395    #[test]
396    fn test_compute_block_into_residual_value() -> TestResult {
397        let (problem, _k) = one_var_problem();
398        let (variables, _, _) = make_index_map(&problem);
399        let block = problem
400            .residual_blocks()
401            .values()
402            .next()
403            .ok_or("no blocks")?;
404        let mut residual_slice = vec![0.0f64; 1];
405        let mut jac_buf = vec![0.0f64; 1];
406        compute_block_into(block, &variables, &mut residual_slice, &mut jac_buf)?;
407        assert!((residual_slice[0] - 5.0).abs() < 1e-12);
408        Ok(())
409    }
410
411    #[test]
412    fn test_compute_block_into_jacobian_shape() -> TestResult {
413        let (problem, _k) = one_var_problem();
414        let (variables, _, _) = make_index_map(&problem);
415        let block = problem
416            .residual_blocks()
417            .values()
418            .next()
419            .ok_or("no blocks")?;
420        let mut residual_slice = vec![0.0f64; 1];
421        let mut jac_buf = vec![0.0f64; 1];
422        let result = compute_block_into(block, &variables, &mut residual_slice, &mut jac_buf)?;
423        assert_eq!(result.residual_dim, 1);
424        assert_eq!(jac_buf.len(), 1); // 1×1
425        Ok(())
426    }
427
428    #[test]
429    fn test_compute_block_into_variable_local_idx() -> TestResult {
430        let (problem, _k) = one_var_problem();
431        let (variables, _, _) = make_index_map(&problem);
432        let block = problem
433            .residual_blocks()
434            .values()
435            .next()
436            .ok_or("no blocks")?;
437        let mut residual_slice = vec![0.0f64; 1];
438        let mut jac_buf = vec![0.0f64; 1];
439        let result = compute_block_into(block, &variables, &mut residual_slice, &mut jac_buf)?;
440        assert_eq!(result.variable_local_idx_size_list.len(), 1);
441        let (local_idx, size) = result.variable_local_idx_size_list[0];
442        assert_eq!(local_idx, 0);
443        assert_eq!(size, 1);
444        Ok(())
445    }
446
447    // -------------------------------------------------------------------------
448    // split_by_row_offsets_mut
449    // -------------------------------------------------------------------------
450
451    #[test]
452    fn test_split_by_row_offsets_mut_basic() {
453        let mut buf = vec![1.0f64, 2.0, 3.0, 4.0, 5.0];
454        let offsets = vec![(0, 2), (3, 2)];
455        let slices = split_by_row_offsets_mut(&mut buf, &offsets);
456        assert_eq!(slices.len(), 2);
457        assert_eq!(slices[0], &[1.0, 2.0]);
458        assert_eq!(slices[1], &[4.0, 5.0]);
459    }
460
461    #[test]
462    fn test_split_by_row_offsets_mut_write() {
463        let mut buf = vec![0.0f64; 4];
464        let offsets = vec![(0, 2), (2, 2)];
465        {
466            let mut slices = split_by_row_offsets_mut(&mut buf, &offsets);
467            slices[0][0] = 1.0;
468            slices[0][1] = 2.0;
469            slices[1][0] = 3.0;
470            slices[1][1] = 4.0;
471        }
472        assert_eq!(buf, vec![1.0, 2.0, 3.0, 4.0]);
473    }
474
475    // -------------------------------------------------------------------------
476    // SparseMode AssemblyBackend
477    // -------------------------------------------------------------------------
478
479    #[test]
480    fn test_sparse_backend_assemble() -> TestResult {
481        let (problem, _k) = one_var_problem();
482        let (variables, index_map, total_dof) = make_index_map(&problem);
483        let sym = crate::linearizer::cpu::sparse::build_symbolic_structure(
484            &problem, &variables, &index_map, total_dof,
485        )?;
486        let (residual, _) =
487            SparseMode::assemble(&problem, &variables, &index_map, Some(&sym), total_dof)?;
488        assert!((residual[(0, 0)] - 5.0).abs() < 1e-12);
489        Ok(())
490    }
491
492    #[test]
493    fn test_sparse_backend_assemble_no_symbolic_returns_error() -> TestResult {
494        let (problem, _k) = one_var_problem();
495        let (variables, index_map, total_dof) = make_index_map(&problem);
496        let result = SparseMode::assemble(&problem, &variables, &index_map, None, total_dof);
497        assert!(result.is_err());
498        Ok(())
499    }
500
501    #[test]
502    fn test_sparse_backend_compute_column_norms() -> TestResult {
503        let (problem, _k) = one_var_problem();
504        let (variables, index_map, total_dof) = make_index_map(&problem);
505        let sym = crate::linearizer::cpu::sparse::build_symbolic_structure(
506            &problem, &variables, &index_map, total_dof,
507        )?;
508        let (_, jacobian) =
509            SparseMode::assemble(&problem, &variables, &index_map, Some(&sym), total_dof)?;
510        let norms = SparseMode::compute_column_norms(&jacobian);
511        assert_eq!(norms.len(), 1);
512        assert!((norms[0] - 1.0).abs() < 1e-12);
513        Ok(())
514    }
515
516    #[test]
517    fn test_sparse_backend_apply_column_scaling() -> TestResult {
518        let (problem, _k) = one_var_problem();
519        let (variables, index_map, total_dof) = make_index_map(&problem);
520        let sym = crate::linearizer::cpu::sparse::build_symbolic_structure(
521            &problem, &variables, &index_map, total_dof,
522        )?;
523        let (_, jacobian) =
524            SparseMode::assemble(&problem, &variables, &index_map, Some(&sym), total_dof)?;
525        let scaling = vec![0.5_f64];
526        let scaled = SparseMode::apply_column_scaling(&jacobian, &scaling);
527        let val = scaled.as_ref().val_of_col(0)[0];
528        assert!((val - 0.5).abs() < 1e-12);
529        Ok(())
530    }
531
532    #[test]
533    fn test_sparse_backend_apply_inverse_scaling() {
534        let step = Mat::from_fn(1, 1, |_, _| 1.0_f64);
535        let scaling = vec![2.0_f64];
536        let result = SparseMode::apply_inverse_scaling(&step, &scaling);
537        assert!((result[(0, 0)] - 2.0).abs() < 1e-12);
538    }
539
540    #[test]
541    fn test_sparse_backend_hessian_vec_product() -> TestResult {
542        let triplets = vec![faer::sparse::Triplet::new(0usize, 0usize, 4.0_f64)];
543        let h = SparseColMat::try_new_from_triplets(1, 1, &triplets)?;
544        let v = Mat::from_fn(1, 1, |_, _| 2.0_f64);
545        let result = SparseMode::hessian_vec_product(&h, &v);
546        assert!((result[(0, 0)] - 8.0).abs() < 1e-12);
547        Ok(())
548    }
549
550    // -------------------------------------------------------------------------
551    // DenseMode AssemblyBackend
552    // -------------------------------------------------------------------------
553
554    #[test]
555    fn test_dense_backend_assemble() -> TestResult {
556        let mut problem = Problem::new(JacobianMode::Dense);
557        let k = problem.add_variable(ManifoldType::RN, dvector![5.0]);
558        problem.add_residual_block(&[k], Box::new(LinearFactor { target: 0.0 }), None);
559        let (variables, index_map, total_dof) = make_index_map(&problem);
560        let (residual, _) = DenseMode::assemble(&problem, &variables, &index_map, None, total_dof)?;
561        assert!((residual[(0, 0)] - 5.0).abs() < 1e-12);
562        Ok(())
563    }
564
565    #[test]
566    fn test_dense_backend_compute_column_norms() {
567        let jacobian = Mat::from_fn(1, 1, |_, _| 1.0_f64);
568        let norms = DenseMode::compute_column_norms(&jacobian);
569        assert_eq!(norms.len(), 1);
570        assert!((norms[0] - 1.0).abs() < 1e-12);
571    }
572
573    #[test]
574    fn test_dense_backend_apply_column_scaling() {
575        let jacobian = Mat::from_fn(1, 1, |_, _| 1.0_f64);
576        let scaling = vec![0.5_f64];
577        let scaled = DenseMode::apply_column_scaling(&jacobian, &scaling);
578        assert!((scaled[(0, 0)] - 0.5).abs() < 1e-12);
579    }
580
581    #[test]
582    fn test_dense_backend_apply_inverse_scaling() {
583        let step = Mat::from_fn(1, 1, |_, _| 1.0_f64);
584        let scaling = vec![2.0_f64];
585        let result = DenseMode::apply_inverse_scaling(&step, &scaling);
586        assert!((result[(0, 0)] - 2.0).abs() < 1e-12);
587    }
588
589    #[test]
590    fn test_dense_backend_hessian_vec_product() {
591        let h = Mat::from_fn(1, 1, |_, _| 4.0_f64);
592        let v = Mat::from_fn(1, 1, |_, _| 2.0_f64);
593        let result = DenseMode::hessian_vec_product(&h, &v);
594        assert!((result[(0, 0)] - 8.0).abs() < 1e-12);
595    }
596}