Skip to main content

apex_solver/linalg/dense/schur/
explicit.rs

1//! # Explicit Dense Schur Complement Solver
2//!
3//! Forms the reduced camera system `S = H_kk − H_ke·H_ee⁻¹·H_keᵀ` explicitly
4//! over a **dense** Hessian and factorizes it with dense Cholesky. Equivalent
5//! to Ceres's `DENSE_SCHUR`.
6//!
7//! Dense storage makes this simpler than the sparse explicit solver: there is
8//! no sparsity pattern to preserve, so extracting `H_kk`/`H_ke`, gathering
9//! `H_ee`'s diagonal blocks, and forming `S` are all direct index-and-copy
10//! operations rather than a symbolic-structure walk. Chunk-wise elimination
11//! (sweeping `J`'s rows to avoid ever forming `JᵀJ`) buys nothing here: the
12//! whole point of chunking is avoiding sparse fill-in on a Hessian too large
13//! to store, and dense mode's target size (< ~500 DOF) means `H` is already
14//! cheap to hold in full. Ceres's own `DENSE_SCHUR` builds `E`/`F`/`S` as
15//! plain dense BLAS blocks for the same reason.
16//!
17//! Targets small-to-medium problems (`JacobianMode::Dense`, < ~500 DOF) —
18//! see [`ExplicitSparseSchur`](crate::linalg::sparse::schur::ExplicitSparseSchur)
19//! for larger sparse problems.
20//!
21//! ## Usage Example
22//!
23//! ```no_run
24//! # use apex_solver::linalg::ExplicitDenseSchur;
25//! # use apex_solver::linalg::StructureAware;
26//! # use apex_solver::core::VarKey;
27//! # use apex_solver::core::variable::ManifoldVariable;
28//! # use slotmap::{SlotMap, SecondaryMap};
29//! # use std::collections::HashSet;
30//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
31//! # let variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
32//! # let variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
33//! # let landmark_keys: HashSet<VarKey> = HashSet::new();
34//! let mut solver = ExplicitDenseSchur::new();
35//! solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
36//! # Ok(())
37//! # }
38//! ```
39
40use faer::Mat;
41
42use crate::core::VarKey;
43use crate::core::variable::ManifoldVariable;
44use crate::linalg::dense::cholesky::solve_spd;
45use crate::linalg::dense::schur::gather::{gather_dense, verify_block_diagonal_dense};
46use crate::linalg::schur::{
47    BlockSpan, EliminatedBlocks, SchurOrdering, SchurPartition, effective_landmark_keys,
48};
49use crate::linalg::{Damping, DenseMode, LinAlgError, LinAlgResult, LinearSolver, StructureAware};
50use slotmap::{SecondaryMap, SlotMap};
51
52/// Explicit Schur Complement Solver for a dense Hessian. Equivalent to
53/// Ceres's `DENSE_SCHUR`.
54#[derive(Debug, Clone)]
55pub struct ExplicitDenseSchur {
56    partition: Option<SchurPartition>,
57    /// `H_ee⁻¹` per eliminated block, gathered and inverted once per solve.
58    eliminated: EliminatedBlocks,
59    ordering: SchurOrdering,
60
61    hessian: Option<Mat<f64>>,
62    gradient: Option<Mat<f64>>,
63}
64
65impl ExplicitDenseSchur {
66    pub fn new() -> Self {
67        Self {
68            partition: None,
69            eliminated: EliminatedBlocks::default(),
70            ordering: SchurOrdering::default(),
71            hessian: None,
72            gradient: None,
73        }
74    }
75
76    /// Set the automatic group-classification ordering (see [`SchurOrdering`]).
77    pub fn with_ordering(mut self, ordering: SchurOrdering) -> Self {
78        self.ordering = ordering;
79        self
80    }
81
82    /// The variable partition, once `initialize_structure` has run.
83    pub fn partition(&self) -> Option<&SchurPartition> {
84        self.partition.as_ref()
85    }
86
87    fn require_partition(&self) -> LinAlgResult<&SchurPartition> {
88        self.partition.as_ref().ok_or_else(|| {
89            LinAlgError::InvalidInput(
90                "Block structure not built. Call initialize_structure() first.".to_string(),
91            )
92        })
93    }
94
95    /// Flattened kept/eliminated global-column lists, in the same order as
96    /// [`SchurPartition::kept_local`]/[`SchurPartition::eliminated_offset`]
97    /// number them — so index `i` here is local index `i` in the reduced
98    /// system (kept) or the eliminated-local space (eliminated).
99    fn column_orderings(partition: &SchurPartition) -> (Vec<usize>, Vec<usize>) {
100        let kept: Vec<usize> = partition
101            .kept_blocks()
102            .iter()
103            .flat_map(|b| (0..b.dof).map(move |o| b.col_start + o))
104            .collect();
105        let eliminated: Vec<usize> = partition
106            .eliminated_blocks()
107            .iter()
108            .flat_map(|b| (0..b.dof).map(move |o| b.col_start + o))
109            .collect();
110        (kept, eliminated)
111    }
112
113    /// Eliminate, solve the reduced system, and back-substitute.
114    fn solve_with_hessian(
115        &mut self,
116        hessian: &Mat<f64>,
117        neg_gradient: &Mat<f64>,
118        damping: Option<&Damping>,
119    ) -> LinAlgResult<Mat<f64>> {
120        let partition = self.require_partition()?;
121        verify_block_diagonal_dense(partition, hessian)?;
122
123        let kept_dof = partition.kept_dof();
124        let eliminated_dof = partition.eliminated_dof();
125        let (kept_global, elim_global) = Self::column_orderings(partition);
126
127        // H_kk, H_ke as plain dense sub-blocks.
128        let mut h_kk = Mat::<f64>::zeros(kept_dof, kept_dof);
129        for (lr, &gr) in kept_global.iter().enumerate() {
130            for (lc, &gc) in kept_global.iter().enumerate() {
131                h_kk[(lr, lc)] = hessian[(gr, gc)];
132            }
133        }
134        let mut h_ke = Mat::<f64>::zeros(kept_dof, eliminated_dof);
135        for (lr, &gr) in kept_global.iter().enumerate() {
136            for (lc, &gc) in elim_global.iter().enumerate() {
137                h_ke[(lr, lc)] = hessian[(gr, gc)];
138            }
139        }
140
141        let mut g_k = Mat::<f64>::zeros(kept_dof, 1);
142        for (lr, &gr) in kept_global.iter().enumerate() {
143            g_k[(lr, 0)] = neg_gradient[(gr, 0)];
144        }
145        let mut g_e = Mat::<f64>::zeros(eliminated_dof, 1);
146        for (lr, &gr) in elim_global.iter().enumerate() {
147            g_e[(lr, 0)] = neg_gradient[(gr, 0)];
148        }
149
150        // λ·D is applied to *both* sides before elimination, matching the
151        // sparse solver: damping the reduced system afterwards would not be
152        // the same problem.
153        if let Some(damping) = damping {
154            for i in 0..kept_dof {
155                h_kk[(i, i)] += damping.diagonal_term(h_kk[(i, i)]);
156            }
157        }
158
159        // `self.eliminated` is moved out for the duration of the gather+invert
160        // so `partition` (borrowed from `self`) and `self.eliminated` are
161        // never borrowed simultaneously, mirroring `ExplicitSparseSchur`.
162        let mut eliminated = std::mem::take(&mut self.eliminated);
163        let prepared = (|| -> LinAlgResult<()> {
164            let partition = self.require_partition()?;
165            gather_dense(&mut eliminated, partition, hessian);
166            if let Some(damping) = damping {
167                eliminated.damp(damping);
168            }
169            eliminated.invert_in_place(partition)
170        })();
171        self.eliminated = eliminated;
172        prepared?;
173
174        let partition = self.require_partition()?;
175
176        // H_ee⁻¹ as one block-diagonal dense matrix, so S and g_reduced fall
177        // out of plain dense matmuls.
178        let mut h_ee_inv = Mat::<f64>::zeros(eliminated_dof, eliminated_dof);
179        for (block_idx, block) in partition.eliminated_blocks().iter().enumerate() {
180            let dof = block.dof;
181            let base = partition.eliminated_offset(block_idx);
182            let inv = self.eliminated.block(block_idx);
183            for c in 0..dof {
184                for r in 0..dof {
185                    h_ee_inv[(base + r, base + c)] = inv[c * dof + r];
186                }
187            }
188        }
189
190        let coupling = &h_ke * &h_ee_inv;
191        let s = &h_kk - &coupling * h_ke.transpose();
192        let g_reduced = &g_k - &coupling * &g_e;
193
194        let delta_k = solve_spd(
195            &s,
196            &g_reduced,
197            "Dense Schur complement factorization failed",
198        )?;
199
200        // δ_e = H_ee⁻¹·(g_e − H_keᵀ·δ_k)
201        let hke_t_delta = h_ke.transpose() * &delta_k;
202        let mut delta_e = Mat::<f64>::zeros(eliminated_dof, 1);
203        for (block_idx, block) in partition.eliminated_blocks().iter().enumerate() {
204            let dof = block.dof;
205            let base = partition.eliminated_offset(block_idx);
206            let inv = self.eliminated.block(block_idx);
207            for r in 0..dof {
208                let mut acc = 0.0;
209                for c in 0..dof {
210                    let rhs = g_e[(base + c, 0)] - hke_t_delta[(base + c, 0)];
211                    acc += inv[c * dof + r] * rhs;
212                }
213                delta_e[(base + r, 0)] = acc;
214            }
215        }
216
217        let mut delta = Mat::<f64>::zeros(partition.total_dof(), 1);
218        for (lr, &gr) in kept_global.iter().enumerate() {
219            delta[(gr, 0)] = delta_k[(lr, 0)];
220        }
221        for (lr, &gr) in elim_global.iter().enumerate() {
222            delta[(gr, 0)] = delta_e[(lr, 0)];
223        }
224        Ok(delta)
225    }
226}
227
228impl Default for ExplicitDenseSchur {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl StructureAware for ExplicitDenseSchur {
235    fn initialize_structure(
236        &mut self,
237        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
238        variable_index_map: &SecondaryMap<VarKey, usize>,
239        schur_landmark_keys: &std::collections::HashSet<VarKey>,
240    ) -> LinAlgResult<()> {
241        let effective_keys =
242            effective_landmark_keys(variables, schur_landmark_keys, &self.ordering);
243
244        let mut kept = Vec::new();
245        let mut eliminated = Vec::new();
246        for (key, variable) in variables {
247            let col_start = *variable_index_map.get(key).ok_or_else(|| {
248                LinAlgError::InvalidInput(format!("VarKey {:?} not found in index map", key))
249            })?;
250            let span = BlockSpan {
251                key,
252                col_start,
253                // Free columns only — fixed tangent coordinates own no
254                // column in the linear solve (ISSUE-0003).
255                dof: variable.free_dof(),
256            };
257            if effective_keys.contains(&key) {
258                eliminated.push(span);
259            } else {
260                kept.push(span);
261            }
262        }
263
264        let partition = SchurPartition::new(kept, eliminated)?;
265        self.eliminated = EliminatedBlocks::new(&partition);
266        self.partition = Some(partition);
267        Ok(())
268    }
269}
270
271impl LinearSolver<DenseMode> for ExplicitDenseSchur {
272    fn solve_normal_equation(
273        &mut self,
274        residuals: &Mat<f64>,
275        jacobian: &Mat<f64>,
276    ) -> LinAlgResult<Mat<f64>> {
277        self.require_partition()?;
278        let hessian = jacobian.transpose() * jacobian;
279        let gradient = jacobian.transpose() * residuals;
280        let mut neg_gradient = Mat::<f64>::zeros(gradient.nrows(), 1);
281        for i in 0..gradient.nrows() {
282            neg_gradient[(i, 0)] = -gradient[(i, 0)];
283        }
284
285        let result = self.solve_with_hessian(&hessian, &neg_gradient, None);
286        if result.is_ok() {
287            self.hessian = Some(hessian);
288            self.gradient = Some(gradient);
289        }
290        result
291    }
292
293    fn solve_augmented_equation(
294        &mut self,
295        residuals: &Mat<f64>,
296        jacobian: &Mat<f64>,
297        damping: &Damping,
298    ) -> LinAlgResult<Mat<f64>> {
299        self.require_partition()?;
300        let hessian = jacobian.transpose() * jacobian;
301        let gradient = jacobian.transpose() * residuals;
302        let mut neg_gradient = Mat::<f64>::zeros(gradient.nrows(), 1);
303        for i in 0..gradient.nrows() {
304            neg_gradient[(i, 0)] = -gradient[(i, 0)];
305        }
306
307        let result = self.solve_with_hessian(&hessian, &neg_gradient, Some(damping));
308        if result.is_ok() {
309            self.hessian = Some(hessian);
310            self.gradient = Some(gradient);
311        }
312        result
313    }
314
315    fn hessian_vec_product(&self, v: &Mat<f64>) -> Option<Mat<f64>> {
316        Some(
317            <DenseMode as crate::linearizer::AssemblyBackend>::hessian_vec_product(
318                self.hessian.as_ref()?,
319                v,
320            ),
321        )
322    }
323
324    fn get_hessian(&self) -> Option<&Mat<f64>> {
325        self.hessian.as_ref()
326    }
327
328    fn get_gradient(&self) -> Option<&Mat<f64>> {
329        self.gradient.as_ref()
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::core::VarKey;
337    use crate::core::variable::Variable;
338    use apex_manifolds::{LieGroup, rn, se3};
339    use nalgebra::DVector;
340    use slotmap::{SecondaryMap, SlotMap};
341
342    type TestResult = Result<(), Box<dyn std::error::Error>>;
343
344    type TestSetup = (
345        SlotMap<VarKey, Box<dyn ManifoldVariable>>,
346        SecondaryMap<VarKey, usize>,
347        Mat<f64>,
348        Mat<f64>,
349        std::collections::HashSet<VarKey>,
350    );
351
352    /// Same 2-camera + 3-landmark shape as the sparse Schur unit tests, built
353    /// as a dense Jacobian directly.
354    fn create_schur_test_setup() -> Result<TestSetup, Box<dyn std::error::Error>> {
355        let se3_id = DVector::from_vec(vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
356        let pt_zero = DVector::from_vec(vec![0.0, 0.0, 0.0]);
357
358        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
359        let cam0 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
360            se3_id.as_slice(),
361        ))));
362        let cam1 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
363            se3_id.as_slice(),
364        ))));
365        let pt0 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
366        let pt1 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
367        let pt2 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
368
369        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
370        variable_index_map.insert(cam0, 0);
371        variable_index_map.insert(cam1, 6);
372        variable_index_map.insert(pt0, 12);
373        variable_index_map.insert(pt1, 15);
374        variable_index_map.insert(pt2, 18);
375
376        let cam_cols = [0usize, 6];
377        let lm_cols = [12usize, 15, 18];
378        let mut jacobian = Mat::<f64>::zeros(36, 21);
379        // Two identical cameras observing three identical landmarks with a
380        // uniform coefficient makes the reduced camera system exactly
381        // rank-deficient (the two cameras become linearly dependent) — sparse
382        // Cholesky silently regularizes past that, but `solve_spd` does not,
383        // matching `DenseCholeskySolver`'s own no-retry contract. Varying the
384        // coefficient per (camera, landmark, k) breaks the symmetry while
385        // keeping H_cc/H_pp diagonal-dominant, so `S` is genuinely PD.
386        for (ci, &cam_col) in cam_cols.iter().enumerate() {
387            for (li, &lm_col) in lm_cols.iter().enumerate() {
388                let row_base = (ci * 3 + li) * 6;
389                for k in 0..6 {
390                    let cam_val = 1.0 + 0.1 * (ci as f64 + 1.0);
391                    let lm_val = 0.3 + 0.05 * ((k + li + 2 * ci) % 5) as f64;
392                    jacobian[(row_base + k, cam_col + k)] = cam_val;
393                    jacobian[(row_base + k, lm_col + (k % 3))] = lm_val;
394                }
395            }
396        }
397        let residuals = Mat::from_fn(36, 1, |i, _| (i % 5) as f64 * 0.1);
398
399        let mut landmark_keys = std::collections::HashSet::new();
400        landmark_keys.insert(pt0);
401        landmark_keys.insert(pt1);
402        landmark_keys.insert(pt2);
403
404        Ok((
405            variables,
406            variable_index_map,
407            jacobian,
408            residuals,
409            landmark_keys,
410        ))
411    }
412
413    #[test]
414    fn test_dense_schur_initialize_structure() -> TestResult {
415        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
416        let mut solver = ExplicitDenseSchur::new();
417        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
418
419        let partition = solver.partition().ok_or("partition is None")?;
420        assert_eq!(partition.kept_dof(), 12);
421        assert_eq!(partition.eliminated_dof(), 9);
422        Ok(())
423    }
424
425    #[test]
426    fn test_dense_schur_solve_normal_equation() -> TestResult {
427        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
428            create_schur_test_setup()?;
429        let mut solver = ExplicitDenseSchur::new();
430        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
431
432        let delta =
433            LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
434        assert_eq!(delta.nrows(), 21);
435        Ok(())
436    }
437
438    #[test]
439    fn test_dense_schur_solve_augmented_equation() -> TestResult {
440        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
441            create_schur_test_setup()?;
442        let mut solver = ExplicitDenseSchur::new();
443        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
444
445        let delta = LinearSolver::<DenseMode>::solve_augmented_equation(
446            &mut solver,
447            &residuals,
448            &jacobian,
449            &Damping::identity(0.1),
450        )?;
451        assert_eq!(delta.nrows(), 21);
452        Ok(())
453    }
454
455    #[test]
456    fn test_dense_schur_matches_dense_cholesky_on_full_system() -> TestResult {
457        use crate::linalg::dense::cholesky::DenseCholeskySolver;
458
459        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
460            create_schur_test_setup()?;
461
462        let mut cholesky = DenseCholeskySolver::new();
463        let reference =
464            LinearSolver::<DenseMode>::solve_normal_equation(&mut cholesky, &residuals, &jacobian)?;
465
466        let mut schur = ExplicitDenseSchur::new();
467        schur.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
468        let step =
469            LinearSolver::<DenseMode>::solve_normal_equation(&mut schur, &residuals, &jacobian)?;
470
471        for i in 0..21 {
472            assert!(
473                (reference[(i, 0)] - step[(i, 0)]).abs() < 1e-9,
474                "component {i}: cholesky {}, schur {}",
475                reference[(i, 0)],
476                step[(i, 0)]
477            );
478        }
479        Ok(())
480    }
481
482    #[test]
483    fn test_dense_schur_get_hessian_gradient() -> TestResult {
484        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
485            create_schur_test_setup()?;
486        let mut solver = ExplicitDenseSchur::new();
487        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
488
489        assert!(LinearSolver::<DenseMode>::get_hessian(&solver).is_none());
490        assert!(LinearSolver::<DenseMode>::get_gradient(&solver).is_none());
491
492        LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
493
494        let h = LinearSolver::<DenseMode>::get_hessian(&solver).ok_or("hessian is None")?;
495        let g = LinearSolver::<DenseMode>::get_gradient(&solver).ok_or("gradient is None")?;
496        assert_eq!(h.nrows(), 21);
497        assert_eq!(g.nrows(), 21);
498        Ok(())
499    }
500
501    #[test]
502    fn test_dense_schur_solve_without_init_returns_error() {
503        let jacobian = Mat::<f64>::zeros(1, 1);
504        let residuals = Mat::<f64>::zeros(1, 1);
505        let mut solver = ExplicitDenseSchur::new();
506        let result =
507            LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian);
508        assert!(result.is_err());
509    }
510
511    /// Non-contiguous, mixed-DOF partition — the same generality the sparse
512    /// solver supports, proven directly against dense Cholesky.
513    #[test]
514    fn test_dense_schur_supports_non_contiguous_mixed_dof_partition() -> TestResult {
515        let a = Variable::new(se3::SE3::from_param_slice(&[
516            1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
517        ]));
518        let b = Variable::new(se3::SE3::from_param_slice(&[
519            1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
520        ]));
521        let p = Variable::new(rn::Rn::new(DVector::from_vec(vec![0.0])));
522        let q = Variable::new(rn::Rn::new(DVector::from_vec(vec![0.0])));
523
524        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
525        let key_a = variables.insert(Box::new(a));
526        let key_p = variables.insert(Box::new(p));
527        let key_b = variables.insert(Box::new(b));
528        let key_q = variables.insert(Box::new(q));
529
530        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
531        variable_index_map.insert(key_a, 0);
532        variable_index_map.insert(key_p, 6);
533        variable_index_map.insert(key_b, 7);
534        variable_index_map.insert(key_q, 13);
535
536        let mut landmark_keys = std::collections::HashSet::new();
537        landmark_keys.insert(key_p);
538        landmark_keys.insert(key_q);
539
540        let mut jacobian = Mat::<f64>::zeros(4 + 14, 14);
541        let mut row = 0usize;
542        for &cam_col in &[0usize, 7] {
543            for &lm_col in &[6usize, 13] {
544                jacobian[(row, cam_col)] = 1.0;
545                jacobian[(row, lm_col)] = 1.0;
546                row += 1;
547            }
548        }
549        for col in 0..14 {
550            jacobian[(row, col)] = 0.5;
551            row += 1;
552        }
553        let residuals = Mat::from_fn(row, 1, |i, _| 0.1 * (i as f64 + 1.0));
554
555        let mut cholesky = crate::linalg::dense::cholesky::DenseCholeskySolver::new();
556        let reference =
557            LinearSolver::<DenseMode>::solve_normal_equation(&mut cholesky, &residuals, &jacobian)?;
558
559        let mut solver = ExplicitDenseSchur::new();
560        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
561        let step =
562            LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
563
564        for i in 0..14 {
565            assert!(
566                (reference[(i, 0)] - step[(i, 0)]).abs() < 1e-9,
567                "component {i}: cholesky {}, schur {}",
568                reference[(i, 0)],
569                step[(i, 0)]
570            );
571        }
572        Ok(())
573    }
574}