Skip to main content

rivrs_sparse/
validate.rs

1//! Numerical validation utilities for solver correctness testing.
2//!
3//! Provides functions to verify LDL^T factorization correctness via:
4//! - **Reconstruction error**: `||P^T A P - L D L^T||_F / ||A||_F`
5//! - **Backward error**: `||Ax - b|| / (||A||_F ||x|| + ||b||)`
6//! - **Inertia comparison**: field-wise equality of eigenvalue sign counts
7//! - **Permutation validation**: checks that a permutation is a valid bijection
8
9use faer::linalg::matmul::matmul;
10use faer::sparse::SparseColMat;
11use faer::{Accum, Col, Mat, Par};
12
13use crate::error::SparseError;
14use crate::io::reference::{DBlock, Inertia, ReferenceFactorization};
15
16/// Validate that `perm` is a valid permutation of `0..n`.
17///
18/// Returns `Ok(())` if `perm` has exactly `n` elements and is a bijection on `0..n`.
19///
20/// # Errors
21///
22/// - `perm.len() != n`
23/// - Any element is out of bounds (`>= n`)
24/// - Any element appears more than once
25pub fn validate_permutation(perm: &[usize], n: usize) -> Result<(), SparseError> {
26    if perm.len() != n {
27        return Err(SparseError::InvalidInput {
28            reason: format!(
29                "permutation length ({}) does not match expected size ({})",
30                perm.len(),
31                n
32            ),
33        });
34    }
35    let mut seen = vec![false; n];
36    for (i, &p) in perm.iter().enumerate() {
37        if p >= n {
38            return Err(SparseError::InvalidInput {
39                reason: format!("permutation[{}] = {} is out of bounds (n = {})", i, p, n),
40            });
41        }
42        if seen[p] {
43            return Err(SparseError::InvalidInput {
44                reason: format!("permutation has duplicate index {} at position {}", p, i),
45            });
46        }
47        seen[p] = true;
48    }
49    Ok(())
50}
51
52/// Compute dense matrix-vector product `A * x`.
53///
54/// Converts `A` to dense and uses BLAS-3 matmul. Intended for validation
55/// of small-to-medium matrices in tests, not for production solves.
56///
57/// # Future optimization
58///
59/// For large matrices, use `faer::sparse::linalg::matmul::sparse_dense_matmul`
60/// to avoid the O(n^2) dense conversion.
61pub fn dense_matvec(a: &SparseColMat<usize, f64>, x: &Col<f64>) -> Col<f64> {
62    let a_dense = a.to_dense();
63    let n = a.nrows();
64    let x_mat = x.as_mat();
65    let mut result = Mat::<f64>::zeros(n, 1);
66    matmul(
67        result.as_mut(),
68        Accum::Replace,
69        a_dense.as_ref(),
70        x_mat,
71        1.0,
72        Par::Seq,
73    );
74    Col::from_fn(n, |i| result[(i, 0)])
75}
76
77/// Compute the relative reconstruction error of a factorization.
78///
79/// Returns `||P^T A P - L D L^T||_F / ||A||_F` where L, D, P come from
80/// the reference factorization. The Frobenius norm is computed via faer's
81/// `norm_l2()` method on `Mat`, which returns `||M||_F` (not the spectral 2-norm).
82///
83/// Returns 0.0 if `||A||_F` is zero.
84pub fn reconstruction_error(
85    a: &SparseColMat<usize, f64>,
86    reference: &ReferenceFactorization,
87) -> f64 {
88    let n = a.nrows();
89    let a_dense = a.to_dense();
90    // faer's Mat::norm_l2() computes the Frobenius norm: sqrt(sum of squared entries)
91    let a_norm = a_dense.norm_l2();
92    if a_norm == 0.0 {
93        return 0.0;
94    }
95
96    // Build P^T A P by reindexing: (PAP)_{i,j} = A_{p[i], p[j]}
97    let perm = &reference.permutation;
98    let pap = Mat::<f64>::from_fn(n, n, |i, j| a_dense[(perm[i], perm[j])]);
99
100    // Build L: n×n identity + strict lower triangle entries
101    let mut l_dense = Mat::<f64>::identity(n, n);
102    for entry in &reference.l_entries {
103        l_dense[(entry.row, entry.col)] = entry.value;
104    }
105
106    // Build D: n×n block diagonal
107    let mut d_dense = Mat::<f64>::zeros(n, n);
108    let mut row_offset = 0;
109    for block in &reference.d_blocks {
110        match block {
111            DBlock::OneByOne { value } => {
112                d_dense[(row_offset, row_offset)] = *value;
113                row_offset += 1;
114            }
115            DBlock::TwoByTwo { values } => {
116                d_dense[(row_offset, row_offset)] = values[0][0];
117                d_dense[(row_offset, row_offset + 1)] = values[0][1];
118                d_dense[(row_offset + 1, row_offset)] = values[1][0];
119                d_dense[(row_offset + 1, row_offset + 1)] = values[1][1];
120                row_offset += 2;
121            }
122        }
123    }
124
125    // Compute L * D
126    let mut ld = Mat::<f64>::zeros(n, n);
127    matmul(
128        ld.as_mut(),
129        Accum::Replace,
130        l_dense.as_ref(),
131        d_dense.as_ref(),
132        1.0,
133        Par::Seq,
134    );
135
136    // Compute L * D * L^T
137    let mut ldlt = Mat::<f64>::zeros(n, n);
138    matmul(
139        ldlt.as_mut(),
140        Accum::Replace,
141        ld.as_ref(),
142        l_dense.as_ref().transpose(),
143        1.0,
144        Par::Seq,
145    );
146
147    // Compute ||P^T A P - L D L^T||_F / ||A||_F
148    let diff = &pap - &ldlt;
149    diff.norm_l2() / a_norm
150}
151
152/// Compute the scaled backward error for a linear solve.
153///
154/// Returns `||Ax - b|| / (||A||_F * ||x|| + ||b||)`.
155///
156/// Returns 0.0 if the denominator is zero (which only occurs when both `A` and `b`
157/// are zero, since the denominator is a sum of non-negative IEEE 754 values).
158///
159/// # Future optimization
160///
161/// Currently converts `A` to dense for the matrix-vector product. For large
162/// matrices, use `faer::sparse::linalg::matmul::sparse_dense_matmul` to avoid
163/// the O(n^2) dense conversion.
164pub fn backward_error(a: &SparseColMat<usize, f64>, x: &Col<f64>, b: &Col<f64>) -> f64 {
165    let n = a.nrows();
166    let a_dense = a.to_dense();
167
168    // Compute r = A*x - b using dense matmul
169    let x_mat = x.as_mat();
170    let mut ax = Mat::<f64>::zeros(n, 1);
171    matmul(
172        ax.as_mut(),
173        Accum::Replace,
174        a_dense.as_ref(),
175        x_mat,
176        1.0,
177        Par::Seq,
178    );
179
180    // r = Ax - b
181    let r = Col::<f64>::from_fn(n, |i| ax[(i, 0)] - b[i]);
182
183    let r_norm = r.norm_l2();
184    let a_norm = a_dense.norm_l2();
185    let x_norm = x.norm_l2();
186    let b_norm = b.norm_l2();
187
188    // The denominator is a sum of non-negative IEEE 754 values, so it is
189    // exactly 0.0 only when all terms are exactly 0.0 (i.e., A=0 and b=0).
190    let denom = a_norm * x_norm + b_norm;
191    if denom == 0.0 {
192        return 0.0;
193    }
194
195    r_norm / denom
196}
197
198/// Compute backward error using sparse matrix-vector multiply.
199///
200/// Returns `||Ax - b|| / (||A||_F * ||x|| + ||b||)`.
201/// Uses direct sparse iteration to avoid O(n^2) dense conversion.
202///
203/// **Matrix storage**: Expects a full symmetric CSC matrix (both triangles
204/// stored), which is what our `.mtx` reader and `SparseColMat::try_new_from_triplets`
205/// produce. Each off-diagonal entry (i,j) appears in both column i and column j.
206///
207/// The Frobenius norm and matrix-vector product are computed directly from
208/// the stored entries. This is O(nnz) work and O(n) extra memory.
209pub fn sparse_backward_error(a: &SparseColMat<usize, f64>, x: &Col<f64>, b: &Col<f64>) -> f64 {
210    let n = a.nrows();
211
212    // Compute A*x directly from the full symmetric CSC.
213    // Since both triangles are stored, a regular matvec is correct.
214    let symbolic = a.symbolic();
215    let col_ptrs = symbolic.col_ptr();
216    let row_indices = symbolic.row_idx();
217    let values = a.val();
218
219    let mut ax = vec![0.0f64; n];
220    for j in 0..n {
221        for idx in col_ptrs[j]..col_ptrs[j + 1] {
222            let i = row_indices[idx];
223            let v = values[idx];
224            ax[i] += v * x[j];
225        }
226    }
227
228    // Compute residual r = Ax - b
229    let mut r_norm_sq = 0.0;
230    for k in 0..n {
231        let r = ax[k] - b[k];
232        r_norm_sq += r * r;
233    }
234    let r_norm = r_norm_sq.sqrt();
235    let x_norm = x.norm_l2();
236    let b_norm = b.norm_l2();
237
238    // Compute ||A||_F from stored values directly.
239    // Full symmetric storage: each off-diagonal entry appears twice,
240    // so sum(v^2) = sum(diag^2) + 2*sum(off_diag^2) = ||A||_F^2.
241    let mut a_norm_sq = 0.0;
242    for j in 0..n {
243        for &v in &values[col_ptrs[j]..col_ptrs[j + 1]] {
244            a_norm_sq += v * v;
245        }
246    }
247    let a_norm = a_norm_sq.sqrt();
248
249    let denom = a_norm * x_norm + b_norm;
250    if denom == 0.0 {
251        return 0.0;
252    }
253
254    r_norm / denom
255}
256
257/// Check whether two inertia values are identical and dimensionally consistent.
258///
259/// Returns `true` only if all three counts match AND both inertias have the
260/// same total dimension (positive + negative + zero).
261pub fn check_inertia(computed: &Inertia, expected: &Inertia) -> bool {
262    computed.dimension() == expected.dimension()
263        && computed.positive == expected.positive
264        && computed.negative == expected.negative
265        && computed.zero == expected.zero
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::io::registry;
272
273    // reconstruction_error tests
274
275    #[test]
276    fn reconstruction_error_arrow_5_pd() {
277        let test = registry::load_test_matrix("arrow-5-pd")
278            .expect("registry error")
279            .expect("matrix should exist");
280        let refdata = test.reference.expect("reference should exist");
281        let err = reconstruction_error(&test.matrix, &refdata);
282        assert!(
283            err < 1e-12,
284            "reconstruction error for arrow-5-pd: {:.2e} (expected < 1e-12)",
285            err
286        );
287    }
288
289    #[test]
290    fn reconstruction_error_stress_delayed_pivots() {
291        let test = registry::load_test_matrix("stress-delayed-pivots")
292            .expect("registry error")
293            .expect("matrix should exist");
294        let refdata = test.reference.expect("reference should exist");
295        let err = reconstruction_error(&test.matrix, &refdata);
296        assert!(
297            err < 1e-12,
298            "reconstruction error for stress-delayed-pivots: {:.2e} (expected < 1e-12)",
299            err
300        );
301    }
302
303    #[test]
304    fn reconstruction_error_with_perturbed_l() {
305        let test = registry::load_test_matrix("arrow-5-pd")
306            .expect("registry error")
307            .expect("matrix should exist");
308        let mut refdata = test.reference.expect("reference should exist");
309        // Perturb an L entry
310        if !refdata.l_entries.is_empty() {
311            refdata.l_entries[0].value += 10.0;
312        }
313        let err = reconstruction_error(&test.matrix, &refdata);
314        assert!(
315            err > 0.01,
316            "perturbed reconstruction error should be large: {:.2e}",
317            err
318        );
319    }
320
321    // backward_error tests
322
323    #[test]
324    fn backward_error_exact_solution() {
325        let test = registry::load_test_matrix("arrow-5-pd")
326            .expect("registry error")
327            .expect("matrix should exist");
328        let a = &test.matrix;
329        let n = a.nrows();
330
331        // Create known x_exact
332        let x_exact = Col::<f64>::from_fn(n, |i| (i + 1) as f64);
333
334        // Compute b = A * x_exact
335        let b = dense_matvec(a, &x_exact);
336
337        let err = backward_error(a, &x_exact, &b);
338        assert!(
339            err < 1e-14,
340            "backward error for exact solution: {:.2e} (expected < 1e-14)",
341            err
342        );
343    }
344
345    #[test]
346    fn backward_error_perturbed_solution() {
347        let test = registry::load_test_matrix("arrow-5-pd")
348            .expect("registry error")
349            .expect("matrix should exist");
350        let a = &test.matrix;
351        let n = a.nrows();
352
353        // Create known x_exact and compute b
354        let x_exact = Col::<f64>::from_fn(n, |i| (i + 1) as f64);
355        let b = dense_matvec(a, &x_exact);
356
357        // Perturb x
358        let x_perturbed = Col::<f64>::from_fn(n, |i| (i + 1) as f64 + 0.1);
359        let err = backward_error(a, &x_perturbed, &b);
360        assert!(
361            err > 1e-4,
362            "backward error for perturbed solution should be measurable: {:.2e}",
363            err
364        );
365    }
366
367    // check_inertia tests
368
369    #[test]
370    fn check_inertia_matching() {
371        let a = Inertia {
372            positive: 5,
373            negative: 3,
374            zero: 0,
375        };
376        let b = Inertia {
377            positive: 5,
378            negative: 3,
379            zero: 0,
380        };
381        assert!(check_inertia(&a, &b));
382    }
383
384    #[test]
385    fn check_inertia_mismatched() {
386        let a = Inertia {
387            positive: 5,
388            negative: 3,
389            zero: 0,
390        };
391        let b = Inertia {
392            positive: 4,
393            negative: 3,
394            zero: 1,
395        };
396        assert!(!check_inertia(&a, &b));
397    }
398
399    // validate_permutation tests
400
401    #[test]
402    fn validate_permutation_valid() {
403        assert!(validate_permutation(&[2, 0, 1], 3).is_ok());
404        assert!(validate_permutation(&[0], 1).is_ok());
405        assert!(validate_permutation(&[], 0).is_ok());
406    }
407
408    #[test]
409    fn validate_permutation_wrong_length() {
410        let result = validate_permutation(&[0, 1], 3);
411        assert!(result.is_err());
412    }
413
414    #[test]
415    fn validate_permutation_out_of_bounds() {
416        let result = validate_permutation(&[0, 5, 2], 3);
417        assert!(result.is_err());
418    }
419
420    #[test]
421    fn validate_permutation_duplicate() {
422        let result = validate_permutation(&[0, 1, 1], 3);
423        assert!(result.is_err());
424    }
425
426    // dense_matvec test
427
428    #[test]
429    fn dense_matvec_matches_manual() {
430        let test = registry::load_test_matrix("arrow-5-pd")
431            .expect("registry error")
432            .expect("matrix should exist");
433        let n = test.matrix.nrows();
434        let x = Col::<f64>::from_fn(n, |i| (i + 1) as f64);
435        let b = dense_matvec(&test.matrix, &x);
436        assert_eq!(b.nrows(), n);
437
438        // Verify backward error is tiny
439        let err = backward_error(&test.matrix, &x, &b);
440        assert!(err < 1e-14);
441    }
442}