Skip to main content

apex_solver/linalg/dense/
qr.rs

1use faer::{Mat, linalg::solvers::Solve};
2
3use crate::linalg::{Damping, DenseMode, LinAlgResult, LinearSolver};
4
5/// Dense QR (column-pivoting) linear solver for CPU.
6///
7/// Optimal for small-to-medium problems (< 500 DOF) where the Hessian may be
8/// nearly rank-deficient. More robust than Cholesky for ill-conditioned systems
9/// because QR decomposition never fails on singular or near-singular matrices.
10#[derive(Debug, Clone)]
11pub struct DenseQRSolver {
12    /// Dense Hessian H = J^T · J (un-augmented, for covariance)
13    hessian: Option<Mat<f64>>,
14
15    /// Dense gradient g = J^T · r
16    gradient: Option<Mat<f64>>,
17}
18
19impl DenseQRSolver {
20    pub fn new() -> Self {
21        Self {
22            hessian: None,
23            gradient: None,
24        }
25    }
26
27    pub fn hessian(&self) -> Option<&Mat<f64>> {
28        self.hessian.as_ref()
29    }
30
31    pub fn gradient(&self) -> Option<&Mat<f64>> {
32        self.gradient.as_ref()
33    }
34
35    /// Solve with dense Jacobian directly (the core dense QR implementation).
36    fn solve_dense_normal(
37        &mut self,
38        residuals: &Mat<f64>,
39        jacobian: &Mat<f64>,
40    ) -> LinAlgResult<Mat<f64>> {
41        // H = J^T · J
42        let hessian = jacobian.transpose() * jacobian;
43        // g = J^T · r
44        let gradient = jacobian.transpose() * residuals;
45
46        // Dense column-pivoting QR factorization (never fails, handles rank-deficient cases)
47        let qr = hessian.as_ref().col_piv_qr();
48
49        // Solve H · dx = -g
50        let dx = qr.solve(-&gradient);
51
52        self.hessian = Some(hessian);
53        self.gradient = Some(gradient);
54
55        Ok(dx)
56    }
57
58    /// Solve with dense Jacobian and LM damping (the core dense QR augmented implementation).
59    fn solve_dense_augmented(
60        &mut self,
61        residuals: &Mat<f64>,
62        jacobian: &Mat<f64>,
63        damping: &Damping,
64    ) -> LinAlgResult<Mat<f64>> {
65        // H = J^T · J
66        let hessian = jacobian.transpose() * jacobian;
67        // g = J^T · r
68        let gradient = jacobian.transpose() * residuals;
69
70        // H_aug = H + λ·D, D_jj = clamp(H_jj, min_diagonal, max_diagonal)
71        let n = hessian.nrows();
72        let mut augmented = hessian.clone();
73        for i in 0..n {
74            augmented[(i, i)] += damping.diagonal_term(hessian[(i, i)]);
75        }
76
77        // QR factorization on augmented system
78        let qr = augmented.as_ref().col_piv_qr();
79
80        // Solve H_aug · dx = -g
81        let dx = qr.solve(-&gradient);
82
83        // Cache the un-augmented Hessian (DogLeg/LM need the true quadratic model)
84        self.hessian = Some(hessian);
85        self.gradient = Some(gradient);
86
87        Ok(dx)
88    }
89}
90
91impl Default for DenseQRSolver {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97// ============================================================================
98// LinearSolver<DenseMode>
99// ============================================================================
100
101impl LinearSolver<DenseMode> for DenseQRSolver {
102    fn solve_normal_equation(
103        &mut self,
104        residuals: &Mat<f64>,
105        jacobian: &Mat<f64>,
106    ) -> LinAlgResult<Mat<f64>> {
107        self.solve_dense_normal(residuals, jacobian)
108    }
109
110    fn solve_augmented_equation(
111        &mut self,
112        residuals: &Mat<f64>,
113        jacobian: &Mat<f64>,
114        damping: &Damping,
115    ) -> LinAlgResult<Mat<f64>> {
116        self.solve_dense_augmented(residuals, jacobian, damping)
117    }
118
119    fn hessian_vec_product(&self, v: &Mat<f64>) -> Option<Mat<f64>> {
120        Some(
121            <DenseMode as crate::linearizer::AssemblyBackend>::hessian_vec_product(
122                self.hessian.as_ref()?,
123                v,
124            ),
125        )
126    }
127
128    fn get_hessian(&self) -> Option<&Mat<f64>> {
129        self.hessian.as_ref()
130    }
131
132    fn get_gradient(&self) -> Option<&Mat<f64>> {
133        self.gradient.as_ref()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    const TOLERANCE: f64 = 1e-10;
142
143    type TestResult = Result<(), Box<dyn std::error::Error>>;
144
145    fn create_test_data() -> (Mat<f64>, Mat<f64>) {
146        // 4×2 Jacobian (overdetermined)
147        let mut j = Mat::zeros(4, 2);
148        j[(0, 0)] = 2.0;
149        j[(0, 1)] = 1.0;
150        j[(1, 0)] = 1.0;
151        j[(1, 1)] = 3.0;
152        j[(2, 0)] = 1.0;
153        j[(2, 1)] = 1.0;
154        j[(3, 0)] = 0.5;
155        j[(3, 1)] = 2.0;
156
157        let mut r = Mat::zeros(4, 1);
158        r[(0, 0)] = 1.0;
159        r[(1, 0)] = 2.0;
160        r[(2, 0)] = 0.5;
161        r[(3, 0)] = 1.5;
162
163        (j, r)
164    }
165
166    #[test]
167    fn test_dense_qr_solver_creation() {
168        let solver = DenseQRSolver::new();
169        assert!(solver.hessian.is_none());
170        assert!(solver.gradient.is_none());
171
172        let default_solver = DenseQRSolver::default();
173        assert!(default_solver.hessian.is_none());
174        assert!(default_solver.gradient.is_none());
175    }
176
177    #[test]
178    fn test_dense_qr_solve_normal_equation() -> TestResult {
179        let (j, r) = create_test_data();
180        let mut solver = DenseQRSolver::new();
181
182        let dx = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
183
184        // Verify: J^T·J·dx ≈ -J^T·r
185        let jtj = j.transpose() * &j;
186        let jtr = j.transpose() * &r;
187        let residual = &jtj * &dx + &jtr;
188
189        for i in 0..dx.nrows() {
190            assert!(
191                residual[(i, 0)].abs() < TOLERANCE,
192                "Residual at index {i}: {}",
193                residual[(i, 0)]
194            );
195        }
196
197        assert!(solver.hessian.is_some());
198        assert!(solver.gradient.is_some());
199
200        Ok(())
201    }
202
203    #[test]
204    fn test_dense_qr_solve_augmented_equation() -> TestResult {
205        let (j, r) = create_test_data();
206        let lambda = 0.1;
207        let mut solver = DenseQRSolver::new();
208
209        let dx = LinearSolver::<DenseMode>::solve_augmented_equation(
210            &mut solver,
211            &r,
212            &j,
213            &Damping::identity(lambda),
214        )?;
215
216        // Verify: (J^T·J + λI)·dx ≈ -J^T·r
217        let mut jtj = j.transpose() * &j;
218        let jtr = j.transpose() * &r;
219        for i in 0..jtj.nrows() {
220            jtj[(i, i)] += lambda;
221        }
222        let residual = &jtj * &dx + &jtr;
223
224        for i in 0..dx.nrows() {
225            assert!(
226                residual[(i, 0)].abs() < TOLERANCE,
227                "Residual at index {i}: {}",
228                residual[(i, 0)]
229            );
230        }
231
232        Ok(())
233    }
234
235    #[test]
236    fn test_dense_qr_augmented_different_lambdas() -> TestResult {
237        let (j, r) = create_test_data();
238        let mut solver = DenseQRSolver::new();
239
240        let dx1 = LinearSolver::<DenseMode>::solve_augmented_equation(
241            &mut solver,
242            &r,
243            &j,
244            &Damping::identity(0.01),
245        )?;
246        let dx2 = LinearSolver::<DenseMode>::solve_augmented_equation(
247            &mut solver,
248            &r,
249            &j,
250            &Damping::identity(1.0),
251        )?;
252
253        let mut different = false;
254        for i in 0..dx1.nrows() {
255            if (dx1[(i, 0)] - dx2[(i, 0)]).abs() > TOLERANCE {
256                different = true;
257                break;
258            }
259        }
260        assert!(
261            different,
262            "Solutions should differ with different lambda values"
263        );
264
265        Ok(())
266    }
267
268    #[test]
269    fn test_dense_qr_rank_deficient_matrix() -> TestResult {
270        let mut solver = DenseQRSolver::new();
271
272        // Rank-deficient Jacobian (3×3, rank 2): second row = 2 × first row
273        let mut j = Mat::zeros(3, 3);
274        j[(0, 0)] = 1.0;
275        j[(0, 1)] = 2.0;
276        j[(0, 2)] = 3.0;
277        j[(1, 0)] = 2.0;
278        j[(1, 1)] = 4.0;
279        j[(1, 2)] = 6.0;
280        j[(2, 0)] = 0.0;
281        j[(2, 1)] = 0.0;
282        j[(2, 2)] = 1.0;
283
284        let mut r = Mat::zeros(3, 1);
285        r[(0, 0)] = 1.0;
286        r[(1, 0)] = 2.0;
287        r[(2, 0)] = 3.0;
288
289        let result = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j);
290        assert!(result.is_ok(), "QR should handle rank-deficient matrices");
291
292        Ok(())
293    }
294
295    #[test]
296    fn test_dense_qr_numerical_accuracy() -> TestResult {
297        let mut solver = DenseQRSolver::new();
298
299        // Identity system: I * x = -b → solution should be b
300        let mut j = Mat::zeros(3, 3);
301        j[(0, 0)] = 1.0;
302        j[(1, 1)] = 1.0;
303        j[(2, 2)] = 1.0;
304
305        let mut r = Mat::zeros(3, 1);
306        r[(0, 0)] = -1.0;
307        r[(1, 0)] = -2.0;
308        r[(2, 0)] = -3.0;
309
310        let dx = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
311
312        for i in 0..3 {
313            let expected = (i + 1) as f64;
314            assert!(
315                (dx[(i, 0)] - expected).abs() < TOLERANCE,
316                "Expected {expected}, got {}",
317                dx[(i, 0)]
318            );
319        }
320
321        Ok(())
322    }
323
324    #[test]
325    fn test_dense_qr_solver_clone() {
326        let solver1 = DenseQRSolver::new();
327        let solver2 = solver1.clone();
328        assert!(solver2.hessian.is_none());
329        assert!(solver2.gradient.is_none());
330    }
331
332    #[test]
333    fn test_dense_qr_zero_lambda_augmented() -> TestResult {
334        let (j, r) = create_test_data();
335        let mut solver = DenseQRSolver::new();
336
337        let normal_dx = LinearSolver::<DenseMode>::solve_normal_equation(&mut solver, &r, &j)?;
338        let augmented_dx = LinearSolver::<DenseMode>::solve_augmented_equation(
339            &mut solver,
340            &r,
341            &j,
342            &Damping::identity(0.0),
343        )?;
344
345        for i in 0..normal_dx.nrows() {
346            assert!(
347                (normal_dx[(i, 0)] - augmented_dx[(i, 0)]).abs() < 1e-8,
348                "Zero-lambda augmented should match normal equation"
349            );
350        }
351
352        Ok(())
353    }
354}