Skip to main content

qmbed/
backend.rs

1//! Coarse-grained numerical backend boundary.
2//!
3//! Physics-facing modules own basis, operator, and workflow semantics. This
4//! module owns conversions into third-party dense and sparse kernels so solver
5//! algorithms do not depend directly on a concrete linear-algebra crate.
6
7use faer::linalg::solvers::Solve;
8use faer::sparse::{SparseColMat, Triplet};
9use faer::{Mat, Side};
10use nalgebra::{DMatrix, SVD as NalgebraSvd, SymmetricEigen};
11use num_complex::Complex64;
12
13use crate::{QmbedError, Result};
14
15/// Backend-neutral result of a complete Hermitian eigendecomposition.
16pub(crate) struct HermitianEigensystem {
17    pub(crate) eigenvalues: Vec<f64>,
18    /// Eigenvectors are stored one column per outer vector.
19    pub(crate) eigenvectors: Vec<Vec<Complex64>>,
20}
21
22/// Backend-neutral result of a complete complex eigendecomposition.
23pub(crate) struct ComplexEigensystem {
24    pub(crate) eigenvalues: Vec<Complex64>,
25    /// Right eigenvectors are stored one column per outer vector.
26    pub(crate) eigenvectors: Vec<Vec<Complex64>>,
27}
28
29fn validate_square_dense(values: &[Complex64], dimension: usize) -> Result<()> {
30    if values.len() != dimension.saturating_mul(dimension) {
31        return Err(QmbedError::DimensionMismatch(format!(
32            "dense backend expected {} values for a {dimension}x{dimension} matrix, got {}",
33            dimension.saturating_mul(dimension),
34            values.len()
35        )));
36    }
37    Ok(())
38}
39
40/// Singular values of a row-major dense matrix in nonincreasing order.
41pub(crate) fn singular_values(
42    values: &[Complex64],
43    rows: usize,
44    columns: usize,
45) -> Result<Vec<f64>> {
46    if values.len() != rows.saturating_mul(columns) {
47        return Err(QmbedError::DimensionMismatch(format!(
48            "dense backend expected {} values for a {rows}x{columns} matrix, got {}",
49            rows.saturating_mul(columns),
50            values.len()
51        )));
52    }
53    let matrix =
54        Mat::<Complex64>::from_fn(rows, columns, |row, column| values[row * columns + column]);
55    if let Ok(decomposition) = matrix.thin_svd() {
56        let singular_values = (0..rows.min(columns))
57            .map(|index| decomposition.S()[index].re)
58            .collect::<Vec<_>>();
59        if singular_values.iter().all(|value| value.is_finite()) {
60            return Ok(singular_values);
61        }
62    }
63
64    let matrix = DMatrix::<Complex64>::from_row_slice(rows, columns, values);
65    let max_iterations = rows
66        .min(columns)
67        .saturating_mul(rows.max(columns))
68        .saturating_mul(64)
69        .max(1_024);
70    let decomposition =
71        NalgebraSvd::try_new(matrix, false, false, 64.0 * f64::EPSILON, max_iterations)
72            .ok_or_else(|| {
73                QmbedError::UnsupportedBackend(
74                    "singular-value decomposition failed in both backends".into(),
75                )
76            })?;
77    let singular_values = decomposition
78        .singular_values
79        .iter()
80        .copied()
81        .collect::<Vec<_>>();
82    if singular_values.iter().any(|value| !value.is_finite()) {
83        return Err(QmbedError::UnsupportedBackend(
84            "singular-value decomposition produced non-finite values".into(),
85        ));
86    }
87    Ok(singular_values)
88}
89
90/// Complete eigendecomposition of a row-major Hermitian matrix.
91pub(crate) fn hermitian_eigenpairs(
92    values: &[Complex64],
93    dimension: usize,
94) -> Result<HermitianEigensystem> {
95    validate_square_dense(values, dimension)?;
96    let matrix = Mat::<Complex64>::from_fn(dimension, dimension, |row, column| {
97        values[row * dimension + column]
98    });
99    match matrix.self_adjoint_eigen(Side::Lower) {
100        Ok(decomposition) => Ok(HermitianEigensystem {
101            eigenvalues: (0..dimension)
102                .map(|index| decomposition.S()[index].re)
103                .collect(),
104            eigenvectors: (0..dimension)
105                .map(|column| {
106                    (0..dimension)
107                        .map(|row| decomposition.U()[(row, column)])
108                        .collect()
109                })
110                .collect(),
111        }),
112        Err(primary_error) => {
113            // Rank-deficient reduced density matrices with large degenerate
114            // null spaces can exhaust one backend's QR budget even though the
115            // Hermitian problem is well conditioned. Keep the backend
116            // boundary resilient by retrying with an independent
117            // tridiagonal/QR implementation rather than leaking a crate-
118            // specific convergence failure into every higher-level solver.
119            let matrix = DMatrix::<Complex64>::from_row_slice(dimension, dimension, values);
120            let max_iterations = dimension
121                .saturating_mul(dimension)
122                .saturating_mul(64)
123                .max(1_024);
124            let decomposition =
125                SymmetricEigen::try_new(matrix, 64.0 * f64::EPSILON, max_iterations).ok_or_else(
126                    || {
127                        QmbedError::UnsupportedBackend(format!(
128                            "self-adjoint eigendecomposition failed in both backends; \
129                     primary error: {primary_error:?}"
130                        ))
131                    },
132                )?;
133            let mut order: Vec<_> = (0..dimension).collect();
134            order.sort_by(|&left, &right| {
135                decomposition.eigenvalues[left]
136                    .total_cmp(&decomposition.eigenvalues[right])
137                    .then_with(|| left.cmp(&right))
138            });
139            Ok(HermitianEigensystem {
140                eigenvalues: order
141                    .iter()
142                    .map(|&index| decomposition.eigenvalues[index])
143                    .collect(),
144                eigenvectors: order
145                    .iter()
146                    .map(|&column| {
147                        (0..dimension)
148                            .map(|row| decomposition.eigenvectors[(row, column)])
149                            .collect()
150                    })
151                    .collect(),
152            })
153        }
154    }
155}
156
157/// Complete right eigendecomposition of a row-major complex matrix.
158pub(crate) fn complex_eigenpairs(
159    values: &[Complex64],
160    dimension: usize,
161) -> Result<ComplexEigensystem> {
162    validate_square_dense(values, dimension)?;
163    let matrix = Mat::<Complex64>::from_fn(dimension, dimension, |row, column| {
164        values[row * dimension + column]
165    });
166    let decomposition = matrix.eigen().map_err(|error| {
167        QmbedError::UnsupportedBackend(format!("complex eigendecomposition failed: {error:?}"))
168    })?;
169    Ok(ComplexEigensystem {
170        eigenvalues: (0..dimension)
171            .map(|index| decomposition.S()[index])
172            .collect(),
173        eigenvectors: (0..dimension)
174            .map(|column| {
175                (0..dimension)
176                    .map(|row| decomposition.U()[(row, column)])
177                    .collect()
178            })
179            .collect(),
180    })
181}
182
183/// Form `exp(coefficient * H)` for a row-major Hermitian matrix.
184pub(crate) fn hermitian_exponential(
185    values: &[Complex64],
186    dimension: usize,
187    coefficient: Complex64,
188) -> Result<Vec<Complex64>> {
189    validate_square_dense(values, dimension)?;
190    if !coefficient.re.is_finite() || !coefficient.im.is_finite() {
191        return Err(QmbedError::InvalidOptions(
192            "matrix exponential coefficient must be finite".into(),
193        ));
194    }
195    let eigensystem = hermitian_eigenpairs(values, dimension)?;
196    let vectors = Mat::<Complex64>::from_fn(dimension, dimension, |row, column| {
197        eigensystem.eigenvectors[column][row]
198    });
199    let weighted = Mat::<Complex64>::from_fn(dimension, dimension, |row, column| {
200        vectors[(row, column)] * (coefficient * eigensystem.eigenvalues[column]).exp()
201    });
202    let exponential = &weighted * vectors.adjoint();
203    let mut output = Vec::with_capacity(dimension.saturating_mul(dimension));
204    for row in 0..dimension {
205        for column in 0..dimension {
206            output.push(exponential[(row, column)]);
207        }
208    }
209    Ok(output)
210}
211
212/// Multiply two row-major square complex matrices.
213pub(crate) fn square_matmul(
214    left: &[Complex64],
215    right: &[Complex64],
216    dimension: usize,
217) -> Result<Vec<Complex64>> {
218    validate_square_dense(left, dimension)?;
219    validate_square_dense(right, dimension)?;
220    let left = Mat::<Complex64>::from_fn(dimension, dimension, |row, column| {
221        left[row * dimension + column]
222    });
223    let right = Mat::<Complex64>::from_fn(dimension, dimension, |row, column| {
224        right[row * dimension + column]
225    });
226    let product = &left * &right;
227    let mut output = Vec::with_capacity(dimension.saturating_mul(dimension));
228    for row in 0..dimension {
229        for column in 0..dimension {
230            output.push(product[(row, column)]);
231        }
232    }
233    Ok(output)
234}
235
236/// Frobenius residual `||U†U - I||_F / n` for a row-major square matrix.
237pub(crate) fn unitarity_residual(values: &[Complex64], dimension: usize) -> Result<f64> {
238    validate_square_dense(values, dimension)?;
239    if dimension == 0 {
240        return Ok(0.0);
241    }
242    let matrix = Mat::<Complex64>::from_fn(dimension, dimension, |row, column| {
243        values[row * dimension + column]
244    });
245    let gram = matrix.adjoint() * &matrix;
246    let mut squared = 0.0;
247    for row in 0..dimension {
248        for column in 0..dimension {
249            let expected = if row == column {
250                Complex64::new(1.0, 0.0)
251            } else {
252                Complex64::new(0.0, 0.0)
253            };
254            squared += (gram[(row, column)] - expected).norm_sqr();
255        }
256    }
257    Ok(squared.sqrt() / dimension as f64)
258}
259
260/// Reusable factorization of `(A - shift I)`.
261pub trait ShiftedLinearSolver: Send + Sync {
262    fn solve(&self, input: &[Complex64], output: &mut [Complex64]) -> Result<()>;
263
264    fn supports_real(&self) -> bool {
265        false
266    }
267
268    fn solve_real(&self, _input: &[f64], _output: &mut [f64]) -> Result<()> {
269        Err(QmbedError::UnsupportedBackend(
270            "shifted factorization does not support real right-hand sides".into(),
271        ))
272    }
273}
274
275enum FaerShiftedFactorization {
276    Real(faer::sparse::linalg::solvers::Lu<usize, f64>),
277    Complex(faer::sparse::linalg::solvers::Lu<usize, Complex64>),
278}
279
280struct FaerShiftedSolver {
281    factorization: FaerShiftedFactorization,
282    dimension: usize,
283}
284
285impl ShiftedLinearSolver for FaerShiftedSolver {
286    fn solve(&self, input: &[Complex64], output: &mut [Complex64]) -> Result<()> {
287        if input.len() != self.dimension || output.len() != self.dimension {
288            return Err(QmbedError::DimensionMismatch(
289                "shifted solve input or output length does not match".into(),
290            ));
291        }
292        match &self.factorization {
293            FaerShiftedFactorization::Real(factorization) => {
294                let mut real = faer::Col::from_fn(self.dimension, |index| input[index].re);
295                let mut imaginary = faer::Col::from_fn(self.dimension, |index| input[index].im);
296                factorization.solve_in_place(real.as_mut());
297                factorization.solve_in_place(imaginary.as_mut());
298                for (index, value) in output.iter_mut().enumerate() {
299                    *value = Complex64::new(real[index], imaginary[index]);
300                }
301            }
302            FaerShiftedFactorization::Complex(factorization) => {
303                let mut right_hand_side = faer::Col::from_fn(self.dimension, |index| input[index]);
304                factorization.solve_in_place(right_hand_side.as_mut());
305                for (index, value) in output.iter_mut().enumerate() {
306                    *value = right_hand_side[index];
307                }
308            }
309        }
310        Ok(())
311    }
312
313    fn supports_real(&self) -> bool {
314        matches!(self.factorization, FaerShiftedFactorization::Real(_))
315    }
316
317    fn solve_real(&self, input: &[f64], output: &mut [f64]) -> Result<()> {
318        if input.len() != self.dimension || output.len() != self.dimension {
319            return Err(QmbedError::DimensionMismatch(
320                "real shifted solve input or output length does not match".into(),
321            ));
322        }
323        let FaerShiftedFactorization::Real(factorization) = &self.factorization else {
324            return Err(QmbedError::UnsupportedBackend(
325                "complex shifted factorization cannot use the real fast path".into(),
326            ));
327        };
328        let mut right_hand_side = faer::Col::from_fn(self.dimension, |index| input[index]);
329        factorization.solve_in_place(right_hand_side.as_mut());
330        for (index, value) in output.iter_mut().enumerate() {
331            *value = right_hand_side[index];
332        }
333        Ok(())
334    }
335}
336
337/// Factor a canonical CSC matrix after applying a real diagonal shift.
338pub(crate) fn factor_shifted_csc(
339    dimension: usize,
340    column_offsets: &[usize],
341    row_indices: &[usize],
342    values: &[Complex64],
343    shift: f64,
344    is_real: bool,
345) -> Result<Box<dyn ShiftedLinearSolver>> {
346    if column_offsets.len() != dimension + 1
347        || row_indices.len() != values.len()
348        || !shift.is_finite()
349    {
350        return Err(QmbedError::DimensionMismatch(
351            "invalid CSC storage for shifted factorization".into(),
352        ));
353    }
354
355    if is_real {
356        let mut triplets = Vec::with_capacity(values.len() + dimension);
357        append_shifted_triplets(
358            dimension,
359            column_offsets,
360            row_indices,
361            values,
362            shift,
363            |row, column, value| {
364                triplets.push(Triplet::new(row, column, value.re));
365            },
366        );
367        let matrix =
368            SparseColMat::<usize, f64>::try_new_from_triplets(dimension, dimension, &triplets)
369                .map_err(|error| {
370                    QmbedError::UnsupportedBackend(format!(
371                        "could not construct real sparse shifted matrix: {error}"
372                    ))
373                })?;
374        let factorization = matrix.sp_lu().map_err(|_| QmbedError::NonConvergence {
375            iterations: 0,
376            residual: f64::INFINITY,
377        })?;
378        return Ok(Box::new(FaerShiftedSolver {
379            factorization: FaerShiftedFactorization::Real(factorization),
380            dimension,
381        }));
382    }
383
384    let mut triplets = Vec::with_capacity(values.len() + dimension);
385    append_shifted_triplets(
386        dimension,
387        column_offsets,
388        row_indices,
389        values,
390        shift,
391        |row, column, value| {
392            triplets.push(Triplet::new(row, column, value));
393        },
394    );
395    let matrix =
396        SparseColMat::<usize, Complex64>::try_new_from_triplets(dimension, dimension, &triplets)
397            .map_err(|error| {
398                QmbedError::UnsupportedBackend(format!(
399                    "could not construct complex sparse shifted matrix: {error}"
400                ))
401            })?;
402    let factorization = matrix.sp_lu().map_err(|_| QmbedError::NonConvergence {
403        iterations: 0,
404        residual: f64::INFINITY,
405    })?;
406    Ok(Box::new(FaerShiftedSolver {
407        factorization: FaerShiftedFactorization::Complex(factorization),
408        dimension,
409    }))
410}
411
412fn append_shifted_triplets(
413    dimension: usize,
414    column_offsets: &[usize],
415    row_indices: &[usize],
416    values: &[Complex64],
417    shift: f64,
418    mut append: impl FnMut(usize, usize, Complex64),
419) {
420    for column in 0..dimension {
421        let mut has_diagonal = false;
422        for position in column_offsets[column]..column_offsets[column + 1] {
423            let row = row_indices[position];
424            let mut value = values[position];
425            if row == column {
426                value -= shift;
427                has_diagonal = true;
428            }
429            append(row, column, value);
430        }
431        if !has_diagonal {
432            append(column, column, Complex64::new(-shift, 0.0));
433        }
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn dense_backends_preserve_hermitian_and_general_eigenpairs() {
443        let hermitian = [
444            Complex64::new(1.0, 0.0),
445            Complex64::new(0.0, 1.0),
446            Complex64::new(0.0, -1.0),
447            Complex64::new(1.0, 0.0),
448        ];
449        let result = hermitian_eigenpairs(&hermitian, 2).unwrap();
450        assert!((result.eigenvalues[0] - 0.0).abs() < 1.0e-12);
451        assert!((result.eigenvalues[1] - 2.0).abs() < 1.0e-12);
452
453        let rotation = [
454            Complex64::new(0.0, 0.0),
455            Complex64::new(-1.0, 0.0),
456            Complex64::new(1.0, 0.0),
457            Complex64::new(0.0, 0.0),
458        ];
459        let result = complex_eigenpairs(&rotation, 2).unwrap();
460        assert!(
461            result
462                .eigenvalues
463                .iter()
464                .all(|value| (value.norm() - 1.0).abs() < 1.0e-12)
465        );
466
467        let dimension = 5;
468        let mut hermitian = vec![Complex64::new(0.0, 0.0); dimension * dimension];
469        for row in 0..dimension {
470            hermitian[row * dimension + row] = Complex64::new(row as f64 - 1.5, 0.0);
471            for column in 0..row {
472                let value = Complex64::new(
473                    (row + column + 1) as f64 / 7.0,
474                    (row as f64 - column as f64) / 11.0,
475                );
476                hermitian[row * dimension + column] = value;
477                hermitian[column * dimension + row] = value.conj();
478            }
479        }
480        let result = hermitian_eigenpairs(&hermitian, dimension).unwrap();
481        for (&eigenvalue, eigenvector) in result.eigenvalues.iter().zip(&result.eigenvectors) {
482            let residual = (0..dimension)
483                .map(|row| {
484                    let applied = (0..dimension)
485                        .map(|column| hermitian[row * dimension + column] * eigenvector[column])
486                        .sum::<Complex64>();
487                    (applied - eigenvalue * eigenvector[row]).norm_sqr()
488                })
489                .sum::<f64>()
490                .sqrt();
491            assert!(residual < 1.0e-12, "Hermitian residual was {residual}");
492        }
493    }
494
495    #[test]
496    fn real_shifted_factorization_accepts_real_and_complex_rhs() {
497        let solver = factor_shifted_csc(
498            2,
499            &[0, 2, 4],
500            &[0, 1, 0, 1],
501            &[
502                Complex64::new(2.0, 0.0),
503                Complex64::new(1.0, 0.0),
504                Complex64::new(1.0, 0.0),
505                Complex64::new(3.0, 0.0),
506            ],
507            0.0,
508            true,
509        )
510        .unwrap();
511        assert!(solver.supports_real());
512        let mut real = [0.0; 2];
513        solver.solve_real(&[1.0, 0.0], &mut real).unwrap();
514        assert!((real[0] - 0.6).abs() < 1.0e-12);
515        assert!((real[1] + 0.2).abs() < 1.0e-12);
516
517        let mut complex = [Complex64::new(0.0, 0.0); 2];
518        solver
519            .solve(
520                &[Complex64::new(1.0, 2.0), Complex64::new(0.0, 0.0)],
521                &mut complex,
522            )
523            .unwrap();
524        assert!((complex[0] - Complex64::new(0.6, 1.2)).norm() < 1.0e-12);
525        assert!((complex[1] - Complex64::new(-0.2, -0.4)).norm() < 1.0e-12);
526    }
527}