Skip to main content

apex_solver/linalg/sparse/
cholesky.rs

1use faer::{
2    Mat, Side,
3    linalg::solvers::Solve,
4    sparse::SparseColMat,
5    sparse::linalg::solvers::{Llt, SymbolicLlt},
6};
7
8use crate::error::ErrorLogging;
9use crate::linalg::sparse::normal_eq::{LazyNormalEquations, NormalEquations};
10use crate::linalg::{Damping, LinAlgError, LinAlgResult, LinearSolver, SparseMode};
11
12#[derive(Debug, Clone)]
13pub struct SparseCholeskySolver {
14    /// Cached symbolic factorization for reuse across iterations.
15    ///
16    /// This is computed once and reused when the sparsity pattern doesn't change,
17    /// providing a 10-15% performance improvement for iterative optimization.
18    symbolic_factorization: Option<SymbolicLlt<usize>>,
19
20    /// Cached symbolic machinery for forming `JᵀJ` and `Jᵀr` in parallel.
21    ne_cache: LazyNormalEquations,
22
23    /// The Hessian matrix, computed as `(J^T *  J)`.
24    ///
25    /// This is `None` if the Hessian could not be computed.
26    hessian: Option<SparseColMat<usize, f64>>,
27
28    /// The gradient vector, computed as `J^T *  r`.
29    ///
30    /// This is `None` if the gradient could not be computed.
31    gradient: Option<Mat<f64>>,
32}
33
34impl SparseCholeskySolver {
35    pub fn new() -> Self {
36        SparseCholeskySolver {
37            symbolic_factorization: None,
38            ne_cache: LazyNormalEquations::default(),
39            hessian: None,
40            gradient: None,
41        }
42    }
43
44    pub fn hessian(&self) -> Option<&SparseColMat<usize, f64>> {
45        self.hessian.as_ref()
46    }
47
48    pub fn gradient(&self) -> Option<&Mat<f64>> {
49        self.gradient.as_ref()
50    }
51}
52
53impl Default for SparseCholeskySolver {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58impl LinearSolver<SparseMode> for SparseCholeskySolver {
59    fn solve_normal_equation(
60        &mut self,
61        residuals: &Mat<f64>,
62        jacobians: &SparseColMat<usize, f64>,
63    ) -> LinAlgResult<Mat<f64>> {
64        // Form the normal equations: H = JᵀJ, g = Jᵀr (parallel faer kernels,
65        // symbolic product cached across iterations).
66        let NormalEquations { hessian, gradient } = self.ne_cache.compute(residuals, jacobians)?;
67
68        let sym = if let Some(ref cached_sym) = self.symbolic_factorization {
69            // Reuse cached symbolic factorization
70            // Note: SymbolicLlt is reference-counted, so clone() is cheap (O(1))
71            // We assume the sparsity pattern is constant across iterations
72            // which is typical in iterative optimization
73            cached_sym.clone()
74        } else {
75            // Create new symbolic factorization and cache it
76            let new_sym = SymbolicLlt::try_new(hessian.symbolic(), Side::Lower).map_err(|e| {
77                LinAlgError::FactorizationFailed(
78                    "Symbolic Cholesky decomposition failed".to_string(),
79                )
80                .log_with_source(e)
81            })?;
82            // Cache it (clone is cheap due to reference counting)
83            self.symbolic_factorization = Some(new_sym.clone());
84            new_sym
85        };
86
87        // Perform numeric factorization using the symbolic structure
88        let cholesky =
89            Llt::try_new_with_symbolic(sym, hessian.as_ref(), Side::Lower).map_err(|e| {
90                LinAlgError::SingularMatrix(
91                    "Cholesky factorization failed (matrix may be singular)".to_string(),
92                )
93                .log_with_source(e)
94            })?;
95
96        let dx = cholesky.solve(-&gradient);
97        self.hessian = Some(hessian);
98        self.gradient = Some(gradient);
99
100        Ok(dx)
101    }
102
103    fn solve_augmented_equation(
104        &mut self,
105        residuals: &Mat<f64>,
106        jacobians: &SparseColMat<usize, f64>,
107        damping: &Damping,
108    ) -> LinAlgResult<Mat<f64>> {
109        // H = JᵀJ, g = Jᵀr (parallel faer kernels)
110        let NormalEquations { hessian, gradient } = self.ne_cache.compute(residuals, jacobians)?;
111
112        // H_aug = H + λ·D — diagonal edit on the cached product pattern.
113        let augmented_hessian = self.ne_cache.damped_hessian(damping)?;
114
115        let sym = if let Some(ref cached_sym) = self.symbolic_factorization {
116            // Reuse cached symbolic factorization
117            // Note: SymbolicLlt is reference-counted, so clone() is cheap (O(1))
118            // We assume the sparsity pattern is constant across iterations
119            // which is typical in iterative optimization
120            cached_sym.clone()
121        } else {
122            // Create new symbolic factorization and cache it
123            let new_sym =
124                SymbolicLlt::try_new(augmented_hessian.symbolic(), Side::Lower).map_err(|e| {
125                    LinAlgError::FactorizationFailed(
126                        "Symbolic Cholesky decomposition failed for augmented system".to_string(),
127                    )
128                    .log_with_source(e)
129                })?;
130            // Cache it (clone is cheap due to reference counting)
131            self.symbolic_factorization = Some(new_sym.clone());
132            new_sym
133        };
134
135        // Perform numeric factorization
136        let cholesky = Llt::try_new_with_symbolic(sym, augmented_hessian.as_ref(), Side::Lower)
137            .map_err(|e| {
138                LinAlgError::SingularMatrix(
139                    "Cholesky factorization failed (matrix may be singular)".to_string(),
140                )
141                .log_with_source(e)
142            })?;
143
144        let dx = cholesky.solve(-&gradient);
145        self.hessian = Some(hessian);
146        self.gradient = Some(gradient);
147
148        Ok(dx)
149    }
150
151    fn hessian_vec_product(&self, v: &Mat<f64>) -> Option<Mat<f64>> {
152        Some(
153            <SparseMode as crate::linearizer::AssemblyBackend>::hessian_vec_product(
154                self.hessian.as_ref()?,
155                v,
156            ),
157        )
158    }
159
160    fn get_hessian(&self) -> Option<&SparseColMat<usize, f64>> {
161        self.hessian.as_ref()
162    }
163
164    fn get_gradient(&self) -> Option<&Mat<f64>> {
165        self.gradient.as_ref()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use faer::sparse::Triplet;
173
174    const TOLERANCE: f64 = 1e-10;
175
176    type TestResult = Result<(), Box<dyn std::error::Error>>;
177
178    /// Helper function to create a simple test matrix and vectors
179    fn create_test_data()
180    -> Result<(SparseColMat<usize, f64>, Mat<f64>), faer::sparse::CreationError> {
181        // Create an overdetermined system (4x3) so that weights have an effect
182        let triplets = vec![
183            Triplet::new(0, 0, 2.0),
184            Triplet::new(0, 1, 1.0),
185            Triplet::new(1, 0, 1.0),
186            Triplet::new(1, 1, 3.0),
187            Triplet::new(1, 2, 1.0),
188            Triplet::new(2, 1, 1.0),
189            Triplet::new(2, 2, 2.0),
190            Triplet::new(3, 0, 1.5), // Add a 4th row for overdetermined system
191            Triplet::new(3, 2, 0.5),
192        ];
193        let jacobian = SparseColMat::try_new_from_triplets(4, 3, &triplets)?;
194
195        let residuals = Mat::from_fn(4, 1, |i, _| match i {
196            0 => 1.0,
197            1 => -2.0,
198            2 => 0.5,
199            3 => 1.2,
200            _ => 0.0,
201        });
202
203        Ok((jacobian, residuals))
204    }
205
206    /// Test basic solver creation and default implementation
207    #[test]
208    fn test_solver_creation() {
209        let solver = SparseCholeskySolver::new();
210        assert!(solver.hessian.is_none());
211        assert!(solver.gradient.is_none());
212
213        let default_solver = SparseCholeskySolver::default();
214        assert!(default_solver.hessian.is_none());
215        assert!(default_solver.gradient.is_none());
216    }
217
218    /// Test normal equation solving with well-conditioned matrix
219    #[test]
220    fn test_solve_normal_equation_well_conditioned() -> TestResult {
221        let mut solver = SparseCholeskySolver::new();
222        let (jacobian, residuals) = create_test_data()?;
223
224        let solution =
225            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
226        assert_eq!(solution.nrows(), 3);
227        assert_eq!(solution.ncols(), 1);
228
229        // Verify the symbolic pattern was cached
230        Ok(())
231    }
232
233    /// Test that symbolic pattern is reused on subsequent calls
234    #[test]
235    fn test_symbolic_pattern_caching() -> TestResult {
236        let mut solver = SparseCholeskySolver::new();
237        let (jacobian, residuals) = create_test_data()?;
238
239        // First solve
240        let sol1 =
241            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
242
243        // Second solve should reuse pattern
244        let sol2 =
245            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
246
247        // Results should be identical
248        for i in 0..sol1.nrows() {
249            assert!((sol1[(i, 0)] - sol2[(i, 0)]).abs() < TOLERANCE);
250        }
251        Ok(())
252    }
253
254    /// Test augmented equation solving
255    #[test]
256    fn test_solve_augmented_equation() -> TestResult {
257        let mut solver = SparseCholeskySolver::new();
258        let (jacobian, residuals) = create_test_data()?;
259        let lambda = 0.1;
260
261        let solution = LinearSolver::<SparseMode>::solve_augmented_equation(
262            &mut solver,
263            &residuals,
264            &jacobian,
265            &Damping::identity(lambda),
266        )?;
267        assert_eq!(solution.nrows(), 3);
268        assert_eq!(solution.ncols(), 1);
269        Ok(())
270    }
271
272    /// Test with different lambda values in augmented system
273    #[test]
274    fn test_augmented_equation_different_lambdas() -> TestResult {
275        let mut solver = SparseCholeskySolver::new();
276        let (jacobian, residuals) = create_test_data()?;
277
278        let lambda1 = 0.01;
279        let lambda2 = 1.0;
280
281        let sol1 = LinearSolver::<SparseMode>::solve_augmented_equation(
282            &mut solver,
283            &residuals,
284            &jacobian,
285            &Damping::identity(lambda1),
286        )?;
287        let sol2 = LinearSolver::<SparseMode>::solve_augmented_equation(
288            &mut solver,
289            &residuals,
290            &jacobian,
291            &Damping::identity(lambda2),
292        )?;
293
294        // Solutions should be different due to different regularization
295        let mut different = false;
296        for i in 0..sol1.nrows() {
297            if (sol1[(i, 0)] - sol2[(i, 0)]).abs() > TOLERANCE {
298                different = true;
299                break;
300            }
301        }
302        assert!(
303            different,
304            "Solutions should differ with different lambda values"
305        );
306        Ok(())
307    }
308
309    /// Test with singular matrix (should return None)
310    #[test]
311    fn test_singular_matrix() -> TestResult {
312        let mut solver = SparseCholeskySolver::new();
313
314        // Create a singular matrix
315        let triplets = vec![
316            Triplet::new(0, 0, 1.0),
317            Triplet::new(0, 1, 2.0),
318            Triplet::new(1, 0, 2.0),
319            Triplet::new(1, 1, 4.0), // Second row is 2x first row
320        ];
321        let singular_jacobian = SparseColMat::try_new_from_triplets(2, 2, &triplets)?;
322        let residuals = Mat::from_fn(2, 1, |i, _| i as f64);
323
324        let result = LinearSolver::<SparseMode>::solve_normal_equation(
325            &mut solver,
326            &residuals,
327            &singular_jacobian,
328        );
329        // Without regularization, singular matrices should fail
330        assert!(result.is_err(), "Singular matrix should return Err");
331        Ok(())
332    }
333
334    /// Test with empty matrix (edge case)
335    #[test]
336    fn test_empty_matrix() -> TestResult {
337        let mut solver = SparseCholeskySolver::new();
338
339        let empty_jacobian = SparseColMat::try_new_from_triplets(0, 0, &[])?;
340        let empty_residuals = Mat::zeros(0, 1);
341
342        let result = LinearSolver::<SparseMode>::solve_normal_equation(
343            &mut solver,
344            &empty_residuals,
345            &empty_jacobian,
346        );
347        if let Ok(solution) = result {
348            assert_eq!(solution.nrows(), 0);
349        }
350        Ok(())
351    }
352
353    /// Test numerical accuracy with known solution
354    #[test]
355    fn test_numerical_accuracy() -> TestResult {
356        let mut solver = SparseCholeskySolver::new();
357
358        // Create a simple 2x2 system with known solution
359        let triplets = vec![
360            Triplet::new(0, 0, 1.0),
361            Triplet::new(0, 1, 0.0),
362            Triplet::new(1, 0, 0.0),
363            Triplet::new(1, 1, 1.0),
364        ];
365        let jacobian = SparseColMat::try_new_from_triplets(2, 2, &triplets)?;
366        let residuals = Mat::from_fn(2, 1, |i, _| -((i + 1) as f64)); // [-1, -2]
367
368        let solution =
369            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
370        // Expected solution should be [1, 2] since J^T * J = I and J^T * (-r) = [1, 2]
371        assert!((solution[(0, 0)] - 1.0).abs() < TOLERANCE);
372        assert!((solution[(1, 0)] - 2.0).abs() < TOLERANCE);
373        Ok(())
374    }
375
376    /// Test clone functionality
377    #[test]
378    fn test_solver_clone() {
379        let solver1 = SparseCholeskySolver::new();
380        let solver2 = solver1.clone();
381        assert!(solver2.hessian.is_none());
382        assert!(solver2.gradient.is_none());
383    }
384
385    /// Test Cholesky decomposition properties
386    #[test]
387    fn test_cholesky_decomposition_properties() -> TestResult {
388        let mut solver = SparseCholeskySolver::new();
389
390        // Create a simple positive definite system
391        let triplets = vec![Triplet::new(0, 0, 2.0), Triplet::new(1, 1, 3.0)];
392        let jacobian = SparseColMat::try_new_from_triplets(2, 2, &triplets)?;
393        let residuals = Mat::from_fn(2, 1, |i, _| (i + 1) as f64);
394
395        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
396
397        // Verify that we have a factorizer and hessian
398        assert!(solver.hessian.is_some());
399
400        // The hessian should be positive definite for Cholesky to work
401        if let Some(hessian) = &solver.hessian {
402            assert_eq!(hessian.nrows(), 2);
403            assert_eq!(hessian.ncols(), 2);
404        }
405        Ok(())
406    }
407
408    /// Test numerical stability with different condition numbers
409    #[test]
410    fn test_cholesky_numerical_stability() -> TestResult {
411        let mut solver = SparseCholeskySolver::new();
412
413        // Create a well-conditioned system
414        let triplets = vec![
415            Triplet::new(0, 0, 1.0),
416            Triplet::new(1, 1, 1.0),
417            Triplet::new(2, 2, 1.0),
418        ];
419        let jacobian = SparseColMat::try_new_from_triplets(3, 3, &triplets)?;
420        let residuals = Mat::from_fn(3, 1, |i, _| -((i + 1) as f64)); // [-1, -2, -3]
421
422        let solution =
423            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
424        // Expected solution should be [1, 2, 3] since H = I and g = [1, 2, 3]
425        for i in 0..3 {
426            let expected = (i + 1) as f64;
427            assert!(
428                (solution[(i, 0)] - expected).abs() < TOLERANCE,
429                "Expected {}, got {}",
430                expected,
431                solution[(i, 0)]
432            );
433        }
434
435        Ok(())
436    }
437
438    /// Test hessian() getter returns None before solve and Some after
439    #[test]
440    fn test_cholesky_hessian_getter() -> TestResult {
441        let mut solver = SparseCholeskySolver::new();
442        assert!(solver.hessian().is_none());
443
444        let (jacobian, residuals) = create_test_data()?;
445        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
446
447        assert!(solver.hessian().is_some());
448        Ok(())
449    }
450
451    /// Test gradient() getter returns None before solve and Some after
452    #[test]
453    fn test_cholesky_gradient_getter() -> TestResult {
454        let mut solver = SparseCholeskySolver::new();
455        assert!(solver.gradient().is_none());
456
457        let (jacobian, residuals) = create_test_data()?;
458        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
459
460        assert!(solver.gradient().is_some());
461        Ok(())
462    }
463
464    /// Test get_hessian() trait method returns Some after solve
465    #[test]
466    fn test_cholesky_get_hessian_trait() -> TestResult {
467        let mut solver = SparseCholeskySolver::new();
468        assert!(LinearSolver::<SparseMode>::get_hessian(&solver).is_none());
469
470        let (jacobian, residuals) = create_test_data()?;
471        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
472
473        assert!(LinearSolver::<SparseMode>::get_hessian(&solver).is_some());
474        Ok(())
475    }
476
477    /// Test get_gradient() trait method returns Some after solve
478    #[test]
479    fn test_cholesky_get_gradient_trait() -> TestResult {
480        let mut solver = SparseCholeskySolver::new();
481        assert!(LinearSolver::<SparseMode>::get_gradient(&solver).is_none());
482
483        let (jacobian, residuals) = create_test_data()?;
484        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
485
486        assert!(LinearSolver::<SparseMode>::get_gradient(&solver).is_some());
487        Ok(())
488    }
489}