Skip to main content

apex_solver/linearizer/cpu/
dense.rs

1//! Dense Jacobian assembly for small-to-medium problems.
2//!
3//! This module assembles the Jacobian directly into a dense `Mat<f64>`, avoiding
4//! sparse data structure overhead. Optimal for problems with < ~500 DOF where
5//! the Jacobian is not extremely sparse.
6
7use std::{
8    collections::HashMap,
9    sync::{Arc, Mutex},
10};
11
12use faer::{Col, Mat};
13use rayon::prelude::*;
14
15use crate::error::ErrorLogging;
16use crate::linearizer::{LinearizerError, LinearizerResult};
17
18use super::super::linearize_block;
19use crate::core::problem::{Problem, VariableEnum};
20
21/// Assemble residuals and dense Jacobian from the current variable values.
22///
23/// Evaluates all residual blocks in parallel, writing Jacobian blocks directly
24/// into a pre-allocated dense matrix. No symbolic structure is needed.
25///
26/// # Arguments
27///
28/// * `problem` - The optimization problem
29/// * `variables` - Current variable values
30/// * `variable_index_map` - Maps variable names to their column offset in the Jacobian
31/// * `total_dof` - Total degrees of freedom (number of columns)
32pub fn assemble_dense(
33    problem: &Problem,
34    variables: &HashMap<String, VariableEnum>,
35    variable_index_map: &HashMap<String, usize>,
36    total_dof: usize,
37) -> LinearizerResult<(Mat<f64>, Mat<f64>)> {
38    let total_residual = Arc::new(Mutex::new(Col::<f64>::zeros(
39        problem.total_residual_dimension,
40    )));
41    let jacobian_dense = Arc::new(Mutex::new(Mat::<f64>::zeros(
42        problem.total_residual_dimension,
43        total_dof,
44    )));
45
46    // Evaluate all blocks in parallel
47    problem.residual_blocks().par_iter().try_for_each(
48        |(_, residual_block)| -> Result<(), LinearizerError> {
49            let block = linearize_block(residual_block, variables, &total_residual)?;
50
51            // Scatter Jacobian block into the dense matrix
52            let mut jac_dense = jacobian_dense.lock().map_err(|e| {
53                LinearizerError::ParallelComputation(
54                    "Failed to acquire lock on dense Jacobian".to_string(),
55                )
56                .log_with_source(e)
57            })?;
58
59            for (i, var_key) in residual_block.variable_key_list.iter().enumerate() {
60                let col_offset = *variable_index_map.get(var_key).ok_or_else(|| {
61                    LinearizerError::Variable(format!(
62                        "Missing key {} in variable-to-column-index mapping",
63                        var_key
64                    ))
65                    .log()
66                })?;
67                let (variable_local_idx, var_size) = block.variable_local_idx_size_list[i];
68                let variable_jac = block
69                    .jacobian
70                    .view((0, variable_local_idx), (block.residual_dim, var_size));
71
72                for row in 0..block.residual_dim {
73                    for col in 0..var_size {
74                        jac_dense[(block.residual_row_start_idx + row, col_offset + col)] =
75                            variable_jac[(row, col)];
76                    }
77                }
78            }
79
80            Ok(())
81        },
82    )?;
83
84    let total_residual = Arc::try_unwrap(total_residual)
85        .map_err(|_| {
86            LinearizerError::ParallelComputation(
87                "Failed to unwrap Arc for total residual".to_string(),
88            )
89            .log()
90        })?
91        .into_inner()
92        .map_err(|e| {
93            LinearizerError::ParallelComputation(
94                "Failed to extract mutex inner value for total residual".to_string(),
95            )
96            .log_with_source(e)
97        })?;
98
99    let jacobian_dense = Arc::try_unwrap(jacobian_dense)
100        .map_err(|_| {
101            LinearizerError::ParallelComputation(
102                "Failed to unwrap Arc for dense Jacobian".to_string(),
103            )
104            .log()
105        })?
106        .into_inner()
107        .map_err(|e| {
108            LinearizerError::ParallelComputation(
109                "Failed to extract mutex inner value for dense Jacobian".to_string(),
110            )
111            .log_with_source(e)
112        })?;
113
114    let residual_faer = total_residual.as_ref().as_mat().to_owned();
115    Ok((residual_faer, jacobian_dense))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::{core::problem::Problem, factors, linalg::JacobianMode, optimizer};
122    use apex_manifolds::ManifoldType;
123    use nalgebra::{DMatrix, DVector, dvector};
124    use std::collections::HashMap;
125
126    type TestResult = Result<(), Box<dyn std::error::Error>>;
127
128    struct LinearFactor {
129        target: f64,
130    }
131
132    impl factors::Factor for LinearFactor {
133        fn linearize(
134            &self,
135            params: &[DVector<f64>],
136            compute_jacobian: bool,
137        ) -> (DVector<f64>, Option<DMatrix<f64>>) {
138            let residual = dvector![params[0][0] - self.target];
139            let jacobian = if compute_jacobian {
140                Some(DMatrix::from_element(1, 1, 1.0))
141            } else {
142                None
143            };
144            (residual, jacobian)
145        }
146
147        fn get_dimension(&self) -> usize {
148            1
149        }
150    }
151
152    fn one_var_dense_problem() -> (Problem, HashMap<String, (ManifoldType, DVector<f64>)>) {
153        let mut problem = Problem::new(JacobianMode::Dense);
154        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 0.0 }), None);
155        let mut init = HashMap::new();
156        init.insert("x".to_string(), (ManifoldType::RN, dvector![5.0]));
157        (problem, init)
158    }
159
160    #[test]
161    fn test_assemble_dense_basic() -> TestResult {
162        let (problem, init) = one_var_dense_problem();
163        let state = optimizer::initialize_optimization_state(&problem, &init)?;
164        let (residual, jacobian) = assemble_dense(
165            &problem,
166            &state.variables,
167            &state.variable_index_map,
168            state.total_dof,
169        )?;
170        assert!((residual[(0, 0)] - 5.0).abs() < 1e-12);
171        assert!((jacobian[(0, 0)] - 1.0).abs() < 1e-12);
172        Ok(())
173    }
174
175    #[test]
176    fn test_assemble_dense_jacobian_dimensions() -> TestResult {
177        let (problem, init) = one_var_dense_problem();
178        let state = optimizer::initialize_optimization_state(&problem, &init)?;
179        let (residual, jacobian) = assemble_dense(
180            &problem,
181            &state.variables,
182            &state.variable_index_map,
183            state.total_dof,
184        )?;
185        assert_eq!(residual.nrows(), problem.total_residual_dimension);
186        assert_eq!(jacobian.nrows(), problem.total_residual_dimension);
187        assert_eq!(jacobian.ncols(), state.total_dof);
188        Ok(())
189    }
190
191    #[test]
192    fn test_assemble_dense_zero_residual() -> TestResult {
193        let mut problem = Problem::new(JacobianMode::Dense);
194        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 3.0 }), None);
195        let mut init = HashMap::new();
196        init.insert("x".to_string(), (ManifoldType::RN, dvector![3.0]));
197        let state = optimizer::initialize_optimization_state(&problem, &init)?;
198        let (residual, _) = assemble_dense(
199            &problem,
200            &state.variables,
201            &state.variable_index_map,
202            state.total_dof,
203        )?;
204        assert!(residual[(0, 0)].abs() < 1e-12);
205        Ok(())
206    }
207
208    #[test]
209    fn test_assemble_dense_two_variables() -> TestResult {
210        let mut problem = Problem::new(JacobianMode::Dense);
211        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 0.0 }), None);
212        problem.add_residual_block(&["y"], Box::new(LinearFactor { target: 0.0 }), None);
213        let mut init = HashMap::new();
214        init.insert("x".to_string(), (ManifoldType::RN, dvector![2.0]));
215        init.insert("y".to_string(), (ManifoldType::RN, dvector![7.0]));
216        let state = optimizer::initialize_optimization_state(&problem, &init)?;
217        let (residual, jacobian) = assemble_dense(
218            &problem,
219            &state.variables,
220            &state.variable_index_map,
221            state.total_dof,
222        )?;
223        assert_eq!(jacobian.nrows(), 2);
224        assert_eq!(jacobian.ncols(), 2);
225        let rsum = residual[(0, 0)].abs() + residual[(1, 0)].abs();
226        assert!((rsum - 9.0).abs() < 1e-12);
227        Ok(())
228    }
229
230    #[test]
231    fn test_assemble_dense_residual_faer_shape() -> TestResult {
232        let (problem, init) = one_var_dense_problem();
233        let state = optimizer::initialize_optimization_state(&problem, &init)?;
234        let (residual, _) = assemble_dense(
235            &problem,
236            &state.variables,
237            &state.variable_index_map,
238            state.total_dof,
239        )?;
240        assert_eq!(residual.nrows(), 1);
241        assert_eq!(residual.ncols(), 1);
242        Ok(())
243    }
244
245    // -------------------------------------------------------------------------
246    // New tests for previously uncovered code paths
247    // -------------------------------------------------------------------------
248
249    /// Factor that connects two variables (verifies column scattering for multi-variable factors).
250    struct BinaryLinearFactor {
251        target_x: f64,
252        target_y: f64,
253    }
254
255    impl factors::Factor for BinaryLinearFactor {
256        fn linearize(
257            &self,
258            params: &[DVector<f64>],
259            compute_jacobian: bool,
260        ) -> (DVector<f64>, Option<DMatrix<f64>>) {
261            let residual =
262                nalgebra::dvector![params[0][0] - self.target_x, params[1][0] - self.target_y];
263            let jacobian = if compute_jacobian {
264                // 2 residuals × 2 variables = 2×2 Jacobian: identity blocks side-by-side
265                let mut j = DMatrix::zeros(2, 2);
266                j[(0, 0)] = 1.0;
267                j[(1, 1)] = 1.0;
268                Some(j)
269            } else {
270                None
271            };
272            (residual, jacobian)
273        }
274
275        fn get_dimension(&self) -> usize {
276            2
277        }
278    }
279
280    /// Test that a binary factor correctly scatters its Jacobian blocks into two separate columns.
281    #[test]
282    fn test_assemble_dense_binary_factor() -> TestResult {
283        let mut problem = Problem::new(JacobianMode::Dense);
284        problem.add_residual_block(
285            &["x", "y"],
286            Box::new(BinaryLinearFactor {
287                target_x: 0.0,
288                target_y: 0.0,
289            }),
290            None,
291        );
292        let mut init = HashMap::new();
293        init.insert("x".to_string(), (ManifoldType::RN, nalgebra::dvector![3.0]));
294        init.insert("y".to_string(), (ManifoldType::RN, nalgebra::dvector![5.0]));
295        let state = optimizer::initialize_optimization_state(&problem, &init)?;
296
297        let (residual, jacobian) = assemble_dense(
298            &problem,
299            &state.variables,
300            &state.variable_index_map,
301            state.total_dof,
302        )?;
303
304        // 2 residuals, 2 DOF columns
305        assert_eq!(residual.nrows(), 2);
306        assert_eq!(jacobian.nrows(), 2);
307        assert_eq!(jacobian.ncols(), 2);
308
309        // Residuals should be the variable values (since targets are 0)
310        let r_sum = residual[(0, 0)].abs() + residual[(1, 0)].abs();
311        assert!((r_sum - 8.0).abs() < 1e-10, "residual sum = {r_sum}");
312
313        // Jacobian: each variable has its own 1×1 identity block
314        // col 0 → x block, col 1 → y block (or vice versa depending on sort order)
315        let jac_sum: f64 = (0..2)
316            .map(|c| (0..2).map(|r| jacobian[(r, c)]).sum::<f64>())
317            .sum();
318        assert!(
319            (jac_sum - 2.0).abs() < 1e-10,
320            "sum of jacobian entries = {jac_sum}"
321        );
322        Ok(())
323    }
324
325    /// Test that a missing variable key in variable_index_map returns an Err.
326    #[test]
327    fn test_assemble_dense_missing_variable_key_returns_error() -> TestResult {
328        let (problem, init) = one_var_dense_problem();
329        let state = optimizer::initialize_optimization_state(&problem, &init)?;
330
331        // Pass an empty index map — the key "x" will be missing
332        let empty_map: HashMap<String, usize> = HashMap::new();
333        let result = assemble_dense(&problem, &state.variables, &empty_map, state.total_dof);
334        assert!(
335            result.is_err(),
336            "missing variable key should produce an Err"
337        );
338        Ok(())
339    }
340
341    /// Test that individual residual values in a multi-block problem are correct.
342    #[test]
343    fn test_assemble_dense_multi_block_residual_values() -> TestResult {
344        let mut problem = Problem::new(JacobianMode::Dense);
345        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 1.0 }), None);
346        problem.add_residual_block(&["x"], Box::new(LinearFactor { target: 4.0 }), None);
347        let mut init = HashMap::new();
348        init.insert("x".to_string(), (ManifoldType::RN, dvector![3.0]));
349        let state = optimizer::initialize_optimization_state(&problem, &init)?;
350
351        let (residual, jacobian) = assemble_dense(
352            &problem,
353            &state.variables,
354            &state.variable_index_map,
355            state.total_dof,
356        )?;
357
358        // 2 residual blocks of dimension 1 each → 2 rows
359        assert_eq!(residual.nrows(), 2);
360
361        // residuals: [3-1, 3-4] = [2, -1] in some order
362        let vals: std::collections::HashSet<i64> =
363            (0..2).map(|i| (residual[(i, 0)] * 1e6) as i64).collect();
364        assert!(vals.contains(&2_000_000), "Missing residual entry 2.0");
365        assert!(vals.contains(&-1_000_000), "Missing residual entry -1.0");
366
367        // Jacobian: both rows should have a 1 in column 0 (single variable "x")
368        assert!((jacobian[(0, 0)] - 1.0).abs() < 1e-10);
369        assert!((jacobian[(1, 0)] - 1.0).abs() < 1e-10);
370        Ok(())
371    }
372}