Skip to main content

apex_solver/linalg/dense/
cholesky.rs

1use faer::{
2    Mat, Side,
3    linalg::solvers::{Llt, LltError, Solve},
4};
5
6use crate::error::ErrorLogging;
7use crate::linalg::{DenseMode, LinAlgError, LinAlgResult, LinearSolver};
8
9/// Dense Cholesky (LLT) linear solver for CPU.
10///
11/// Optimal for small-to-medium problems (< 500 DOF) where the Hessian is
12/// moderately dense. Avoids sparse data structure overhead and benefits
13/// from dense BLAS routines.
14#[derive(Debug, Clone)]
15pub struct DenseCholeskySolver {
16    /// Dense Hessian H = J^T · J
17    hessian: Option<Mat<f64>>,
18
19    /// Dense gradient g = J^T · r
20    gradient: Option<Mat<f64>>,
21
22    /// Cached dense Cholesky factorization for covariance computation
23    factorizer: Option<Llt<f64>>,
24
25    /// The parameter covariance matrix (H^{-1}), computed lazily
26    covariance_matrix: Option<Mat<f64>>,
27}
28
29impl DenseCholeskySolver {
30    pub fn new() -> Self {
31        Self {
32            hessian: None,
33            gradient: None,
34            factorizer: None,
35            covariance_matrix: None,
36        }
37    }
38
39    /// Solve with dense Jacobian directly (the core dense implementation).
40    fn solve_dense_normal(
41        &mut self,
42        residuals: &Mat<f64>,
43        jacobian: &Mat<f64>,
44    ) -> LinAlgResult<Mat<f64>> {
45        // H = J^T · J
46        let hessian = jacobian.transpose() * jacobian;
47        // g = J^T · r
48        let gradient = jacobian.transpose() * residuals;
49
50        // Dense Cholesky factorization
51        let llt = hessian
52            .as_ref()
53            .llt(Side::Lower)
54            .map_err(|e| map_llt_error(e, "Dense Cholesky factorization failed"))?;
55
56        // Solve H · dx = -g
57        let dx = llt.solve(-&gradient);
58
59        self.factorizer = Some(llt);
60        self.hessian = Some(hessian);
61        self.gradient = Some(gradient);
62        self.covariance_matrix = None;
63
64        Ok(dx)
65    }
66
67    /// Solve with dense Jacobian and damping (the core dense augmented implementation).
68    fn solve_dense_augmented(
69        &mut self,
70        residuals: &Mat<f64>,
71        jacobian: &Mat<f64>,
72        lambda: f64,
73    ) -> LinAlgResult<Mat<f64>> {
74        // H = J^T · J
75        let hessian = jacobian.transpose() * jacobian;
76        // g = J^T · r
77        let gradient = jacobian.transpose() * residuals;
78
79        // H_aug = H + λI
80        let n = hessian.nrows();
81        let mut augmented = hessian.clone();
82        for i in 0..n {
83            augmented[(i, i)] += lambda;
84        }
85
86        // Dense Cholesky factorization on augmented system
87        let llt = augmented
88            .as_ref()
89            .llt(Side::Lower)
90            .map_err(|e| map_llt_error(e, "Augmented dense Cholesky factorization failed"))?;
91
92        // Solve H_aug · dx = -g
93        let dx = llt.solve(-&gradient);
94
95        // Cache the un-augmented Hessian (DogLeg/LM need the true quadratic model)
96        self.factorizer = Some(llt);
97        self.hessian = Some(hessian);
98        self.gradient = Some(gradient);
99        self.covariance_matrix = None;
100
101        Ok(dx)
102    }
103}
104
105impl Default for DenseCholeskySolver {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111// ============================================================================
112// LinearSolver<DenseMode>
113// ============================================================================
114
115impl LinearSolver<DenseMode> for DenseCholeskySolver {
116    fn solve_normal_equation(
117        &mut self,
118        residuals: &Mat<f64>,
119        jacobian: &Mat<f64>,
120    ) -> LinAlgResult<Mat<f64>> {
121        self.solve_dense_normal(residuals, jacobian)
122    }
123
124    fn solve_augmented_equation(
125        &mut self,
126        residuals: &Mat<f64>,
127        jacobian: &Mat<f64>,
128        lambda: f64,
129    ) -> LinAlgResult<Mat<f64>> {
130        self.solve_dense_augmented(residuals, jacobian, lambda)
131    }
132
133    fn get_hessian(&self) -> Option<&Mat<f64>> {
134        self.hessian.as_ref()
135    }
136
137    fn get_gradient(&self) -> Option<&Mat<f64>> {
138        self.gradient.as_ref()
139    }
140
141    fn compute_covariance_matrix(&mut self) -> Option<&Mat<f64>> {
142        if self.covariance_matrix.is_none()
143            && let Some(hessian) = &self.hessian
144            && let Some(factorizer) = &self.factorizer
145        {
146            let n = hessian.nrows();
147            let identity = Mat::identity(n, n);
148            let cov = factorizer.solve(&identity);
149            self.covariance_matrix = Some(cov);
150        }
151        self.covariance_matrix.as_ref()
152    }
153
154    fn get_covariance_matrix(&self) -> Option<&Mat<f64>> {
155        self.covariance_matrix.as_ref()
156    }
157}
158
159/// Map faer's LLT error to our LinAlgError
160fn map_llt_error(e: LltError, context: &str) -> LinAlgError {
161    LinAlgError::FactorizationFailed(format!("{context}: {e:?}")).log()
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    const TOLERANCE: f64 = 1e-10;
169
170    type TestResult = Result<(), Box<dyn std::error::Error>>;
171
172    fn create_test_data() -> (Mat<f64>, Mat<f64>) {
173        // 4×2 Jacobian (overdetermined)
174        let mut j = Mat::zeros(4, 2);
175        j[(0, 0)] = 2.0;
176        j[(0, 1)] = 1.0;
177        j[(1, 0)] = 1.0;
178        j[(1, 1)] = 3.0;
179        j[(2, 0)] = 1.0;
180        j[(2, 1)] = 1.0;
181        j[(3, 0)] = 0.5;
182        j[(3, 1)] = 2.0;
183
184        let mut r = Mat::zeros(4, 1);
185        r[(0, 0)] = 1.0;
186        r[(1, 0)] = 2.0;
187        r[(2, 0)] = 0.5;
188        r[(3, 0)] = 1.5;
189
190        (j, r)
191    }
192
193    #[test]
194    fn test_dense_cholesky_solve_normal() -> TestResult {
195        let (j, r) = create_test_data();
196        let mut solver = DenseCholeskySolver::new();
197
198        let dx = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
199
200        // Verify: J^T·J·dx ≈ -J^T·r
201        let jtj = j.transpose() * &j;
202        let jtr = j.transpose() * &r;
203        let residual = &jtj * &dx + &jtr;
204
205        for i in 0..dx.nrows() {
206            assert!(
207                residual[(i, 0)].abs() < TOLERANCE,
208                "Residual at index {}: {}",
209                i,
210                residual[(i, 0)]
211            );
212        }
213
214        assert!(solver.hessian.is_some());
215        assert!(solver.gradient.is_some());
216
217        Ok(())
218    }
219
220    #[test]
221    fn test_dense_cholesky_solve_augmented() -> TestResult {
222        let (j, r) = create_test_data();
223        let lambda = 0.1;
224        let mut solver = DenseCholeskySolver::new();
225
226        let dx = LinearSolver::<DenseMode>::solve_augmented_equation(&mut solver, &r, &j, lambda)?;
227
228        // Verify: (J^T·J + λI)·dx ≈ -J^T·r
229        let mut jtj = j.transpose() * &j;
230        let jtr = j.transpose() * &r;
231        for i in 0..jtj.nrows() {
232            jtj[(i, i)] += lambda;
233        }
234        let residual = &jtj * &dx + &jtr;
235
236        for i in 0..dx.nrows() {
237            assert!(
238                residual[(i, 0)].abs() < TOLERANCE,
239                "Residual at index {}: {}",
240                i,
241                residual[(i, 0)]
242            );
243        }
244
245        Ok(())
246    }
247
248    #[test]
249    fn test_dense_cholesky_covariance_computation() -> TestResult {
250        let (j, r) = create_test_data();
251        let mut solver = DenseCholeskySolver::new();
252
253        LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
254
255        let cov = LinearSolver::<DenseMode>::compute_covariance_matrix(&mut solver)
256            .ok_or("covariance should be computable")?;
257        let n = cov.nrows();
258
259        // Symmetry
260        for i in 0..n {
261            for k in 0..n {
262                assert!(
263                    (cov[(i, k)] - cov[(k, i)]).abs() < TOLERANCE,
264                    "Covariance not symmetric at ({i}, {k})"
265                );
266            }
267        }
268
269        // Positive diagonal
270        for i in 0..n {
271            assert!(cov[(i, i)] > 0.0, "Diagonal entry {i} should be positive");
272        }
273
274        Ok(())
275    }
276
277    #[test]
278    fn test_dense_cholesky_covariance_well_conditioned() -> TestResult {
279        let mut solver = DenseCholeskySolver::new();
280
281        // J = diag(2, 3) → H = diag(4, 9) → H^{-1} = diag(0.25, 1/9)
282        let mut j = Mat::zeros(2, 2);
283        j[(0, 0)] = 2.0;
284        j[(1, 1)] = 3.0;
285
286        let mut r = Mat::zeros(2, 1);
287        r[(0, 0)] = 1.0;
288        r[(1, 0)] = 2.0;
289
290        LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
291
292        let cov = LinearSolver::<DenseMode>::compute_covariance_matrix(&mut solver)
293            .ok_or("covariance computation failed")?;
294        assert!(
295            (cov[(0, 0)] - 0.25).abs() < TOLERANCE,
296            "cov[0,0] should be 0.25"
297        );
298        assert!(
299            (cov[(1, 1)] - 1.0 / 9.0).abs() < TOLERANCE,
300            "cov[1,1] should be 1/9"
301        );
302        assert!(cov[(0, 1)].abs() < TOLERANCE);
303        assert!(cov[(1, 0)].abs() < TOLERANCE);
304
305        Ok(())
306    }
307
308    #[test]
309    fn test_dense_cholesky_covariance_caching() -> TestResult {
310        let (j, r) = create_test_data();
311        let mut solver = DenseCholeskySolver::new();
312
313        LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
314
315        LinearSolver::<DenseMode>::compute_covariance_matrix(&mut solver);
316        let ptr1 = solver
317            .covariance_matrix
318            .as_ref()
319            .ok_or("covariance not cached after first call")?
320            .as_ptr();
321
322        // Second call should return cached result (same pointer)
323        LinearSolver::<DenseMode>::compute_covariance_matrix(&mut solver);
324        let ptr2 = solver
325            .covariance_matrix
326            .as_ref()
327            .ok_or("covariance not cached after second call")?
328            .as_ptr();
329
330        assert_eq!(ptr1, ptr2, "Covariance matrix should be cached");
331
332        Ok(())
333    }
334
335    // -------------------------------------------------------------------------
336    // New tests for previously uncovered code paths
337    // -------------------------------------------------------------------------
338
339    /// get_hessian() and get_gradient() should return None before any solve.
340    #[test]
341    fn test_accessors_before_solve() {
342        let solver = DenseCholeskySolver::new();
343        assert!(
344            LinearSolver::<DenseMode>::get_hessian(&solver).is_none(),
345            "hessian should be None before solve"
346        );
347        assert!(
348            LinearSolver::<DenseMode>::get_gradient(&solver).is_none(),
349            "gradient should be None before solve"
350        );
351    }
352
353    /// get_covariance_matrix() should return None before any solve.
354    #[test]
355    fn test_get_covariance_before_solve() {
356        let mut solver = DenseCholeskySolver::new();
357        // No solve has happened yet
358        assert!(
359            LinearSolver::<DenseMode>::compute_covariance_matrix(&mut solver).is_none(),
360            "covariance should be None when no factorizer is cached"
361        );
362        assert!(
363            LinearSolver::<DenseMode>::get_covariance_matrix(&solver).is_none(),
364            "get_covariance_matrix should be None before compute"
365        );
366    }
367
368    /// DenseCholeskySolver::default() should behave identically to new().
369    #[test]
370    fn test_default_equals_new() {
371        let solver = DenseCholeskySolver::default();
372        assert!(solver.hessian.is_none());
373        assert!(solver.gradient.is_none());
374        assert!(solver.factorizer.is_none());
375        assert!(solver.covariance_matrix.is_none());
376    }
377
378    /// A rank-deficient (singular) Jacobian should return an Err from solve_normal_equation.
379    #[test]
380    fn test_singular_jacobian_returns_error() {
381        let mut solver = DenseCholeskySolver::new();
382
383        // Jacobian with an all-zero column → H = J^T J is singular
384        let mut j = Mat::zeros(3, 2);
385        j[(0, 0)] = 1.0;
386        j[(1, 0)] = 2.0;
387        j[(2, 0)] = 3.0;
388        // column 1 is all zeros
389
390        let mut r = Mat::zeros(3, 1);
391        r[(0, 0)] = 1.0;
392
393        let result = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j);
394        assert!(
395            result.is_err(),
396            "Singular Jacobian must produce a factorization error"
397        );
398    }
399
400    /// After a second solve, the cached covariance_matrix should be reset to None.
401    #[test]
402    fn test_covariance_reset_after_second_solve() -> TestResult {
403        let (j, r) = create_test_data();
404        let mut solver = DenseCholeskySolver::new();
405
406        // First solve + covariance computation
407        LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
408        LinearSolver::<DenseMode>::compute_covariance_matrix(&mut solver);
409        assert!(
410            solver.covariance_matrix.is_some(),
411            "covariance should exist after first compute"
412        );
413
414        // Second solve should invalidate the cached covariance
415        LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
416        assert!(
417            solver.covariance_matrix.is_none(),
418            "covariance should be None after a new solve"
419        );
420        Ok(())
421    }
422
423    /// solve_augmented_equation with lambda=0 should produce the same result as solve_normal_equation.
424    #[test]
425    fn test_augmented_with_zero_lambda_matches_normal() -> TestResult {
426        let (j, r) = create_test_data();
427        let mut solver_n = DenseCholeskySolver::new();
428        let mut solver_a = DenseCholeskySolver::new();
429
430        let dx_normal = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver_n, &r, &j)?;
431        let dx_augmented =
432            LinearSolver::<DenseMode>::solve_augmented_equation(&mut solver_a, &r, &j, 0.0)?;
433
434        for i in 0..dx_normal.nrows() {
435            assert!(
436                (dx_normal[(i, 0)] - dx_augmented[(i, 0)]).abs() < TOLERANCE,
437                "Element {} differs: normal={}, augmented(λ=0)={}",
438                i,
439                dx_normal[(i, 0)],
440                dx_augmented[(i, 0)]
441            );
442        }
443        Ok(())
444    }
445}