Skip to main content

vyre_primitives/math/
tensor_train_decompose.rs

1//! Tensor-train decomposition via SVD-truncation per mode (#P-PRIM-12).
2//!
3//! Decomposes an n-mode tensor into a chain of TT cores.
4//!
5//! Composes `symmetric_eigen_jacobi` (the eigensolve) and `dot_partial` (the core columns).
6//!
7//! Algorithm: TT-SVD (Oseledets 2011).
8
9use std::sync::Arc;
10
11use vyre_foundation::ir::model::expr::{GeneratorRef, Ident};
12use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
13
14use crate::math::dot_partial::{dot_partial, OP_ID as DOT_PARTIAL_OP_ID};
15use crate::math::symmetric_eigen_jacobi::jacobi_eigen_region;
16
17/// `row * cols + col` flat index for a row-major matrix.
18fn tt_idx(row: Expr, cols: u32, col: Expr) -> Expr {
19    Expr::add(Expr::mul(row, Expr::u32(cols)), col)
20}
21
22/// Op id.
23pub const OP_ID: &str = "vyre-primitives::math::tensor_train_decompose";
24
25/// Build a TT-decomposition Program.
26///
27/// Due to the complex sequence of SVDs and reshapes, this primitive
28/// implements one mode-truncation step. Full decomposition is achieved
29/// by a chain of these steps.
30///
31/// Inputs:
32/// - `input_matrix`: $r_{prev} \times (n_k \cdot \text{rem})$ matrix.
33/// - `u_out`: $r_{prev} \times n_k \times r_{next}$ core (output).
34/// - `rem_out`: $r_{next} \times \text{rem}$ next matrix.
35#[must_use]
36pub fn tensor_train_decompose_step(
37    input_matrix: &str,
38    u_out: &str,
39    rem_out: &str,
40    r_prev: u32,
41    nk: u32,
42    rem: u32,
43    r_next: u32,
44) -> Program {
45    let Some(input_rows) = r_prev.checked_mul(nk) else {
46        return crate::invalid_output_program(
47            OP_ID,
48            u_out,
49            DataType::F32,
50            "Fix: tensor_train_decompose_step r_prev * nk must fit in u32.".to_owned(),
51        );
52    };
53    let Some(input_count) = input_rows.checked_mul(rem) else {
54        return crate::invalid_output_program(
55            OP_ID,
56            u_out,
57            DataType::F32,
58            "Fix: tensor_train_decompose_step input count must fit in u32.".to_owned(),
59        );
60    };
61    let Some(u_count) = input_rows.checked_mul(r_next) else {
62        return crate::invalid_output_program(
63            OP_ID,
64            u_out,
65            DataType::F32,
66            "Fix: tensor_train_decompose_step core count must fit in u32.".to_owned(),
67        );
68    };
69    let Some(rem_count) = r_next.checked_mul(rem) else {
70        return crate::invalid_output_program(
71            OP_ID,
72            u_out,
73            DataType::F32,
74            "Fix: tensor_train_decompose_step remainder count must fit in u32.".to_owned(),
75        );
76    };
77    let Some(gram_count) = rem.checked_mul(rem) else {
78        return crate::invalid_output_program(
79            OP_ID,
80            u_out,
81            DataType::F32,
82            "Fix: tensor_train_decompose_step Gram matrix count must fit in u32.".to_owned(),
83        );
84    };
85    if r_prev == 0 || nk == 0 || rem == 0 || r_next == 0 {
86        return crate::invalid_output_program(
87            OP_ID,
88            u_out,
89            DataType::F32,
90            "Fix: tensor_train_decompose_step dimensions and ranks must be non-zero.".to_owned(),
91        );
92    }
93    if r_next > rem {
94        return crate::invalid_output_program(
95            OP_ID,
96            u_out,
97            DataType::F32,
98            "Fix: tensor_train_decompose_step requires r_next <= rem for emitted rank columns."
99                .to_owned(),
100        );
101    }
102
103    // Real per-mode TT-SVD (Oseledets 2011) of the `m x n` unfolding `M` (m = r_prev*nk rows,
104    // n = rem columns), in f32, run serially on lane 0. Mirrors the CPU reference
105    // `truncated_svd_into`: (1) Gram matrix G = MᵀM (n x n, symmetric PSD); (2) eigendecompose G by
106    // composing `symmetric_eigen_jacobi` → eigenvalues + eigenvectors V; (3) take the r_next
107    // largest eigenpairs:
108    // σ = √max(λ,0); core column U[:,k] = M·V[:,k]/σ; remainder row = σ·V[:,k]ᵀ carried to the next
109    // mode. `tt_ata`/`tt_evec`/`tt_eval` are internal f32 scratch (Gram, eigenvectors, eigenvalues);
110    // after selecting an eigenpair its eigenvalue is marked used (−inf) so the next rank picks the
111    // next-largest (a serial argmax in place of a sort primitive).
112    let m = input_rows;
113    let n = rem;
114    let neg_big = Expr::f32(-1.0e30);
115    let mut body: Vec<Node> = Vec::new();
116
117    // 1. Gram matrix G[ca,cb] = Σ_row M[row,ca]·M[row,cb].
118    body.push(Node::loop_for(
119        "tt_ca",
120        Expr::u32(0),
121        Expr::u32(n),
122        vec![Node::loop_for(
123            "tt_cb",
124            Expr::u32(0),
125            Expr::u32(n),
126            vec![
127                Node::let_bind("tt_gacc", Expr::f32(0.0)),
128                Node::loop_for(
129                    "tt_grow",
130                    Expr::u32(0),
131                    Expr::u32(m),
132                    vec![Node::assign(
133                        "tt_gacc",
134                        Expr::add(
135                            Expr::var("tt_gacc"),
136                            Expr::mul(
137                                Expr::load(
138                                    input_matrix,
139                                    tt_idx(Expr::var("tt_grow"), n, Expr::var("tt_ca")),
140                                ),
141                                Expr::load(
142                                    input_matrix,
143                                    tt_idx(Expr::var("tt_grow"), n, Expr::var("tt_cb")),
144                                ),
145                            ),
146                        ),
147                    )],
148                ),
149                Node::store(
150                    "tt_ata",
151                    tt_idx(Expr::var("tt_ca"), n, Expr::var("tt_cb")),
152                    Expr::var("tt_gacc"),
153                ),
154            ],
155        )],
156    ));
157
158    // 2. Eigendecompose the Gram matrix. ONE-PLACE: `symmetric_eigen_jacobi` owns the only
159    // Program-emitting spelling of the symmetric Jacobi eigendecomposition in this crate, and
160    // the child region records that composition edge in the IR rather than leaving the nodes
161    // spliced in bare where no audit can distinguish them from a hand-rolled eigensolve.
162    body.push(jacobi_eigen_region(
163        OP_ID, "tt_ata", "tt_evec", "tt_eval", n,
164    ));
165
166    // 3. Truncated SVD: r_next largest eigenpairs → core U and remainder S·Vᵀ.
167    body.push(Node::loop_for(
168        "tt_rank",
169        Expr::u32(0),
170        Expr::u32(r_next),
171        vec![
172            // argmax over remaining eigenvalues.
173            Node::let_bind("tt_best", neg_big.clone()),
174            Node::let_bind("tt_eidx", Expr::u32(0)),
175            Node::loop_for(
176                "tt_e",
177                Expr::u32(0),
178                Expr::u32(n),
179                vec![
180                    Node::let_bind("tt_ev", Expr::load("tt_eval", Expr::var("tt_e"))),
181                    Node::let_bind("tt_gt", Expr::gt(Expr::var("tt_ev"), Expr::var("tt_best"))),
182                    Node::assign(
183                        "tt_eidx",
184                        Expr::select(Expr::var("tt_gt"), Expr::var("tt_e"), Expr::var("tt_eidx")),
185                    ),
186                    Node::assign(
187                        "tt_best",
188                        Expr::select(Expr::var("tt_gt"), Expr::var("tt_ev"), Expr::var("tt_best")),
189                    ),
190                ],
191            ),
192            // σ = √max(λ, 0).
193            Node::let_bind(
194                "tt_sigma",
195                Expr::sqrt(Expr::max(Expr::var("tt_best"), Expr::f32(0.0))),
196            ),
197            // Store the eigenvector as the remainder row rem_out[rank, :] = V[:, eidx].
198            Node::loop_for(
199                "tt_vc",
200                Expr::u32(0),
201                Expr::u32(n),
202                vec![Node::store(
203                    rem_out,
204                    tt_idx(Expr::var("tt_rank"), n, Expr::var("tt_vc")),
205                    Expr::load(
206                        "tt_evec",
207                        tt_idx(Expr::var("tt_vc"), n, Expr::var("tt_eidx")),
208                    ),
209                )],
210            ),
211            // Core column: U[row, rank] = (Σ_c M[row,c]·V[c,eidx]) / σ  (0 when σ ≈ 0).
212            Node::loop_for(
213                "tt_ur",
214                Expr::u32(0),
215                Expr::u32(m),
216                vec![
217                    Node::let_bind("tt_dot", Expr::f32(0.0)),
218                    // Composed from the registered `dot_partial` primitive rather than a
219                    // second hand-rolled accumulation loop: row `tt_ur` of the unfolding and
220                    // row `tt_rank` of the remainder are both unit-stride runs of `n` f32,
221                    // which is exactly that primitive's contract.
222                    Node::Region {
223                        generator: Ident::from(DOT_PARTIAL_OP_ID),
224                        source_region: Some(GeneratorRef {
225                            name: OP_ID.to_string(),
226                        }),
227                        body: Arc::new(vec![dot_partial(
228                            input_matrix,
229                            rem_out,
230                            "tt_dot",
231                            Expr::mul(Expr::var("tt_ur"), Expr::u32(n)),
232                            Expr::mul(Expr::var("tt_rank"), Expr::u32(n)),
233                            n,
234                        )]),
235                    },
236                    Node::store(
237                        u_out,
238                        tt_idx(Expr::var("tt_ur"), r_next, Expr::var("tt_rank")),
239                        Expr::select(
240                            Expr::gt(Expr::var("tt_sigma"), Expr::f32(1.0e-6)),
241                            Expr::div(Expr::var("tt_dot"), Expr::var("tt_sigma")),
242                            Expr::f32(0.0),
243                        ),
244                    ),
245                ],
246            ),
247            // Scale the stored eigenvector row by σ so rem_out[rank, :] = σ·V[:, eidx]ᵀ = (S·Vᵀ) row.
248            Node::loop_for(
249                "tt_sc",
250                Expr::u32(0),
251                Expr::u32(n),
252                vec![Node::store(
253                    rem_out,
254                    tt_idx(Expr::var("tt_rank"), n, Expr::var("tt_sc")),
255                    Expr::mul(
256                        Expr::load(rem_out, tt_idx(Expr::var("tt_rank"), n, Expr::var("tt_sc"))),
257                        Expr::var("tt_sigma"),
258                    ),
259                )],
260            ),
261            // Mark this eigenvalue used so the next rank picks the next-largest.
262            Node::store("tt_eval", Expr::var("tt_eidx"), neg_big.clone()),
263        ],
264    ));
265
266    Program::wrapped(
267        vec![
268            BufferDecl::storage(input_matrix, 0, BufferAccess::ReadOnly, DataType::F32)
269                .with_count(input_count),
270            BufferDecl::storage(u_out, 1, BufferAccess::ReadWrite, DataType::F32)
271                .with_count(u_count),
272            BufferDecl::storage(rem_out, 2, BufferAccess::ReadWrite, DataType::F32)
273                .with_count(rem_count),
274            BufferDecl::storage("tt_ata", 3, BufferAccess::ReadWrite, DataType::F32)
275                .with_count(gram_count),
276            BufferDecl::storage("tt_evec", 4, BufferAccess::ReadWrite, DataType::F32)
277                .with_count(gram_count),
278            BufferDecl::storage("tt_eval", 5, BufferAccess::ReadWrite, DataType::F32).with_count(n),
279        ],
280        [1, 1, 1],
281        vec![Node::Region {
282            generator: Ident::from(OP_ID),
283            source_region: None,
284            body: Arc::new(vec![Node::if_then(
285                Expr::eq(Expr::InvocationId { axis: 0 }, Expr::u32(0)),
286                body,
287            )]),
288        }],
289    )
290}
291
292/// CPU reference: Full TT-SVD.
293#[cfg(any(test, feature = "cpu-parity"))]
294#[must_use]
295pub fn cpu_ref(tensor: &[f64], dims: &[u32], target_ranks: &[u32]) -> Vec<Vec<f64>> {
296    let mut cores = Vec::new();
297    let mut scratch = TensorTrainCpuScratch::default();
298    cpu_ref_into(tensor, dims, target_ranks, &mut cores, &mut scratch);
299    cores
300}
301
302/// Reusable scratch for the tensor-train CPU oracle.
303#[cfg(any(test, feature = "cpu-parity"))]
304#[derive(Debug, Default)]
305pub struct TensorTrainCpuScratch {
306    c: Vec<f64>,
307    next_c: Vec<f64>,
308    u: Vec<f64>,
309    s: Vec<f64>,
310    vt: Vec<f64>,
311    ata: Vec<f64>,
312    eigenvalues: Vec<f64>,
313    eigenvectors: Vec<f64>,
314    order: Vec<usize>,
315}
316
317#[cfg(any(test, feature = "cpu-parity"))]
318impl TensorTrainCpuScratch {
319    /// Construct empty tensor-train CPU scratch.
320    #[must_use]
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    /// Clear all scratch buffers while retaining their allocations.
326    pub fn clear(&mut self) {
327        self.c.clear();
328        self.next_c.clear();
329        self.u.clear();
330        self.s.clear();
331        self.vt.clear();
332        self.ata.clear();
333        self.eigenvalues.clear();
334        self.eigenvectors.clear();
335        self.order.clear();
336    }
337}
338
339/// CPU reference: Full TT-SVD using caller-owned core storage and scratch.
340#[cfg(any(test, feature = "cpu-parity"))]
341pub fn cpu_ref_into(
342    tensor: &[f64],
343    dims: &[u32],
344    target_ranks: &[u32],
345    cores: &mut Vec<Vec<f64>>,
346    scratch: &mut TensorTrainCpuScratch,
347) {
348    let d = dims.len();
349    if d == 0 || dims.iter().any(|&dim| dim == 0) || target_ranks.len() != d + 1 {
350        cores.clear();
351        scratch.clear();
352        return;
353    }
354    let Some(expected_len) = dims
355        .iter()
356        .try_fold(1usize, |acc, &dim| acc.checked_mul(dim as usize))
357    else {
358        cores.clear();
359        scratch.clear();
360        return;
361    };
362
363    scratch.c.clear();
364    scratch.c.resize(expected_len, 0.0);
365    let copy_len = expected_len.min(tensor.len());
366    scratch.c[..copy_len].copy_from_slice(&tensor[..copy_len]);
367    let mut r_prev = 1usize;
368    let mut core_index = 0usize;
369
370    for k in 0..(d - 1) {
371        let nk = dims[k] as usize;
372        let r_next = (target_ranks[k + 1] as usize).max(1);
373        let m = r_prev * nk;
374        if m == 0 || scratch.c.len() % m != 0 {
375            cores.truncate(core_index);
376            return;
377        }
378        let n = scratch.c.len() / m;
379
380        truncated_svd_into(
381            &scratch.c,
382            m,
383            n,
384            r_next,
385            &mut scratch.u,
386            &mut scratch.s,
387            &mut scratch.vt,
388            &mut scratch.ata,
389            &mut scratch.eigenvalues,
390            &mut scratch.eigenvectors,
391            &mut scratch.order,
392        );
393
394        write_core(cores, core_index, &scratch.u);
395        core_index += 1;
396
397        scratch.next_c.clear();
398        scratch.next_c.resize(r_next * n, 0.0);
399        for i in 0..r_next {
400            for j in 0..n {
401                scratch.next_c[i * n + j] = scratch.s[i] * scratch.vt[i * n + j];
402            }
403        }
404        std::mem::swap(&mut scratch.c, &mut scratch.next_c);
405        r_prev = r_next;
406    }
407    write_core(cores, core_index, &scratch.c);
408    core_index += 1;
409    cores.truncate(core_index);
410}
411
412#[cfg(any(test, feature = "cpu-parity"))]
413fn write_core(cores: &mut Vec<Vec<f64>>, index: usize, values: &[f64]) {
414    if index == cores.len() {
415        cores.push(Vec::new());
416    }
417    cores[index].clear();
418    cores[index].extend_from_slice(values);
419}
420
421#[cfg(any(test, feature = "cpu-parity"))]
422fn truncated_svd(matrix: &[f64], m: usize, n: usize, r: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
423    let mut u = Vec::new();
424    let mut s = Vec::new();
425    let mut vt = Vec::new();
426    let mut ata = Vec::new();
427    let mut eigenvalues = Vec::new();
428    let mut eigenvectors = Vec::new();
429    let mut order = Vec::new();
430    truncated_svd_into(
431        matrix,
432        m,
433        n,
434        r,
435        &mut u,
436        &mut s,
437        &mut vt,
438        &mut ata,
439        &mut eigenvalues,
440        &mut eigenvectors,
441        &mut order,
442    );
443    (u, s, vt)
444}
445
446#[cfg(any(test, feature = "cpu-parity"))]
447#[allow(clippy::too_many_arguments)]
448fn truncated_svd_into(
449    matrix: &[f64],
450    m: usize,
451    n: usize,
452    r: usize,
453    u: &mut Vec<f64>,
454    s: &mut Vec<f64>,
455    vt: &mut Vec<f64>,
456    ata: &mut Vec<f64>,
457    eigenvalues: &mut Vec<f64>,
458    eigenvectors: &mut Vec<f64>,
459    order: &mut Vec<usize>,
460) {
461    u.clear();
462    s.clear();
463    vt.clear();
464    let Some(matrix_len) = m.checked_mul(n) else {
465        return;
466    };
467    let Some(u_len) = m.checked_mul(r) else {
468        return;
469    };
470    let Some(vt_len) = r.checked_mul(n) else {
471        return;
472    };
473    if n == 0 || r == 0 || matrix.len() != matrix_len || r > n {
474        u.resize(u_len, 0.0);
475        s.resize(r, 0.0);
476        vt.resize(vt_len, 0.0);
477        return;
478    }
479
480    ata.clear();
481    ata.resize(n * n, 0.0);
482    for row in 0..m {
483        for col_a in 0..n {
484            let a = matrix[row * n + col_a];
485            for col_b in 0..n {
486                ata[col_a * n + col_b] += a * matrix[row * n + col_b];
487            }
488        }
489    }
490
491    symmetric_eigen_jacobi_into(ata, n, eigenvalues, eigenvectors);
492    order.clear();
493    order.extend(0..n);
494    order.sort_by(|&left, &right| {
495        eigenvalues[right]
496            .partial_cmp(&eigenvalues[left])
497            .unwrap_or(std::cmp::Ordering::Equal)
498    });
499
500    u.resize(u_len, 0.0);
501    s.resize(r, 0.0);
502    vt.resize(vt_len, 0.0);
503
504    for rank in 0..r {
505        let eig_index = order[rank];
506        let sigma = eigenvalues[eig_index].max(0.0).sqrt();
507        s[rank] = sigma;
508        for col in 0..n {
509            vt[rank * n + col] = eigenvectors[col * n + eig_index];
510        }
511        if sigma > 1e-12 {
512            for row in 0..m {
513                let mut dot = 0.0;
514                for col in 0..n {
515                    dot += matrix[row * n + col] * vt[rank * n + col];
516                }
517                u[row * r + rank] = dot / sigma;
518            }
519        }
520    }
521}
522
523#[cfg(any(test, feature = "cpu-parity"))]
524fn symmetric_eigen_jacobi(mut a: Vec<f64>, n: usize) -> (Vec<f64>, Vec<f64>) {
525    let mut eigenvalues = Vec::new();
526    let mut eigenvectors = Vec::new();
527    symmetric_eigen_jacobi_into(&mut a, n, &mut eigenvalues, &mut eigenvectors);
528    (eigenvalues, eigenvectors)
529}
530
531#[cfg(any(test, feature = "cpu-parity"))]
532fn symmetric_eigen_jacobi_into(
533    a: &mut Vec<f64>,
534    n: usize,
535    eigenvalues: &mut Vec<f64>,
536    eigenvectors: &mut Vec<f64>,
537) {
538    eigenvalues.clear();
539    eigenvectors.clear();
540    let Some(square_len) = n.checked_mul(n) else {
541        return;
542    };
543    if n == 0 {
544        return;
545    }
546    a.resize(square_len, 0.0);
547    eigenvectors.resize(square_len, 0.0);
548    for i in 0..n {
549        eigenvectors[i * n + i] = 1.0;
550    }
551
552    let max_sweeps = (16 * n.max(1) * n.max(1)).max(32);
553    for _ in 0..max_sweeps {
554        let mut p = 0usize;
555        let mut q = 0usize;
556        let mut max_offdiag = 0.0;
557        for i in 0..n {
558            for j in (i + 1)..n {
559                let value = a[i * n + j].abs();
560                if value > max_offdiag {
561                    max_offdiag = value;
562                    p = i;
563                    q = j;
564                }
565            }
566        }
567        if max_offdiag <= 1e-12 {
568            break;
569        }
570
571        let app = a[p * n + p];
572        let aqq = a[q * n + q];
573        let apq = a[p * n + q];
574        let tau = (aqq - app) / (2.0 * apq);
575        let t = tau.signum() / (tau.abs() + (1.0 + tau * tau).sqrt());
576        let c = 1.0 / (1.0 + t * t).sqrt();
577        let s = t * c;
578
579        for k in 0..n {
580            let akp = a[k * n + p];
581            let akq = a[k * n + q];
582            a[k * n + p] = c * akp - s * akq;
583            a[k * n + q] = s * akp + c * akq;
584        }
585        for k in 0..n {
586            let apk = a[p * n + k];
587            let aqk = a[q * n + k];
588            a[p * n + k] = c * apk - s * aqk;
589            a[q * n + k] = s * apk + c * aqk;
590        }
591        a[p * n + q] = 0.0;
592        a[q * n + p] = 0.0;
593
594        for k in 0..n {
595            let vkp = eigenvectors[k * n + p];
596            let vkq = eigenvectors[k * n + q];
597            eigenvectors[k * n + p] = c * vkp - s * vkq;
598            eigenvectors[k * n + q] = s * vkp + c * vkq;
599        }
600    }
601
602    eigenvalues.extend((0..n).map(|i| a[i * n + i]));
603}
604
605#[cfg(feature = "inventory-registry")]
606inventory::submit! {
607    vyre_foundation::operation::OperationRegistration::primitive(
608        OP_ID,
609        // m = r_prev*nk = 4 rows, n = rem = 2 columns, r_next = 1 (rank-1 truncation).
610        //
611        // The unfolding is TALL on purpose. A wide one (m < n) makes the Gram matrix
612        // rank-deficient, its null space is a degenerate eigen-subspace, and any
613        // eigenvector basis for it is equally correct. That leaves the eigenvector
614        // buffer with no single right answer and no oracle can pin it. With m >= n and
615        // distinct eigenvalues every output is determined, up to the sign convention
616        // `jacobi_eigen_body` now fixes.
617        || tensor_train_decompose_step("in", "u", "rem", 2, 2, 2, 1),
618        Some(|| {
619            let to_bytes = |vals: &[f32]| crate::wire::pack_f32_slice(vals);
620            // One f32 input per buffer in binding order: input_matrix (4x2), then the writable
621            // core/remainder/scratch buffers zero-initialized (backend zero-allocation).
622            vec![vec![
623                to_bytes(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]), // input_matrix (4x2)
624                to_bytes(&[0.0; 4]),                                 // u_out (4x1)
625                to_bytes(&[0.0; 2]),                                 // rem_out (1x2)
626                to_bytes(&[0.0; 4]),                                 // tt_ata (2x2)
627                to_bytes(&[0.0; 4]),                                 // tt_evec (2x2)
628                to_bytes(&[0.0; 2]),                                 // tt_eval (2)
629            ]]
630        }),
631        // Exact oracle for the rank-1 truncation of M = [[1,2],[3,4],[5,6],[7,8]].
632        //
633        // Derived analytically, not captured from a run. G = MtM = [[84,100],[100,120]],
634        // whose eigenvalues are (204 +- sqrt(41296))/2, so lambda1 = 203.6071 and
635        // lambda2 = 0.39291. sigma1 = sqrt(lambda1) = 14.2691. The dominant eigenvector
636        // solves (84 - lambda1)x + 100y = 0, giving v1 = (0.64142, 0.76719) once
637        // normalized and sign-canonicalized. From those: rem = sigma1 * v1t, and
638        // u = M*v1/sigma1.
639        //
640        // The op used to ship no oracle at all, on the reasoning that a truncated SVD is
641        // basis-dependent. Two of the three ambiguities are removed rather than tolerated:
642        // the sign by `jacobi_eigen_body`'s canonicalization, the degenerate subspace by
643        // the tall unfolding above. Ordering is not an ambiguity here because the two
644        // eigenvalues differ by three orders of magnitude.
645        Some(|| {
646            let to_bytes = |vals: &[f32]| crate::wire::pack_f32_slice(vals);
647            vec![vec![
648                to_bytes(&[0.15248324, 0.3499184, 0.5473535, 0.7447886]), // u_out = M*v1/sigma1
649                to_bytes(&[9.152527, 10.947071]),                        // rem_out = sigma1*v1t
650                to_bytes(&[0.39291155, 0.0, 0.0, 203.6071]),             // tt_ata diagonalized
651                to_bytes(&[0.7671874, 0.64142305, -0.64142305, 0.7671874]), // tt_evec columns
652                to_bytes(&[0.39291155, -1.0e30]),                        // tt_eval, top marked used
653            ]]
654        }),
655    )
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn cpu_ref_rank_1_decomposition() {
664        // T(i, j) = 1.0
665        let tensor = vec![1.0; 4];
666        let dims = vec![2, 2];
667        let ranks = vec![1, 1, 1];
668        let cores = cpu_ref(&tensor, &dims, &ranks);
669        assert_eq!(cores.len(), 2);
670        assert_eq!(cores[0].len(), 2); // 1 * 2 * 1
671        assert_eq!(cores[1].len(), 2); // 1 * 2 * 1
672    }
673
674    #[test]
675    fn cpu_ref_3mode() {
676        let tensor = vec![1.0; 8];
677        let dims = vec![2, 2, 2];
678        let ranks = vec![1, 1, 1, 1];
679        let cores = cpu_ref(&tensor, &dims, &ranks);
680        assert_eq!(cores.len(), 3);
681    }
682
683    #[test]
684    fn cpu_ref_varying_ranks() {
685        let tensor = vec![0.0; 12]; // 2 x 3 x 2
686        let dims = vec![2, 3, 2];
687        let ranks = vec![1, 2, 2, 1];
688        let cores = cpu_ref(&tensor, &dims, &ranks);
689        assert_eq!(cores.len(), 3);
690        assert_eq!(cores[0].len(), 4); // 1 * 2 * 2
691        assert_eq!(cores[1].len(), 12); // 2 * 3 * 2
692        assert_eq!(cores[2].len(), 4); // 2 * 2 * 1
693    }
694
695    #[test]
696    fn cpu_ref_into_reuses_core_vectors_and_svd_scratch() {
697        let tensor = vec![1.0; 8];
698        let dims = vec![2, 2, 2];
699        let ranks = vec![1, 1, 1, 1];
700        let mut cores = vec![
701            vec![99.0; 16],
702            vec![88.0; 16],
703            vec![77.0; 16],
704            vec![66.0; 16],
705        ];
706        let core_caps = cores.iter().map(Vec::capacity).collect::<Vec<_>>();
707        let mut scratch = TensorTrainCpuScratch::new();
708        scratch.c.reserve(32);
709        scratch.next_c.reserve(32);
710        scratch.u.reserve(32);
711        scratch.s.reserve(8);
712        scratch.vt.reserve(32);
713        scratch.ata.reserve(32);
714        scratch.eigenvalues.reserve(8);
715        scratch.eigenvectors.reserve(32);
716        scratch.order.reserve(8);
717        let scratch_caps = [
718            scratch.c.capacity(),
719            scratch.next_c.capacity(),
720            scratch.u.capacity(),
721            scratch.s.capacity(),
722            scratch.vt.capacity(),
723            scratch.ata.capacity(),
724            scratch.eigenvalues.capacity(),
725            scratch.eigenvectors.capacity(),
726            scratch.order.capacity(),
727        ];
728
729        cpu_ref_into(&tensor, &dims, &ranks, &mut cores, &mut scratch);
730
731        assert_eq!(cores.len(), 3);
732        assert_eq!(cores[0].len(), 2);
733        assert_eq!(cores[1].len(), 2);
734        assert_eq!(cores[2].len(), 2);
735        assert_eq!(cores[0].capacity(), core_caps[0]);
736        assert_eq!(cores[1].capacity(), core_caps[1]);
737        assert_eq!(cores[2].capacity(), core_caps[2]);
738        assert_eq!(scratch.c.capacity(), scratch_caps[0]);
739        assert_eq!(scratch.next_c.capacity(), scratch_caps[1]);
740        assert_eq!(scratch.u.capacity(), scratch_caps[2]);
741        assert_eq!(scratch.s.capacity(), scratch_caps[3]);
742        assert_eq!(scratch.vt.capacity(), scratch_caps[4]);
743        assert_eq!(scratch.ata.capacity(), scratch_caps[5]);
744        assert_eq!(scratch.eigenvalues.capacity(), scratch_caps[6]);
745        assert_eq!(scratch.eigenvectors.capacity(), scratch_caps[7]);
746        assert_eq!(scratch.order.capacity(), scratch_caps[8]);
747
748        cpu_ref_into(&tensor[..4], &[2, 2], &[1, 1, 1], &mut cores, &mut scratch);
749        assert_eq!(cores.len(), 2);
750        assert_eq!(cores[0].len(), 2);
751        assert_eq!(cores[1].len(), 2);
752        assert_eq!(cores[0].capacity(), core_caps[0]);
753        assert_eq!(cores[1].capacity(), core_caps[1]);
754    }
755
756    #[test]
757    fn truncated_svd_into_reuses_all_supplied_buffers() {
758        let matrix = vec![1.0, 2.0, 3.0, 4.0];
759        let mut u = Vec::with_capacity(8);
760        let mut s = Vec::with_capacity(4);
761        let mut vt = Vec::with_capacity(8);
762        let mut ata = Vec::with_capacity(8);
763        let mut eigenvalues = Vec::with_capacity(4);
764        let mut eigenvectors = Vec::with_capacity(8);
765        let mut order = Vec::with_capacity(4);
766        let caps = [
767            u.capacity(),
768            s.capacity(),
769            vt.capacity(),
770            ata.capacity(),
771            eigenvalues.capacity(),
772            eigenvectors.capacity(),
773            order.capacity(),
774        ];
775
776        truncated_svd_into(
777            &matrix,
778            2,
779            2,
780            2,
781            &mut u,
782            &mut s,
783            &mut vt,
784            &mut ata,
785            &mut eigenvalues,
786            &mut eigenvectors,
787            &mut order,
788        );
789
790        assert_eq!(u.len(), 4);
791        assert_eq!(s.len(), 2);
792        assert_eq!(vt.len(), 4);
793        assert_eq!(u.capacity(), caps[0]);
794        assert_eq!(s.capacity(), caps[1]);
795        assert_eq!(vt.capacity(), caps[2]);
796        assert_eq!(ata.capacity(), caps[3]);
797        assert_eq!(eigenvalues.capacity(), caps[4]);
798        assert_eq!(eigenvectors.capacity(), caps[5]);
799        assert_eq!(order.capacity(), caps[6]);
800    }
801
802    #[test]
803    fn truncated_svd_columns_are_orthonormal() {
804        let matrix = vec![1.0, 2.0, 3.0, 4.0];
805        let (u, _, _) = truncated_svd(&matrix, 2, 2, 2);
806        let dot = u[0] * u[1] + u[2] * u[3];
807        let n0 = u[0] * u[0] + u[2] * u[2];
808        let n1 = u[1] * u[1] + u[3] * u[3];
809        assert!(dot.abs() < 1e-8, "left singular vectors must be orthogonal");
810        assert!((n0 - 1.0).abs() < 1e-8, "first vector must be unit length");
811        assert!((n1 - 1.0).abs() < 1e-8, "second vector must be unit length");
812    }
813
814    #[test]
815    fn truncated_svd_full_rank_reconstructs_matrix() {
816        let matrix = vec![1.0, 2.0, 3.0, 4.0];
817        let (u, s, vt) = truncated_svd(&matrix, 2, 2, 2);
818        let mut reconstructed = [0.0_f64; 4];
819        for row in 0..2 {
820            for col in 0..2 {
821                for rank in 0..2 {
822                    reconstructed[row * 2 + col] +=
823                        u[row * 2 + rank] * s[rank] * vt[rank * 2 + col];
824                }
825            }
826        }
827        for (actual, expected) in reconstructed.iter().zip(matrix.iter()) {
828            assert!(
829                (actual - expected).abs() < 1e-8,
830                "full-rank SVD reconstruction drifted: actual={actual}, expected={expected}"
831            );
832        }
833    }
834
835    #[test]
836    fn program_buffer_layout() {
837        use vyre_foundation::ir::{BufferAccess, DataType};
838        let p = tensor_train_decompose_step("in", "u", "rem", 1, 2, 4, 1);
839        // input_matrix (RO) + u_out/rem_out (RW outputs) + tt_ata/tt_evec/tt_eval (RW f32 scratch
840        // for the Gram matrix, eigenvectors, eigenvalues) = 6 buffers, all f32.
841        assert_eq!(p.buffers.len(), 6);
842        assert!(p.buffers.iter().all(|b| b.element() == DataType::F32));
843        assert_eq!(p.buffers[0].access(), BufferAccess::ReadOnly);
844        assert!(p.buffers[1..]
845            .iter()
846            .all(|b| b.access() == BufferAccess::ReadWrite));
847    }
848}