Skip to main content

apex_solver/linearizer/cpu/
sparse.rs

1//! Sparse Jacobian assembly using symbolic sparsity patterns.
2//!
3//! This module handles building the symbolic structure (sparsity pattern) once,
4//! then efficiently filling in numerical values during each optimization iteration.
5//! Uses `SparseColMat` from `faer` for the Jacobian representation.
6
7use std::{
8    collections::HashMap,
9    sync::{Arc, Mutex},
10};
11
12use faer::{
13    Col, Mat,
14    sparse::{Argsort, Pair, SparseColMat, SymbolicSparseColMat},
15};
16use rayon::prelude::*;
17
18use crate::error::ErrorLogging;
19use crate::linearizer::{LinearizerError, LinearizerResult};
20
21use super::super::linearize_block;
22use crate::core::problem::{Problem, VariableEnum};
23
24/// Symbolic structure for sparse matrix operations.
25///
26/// Contains the sparsity pattern (which entries are non-zero) and an ordering
27/// for efficient numerical computation. This is computed once at the beginning
28/// and reused throughout optimization.
29///
30/// # Fields
31///
32/// - `pattern`: The symbolic sparse column matrix structure (row/col indices of non-zeros)
33/// - `order`: A fill-reducing ordering/permutation for numerical stability
34pub struct SymbolicStructure {
35    pub pattern: SymbolicSparseColMat<usize>,
36    pub order: Argsort<usize>,
37}
38
39/// Build the symbolic sparsity structure for the Jacobian matrix.
40///
41/// This pre-computes which entries in the Jacobian will be non-zero based on the
42/// factor graph connectivity. Each factor connecting variables contributes a dense
43/// block to the Jacobian; this function records all such (row, col) pairs.
44///
45/// Called once before optimization begins. The resulting structure is reused for
46/// efficient numerical assembly in [`assemble_sparse()`].
47///
48/// # Arguments
49///
50/// * `problem` - The optimization problem (provides residual blocks and their structure)
51/// * `variables` - Current variable values (provides DOF sizes)
52/// * `variable_index_map` - Maps variable names to their column offset in the Jacobian
53/// * `total_dof` - Total degrees of freedom (number of columns)
54pub fn build_symbolic_structure(
55    problem: &Problem,
56    variables: &HashMap<String, VariableEnum>,
57    variable_index_map: &HashMap<String, usize>,
58    total_dof: usize,
59) -> LinearizerResult<SymbolicStructure> {
60    let mut indices = Vec::<Pair<usize, usize>>::new();
61
62    problem
63        .residual_blocks()
64        .iter()
65        .for_each(|(_, residual_block)| {
66            let mut variable_local_idx_size_list = Vec::<(usize, usize)>::new();
67            let mut count_variable_local_idx: usize = 0;
68
69            for var_key in &residual_block.variable_key_list {
70                if let Some(variable) = variables.get(var_key) {
71                    variable_local_idx_size_list
72                        .push((count_variable_local_idx, variable.get_size()));
73                    count_variable_local_idx += variable.get_size();
74                }
75            }
76
77            for (i, var_key) in residual_block.variable_key_list.iter().enumerate() {
78                if let Some(variable_global_idx) = variable_index_map.get(var_key) {
79                    let (_, var_size) = variable_local_idx_size_list[i];
80
81                    for row_idx in 0..residual_block.factor.get_dimension() {
82                        for col_idx in 0..var_size {
83                            let global_row_idx = residual_block.residual_row_start_idx + row_idx;
84                            let global_col_idx = variable_global_idx + col_idx;
85                            indices.push(Pair::new(global_row_idx, global_col_idx));
86                        }
87                    }
88                }
89            }
90        });
91
92    let (pattern, order) = SymbolicSparseColMat::try_new_from_indices(
93        problem.total_residual_dimension,
94        total_dof,
95        &indices,
96    )
97    .map_err(|e| {
98        LinearizerError::SymbolicStructure(
99            "Failed to build symbolic sparse matrix structure".to_string(),
100        )
101        .log_with_source(e)
102    })?;
103
104    Ok(SymbolicStructure { pattern, order })
105}
106
107/// Assemble residuals and sparse Jacobian from the current variable values.
108///
109/// Evaluates all residual blocks in parallel, collecting per-block Jacobian values
110/// in column-major order. Uses the pre-computed symbolic structure to efficiently
111/// construct the final `SparseColMat`.
112///
113/// # Arguments
114///
115/// * `problem` - The optimization problem
116/// * `variables` - Current variable values
117/// * `variable_index_map` - Maps variable names to their column offset in the Jacobian
118/// * `symbolic_structure` - Pre-computed sparsity pattern from [`build_symbolic_structure()`]
119pub fn assemble_sparse(
120    problem: &Problem,
121    variables: &HashMap<String, VariableEnum>,
122    variable_index_map: &HashMap<String, usize>,
123    symbolic_structure: &SymbolicStructure,
124) -> LinearizerResult<(Mat<f64>, SparseColMat<usize, f64>)> {
125    let total_residual = Arc::new(Mutex::new(Col::<f64>::zeros(
126        problem.total_residual_dimension,
127    )));
128
129    let total_nnz = symbolic_structure.pattern.compute_nnz();
130
131    // Evaluate all blocks in parallel, collecting sparse Jacobian values
132    let jacobian_blocks: Result<Vec<(usize, Vec<f64>)>, LinearizerError> = problem
133        .residual_blocks()
134        .par_iter()
135        .map(|(_, residual_block)| {
136            let values = scatter_sparse_block(
137                residual_block,
138                variables,
139                variable_index_map,
140                &total_residual,
141            )?;
142            let size = values.len();
143            Ok((size, values))
144        })
145        .collect();
146
147    let jacobian_blocks = jacobian_blocks?;
148
149    // Flatten block values into a single contiguous array
150    let mut jacobian_values = Vec::with_capacity(total_nnz);
151    for (_size, mut block_values) in jacobian_blocks {
152        jacobian_values.append(&mut block_values);
153    }
154
155    let total_residual = Arc::try_unwrap(total_residual)
156        .map_err(|_| {
157            LinearizerError::ParallelComputation(
158                "Failed to unwrap Arc for total residual".to_string(),
159            )
160            .log()
161        })?
162        .into_inner()
163        .map_err(|e| {
164            LinearizerError::ParallelComputation(
165                "Failed to extract mutex inner value for total residual".to_string(),
166            )
167            .log_with_source(e)
168        })?;
169
170    let residual_faer = total_residual.as_ref().as_mat().to_owned();
171    let jacobian_sparse = SparseColMat::new_from_argsort(
172        symbolic_structure.pattern.clone(),
173        &symbolic_structure.order,
174        jacobian_values.as_slice(),
175    )
176    .map_err(|e| {
177        LinearizerError::SymbolicStructure(
178            "Failed to create sparse Jacobian from argsort".to_string(),
179        )
180        .log_with_source(e)
181    })?;
182
183    Ok((residual_faer, jacobian_sparse))
184}
185
186/// Linearize a single block and extract Jacobian values in column-major order for sparse assembly.
187///
188/// Uses the shared [`linearize_block()`] helper, then scatters the block Jacobian
189/// into a flat `Vec<f64>` matching the symbolic structure's expected ordering.
190fn scatter_sparse_block(
191    residual_block: &crate::core::residual_block::ResidualBlock,
192    variables: &HashMap<String, VariableEnum>,
193    variable_index_map: &HashMap<String, usize>,
194    total_residual: &Arc<Mutex<Col<f64>>>,
195) -> LinearizerResult<Vec<f64>> {
196    let block = linearize_block(residual_block, variables, total_residual)?;
197
198    let mut local_jacobian_values = Vec::new();
199    for (i, var_key) in residual_block.variable_key_list.iter().enumerate() {
200        if variable_index_map.contains_key(var_key) {
201            let (variable_local_idx, var_size) = block.variable_local_idx_size_list[i];
202            let variable_jac = block
203                .jacobian
204                .view((0, variable_local_idx), (block.residual_dim, var_size));
205
206            for row_idx in 0..block.residual_dim {
207                for col_idx in 0..var_size {
208                    local_jacobian_values.push(variable_jac[(row_idx, col_idx)]);
209                }
210            }
211        } else {
212            return Err(LinearizerError::Variable(format!(
213                "Missing key {} in variable-to-column-index mapping",
214                var_key
215            ))
216            .log());
217        }
218    }
219
220    Ok(local_jacobian_values)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::{core::problem::Problem, factors, linalg::JacobianMode, optimizer};
227    use apex_manifolds::ManifoldType;
228    use nalgebra::{DMatrix, DVector, dvector};
229    use std::collections::HashMap;
230
231    type TestResult = Result<(), Box<dyn std::error::Error>>;
232
233    struct LinearFactor {
234        target: f64,
235    }
236
237    impl factors::Factor for LinearFactor {
238        fn linearize(
239            &self,
240            params: &[DVector<f64>],
241            compute_jacobian: bool,
242        ) -> (DVector<f64>, Option<DMatrix<f64>>) {
243            let residual = dvector![params[0][0] - self.target];
244            let jacobian = if compute_jacobian {
245                Some(DMatrix::from_element(1, 1, 1.0))
246            } else {
247                None
248            };
249            (residual, jacobian)
250        }
251
252        fn get_dimension(&self) -> usize {
253            1
254        }
255    }
256
257    fn one_var_problem() -> (Problem, HashMap<String, (ManifoldType, DVector<f64>)>) {
258        let mut problem = Problem::new(JacobianMode::Sparse);
259        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 0.0 }), None);
260        let mut init = HashMap::new();
261        init.insert("x".to_string(), (ManifoldType::RN, dvector![5.0]));
262        (problem, init)
263    }
264
265    // -------------------------------------------------------------------------
266    // build_symbolic_structure
267    // -------------------------------------------------------------------------
268
269    #[test]
270    fn test_build_symbolic_structure_nnz() -> TestResult {
271        let (problem, init) = one_var_problem();
272        let state = optimizer::initialize_optimization_state(&problem, &init)?;
273        let sym = build_symbolic_structure(
274            &problem,
275            &state.variables,
276            &state.variable_index_map,
277            state.total_dof,
278        )?;
279        assert_eq!(sym.pattern.compute_nnz(), 1);
280        Ok(())
281    }
282
283    #[test]
284    fn test_build_symbolic_structure_dimensions() -> TestResult {
285        let (problem, init) = one_var_problem();
286        let state = optimizer::initialize_optimization_state(&problem, &init)?;
287        let sym = build_symbolic_structure(
288            &problem,
289            &state.variables,
290            &state.variable_index_map,
291            state.total_dof,
292        )?;
293        assert_eq!(sym.pattern.nrows(), 1);
294        assert_eq!(sym.pattern.ncols(), 1);
295        Ok(())
296    }
297
298    #[test]
299    fn test_build_symbolic_structure_two_factors() -> TestResult {
300        let mut problem = Problem::new(JacobianMode::Sparse);
301        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 0.0 }), None);
302        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 1.0 }), None);
303        let mut init = HashMap::new();
304        init.insert("x".to_string(), (ManifoldType::RN, dvector![5.0]));
305        let state = optimizer::initialize_optimization_state(&problem, &init)?;
306        let sym = build_symbolic_structure(
307            &problem,
308            &state.variables,
309            &state.variable_index_map,
310            state.total_dof,
311        )?;
312        assert_eq!(sym.pattern.compute_nnz(), 2);
313        Ok(())
314    }
315
316    // -------------------------------------------------------------------------
317    // assemble_sparse
318    // -------------------------------------------------------------------------
319
320    #[test]
321    fn test_assemble_sparse_basic() -> TestResult {
322        let (problem, init) = one_var_problem();
323        let state = optimizer::initialize_optimization_state(&problem, &init)?;
324        let sym = state
325            .symbolic_structure
326            .ok_or("symbolic_structure is None")?;
327        let (residual, _) =
328            assemble_sparse(&problem, &state.variables, &state.variable_index_map, &sym)?;
329        assert!((residual[(0, 0)] - 5.0).abs() < 1e-12);
330        Ok(())
331    }
332
333    #[test]
334    fn test_assemble_sparse_jacobian_value() -> TestResult {
335        let (problem, init) = one_var_problem();
336        let state = optimizer::initialize_optimization_state(&problem, &init)?;
337        let sym = state
338            .symbolic_structure
339            .ok_or("symbolic_structure is None")?;
340        let (_, jacobian) =
341            assemble_sparse(&problem, &state.variables, &state.variable_index_map, &sym)?;
342        let val = jacobian.as_ref().val_of_col(0)[0];
343        assert!((val - 1.0).abs() < 1e-12);
344        Ok(())
345    }
346
347    #[test]
348    fn test_assemble_sparse_zero_residual() -> TestResult {
349        let mut problem = Problem::new(JacobianMode::Sparse);
350        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 3.0 }), None);
351        let mut init = HashMap::new();
352        init.insert("x".to_string(), (ManifoldType::RN, dvector![3.0]));
353        let state = optimizer::initialize_optimization_state(&problem, &init)?;
354        let sym = state
355            .symbolic_structure
356            .ok_or("symbolic_structure is None")?;
357        let (residual, _) =
358            assemble_sparse(&problem, &state.variables, &state.variable_index_map, &sym)?;
359        assert!(residual[(0, 0)].abs() < 1e-12);
360        Ok(())
361    }
362
363    #[test]
364    fn test_assemble_sparse_dimensions() -> TestResult {
365        let (problem, init) = one_var_problem();
366        let state = optimizer::initialize_optimization_state(&problem, &init)?;
367        let sym = state
368            .symbolic_structure
369            .ok_or("symbolic_structure is None")?;
370        let (residual, jacobian) =
371            assemble_sparse(&problem, &state.variables, &state.variable_index_map, &sym)?;
372        assert_eq!(residual.nrows(), 1);
373        assert_eq!(residual.ncols(), 1);
374        assert_eq!(jacobian.nrows(), 1);
375        assert_eq!(jacobian.ncols(), 1);
376        Ok(())
377    }
378
379    #[test]
380    fn test_assemble_sparse_two_variables() -> TestResult {
381        let mut problem = Problem::new(JacobianMode::Sparse);
382        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 0.0 }), None);
383        problem.add_residual_block(&["y"], Box::new(LinearFactor { target: 0.0 }), None);
384        let mut init = HashMap::new();
385        init.insert("x".to_string(), (ManifoldType::RN, dvector![2.0]));
386        init.insert("y".to_string(), (ManifoldType::RN, dvector![7.0]));
387        let state = optimizer::initialize_optimization_state(&problem, &init)?;
388        let sym = state
389            .symbolic_structure
390            .ok_or("symbolic_structure is None")?;
391        let (residual, _) =
392            assemble_sparse(&problem, &state.variables, &state.variable_index_map, &sym)?;
393        assert_eq!(residual.nrows(), 2);
394        let rsum = residual[(0, 0)].abs() + residual[(1, 0)].abs();
395        assert!((rsum - 9.0).abs() < 1e-12);
396        Ok(())
397    }
398
399    /// Exercises the `CoreError::Variable` error path inside `scatter_sparse_block()`.
400    ///
401    /// The symbolic structure is built correctly, but we pass an empty
402    /// `variable_index_map` to `assemble_sparse` so the variable key lookup
403    /// fails, triggering the `Missing key` error branch.
404    #[test]
405    fn test_assemble_sparse_missing_variable_key_returns_error() -> TestResult {
406        let (problem, init) = one_var_problem();
407        let state = optimizer::initialize_optimization_state(&problem, &init)?;
408        let sym = state
409            .symbolic_structure
410            .ok_or("symbolic_structure is None")?;
411
412        // Pass an empty variable_index_map: "x" is missing → should trigger CoreError::Variable
413        let empty_map = HashMap::new();
414        let result = assemble_sparse(&problem, &state.variables, &empty_map, &sym);
415        assert!(
416            result.is_err(),
417            "assemble_sparse with missing variable key should return Err"
418        );
419        Ok(())
420    }
421}