Skip to main content

tensor4all_core/defaults/
qr.rs

1//! QR decomposition for tensors.
2//!
3//! This module works with concrete types (`DynIndex`, `IdxTensor`) only.
4
5use crate::defaults::idx_tensor::unfold_split_inner;
6use crate::defaults::DynIndex;
7use crate::global_default::GlobalDefault;
8use crate::IdxTensor;
9use num_complex::{Complex64, ComplexFloat};
10use tenferro::DType;
11use tenferro_linalg::EagerTensorLinalgExt;
12use thiserror::Error;
13
14/// Error type for QR operations in tensor4all-linalg.
15#[derive(Debug, Error)]
16pub enum QrError {
17    /// QR computation failed.
18    #[error("QR computation failed: {0}")]
19    ComputationError(#[from] anyhow::Error),
20    /// Invalid relative tolerance value (must be finite and non-negative).
21    #[error("Invalid rtol value: {0}. rtol must be finite and non-negative.")]
22    InvalidRtol(f64),
23}
24
25/// Options for QR decomposition with truncation control.
26///
27/// # Examples
28///
29/// ```
30/// use tensor4all_core::qr::{QrOptions, qr_with};
31/// use tensor4all_core::{DynIndex, TensorContractionLike, IdxTensor};
32///
33/// let i = DynIndex::new_dyn(3);
34/// let j = DynIndex::new_dyn(3);
35/// let data: Vec<f64> = (0..9).map(|x| x as f64).collect();
36/// let tensor = IdxTensor::from_dense(vec![i.clone(), j.clone()], data).unwrap();
37///
38/// let opts = QrOptions::new().with_rtol(1e-10);
39/// let (q, r) = qr_with::<f64>(&tensor, &[i], &opts).unwrap();
40///
41/// // Q * R recovers the original tensor
42/// let recovered = q.contract_pair(&r).unwrap();
43/// assert!(tensor.distance(&recovered).unwrap() < 1e-12);
44/// ```
45#[derive(Debug, Clone, Copy)]
46pub struct QrOptions {
47    /// Relative tolerance for QR row-norm truncation.
48    /// If `None`, uses the global default.
49    pub rtol: Option<f64>,
50    truncate: bool,
51}
52
53impl Default for QrOptions {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl QrOptions {
60    /// Create new QR options with no overrides.
61    #[must_use]
62    pub fn new() -> Self {
63        Self {
64            rtol: None,
65            truncate: true,
66        }
67    }
68
69    /// Set the QR truncation tolerance.
70    #[must_use]
71    pub fn with_rtol(mut self, rtol: f64) -> Self {
72        self.rtol = Some(rtol);
73        self
74    }
75
76    pub(crate) fn full_rank() -> Self {
77        Self {
78            rtol: None,
79            truncate: false,
80        }
81    }
82}
83
84// Global default rtol using the unified GlobalDefault type
85// Default value: 1e-15 (very strict, near machine precision)
86static DEFAULT_QR_RTOL: GlobalDefault = GlobalDefault::new(1e-15);
87
88/// Get the global default rtol for QR truncation.
89///
90/// The default value is 1e-15 (very strict, near machine precision).
91pub fn default_qr_rtol() -> f64 {
92    DEFAULT_QR_RTOL.get()
93}
94
95/// Set the global default rtol for QR truncation.
96///
97/// # Arguments
98/// * `rtol` - Relative tolerance (must be finite and non-negative)
99///
100/// # Errors
101/// Returns `QrError::InvalidRtol` if rtol is not finite or is negative.
102pub fn set_default_qr_rtol(rtol: f64) -> Result<(), QrError> {
103    DEFAULT_QR_RTOL
104        .set(rtol)
105        .map_err(|e| QrError::InvalidRtol(e.0))
106}
107
108fn compute_retained_rank_qr_from_dense<T>(
109    r_full: &[T],
110    k: usize,
111    n: usize,
112    rtol: f64,
113) -> Result<usize, QrError>
114where
115    T: ComplexFloat,
116    <T as ComplexFloat>::Real: Into<f64>,
117{
118    if k == 0 || n == 0 {
119        return Ok(1);
120    }
121
122    let max_diag = k.min(n);
123
124    // Compute row norms of R (upper triangular: row i has entries from column i..n).
125    // Use relative comparison against the maximum row norm, matching
126    // compute_retained_rank_qr. The previous implementation compared diagonal
127    // elements absolutely and broke at the first small value, which is incorrect
128    // for non-pivoted QR where diagonal elements are not necessarily in
129    // decreasing order.
130    let row_norms: Vec<f64> = (0..max_diag)
131        .map(|i| {
132            let mut norm_sq: f64 = 0.0;
133            for j in i..n {
134                let val: f64 = r_full[i + j * k].abs().into();
135                norm_sq += val * val;
136            }
137            norm_sq.sqrt()
138        })
139        .collect();
140
141    let max_row_norm = row_norms.iter().cloned().fold(0.0_f64, f64::max);
142    if max_row_norm == 0.0 {
143        return Ok(1);
144    }
145
146    let threshold = rtol * max_row_norm;
147    let r = row_norms.iter().filter(|&&norm| norm >= threshold).count();
148    Ok(r.max(1))
149}
150
151/// Compute QR decomposition of a tensor with arbitrary rank, returning (Q, R).
152///
153/// This function uses the global default rtol for truncation.
154/// See `qr_with` for per-call rtol control.
155///
156/// This function computes the thin QR decomposition, where for an unfolded matrix A (m×n),
157/// we return Q (m×k) and R (k×n) with k = min(m, n).
158///
159/// The input tensor can have any rank >= 2, and indices are split into left and right groups.
160/// The tensor is unfolded into a matrix by grouping left indices as rows and right indices as columns.
161///
162/// Truncation is performed based on R's row norms: rows whose norm is below
163/// `rtol * max_row_norm` are discarded.
164///
165/// For the mathematical convention:
166/// \[ A = Q * R \]
167/// where Q is orthogonal (or unitary for complex) and R is upper triangular.
168///
169/// # Arguments
170/// * `t` - Input tensor with DenseF64 or DenseC64 storage
171/// * `left_inds` - Indices to place on the left (row) side of the unfolded matrix
172///
173/// # Returns
174/// A tuple `(Q, R)` where:
175/// - `Q` is a tensor with indices `[left_inds..., bond_index]` and dimensions `[left_dims..., r]`
176/// - `R` is a tensor with indices `[bond_index, right_inds...]` and dimensions `[r, right_dims...]`
177///
178///   where `r` is the retained rank (≤ min(m, n)) determined by rtol truncation.
179///
180/// # Errors
181/// Returns `QrError` if:
182/// - The tensor rank is < 2
183/// - Storage is not DenseF64 or DenseC64
184/// - `left_inds` is empty or contains all indices
185/// - `left_inds` contains indices not in the tensor or duplicates
186/// - The QR computation fails
187///
188/// # Examples
189///
190/// ```
191/// use tensor4all_core::{IdxTensor, DynIndex, qr};
192///
193/// // Create a 4x3 matrix
194/// let i = DynIndex::new_dyn(4);
195/// let j = DynIndex::new_dyn(3);
196/// // Identity-like data (4x3 column-major)
197/// let data: Vec<f64> = (0..12).map(|x| x as f64).collect();
198/// let t = IdxTensor::from_dense(vec![i.clone(), j.clone()], data).unwrap();
199///
200/// let (q, r) = qr::<f64>(&t, &[i.clone()]).unwrap();
201///
202/// // Q has shape (4, bond) and R has shape (bond, 3)
203/// assert_eq!(q.dims()[0], 4);
204/// assert_eq!(r.dims()[r.dims().len() - 1], 3);
205/// ```
206pub fn qr<T>(t: &IdxTensor, left_inds: &[DynIndex]) -> Result<(IdxTensor, IdxTensor), QrError> {
207    qr_with::<T>(t, left_inds, &QrOptions::default())
208}
209
210/// Compute QR decomposition of a tensor with arbitrary rank, returning (Q, R).
211///
212/// This function allows per-call control of the truncation tolerance via `QrOptions`.
213/// If `options.rtol` is `None`, uses the global default rtol.
214///
215/// This function computes the thin QR decomposition, where for an unfolded matrix A (m×n),
216/// we return Q (m×k) and R (k×n) with k = min(m, n).
217///
218/// The input tensor can have any rank >= 2, and indices are split into left and right groups.
219/// The tensor is unfolded into a matrix by grouping left indices as rows and right indices as columns.
220///
221/// Truncation is performed based on R's row norms: rows whose norm is below
222/// `rtol * max_row_norm` are discarded.
223///
224/// For the mathematical convention:
225/// \[ A = Q * R \]
226/// where Q is orthogonal (or unitary for complex) and R is upper triangular.
227///
228/// # Arguments
229/// * `t` - Input tensor with DenseF64 or DenseC64 storage
230/// * `left_inds` - Indices to place on the left (row) side of the unfolded matrix
231/// * `options` - QR options including rtol for truncation control
232///
233/// # Returns
234/// A tuple `(Q, R)` where:
235/// - `Q` is a tensor with indices `[left_inds..., bond_index]` and dimensions `[left_dims..., r]`
236/// - `R` is a tensor with indices `[bond_index, right_inds...]` and dimensions `[r, right_dims...]`
237///
238///   where `r` is the retained rank (≤ min(m, n)) determined by rtol truncation.
239///
240/// # Errors
241/// Returns `QrError` if:
242/// - The tensor rank is < 2
243/// - Storage is not DenseF64 or DenseC64
244/// - `left_inds` is empty or contains all indices
245/// - `left_inds` contains indices not in the tensor or duplicates
246/// - The QR computation fails
247/// - `options.rtol` is invalid (not finite or negative)
248pub fn qr_with<T>(
249    t: &IdxTensor,
250    left_inds: &[DynIndex],
251    options: &QrOptions,
252) -> Result<(IdxTensor, IdxTensor), QrError> {
253    // Unfold tensor into an eager rank-2 tensor so linalg AD nodes stay connected.
254    let (matrix_inner, _, m, n, left_indices, right_indices) = unfold_split_inner(t, left_inds)
255        .map_err(|e| anyhow::anyhow!("Failed to unfold tensor: {}", e))
256        .map_err(QrError::ComputationError)?;
257    let k = m.min(n);
258    let (mut q_inner, mut r_inner) = matrix_inner
259        .qr()
260        .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
261
262    let r = if options.truncate {
263        // Determine rtol to use
264        let rtol = options.rtol.unwrap_or(default_qr_rtol());
265        if !rtol.is_finite() || rtol < 0.0 {
266            return Err(QrError::InvalidRtol(rtol));
267        }
268
269        let value = r_inner
270            .value()
271            .map_err(|source| QrError::ComputationError(anyhow::Error::new(source)))?;
272        match r_inner.dtype() {
273            DType::F64 => {
274                let values = value
275                    .as_slice::<f64>()
276                    .map_err(|source| QrError::ComputationError(anyhow::Error::new(source)))?;
277                compute_retained_rank_qr_from_dense(values, k, n, rtol)?
278            }
279            DType::C64 => {
280                let values = value
281                    .as_slice::<Complex64>()
282                    .map_err(|source| QrError::ComputationError(anyhow::Error::new(source)))?;
283                compute_retained_rank_qr_from_dense(values, k, n, rtol)?
284            }
285            other => {
286                return Err(QrError::ComputationError(anyhow::anyhow!(
287                    "native QR returned unsupported scalar type {other:?}"
288                )));
289            }
290        }
291    } else {
292        k
293    };
294    if r < k {
295        let keep: Vec<usize> = (0..r).collect();
296        q_inner = q_inner
297            .take_axis(1, &keep)
298            .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
299        r_inner = r_inner
300            .take_axis(0, &keep)
301            .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
302    }
303
304    let bond_index = DynIndex::new_bond(r)
305        .map_err(|e| anyhow::anyhow!("Failed to create Link index: {:?}", e))
306        .map_err(QrError::ComputationError)?;
307
308    let mut q_indices = left_indices.clone();
309    q_indices.push(bond_index.clone());
310    let q_dims: Vec<usize> = q_indices.iter().map(|idx| idx.dim).collect();
311    let q_reshaped = q_inner.reshape(&q_dims).map_err(|e| {
312        QrError::ComputationError(anyhow::anyhow!("eager QR Q reshape failed: {e}"))
313    })?;
314    let q = IdxTensor::from_inner(q_indices, q_reshaped).map_err(QrError::ComputationError)?;
315
316    let mut r_indices = vec![bond_index.clone()];
317    r_indices.extend_from_slice(&right_indices);
318    let r_dims: Vec<usize> = r_indices.iter().map(|idx| idx.dim).collect();
319    let r_reshaped = r_inner.reshape(&r_dims).map_err(|e| {
320        QrError::ComputationError(anyhow::anyhow!("eager QR R reshape failed: {e}"))
321    })?;
322    let r = IdxTensor::from_inner(r_indices, r_reshaped).map_err(QrError::ComputationError)?;
323
324    Ok((q, r))
325}
326
327#[cfg(test)]
328mod tests;