Skip to main content

apex_solver/linalg/dense/
cholesky.rs

1use faer::{
2    Mat, Side,
3    linalg::solvers::{LltError, Solve},
4};
5
6use crate::error::ErrorLogging;
7use crate::linalg::{Damping, 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
23impl DenseCholeskySolver {
24    pub fn new() -> Self {
25        Self {
26            hessian: None,
27            gradient: None,
28        }
29    }
30
31    /// Solve with dense Jacobian directly (the core dense implementation).
32    fn solve_dense_normal(
33        &mut self,
34        residuals: &Mat<f64>,
35        jacobian: &Mat<f64>,
36    ) -> LinAlgResult<Mat<f64>> {
37        // H = J^T · J
38        let hessian = jacobian.transpose() * jacobian;
39        // g = J^T · r
40        let gradient = jacobian.transpose() * residuals;
41
42        // Solve H · dx = -g
43        let dx = solve_spd(
44            &hessian,
45            &(-&gradient),
46            "Dense Cholesky factorization failed",
47        )?;
48
49        self.hessian = Some(hessian);
50        self.gradient = Some(gradient);
51
52        Ok(dx)
53    }
54
55    /// Solve with dense Jacobian and damping (the core dense augmented implementation).
56    fn solve_dense_augmented(
57        &mut self,
58        residuals: &Mat<f64>,
59        jacobian: &Mat<f64>,
60        damping: &Damping,
61    ) -> LinAlgResult<Mat<f64>> {
62        // H = J^T · J
63        let hessian = jacobian.transpose() * jacobian;
64        // g = J^T · r
65        let gradient = jacobian.transpose() * residuals;
66
67        // H_aug = H + λ·D, D_jj = clamp(H_jj, min_diagonal, max_diagonal)
68        let n = hessian.nrows();
69        let mut augmented = hessian.clone();
70        for i in 0..n {
71            augmented[(i, i)] += damping.diagonal_term(hessian[(i, i)]);
72        }
73
74        // Solve H_aug · dx = -g
75        let dx = solve_spd(
76            &augmented,
77            &(-&gradient),
78            "Augmented dense Cholesky factorization failed",
79        )?;
80
81        // Cache the un-augmented Hessian (DogLeg/LM need the true quadratic model)
82        self.hessian = Some(hessian);
83        self.gradient = Some(gradient);
84
85        Ok(dx)
86    }
87}
88
89/// Solve `a · x = b` for SPD `a` via dense Cholesky (LLT).
90///
91/// Shared by every dense solver that reduces to one SPD system —
92/// `DenseCholeskySolver` and `ExplicitDenseSchur`'s reduced camera system —
93/// so the factorization and error-mapping logic exists in exactly one place.
94pub(crate) fn solve_spd(a: &Mat<f64>, b: &Mat<f64>, context: &str) -> LinAlgResult<Mat<f64>> {
95    let llt = a
96        .as_ref()
97        .llt(Side::Lower)
98        .map_err(|e| map_llt_error(e, context))?;
99    Ok(llt.solve(b))
100}
101
102impl Default for DenseCholeskySolver {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108// ============================================================================
109// LinearSolver<DenseMode>
110// ============================================================================
111
112impl LinearSolver<DenseMode> for DenseCholeskySolver {
113    fn solve_normal_equation(
114        &mut self,
115        residuals: &Mat<f64>,
116        jacobian: &Mat<f64>,
117    ) -> LinAlgResult<Mat<f64>> {
118        self.solve_dense_normal(residuals, jacobian)
119    }
120
121    fn solve_augmented_equation(
122        &mut self,
123        residuals: &Mat<f64>,
124        jacobian: &Mat<f64>,
125        damping: &Damping,
126    ) -> LinAlgResult<Mat<f64>> {
127        self.solve_dense_augmented(residuals, jacobian, damping)
128    }
129
130    fn hessian_vec_product(&self, v: &Mat<f64>) -> Option<Mat<f64>> {
131        Some(
132            <DenseMode as crate::linearizer::AssemblyBackend>::hessian_vec_product(
133                self.hessian.as_ref()?,
134                v,
135            ),
136        )
137    }
138
139    fn get_hessian(&self) -> Option<&Mat<f64>> {
140        self.hessian.as_ref()
141    }
142
143    fn get_gradient(&self) -> Option<&Mat<f64>> {
144        self.gradient.as_ref()
145    }
146}
147
148/// Map faer's LLT error to our LinAlgError
149fn map_llt_error(e: LltError, context: &str) -> LinAlgError {
150    LinAlgError::FactorizationFailed(format!("{context}: {e:?}")).log()
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    const TOLERANCE: f64 = 1e-10;
158
159    type TestResult = Result<(), Box<dyn std::error::Error>>;
160
161    fn create_test_data() -> (Mat<f64>, Mat<f64>) {
162        // 4×2 Jacobian (overdetermined)
163        let mut j = Mat::zeros(4, 2);
164        j[(0, 0)] = 2.0;
165        j[(0, 1)] = 1.0;
166        j[(1, 0)] = 1.0;
167        j[(1, 1)] = 3.0;
168        j[(2, 0)] = 1.0;
169        j[(2, 1)] = 1.0;
170        j[(3, 0)] = 0.5;
171        j[(3, 1)] = 2.0;
172
173        let mut r = Mat::zeros(4, 1);
174        r[(0, 0)] = 1.0;
175        r[(1, 0)] = 2.0;
176        r[(2, 0)] = 0.5;
177        r[(3, 0)] = 1.5;
178
179        (j, r)
180    }
181
182    #[test]
183    fn test_dense_cholesky_solve_normal() -> TestResult {
184        let (j, r) = create_test_data();
185        let mut solver = DenseCholeskySolver::new();
186
187        let dx = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
188
189        // Verify: J^T·J·dx ≈ -J^T·r
190        let jtj = j.transpose() * &j;
191        let jtr = j.transpose() * &r;
192        let residual = &jtj * &dx + &jtr;
193
194        for i in 0..dx.nrows() {
195            assert!(
196                residual[(i, 0)].abs() < TOLERANCE,
197                "Residual at index {}: {}",
198                i,
199                residual[(i, 0)]
200            );
201        }
202
203        assert!(solver.hessian.is_some());
204        assert!(solver.gradient.is_some());
205
206        Ok(())
207    }
208
209    #[test]
210    fn test_dense_cholesky_solve_augmented() -> TestResult {
211        let (j, r) = create_test_data();
212        let lambda = 0.1;
213        let mut solver = DenseCholeskySolver::new();
214
215        let dx = LinearSolver::<DenseMode>::solve_augmented_equation(
216            &mut solver,
217            &r,
218            &j,
219            &Damping::identity(lambda),
220        )?;
221
222        // Verify: (J^T·J + λI)·dx ≈ -J^T·r
223        let mut jtj = j.transpose() * &j;
224        let jtr = j.transpose() * &r;
225        for i in 0..jtj.nrows() {
226            jtj[(i, i)] += lambda;
227        }
228        let residual = &jtj * &dx + &jtr;
229
230        for i in 0..dx.nrows() {
231            assert!(
232                residual[(i, 0)].abs() < TOLERANCE,
233                "Residual at index {}: {}",
234                i,
235                residual[(i, 0)]
236            );
237        }
238
239        Ok(())
240    }
241
242    // -------------------------------------------------------------------------
243    // New tests for previously uncovered code paths
244    // -------------------------------------------------------------------------
245
246    /// get_hessian() and get_gradient() should return None before any solve.
247    #[test]
248    fn test_accessors_before_solve() {
249        let solver = DenseCholeskySolver::new();
250        assert!(
251            LinearSolver::<DenseMode>::get_hessian(&solver).is_none(),
252            "hessian should be None before solve"
253        );
254        assert!(
255            LinearSolver::<DenseMode>::get_gradient(&solver).is_none(),
256            "gradient should be None before solve"
257        );
258    }
259
260    /// DenseCholeskySolver::default() should behave identically to new().
261    #[test]
262    fn test_default_equals_new() {
263        let solver = DenseCholeskySolver::default();
264        assert!(solver.hessian.is_none());
265        assert!(solver.gradient.is_none());
266    }
267
268    /// A rank-deficient (singular) Jacobian should return an Err from solve_normal_equation.
269    #[test]
270    fn test_singular_jacobian_returns_error() {
271        let mut solver = DenseCholeskySolver::new();
272
273        // Jacobian with an all-zero column → H = J^T J is singular
274        let mut j = Mat::zeros(3, 2);
275        j[(0, 0)] = 1.0;
276        j[(1, 0)] = 2.0;
277        j[(2, 0)] = 3.0;
278        // column 1 is all zeros
279
280        let mut r = Mat::zeros(3, 1);
281        r[(0, 0)] = 1.0;
282
283        let result = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j);
284        assert!(
285            result.is_err(),
286            "Singular Jacobian must produce a factorization error"
287        );
288    }
289
290    /// solve_augmented_equation with lambda=0 should produce the same result as solve_normal_equation.
291    #[test]
292    fn test_augmented_with_zero_lambda_matches_normal() -> TestResult {
293        let (j, r) = create_test_data();
294        let mut solver_n = DenseCholeskySolver::new();
295        let mut solver_a = DenseCholeskySolver::new();
296
297        let dx_normal = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver_n, &r, &j)?;
298        let dx_augmented = LinearSolver::<DenseMode>::solve_augmented_equation(
299            &mut solver_a,
300            &r,
301            &j,
302            &Damping::identity(0.0),
303        )?;
304
305        for i in 0..dx_normal.nrows() {
306            assert!(
307                (dx_normal[(i, 0)] - dx_augmented[(i, 0)]).abs() < TOLERANCE,
308                "Element {} differs: normal={}, augmented(λ=0)={}",
309                i,
310                dx_normal[(i, 0)],
311                dx_augmented[(i, 0)]
312            );
313        }
314        Ok(())
315    }
316}