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 super::eager_session::with_eager_session;
6use crate::defaults::idx_tensor::{unfold_index_sets, unfold_split_inner, unfold_split_inner_in};
7use crate::defaults::DynIndex;
8use crate::global_default::GlobalDefault;
9use crate::{ExecutionContext, IdxTensor, TensorElement};
10use num_complex::{Complex64, ComplexFloat};
11use tenferro::DType;
12use tenferro_ad::EagerTensor;
13use tenferro_linalg::EagerSessionLinalgExt;
14use thiserror::Error;
15
16/// Error type for QR operations in tensor4all-linalg.
17#[derive(Debug, Error)]
18pub enum QrError {
19    /// QR computation failed.
20    #[error("QR computation failed: {0}")]
21    ComputationError(#[from] anyhow::Error),
22    /// Invalid relative tolerance value (must be finite and non-negative).
23    #[error("Invalid rtol value: {0}. rtol must be finite and non-negative.")]
24    InvalidRtol(f64),
25    /// The operand storage cannot enter the runtime-free explicit-session route.
26    ///
27    /// The session routes publish plain host factors, so an AD-tracked operand would lose its graph;
28    /// they refuse it instead.
29    #[error("Unsupported storage type: {0}")]
30    UnsupportedStorage(
31        /// Description of the unsupported operand storage
32        &'static str,
33    ),
34}
35
36/// Options for QR decomposition with truncation control.
37///
38/// # Examples
39///
40/// ```
41/// use tensor4all_core::qr::{QrOptions, qr_with};
42/// use tensor4all_core::{DynIndex, TensorContractionLike, IdxTensor};
43///
44/// let i = DynIndex::new_dyn(3);
45/// let j = DynIndex::new_dyn(3);
46/// let data: Vec<f64> = (0..9).map(|x| x as f64).collect();
47/// let tensor = IdxTensor::from_dense(vec![i.clone(), j.clone()], data).unwrap();
48///
49/// let opts = QrOptions::new().with_rtol(1e-10);
50/// let (q, r) = qr_with::<f64>(&tensor, &[i], &opts).unwrap();
51///
52/// // Q * R recovers the original tensor
53/// let recovered = q.contract_pair(&r).unwrap();
54/// assert!(tensor.distance(&recovered).unwrap() < 1e-12);
55/// ```
56#[derive(Debug, Clone, Copy)]
57pub struct QrOptions {
58    /// Relative tolerance for QR row-norm truncation.
59    /// If `None`, uses the global default.
60    pub rtol: Option<f64>,
61    truncate: bool,
62}
63
64impl Default for QrOptions {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl QrOptions {
71    /// Create new QR options with no overrides.
72    #[must_use]
73    pub fn new() -> Self {
74        Self {
75            rtol: None,
76            truncate: true,
77        }
78    }
79
80    /// Set the QR truncation tolerance.
81    #[must_use]
82    pub fn with_rtol(mut self, rtol: f64) -> Self {
83        self.rtol = Some(rtol);
84        self
85    }
86
87    pub(crate) fn full_rank() -> Self {
88        Self {
89            rtol: None,
90            truncate: false,
91        }
92    }
93}
94
95// Global default rtol using the unified GlobalDefault type
96// Default value: 1e-15 (very strict, near machine precision)
97static DEFAULT_QR_RTOL: GlobalDefault = GlobalDefault::new(1e-15);
98
99/// Get the global default rtol for QR truncation.
100///
101/// The default value is 1e-15 (very strict, near machine precision).
102pub fn default_qr_rtol() -> f64 {
103    DEFAULT_QR_RTOL.get()
104}
105
106/// Set the global default rtol for QR truncation.
107///
108/// # Arguments
109/// * `rtol` - Relative tolerance (must be finite and non-negative)
110///
111/// # Errors
112/// Returns `QrError::InvalidRtol` if rtol is not finite or is negative.
113pub fn set_default_qr_rtol(rtol: f64) -> Result<(), QrError> {
114    DEFAULT_QR_RTOL
115        .set(rtol)
116        .map_err(|e| QrError::InvalidRtol(e.0))
117}
118
119fn compute_retained_rows_qr_from_dense<T>(
120    r_full: &[T],
121    k: usize,
122    n: usize,
123    rtol: f64,
124) -> Result<Vec<usize>, QrError>
125where
126    T: ComplexFloat,
127    <T as ComplexFloat>::Real: Into<f64>,
128{
129    if k == 0 || n == 0 {
130        return Ok(vec![0]);
131    }
132
133    let max_diag = k.min(n);
134
135    // Compute row norms of R (upper triangular: row i has entries from column i..n).
136    // Use relative comparison against the maximum row norm, matching
137    // compute_retained_rank_qr. The previous implementation compared diagonal
138    // elements absolutely and broke at the first small value, which is incorrect
139    // for non-pivoted QR where diagonal elements are not necessarily in
140    // decreasing order. Neither are the row norms: a non-pivoted QR of a
141    // rank-deficient matrix can leave a negligible row before a large one, so
142    // the retained rows are selected individually, never as a prefix.
143    let row_norms: Vec<f64> = (0..max_diag)
144        .map(|i| {
145            let mut norm_sq: f64 = 0.0;
146            for j in i..n {
147                let val: f64 = r_full[i + j * k].abs().into();
148                norm_sq += val * val;
149            }
150            norm_sq.sqrt()
151        })
152        .collect();
153
154    Ok(retained_rows_from_row_norms(&row_norms, rtol))
155}
156
157/// Select the retained rows of the upper-triangular QR factor from its row norms.
158///
159/// Returns the increasing indices of the rows whose norm is at least
160/// `rtol * max_row_norm`; at least one row (row 0 for an all-zero factor) is kept.
161/// Dropping row `i` of `R` together with column `i` of `Q` changes `Q R` by at most
162/// that row's norm, wherever the row sits.
163///
164/// Shared by the host dense path and the device path (which reduces the norms
165/// on-device and reads back only this `k`-element decision vector).
166fn retained_rows_from_row_norms(row_norms: &[f64], rtol: f64) -> Vec<usize> {
167    let max_row_norm = row_norms.iter().cloned().fold(0.0_f64, f64::max);
168    if max_row_norm == 0.0 {
169        return vec![0];
170    }
171
172    let threshold = rtol * max_row_norm;
173    row_norms
174        .iter()
175        .enumerate()
176        .filter(|&(_, &norm)| norm >= threshold)
177        .map(|(row, _)| row)
178        .collect()
179}
180
181/// Read the `k` upper-triangular row norms of a resident `R` factor.
182///
183/// All arithmetic stays in the operand's owning runtime; only the `k`-element
184/// decision vector crosses the explicit readback boundary through `context`.
185#[cfg(feature = "tenferro-cuda")]
186fn resident_row_norms(
187    r_inner: &EagerTensor,
188    k: usize,
189    context: &ExecutionContext,
190) -> Result<Vec<f64>, QrError> {
191    // The resident factor's own runtime runs the reduction in one eager session, as the neighbouring CUDA-only
192    // paths do; only the decision vector crosses back through `context`.
193    let sum_squares = with_eager_session(r_inner.runtime(), Some(context), |session| {
194        let upper = session.triu(r_inner, 0)?;
195        let magnitudes = session.abs(&upper)?;
196        session.reduce_sum_squares(&magnitudes, Some(&[1]))
197    })
198    .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
199    if sum_squares.shape() != [k] {
200        return Err(QrError::ComputationError(anyhow::anyhow!(
201            "device QR row-norm reduction returned shape {:?}, expected [{k}]",
202            sum_squares.shape()
203        )));
204    }
205    let decision = IdxTensor::from_inner(vec![DynIndex::new_dyn(k)], sum_squares)
206        .map_err(QrError::ComputationError)?;
207    let values = decision
208        .read_decision_data(context)
209        .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
210    Ok(values.into_iter().map(|value| value.sqrt()).collect())
211}
212
213/// Compute QR decomposition of a tensor with arbitrary rank, returning (Q, R).
214///
215/// This function uses the global default rtol for truncation.
216/// See `qr_with` for per-call rtol control.
217///
218/// This function computes the thin QR decomposition, where for an unfolded matrix A (m×n),
219/// we return Q (m×k) and R (k×n) with k = min(m, n).
220///
221/// The input tensor can have any rank >= 2, and indices are split into left and right groups.
222/// The tensor is unfolded into a matrix by grouping left indices as rows and right indices as columns.
223///
224/// Truncation is performed based on R's row norms: rows whose norm is below
225/// `rtol * max_row_norm` are discarded.
226///
227/// For the mathematical convention:
228/// \[ A = Q * R \]
229/// where Q is orthogonal (or unitary for complex) and R is upper triangular.
230///
231/// # Arguments
232/// * `t` - Input tensor with DenseF64 or DenseC64 storage
233/// * `left_inds` - Indices to place on the left (row) side of the unfolded matrix
234///
235/// # Returns
236/// A tuple `(Q, R)` where:
237/// - `Q` is a tensor with indices `[left_inds..., bond_index]` and dimensions `[left_dims..., r]`
238/// - `R` is a tensor with indices `[bond_index, right_inds...]` and dimensions `[r, right_dims...]`
239///
240///   where `r` is the retained rank (≤ min(m, n)) determined by rtol truncation.
241///
242/// # Errors
243/// Returns `QrError` if:
244/// - The tensor rank is < 2
245/// - Storage is not DenseF64 or DenseC64
246/// - `left_inds` is empty or contains all indices
247/// - `left_inds` contains indices not in the tensor or duplicates
248/// - The QR computation fails
249///
250/// # Examples
251///
252/// ```
253/// use tensor4all_core::{IdxTensor, DynIndex, qr};
254///
255/// // Create a 4x3 matrix
256/// let i = DynIndex::new_dyn(4);
257/// let j = DynIndex::new_dyn(3);
258/// // Identity-like data (4x3 column-major)
259/// let data: Vec<f64> = (0..12).map(|x| x as f64).collect();
260/// let t = IdxTensor::from_dense(vec![i.clone(), j.clone()], data).unwrap();
261///
262/// let (q, r) = qr::<f64>(&t, &[i.clone()]).unwrap();
263///
264/// // Q has shape (4, bond) and R has shape (bond, 3)
265/// assert_eq!(q.dims()[0], 4);
266/// assert_eq!(r.dims()[r.dims().len() - 1], 3);
267/// ```
268pub fn qr<T>(t: &IdxTensor, left_inds: &[DynIndex]) -> Result<(IdxTensor, IdxTensor), QrError> {
269    qr_with::<T>(t, left_inds, &QrOptions::default())
270}
271
272/// Compute QR decomposition of a tensor with arbitrary rank, returning (Q, R).
273///
274/// This function allows per-call control of the truncation tolerance via `QrOptions`.
275/// If `options.rtol` is `None`, uses the global default rtol.
276///
277/// This function computes the thin QR decomposition, where for an unfolded matrix A (m×n),
278/// we return Q (m×k) and R (k×n) with k = min(m, n).
279///
280/// The input tensor can have any rank >= 2, and indices are split into left and right groups.
281/// The tensor is unfolded into a matrix by grouping left indices as rows and right indices as columns.
282///
283/// Truncation is performed based on R's row norms: rows whose norm is below
284/// `rtol * max_row_norm` are discarded.
285///
286/// For the mathematical convention:
287/// \[ A = Q * R \]
288/// where Q is orthogonal (or unitary for complex) and R is upper triangular.
289///
290/// # Arguments
291/// * `t` - Input tensor with DenseF64 or DenseC64 storage
292/// * `left_inds` - Indices to place on the left (row) side of the unfolded matrix
293/// * `options` - QR options including rtol for truncation control
294///
295/// # Returns
296/// A tuple `(Q, R)` where:
297/// - `Q` is a tensor with indices `[left_inds..., bond_index]` and dimensions `[left_dims..., r]`
298/// - `R` is a tensor with indices `[bond_index, right_inds...]` and dimensions `[r, right_dims...]`
299///
300///   where `r` is the retained rank (≤ min(m, n)) determined by rtol truncation.
301///
302/// # Errors
303/// Returns `QrError` if:
304/// - The tensor rank is < 2
305/// - Storage is not DenseF64 or DenseC64
306/// - `left_inds` is empty or contains all indices
307/// - `left_inds` contains indices not in the tensor or duplicates
308/// - The QR computation fails
309/// - `options.rtol` is invalid (not finite or negative)
310pub fn qr_with<T>(
311    t: &IdxTensor,
312    left_inds: &[DynIndex],
313    options: &QrOptions,
314) -> Result<(IdxTensor, IdxTensor), QrError> {
315    if t.is_cuda_resident() {
316        return Err(QrError::ComputationError(anyhow::anyhow!(
317            "CUDA-resident input requires qr_with_in with the owning execution context"
318        )));
319    }
320    // Unfold tensor into an eager rank-2 tensor so linalg AD nodes stay connected.
321    let (matrix_inner, _, m, n, left_indices, right_indices) = unfold_split_inner(t, left_inds)
322        .map_err(|e| anyhow::anyhow!("Failed to unfold tensor: {}", e))
323        .map_err(QrError::ComputationError)?;
324    let k = m.min(n);
325    let (q_inner, r_inner) = with_eager_session(matrix_inner.runtime(), None, |session| {
326        session.qr(&matrix_inner)
327    })
328    .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
329
330    let keep = qr_retained_rows(&r_inner, k, n, options, None)?;
331    qr_assemble(q_inner, r_inner, k, keep, left_indices, right_indices, None)
332}
333
334/// Compute QR decomposition in a caller-owned execution context.
335///
336/// The factorization, truncation slicing, and result assembly all execute in
337/// `context`. With a CUDA context the retained-rank decision reads back only
338/// the `k`-element row-norm vector through [`IdxTensor::read_decision_data`];
339/// with a CPU context the rank decision uses the same host read as [`qr_with`].
340///
341/// # Arguments
342///
343/// * `t` - Input tensor, which must belong to `context`.
344/// * `left_inds` - Indices to place on the left (row) side of the unfolded matrix.
345/// * `options` - QR options including rtol for truncation control.
346/// * `context` - Caller-owned execution context owning the input and results.
347///
348/// # Examples
349///
350/// ```
351/// use std::sync::Arc;
352/// use tensor4all_core::qr::{QrOptions, qr_with_in};
353/// use tensor4all_core::{DynIndex, ExecutionContext, IdxTensor, TensorContractionLike};
354/// use tensor4all_tensorbackend::CpuExecutionContext;
355/// use tensor4all_tensorbackend::CpuBackend;
356///
357/// let context = ExecutionContext::Cpu(Arc::new(
358///     CpuExecutionContext::from_backend(CpuBackend::new()),
359/// ));
360/// let i = DynIndex::new_dyn(4);
361/// let j = DynIndex::new_dyn(3);
362/// let data: Vec<f64> = (0..12).map(|x| x as f64).collect();
363/// let t = IdxTensor::from_dense_in(&context, vec![i.clone(), j.clone()], data)?;
364/// let (q, r) = qr_with_in::<f64>(&t, &[i.clone()], &QrOptions::default(), &context)?;
365/// let recovered = q.contract_pair(&r)?;
366/// let expected = t.to_vec::<f64>()?;
367/// let actual = recovered.to_vec::<f64>()?;
368/// let residual = expected
369///     .iter()
370///     .zip(actual.iter())
371///     .map(|(a, b)| (a - b).abs())
372///     .fold(0.0_f64, f64::max);
373/// assert!(residual < 1e-12, "QR reconstruction residual {residual}");
374/// # Ok::<(), Box<dyn std::error::Error>>(())
375/// ```
376///
377/// # Errors
378///
379/// Returns `QrError` when the tensor does not belong to `context`, when the
380/// indices, storage, or options are invalid, or when the factorization or
381/// explicit decision readback fails. When this build cannot execute in
382/// `context`, the rejection carries
383/// [`IdxTensorError::UnsupportedExecutionContext`](crate::IdxTensorError::UnsupportedExecutionContext)
384/// as its source.
385pub fn qr_with_in<T>(
386    t: &IdxTensor,
387    left_inds: &[DynIndex],
388    options: &QrOptions,
389    context: &ExecutionContext,
390) -> Result<(IdxTensor, IdxTensor), QrError> {
391    t.validate_context(context)
392        .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
393    let (matrix_inner, _, m, n, left_indices, right_indices) =
394        unfold_split_inner_in(t, left_inds, context)
395            .map_err(|e| anyhow::anyhow!("Failed to unfold tensor: {}", e))
396            .map_err(QrError::ComputationError)?;
397    let k = m.min(n);
398    let (q_inner, r_inner) = with_eager_session(matrix_inner.runtime(), Some(context), |session| {
399        session.qr(&matrix_inner)
400    })
401    .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
402
403    let keep = qr_retained_rows(&r_inner, k, n, options, Some(context))?;
404    qr_assemble(
405        q_inner,
406        r_inner,
407        k,
408        keep,
409        left_indices,
410        right_indices,
411        Some(context),
412    )
413}
414
415/// Select the retained rows of `R`, reading the decision payload through `context`
416/// for device-resident factors and directly from host memory otherwise.
417fn qr_retained_rows(
418    r_inner: &EagerTensor,
419    k: usize,
420    n: usize,
421    options: &QrOptions,
422    context: Option<&ExecutionContext>,
423) -> Result<Vec<usize>, QrError> {
424    if !options.truncate {
425        return Ok((0..k).collect());
426    }
427    // Determine rtol to use; validity is checked by the shared policy body.
428    let rtol = options.rtol.unwrap_or(default_qr_rtol());
429
430    #[cfg(feature = "tenferro-cuda")]
431    if let Some(context) = context {
432        if matches!(context, ExecutionContext::Cuda(_)) {
433            let row_norms = resident_row_norms(r_inner, k, context)?;
434            return Ok(retained_rows_from_row_norms(&row_norms, rtol));
435        }
436    }
437    #[cfg(not(feature = "tenferro-cuda"))]
438    let _ = context;
439
440    let value = r_inner
441        .value()
442        .map_err(|source| QrError::ComputationError(anyhow::Error::new(source)))?;
443    match r_inner.dtype() {
444        DType::F64 => {
445            let values = value
446                .as_slice::<f64>()
447                .map_err(|source| QrError::ComputationError(anyhow::Error::new(source)))?;
448            retain_rows_from_payload(values, k, n, rtol)
449        }
450        DType::C64 => {
451            let values = value
452                .as_slice::<Complex64>()
453                .map_err(|source| QrError::ComputationError(anyhow::Error::new(source)))?;
454            retain_rows_from_payload(values, k, n, rtol)
455        }
456        other => Err(QrError::ComputationError(anyhow::anyhow!(
457            "native QR returned unsupported scalar type {other:?}"
458        ))),
459    }
460}
461
462/// The retained-row decision on a host `R` payload.
463///
464/// This is the one place the QR truncation policy is applied; the eager, context-scoped and
465/// explicit-session QR routes all funnel their `R` payload through it, so a policy change cannot
466/// diverge between the frontends.
467fn retain_rows_from_payload<T>(
468    r_full: &[T],
469    k: usize,
470    n: usize,
471    rtol: f64,
472) -> Result<Vec<usize>, QrError>
473where
474    T: ComplexFloat,
475    <T as ComplexFloat>::Real: Into<f64>,
476{
477    if !options_rtol_is_valid(rtol) {
478        return Err(QrError::InvalidRtol(rtol));
479    }
480    compute_retained_rows_qr_from_dense(r_full, k, n, rtol)
481}
482
483/// `rtol` validity shared by every QR route.
484fn options_rtol_is_valid(rtol: f64) -> bool {
485    rtol.is_finite() && rtol >= 0.0
486}
487
488/// Truncated QR on a caller-held explicit session.
489///
490/// The runtime-free counterpart of [`qr_with`]: the index bookkeeping, the retained-row decision and
491/// the factor assembly are the same code paths, while the decomposition runs on `session` and the
492/// factors are published as plain host-storage [`IdxTensor`]s. Nothing here enters a session or the
493/// eager runtime, so this is the route a scheduler-owned lane uses.
494///
495/// # Errors
496///
497/// Returns [`QrError`] when the split is invalid, when `options.rtol` is not finite and nonnegative,
498/// when the operand is AD-tracked ([`QrError::UnsupportedStorage`] - this route publishes plain host
499/// factors and would silently sever the graph), when the session rejects the decomposition, or when a
500/// factor cannot be published.
501///
502/// # Examples
503///
504/// ```
505/// use tensor4all_core::{qr::QrOptions, DynIndex, IdxTensor};
506/// use tensor4all_core::qr_with_on_session;
507/// use tensor4all_tensorbackend::{explicit::Session, CpuBackend};
508///
509/// let i = DynIndex::new_dyn(4);
510/// let j = DynIndex::new_dyn(3);
511/// let data: Vec<f64> = (0..12).map(|x| x as f64).collect();
512/// let tensor = IdxTensor::from_dense(vec![i.clone(), j.clone()], data)?;
513///
514/// let mut backend = CpuBackend::with_threads(1)?;
515/// let mut session = Session::new(backend.engine_mut());
516/// let (q, r) =
517///     qr_with_on_session::<f64>(&mut session, &tensor, std::slice::from_ref(&i), &QrOptions::new())?;
518/// assert_eq!(q.dims()[0], 4);
519/// assert_eq!(r.dims()[1], 3);
520/// assert_eq!(q.dims()[1], r.dims()[0], "the shared bond dimension");
521/// # Ok::<(), Box<dyn std::error::Error>>(())
522/// ```
523pub fn qr_with_on_session<T>(
524    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
525    t: &IdxTensor,
526    left_inds: &[DynIndex],
527    options: &QrOptions,
528) -> Result<(IdxTensor, IdxTensor), QrError>
529where
530    T: TensorElement + ComplexFloat + tensor4all_tensorbackend::StorageScalar,
531    <T as ComplexFloat>::Real: Into<f64>,
532{
533    if t.tracks_grad() {
534        return Err(QrError::UnsupportedStorage(
535            crate::defaults::idx_tensor::TRACKED_SESSION_OPERAND_REFUSAL,
536        ));
537    }
538    let (left_indices, right_indices, order) =
539        unfold_index_sets(t, left_inds).map_err(|error| {
540            QrError::ComputationError(anyhow::anyhow!("Failed to unfold tensor: {error}"))
541        })?;
542    // Read and reorder the operand's own host payload: entering the eager
543    // runtime for a transpose would be a second session entry on this thread.
544    let (dims, data) = t
545        .host_dense_permuted::<T>(&order)
546        .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
547    let left_len = left_indices.len();
548    let m = crate::defaults::idx_tensor::checked_product(&dims[..left_len])
549        .map_err(QrError::ComputationError)?;
550    let n = crate::defaults::idx_tensor::checked_product(&dims[left_len..])
551        .map_err(QrError::ComputationError)?;
552    let k = m.min(n);
553    let matrix = tenferro::Tensor::from_vec_col_major(vec![m, n], data)
554        .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
555    let (q_value, r_value) = session
556        .qr(&matrix)
557        .map_err(|error| QrError::ComputationError(anyhow::anyhow!("{error}")))?;
558    let keep = if options.truncate {
559        let r_payload = r_value
560            .as_slice::<T>()
561            .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
562        let rtol = options.rtol.unwrap_or(default_qr_rtol());
563        retain_rows_from_payload(r_payload, k, n, rtol)?
564    } else {
565        (0..k).collect()
566    };
567    let rank = keep.len();
568    let q_payload = q_value
569        .as_slice::<T>()
570        .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
571    let r_payload = r_value
572        .as_slice::<T>()
573        .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
574    let bond_index = DynIndex::new_bond(rank).map_err(|error| {
575        QrError::ComputationError(anyhow::anyhow!("Failed to create Link index: {error:?}"))
576    })?;
577
578    // `Q` is column-major `[m, k]`: kept columns are the bond axis of the left factor.
579    let mut q_data = Vec::with_capacity(m * rank);
580    for &column in &keep {
581        let start = column.checked_mul(m).ok_or_else(|| {
582            QrError::ComputationError(anyhow::anyhow!("QR Q column offset overflows usize"))
583        })?;
584        let end = start.checked_add(m).ok_or_else(|| {
585            QrError::ComputationError(anyhow::anyhow!("QR Q column end overflows usize"))
586        })?;
587        q_data.extend_from_slice(q_payload.get(start..end).ok_or_else(|| {
588            QrError::ComputationError(anyhow::anyhow!("QR Q column {column} is out of bounds"))
589        })?);
590    }
591    // `R` is column-major `[k, n]`: kept rows are gathered within every right column.
592    let mut r_data = Vec::with_capacity(rank * n);
593    for column in 0..n {
594        let base = column.checked_mul(k).ok_or_else(|| {
595            QrError::ComputationError(anyhow::anyhow!("QR R column offset overflows usize"))
596        })?;
597        for &row in &keep {
598            let value = r_payload.get(base + row).ok_or_else(|| {
599                QrError::ComputationError(anyhow::anyhow!("QR R row {row} is out of bounds"))
600            })?;
601            r_data.push(*value);
602        }
603    }
604
605    let mut q_indices = left_indices;
606    q_indices.push(bond_index.clone());
607    let mut r_indices = vec![bond_index];
608    r_indices.extend(right_indices);
609    let q = IdxTensor::from_host_dense(q_indices, q_data)
610        .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
611    let r = IdxTensor::from_host_dense(r_indices, r_data)
612        .map_err(|error| QrError::ComputationError(anyhow::Error::new(error)))?;
613    Ok((q, r))
614}
615
616/// Slice the thin factors to the retained rows of `R` (and the matching columns
617/// of `Q`) and wrap them as [`IdxTensor`]s.
618fn qr_assemble(
619    mut q_inner: EagerTensor,
620    mut r_inner: EagerTensor,
621    k: usize,
622    keep: Vec<usize>,
623    left_indices: Vec<DynIndex>,
624    right_indices: Vec<DynIndex>,
625    context: Option<&ExecutionContext>,
626) -> Result<(IdxTensor, IdxTensor), QrError> {
627    let r = keep.len();
628    if r < k {
629        q_inner = with_eager_session(q_inner.runtime(), context, |session| {
630            session.take_axis(&q_inner, 1, &keep)
631        })
632        .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
633        r_inner = with_eager_session(r_inner.runtime(), context, |session| {
634            session.take_axis(&r_inner, 0, &keep)
635        })
636        .map_err(|e| QrError::ComputationError(anyhow::anyhow!("{e}")))?;
637    }
638
639    let bond_index = DynIndex::new_bond(r)
640        .map_err(|e| anyhow::anyhow!("Failed to create Link index: {:?}", e))
641        .map_err(QrError::ComputationError)?;
642
643    let mut q_indices = left_indices.clone();
644    q_indices.push(bond_index.clone());
645    let q_dims: Vec<usize> = q_indices.iter().map(|idx| idx.dim).collect();
646    let q_reshaped = with_eager_session(q_inner.runtime(), context, |session| {
647        session.reshape(&q_inner, &q_dims)
648    })
649    .map_err(|e| QrError::ComputationError(anyhow::anyhow!("eager QR Q reshape failed: {e}")))?;
650    let q = IdxTensor::from_inner(q_indices, q_reshaped).map_err(QrError::ComputationError)?;
651
652    let mut r_indices = vec![bond_index.clone()];
653    r_indices.extend_from_slice(&right_indices);
654    let r_dims: Vec<usize> = r_indices.iter().map(|idx| idx.dim).collect();
655    let r_reshaped = with_eager_session(r_inner.runtime(), context, |session| {
656        session.reshape(&r_inner, &r_dims)
657    })
658    .map_err(|e| QrError::ComputationError(anyhow::anyhow!("eager QR R reshape failed: {e}")))?;
659    let r = IdxTensor::from_inner(r_indices, r_reshaped).map_err(QrError::ComputationError)?;
660
661    Ok((q, r))
662}
663
664#[cfg(test)]
665mod tests;