Skip to main content

apex_solver/linalg/sparse/
qr.rs

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