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::{ExecutionContext, IdxTensor};
9use num_complex::{Complex64, ComplexFloat};
10use tenferro::DType;
11use tenferro_ad::EagerTensor;
12use tenferro_linalg::EagerTensorLinalgExt;
13use thiserror::Error;
14
15/// Error type for QR operations in tensor4all-linalg.
16#[derive(Debug, Error)]
17pub enum QrError {
18 /// QR computation failed.
19 #[error("QR computation failed: {0}")]
20 ComputationError(#[from] anyhow::Error),
21 /// Invalid relative tolerance value (must be finite and non-negative).
22 #[error("Invalid rtol value: {0}. rtol must be finite and non-negative.")]
23 InvalidRtol(f64),
24}
25
26/// Options for QR decomposition with truncation control.
27///
28/// # Examples
29///
30/// ```
31/// use tensor4all_core::qr::{QrOptions, qr_with};
32/// use tensor4all_core::{DynIndex, TensorContractionLike, IdxTensor};
33///
34/// let i = DynIndex::new_dyn(3);
35/// let j = DynIndex::new_dyn(3);
36/// let data: Vec<f64> = (0..9).map(|x| x as f64).collect();
37/// let tensor = IdxTensor::from_dense(vec![i.clone(), j.clone()], data).unwrap();
38///
39/// let opts = QrOptions::new().with_rtol(1e-10);
40/// let (q, r) = qr_with::<f64>(&tensor, &[i], &opts).unwrap();
41///
42/// // Q * R recovers the original tensor
43/// let recovered = q.contract_pair(&r).unwrap();
44/// assert!(tensor.distance(&recovered).unwrap() < 1e-12);
45/// ```
46#[derive(Debug, Clone, Copy)]
47pub struct QrOptions {
48 /// Relative tolerance for QR row-norm truncation.
49 /// If `None`, uses the global default.
50 pub rtol: Option<f64>,
51 truncate: bool,
52}
53
54impl Default for QrOptions {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl QrOptions {
61 /// Create new QR options with no overrides.
62 #[must_use]
63 pub fn new() -> Self {
64 Self {
65 rtol: None,
66 truncate: true,
67 }
68 }
69
70 /// Set the QR truncation tolerance.
71 #[must_use]
72 pub fn with_rtol(mut self, rtol: f64) -> Self {
73 self.rtol = Some(rtol);
74 self
75 }
76
77 pub(crate) fn full_rank() -> Self {
78 Self {
79 rtol: None,
80 truncate: false,
81 }
82 }
83}
84
85// Global default rtol using the unified GlobalDefault type
86// Default value: 1e-15 (very strict, near machine precision)
87static DEFAULT_QR_RTOL: GlobalDefault = GlobalDefault::new(1e-15);
88
89/// Get the global default rtol for QR truncation.
90///
91/// The default value is 1e-15 (very strict, near machine precision).
92pub fn default_qr_rtol() -> f64 {
93 DEFAULT_QR_RTOL.get()
94}
95
96/// Set the global default rtol for QR truncation.
97///
98/// # Arguments
99/// * `rtol` - Relative tolerance (must be finite and non-negative)
100///
101/// # Errors
102/// Returns `QrError::InvalidRtol` if rtol is not finite or is negative.
103pub fn set_default_qr_rtol(rtol: f64) -> Result<(), QrError> {
104 DEFAULT_QR_RTOL
105 .set(rtol)
106 .map_err(|e| QrError::InvalidRtol(e.0))
107}
108
109fn compute_retained_rank_qr_from_dense<T>(
110 r_full: &[T],
111 k: usize,
112 n: usize,
113 rtol: f64,
114) -> Result<usize, QrError>
115where
116 T: ComplexFloat,
117 <T as ComplexFloat>::Real: Into<f64>,
118{
119 if k == 0 || n == 0 {
120 return Ok(1);
121 }
122
123 let max_diag = k.min(n);
124
125 // Compute row norms of R (upper triangular: row i has entries from column i..n).
126 // Use relative comparison against the maximum row norm, matching
127 // compute_retained_rank_qr. The previous implementation compared diagonal
128 // elements absolutely and broke at the first small value, which is incorrect
129 // for non-pivoted QR where diagonal elements are not necessarily in
130 // decreasing order.
131 let row_norms: Vec<f64> = (0..max_diag)
132 .map(|i| {
133 let mut norm_sq: f64 = 0.0;
134 for j in i..n {
135 let val: f64 = r_full[i + j * k].abs().into();
136 norm_sq += val * val;
137 }
138 norm_sq.sqrt()
139 })
140 .collect();
141
142 Ok(retained_rank_from_row_norms(&row_norms, rtol))
143}
144
145/// Select the retained QR rank from per-row norms of the upper-triangular factor.
146///
147/// Shared by the host dense path and the device path (which reduces the norms
148/// on-device and reads back only this `k`-element decision vector).
149fn retained_rank_from_row_norms(row_norms: &[f64], rtol: f64) -> usize {
150 let max_row_norm = row_norms.iter().cloned().fold(0.0_f64, f64::max);
151 if max_row_norm == 0.0 {
152 return 1;
153 }
154
155 let threshold = rtol * max_row_norm;
156 let r = row_norms.iter().filter(|&&norm| norm >= threshold).count();
157 r.max(1)
158}
159
160/// Read the `k` upper-triangular row norms of a resident `R` factor.
161///
162/// All arithmetic stays in the operand's owning runtime; only the `k`-element
163/// decision vector crosses the explicit readback boundary through `context`.
164#[cfg(feature = "tenferro-cuda")]
165fn resident_row_norms(
166 r_inner: &EagerTensor,
167 k: usize,
168 context: &ExecutionContext,
169) -> Result<Vec<f64>, QrError> {
170 let upper = r_inner
171 .triu(0)
172 .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
173 let magnitudes = upper
174 .abs()
175 .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
176 let sum_squares = magnitudes
177 .reduce_sum_squares(&[1])
178 .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
179 if sum_squares.shape() != [k] {
180 return Err(QrError::ComputationError(anyhow::anyhow!(
181 "device QR row-norm reduction returned shape {:?}, expected [{k}]",
182 sum_squares.shape()
183 )));
184 }
185 let decision = IdxTensor::from_inner(vec![DynIndex::new_dyn(k)], sum_squares)
186 .map_err(QrError::ComputationError)?;
187 let values = decision
188 .read_decision_data(context)
189 .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
190 Ok(values.into_iter().map(|value| value.sqrt()).collect())
191}
192
193/// Compute QR decomposition of a tensor with arbitrary rank, returning (Q, R).
194///
195/// This function uses the global default rtol for truncation.
196/// See `qr_with` for per-call rtol control.
197///
198/// This function computes the thin QR decomposition, where for an unfolded matrix A (m×n),
199/// we return Q (m×k) and R (k×n) with k = min(m, n).
200///
201/// The input tensor can have any rank >= 2, and indices are split into left and right groups.
202/// The tensor is unfolded into a matrix by grouping left indices as rows and right indices as columns.
203///
204/// Truncation is performed based on R's row norms: rows whose norm is below
205/// `rtol * max_row_norm` are discarded.
206///
207/// For the mathematical convention:
208/// \[ A = Q * R \]
209/// where Q is orthogonal (or unitary for complex) and R is upper triangular.
210///
211/// # Arguments
212/// * `t` - Input tensor with DenseF64 or DenseC64 storage
213/// * `left_inds` - Indices to place on the left (row) side of the unfolded matrix
214///
215/// # Returns
216/// A tuple `(Q, R)` where:
217/// - `Q` is a tensor with indices `[left_inds..., bond_index]` and dimensions `[left_dims..., r]`
218/// - `R` is a tensor with indices `[bond_index, right_inds...]` and dimensions `[r, right_dims...]`
219///
220/// where `r` is the retained rank (≤ min(m, n)) determined by rtol truncation.
221///
222/// # Errors
223/// Returns `QrError` if:
224/// - The tensor rank is < 2
225/// - Storage is not DenseF64 or DenseC64
226/// - `left_inds` is empty or contains all indices
227/// - `left_inds` contains indices not in the tensor or duplicates
228/// - The QR computation fails
229///
230/// # Examples
231///
232/// ```
233/// use tensor4all_core::{IdxTensor, DynIndex, qr};
234///
235/// // Create a 4x3 matrix
236/// let i = DynIndex::new_dyn(4);
237/// let j = DynIndex::new_dyn(3);
238/// // Identity-like data (4x3 column-major)
239/// let data: Vec<f64> = (0..12).map(|x| x as f64).collect();
240/// let t = IdxTensor::from_dense(vec![i.clone(), j.clone()], data).unwrap();
241///
242/// let (q, r) = qr::<f64>(&t, &[i.clone()]).unwrap();
243///
244/// // Q has shape (4, bond) and R has shape (bond, 3)
245/// assert_eq!(q.dims()[0], 4);
246/// assert_eq!(r.dims()[r.dims().len() - 1], 3);
247/// ```
248pub fn qr<T>(t: &IdxTensor, left_inds: &[DynIndex]) -> Result<(IdxTensor, IdxTensor), QrError> {
249 qr_with::<T>(t, left_inds, &QrOptions::default())
250}
251
252/// Compute QR decomposition of a tensor with arbitrary rank, returning (Q, R).
253///
254/// This function allows per-call control of the truncation tolerance via `QrOptions`.
255/// If `options.rtol` is `None`, uses the global default rtol.
256///
257/// This function computes the thin QR decomposition, where for an unfolded matrix A (m×n),
258/// we return Q (m×k) and R (k×n) with k = min(m, n).
259///
260/// The input tensor can have any rank >= 2, and indices are split into left and right groups.
261/// The tensor is unfolded into a matrix by grouping left indices as rows and right indices as columns.
262///
263/// Truncation is performed based on R's row norms: rows whose norm is below
264/// `rtol * max_row_norm` are discarded.
265///
266/// For the mathematical convention:
267/// \[ A = Q * R \]
268/// where Q is orthogonal (or unitary for complex) and R is upper triangular.
269///
270/// # Arguments
271/// * `t` - Input tensor with DenseF64 or DenseC64 storage
272/// * `left_inds` - Indices to place on the left (row) side of the unfolded matrix
273/// * `options` - QR options including rtol for truncation control
274///
275/// # Returns
276/// A tuple `(Q, R)` where:
277/// - `Q` is a tensor with indices `[left_inds..., bond_index]` and dimensions `[left_dims..., r]`
278/// - `R` is a tensor with indices `[bond_index, right_inds...]` and dimensions `[r, right_dims...]`
279///
280/// where `r` is the retained rank (≤ min(m, n)) determined by rtol truncation.
281///
282/// # Errors
283/// Returns `QrError` if:
284/// - The tensor rank is < 2
285/// - Storage is not DenseF64 or DenseC64
286/// - `left_inds` is empty or contains all indices
287/// - `left_inds` contains indices not in the tensor or duplicates
288/// - The QR computation fails
289/// - `options.rtol` is invalid (not finite or negative)
290pub fn qr_with<T>(
291 t: &IdxTensor,
292 left_inds: &[DynIndex],
293 options: &QrOptions,
294) -> Result<(IdxTensor, IdxTensor), QrError> {
295 if t.is_cuda_resident() {
296 return Err(QrError::ComputationError(anyhow::anyhow!(
297 "CUDA-resident input requires qr_with_in with the owning execution context"
298 )));
299 }
300 // Unfold tensor into an eager rank-2 tensor so linalg AD nodes stay connected.
301 let (matrix_inner, _, m, n, left_indices, right_indices) = unfold_split_inner(t, left_inds)
302 .map_err(|e| anyhow::anyhow!("Failed to unfold tensor: {}", e))
303 .map_err(QrError::ComputationError)?;
304 let k = m.min(n);
305 let (q_inner, r_inner) = matrix_inner
306 .qr()
307 .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
308
309 let r = qr_retained_rank(&r_inner, k, n, options, None)?;
310 qr_assemble(q_inner, r_inner, k, r, left_indices, right_indices)
311}
312
313/// Compute QR decomposition in a caller-owned execution context.
314///
315/// The factorization, truncation slicing, and result assembly all execute in
316/// `context`. With a CUDA context the retained-rank decision reads back only
317/// the `k`-element row-norm vector through [`IdxTensor::read_decision_data`];
318/// with a CPU context the rank decision uses the same host read as [`qr_with`].
319///
320/// # Arguments
321///
322/// * `t` - Input tensor, which must belong to `context`.
323/// * `left_inds` - Indices to place on the left (row) side of the unfolded matrix.
324/// * `options` - QR options including rtol for truncation control.
325/// * `context` - Caller-owned execution context owning the input and results.
326///
327/// # Examples
328///
329/// ```
330/// use std::sync::Arc;
331/// use tensor4all_core::qr::{QrOptions, qr_with_in};
332/// use tensor4all_core::{DynIndex, ExecutionContext, IdxTensor, TensorContractionLike};
333/// use tensor4all_tensorbackend::CpuExecutionContext;
334/// use tenferro_cpu::CpuBackend;
335///
336/// let context = ExecutionContext::Cpu(Arc::new(
337/// CpuExecutionContext::from_backend(CpuBackend::new()),
338/// ));
339/// let i = DynIndex::new_dyn(4);
340/// let j = DynIndex::new_dyn(3);
341/// let data: Vec<f64> = (0..12).map(|x| x as f64).collect();
342/// let t = IdxTensor::from_dense_in(&context, vec![i.clone(), j.clone()], data)?;
343/// let (q, r) = qr_with_in::<f64>(&t, &[i.clone()], &QrOptions::default(), &context)?;
344/// let recovered = q.contract_pair(&r)?;
345/// let expected = t.to_vec::<f64>()?;
346/// let actual = recovered.to_vec::<f64>()?;
347/// let residual = expected
348/// .iter()
349/// .zip(actual.iter())
350/// .map(|(a, b)| (a - b).abs())
351/// .fold(0.0_f64, f64::max);
352/// assert!(residual < 1e-12, "QR reconstruction residual {residual}");
353/// # Ok::<(), Box<dyn std::error::Error>>(())
354/// ```
355///
356/// # Errors
357///
358/// Returns `QrError` when the tensor does not belong to `context`, when the
359/// indices, storage, or options are invalid, or when the factorization or
360/// explicit decision readback fails.
361pub fn qr_with_in<T>(
362 t: &IdxTensor,
363 left_inds: &[DynIndex],
364 options: &QrOptions,
365 context: &ExecutionContext,
366) -> Result<(IdxTensor, IdxTensor), QrError> {
367 t.validate_context(context)
368 .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
369 let (matrix_inner, _, m, n, left_indices, right_indices) = unfold_split_inner(t, left_inds)
370 .map_err(|e| anyhow::anyhow!("Failed to unfold tensor: {}", e))
371 .map_err(QrError::ComputationError)?;
372 let k = m.min(n);
373 let (q_inner, r_inner) = matrix_inner
374 .qr()
375 .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
376
377 let r = qr_retained_rank(&r_inner, k, n, options, Some(context))?;
378 qr_assemble(q_inner, r_inner, k, r, left_indices, right_indices)
379}
380
381/// Select the retained QR rank, reading the decision payload through `context`
382/// for device-resident factors and directly from host memory otherwise.
383fn qr_retained_rank(
384 r_inner: &EagerTensor,
385 k: usize,
386 n: usize,
387 options: &QrOptions,
388 context: Option<&ExecutionContext>,
389) -> Result<usize, QrError> {
390 if !options.truncate {
391 return Ok(k);
392 }
393 // Determine rtol to use
394 let rtol = options.rtol.unwrap_or(default_qr_rtol());
395 if !rtol.is_finite() || rtol < 0.0 {
396 return Err(QrError::InvalidRtol(rtol));
397 }
398
399 #[cfg(feature = "tenferro-cuda")]
400 if let Some(context) = context {
401 if matches!(context, ExecutionContext::Cuda(_)) {
402 let row_norms = resident_row_norms(r_inner, k, context)?;
403 return Ok(retained_rank_from_row_norms(&row_norms, rtol));
404 }
405 }
406 #[cfg(not(feature = "tenferro-cuda"))]
407 let _ = context;
408
409 let value = r_inner
410 .value()
411 .map_err(|source| QrError::ComputationError(anyhow::Error::new(source)))?;
412 match r_inner.dtype() {
413 DType::F64 => {
414 let values = value
415 .as_slice::<f64>()
416 .map_err(|source| QrError::ComputationError(anyhow::Error::new(source)))?;
417 compute_retained_rank_qr_from_dense(values, k, n, rtol)
418 }
419 DType::C64 => {
420 let values = value
421 .as_slice::<Complex64>()
422 .map_err(|source| QrError::ComputationError(anyhow::Error::new(source)))?;
423 compute_retained_rank_qr_from_dense(values, k, n, rtol)
424 }
425 other => Err(QrError::ComputationError(anyhow::anyhow!(
426 "native QR returned unsupported scalar type {other:?}"
427 ))),
428 }
429}
430
431/// Slice the thin factors to the retained rank and wrap them as [`IdxTensor`]s.
432fn qr_assemble(
433 mut q_inner: EagerTensor,
434 mut r_inner: EagerTensor,
435 k: usize,
436 r: usize,
437 left_indices: Vec<DynIndex>,
438 right_indices: Vec<DynIndex>,
439) -> Result<(IdxTensor, IdxTensor), QrError> {
440 if r < k {
441 let keep: Vec<usize> = (0..r).collect();
442 q_inner = q_inner
443 .take_axis(1, &keep)
444 .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
445 r_inner = r_inner
446 .take_axis(0, &keep)
447 .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
448 }
449
450 let bond_index = DynIndex::new_bond(r)
451 .map_err(|e| anyhow::anyhow!("Failed to create Link index: {:?}", e))
452 .map_err(QrError::ComputationError)?;
453
454 let mut q_indices = left_indices.clone();
455 q_indices.push(bond_index.clone());
456 let q_dims: Vec<usize> = q_indices.iter().map(|idx| idx.dim).collect();
457 let q_reshaped = q_inner.reshape(&q_dims).map_err(|e| {
458 QrError::ComputationError(anyhow::anyhow!("eager QR Q reshape failed: {e}"))
459 })?;
460 let q = IdxTensor::from_inner(q_indices, q_reshaped).map_err(QrError::ComputationError)?;
461
462 let mut r_indices = vec![bond_index.clone()];
463 r_indices.extend_from_slice(&right_indices);
464 let r_dims: Vec<usize> = r_indices.iter().map(|idx| idx.dim).collect();
465 let r_reshaped = r_inner.reshape(&r_dims).map_err(|e| {
466 QrError::ComputationError(anyhow::anyhow!("eager QR R reshape failed: {e}"))
467 })?;
468 let r = IdxTensor::from_inner(r_indices, r_reshaped).map_err(QrError::ComputationError)?;
469
470 Ok((q, r))
471}
472
473#[cfg(test)]
474mod tests;