open-standards/wai/wai-quantum/src/linalg.rs
Transaction Science e2a6baa5b1
Some checks failed
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m58s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m30s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m11s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m40s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 4m29s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 12s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 2m11s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 29s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 46s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 1m31s
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Successful in 1m4s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 1m2s
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Successful in 3m56s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 1m45s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m41s
standards workspaces build + test / aac-rs (pull_request) Successful in 1m37s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m0s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 41s
standards workspaces build + test / map-rs (pull_request) Successful in 41s
standards workspaces build + test / mesh-rs (pull_request) Successful in 34s
standards workspaces build + test / openpay (pull_request) Successful in 2m7s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 6m5s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 34s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 2m9s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m57s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m58s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 4m0s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m53s
standards workspaces build + test / sense-rs (pull_request) Successful in 3m57s
standards workspaces build + test / macOS energy backends (pull_request) Failing after 6m44s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 8m36s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 35s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 3m2s
wai-quantum 0.3.35: time evolution of spin chains by TDVP, held to exact answers
quantum_tdvp evolves a matrix-product state by the two-site time-dependent
variational principle, a symmetric second-order sweep:
- each bond goes forward half a step under its effective Hamiltonian, and the
  new centre goes back half a step under its one-site one;
- the exponentials are Lanczos with full re-orthogonalisation, and a step
  that does not converge is halved;
- bonds are split by a Jacobi SVD, so amplitudes resolve to rounding;
- every bond's basis is completed with null vectors up to max_bond, which
  removes the projection error a product state's small bonds would cause.

The referees are exact Krylov evolution of all amplitudes (up to 20 sites) and
the free-fermion Ising quench at any length, including the Loschmidt echo.

Checked:
- complete bases are exact to rounding at any step size;
- at 100 sites and bond 64 the critical quench stays within 1e-9 of the exact
  answer through t = 3, and past that the error follows the reported
  discarded weight;
- runs are bit-identical across thread counts and natively vs wasm32-wasip2
  (new pin);
- the CLI command tdvp prints the same bytes natively and under WASI.

A fourth-order Yoshida composition was measured as less accurate and slower,
and was removed; the module docs record why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 11:13:38 -04:00

419 lines
16 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Dense complex linear algebra shared by the tensor-network backends: a
//! complex number, a one-sided Jacobi SVD and a Householder QR. Only
//! `+ − × ÷ √`, in a fixed order, so every result is the same on every machine.
#![allow(clippy::should_implement_trait, clippy::needless_range_loop)]
// ===========================================================================
// Complex f64 (deterministic: only + - * / sqrt, no FMA/transcendentals)
// ===========================================================================
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct C {
pub re: f64,
pub im: f64,
}
// The tensor-network layer uses only the type and its constants; the
// arithmetic helpers serve the decomposition backends.
#[cfg_attr(not(any(feature = "quantum_mps", feature = "quantum_bptn")), allow(dead_code))]
impl C {
pub(crate) const ZERO: C = C { re: 0.0, im: 0.0 };
pub(crate) const ONE: C = C { re: 1.0, im: 0.0 };
#[inline]
pub(crate) fn new(re: f64, im: f64) -> C {
C { re, im }
}
#[inline]
pub(crate) fn add(self, o: C) -> C {
C { re: self.re + o.re, im: self.im + o.im }
}
#[inline]
pub(crate) fn sub(self, o: C) -> C {
C { re: self.re - o.re, im: self.im - o.im }
}
#[inline]
pub(crate) fn mul(self, o: C) -> C {
C { re: self.re * o.re - self.im * o.im, im: self.re * o.im + self.im * o.re }
}
#[inline]
pub(crate) fn conj(self) -> C {
C { re: self.re, im: -self.im }
}
#[inline]
pub(crate) fn scale(self, s: f64) -> C {
C { re: self.re * s, im: self.im * s }
}
#[inline]
pub(crate) fn norm2(self) -> f64 {
self.re * self.re + self.im * self.im
}
}
// ===========================================================================
// Complex one-sided Jacobi SVD (M = U · diag(S) · V†)
// ===========================================================================
/// SVD of an `rows × cols` matrix given by its **columns** (`m[c]` is column `c`, a
/// length-`rows` vector). Returns `(u_cols, s, v_cols)` where each `u_cols[k]` is a
/// length-`rows` left singular vector, `s[k]` its singular value, `v_cols[k]` a
/// length-`cols` right singular vector — sorted by `s` descending. Deterministic:
/// fixed sweep order, fixed sweep cap, only `+ − × ÷ √`.
#[cfg(feature = "quantum_mps")]
pub(crate) fn jacobi_svd(mut m: Vec<Vec<C>>, rows: usize, cols: usize) -> (Vec<Vec<C>>, Vec<f64>, Vec<Vec<C>>) {
// V starts as identity (cols × cols), stored by columns.
let mut v: Vec<Vec<C>> = (0..cols)
.map(|j| (0..cols).map(|i| if i == j { C::ONE } else { C::ZERO }).collect())
.collect();
let eps = 1e-14;
for _sweep in 0..60 {
let mut off = 0.0f64;
for p in 0..cols {
for q in (p + 1)..cols {
// gram entries of columns p,q
let mut alpha = 0.0; // <c_p,c_p>
let mut beta = 0.0; // <c_q,c_q>
let mut gamma = C::ZERO; // <c_p,c_q> = sum conj(c_p)·c_q
for k in 0..rows {
alpha += m[p][k].norm2();
beta += m[q][k].norm2();
gamma = gamma.add(m[p][k].conj().mul(m[q][k]));
}
let g2 = gamma.norm2();
off += g2;
if g2 <= eps * alpha * beta || g2 == 0.0 {
continue;
}
let gabs = g2.sqrt();
// phase-rotate column q so <c_p,c_q> becomes real +gabs
let ph = C::new(gamma.re / gabs, gamma.im / gabs); // unit
let cph = ph.conj();
for k in 0..rows {
m[q][k] = m[q][k].mul(cph);
}
for k in 0..cols {
v[q][k] = v[q][k].mul(cph);
}
// real Jacobi rotation with a=alpha,b=beta,g=gabs
let tau = (beta - alpha) / (2.0 * gabs);
let t = if tau >= 0.0 {
1.0 / (tau + (1.0 + tau * tau).sqrt())
} else {
-1.0 / (-tau + (1.0 + tau * tau).sqrt())
};
let cs = 1.0 / (1.0 + t * t).sqrt();
let sn = t * cs;
for k in 0..rows {
let x = m[p][k];
let y = m[q][k];
m[p][k] = x.scale(cs).sub(y.scale(sn));
m[q][k] = x.scale(sn).add(y.scale(cs));
}
for k in 0..cols {
let x = v[p][k];
let y = v[q][k];
v[p][k] = x.scale(cs).sub(y.scale(sn));
v[q][k] = x.scale(sn).add(y.scale(cs));
}
}
}
if off <= eps {
break;
}
}
// singular values = column norms; left vectors = normalized columns
let mut s = vec![0.0; cols];
for j in 0..cols {
s[j] = (0..rows).map(|k| m[j][k].norm2()).sum::<f64>().sqrt();
}
let mut u: Vec<Vec<C>> = m;
for j in 0..cols {
if s[j] > 1e-300 {
let inv = 1.0 / s[j];
for k in 0..rows {
u[j][k] = u[j][k].scale(inv);
}
}
}
// sort by singular value descending (deterministic)
let mut order: Vec<usize> = (0..cols).collect();
order.sort_by(|&a, &b| s[b].partial_cmp(&s[a]).unwrap_or(std::cmp::Ordering::Equal));
let s2: Vec<f64> = order.iter().map(|&i| s[i]).collect();
let u2: Vec<Vec<C>> = order.iter().map(|&i| u[i].clone()).collect();
let v2: Vec<Vec<C>> = order.iter().map(|&i| v[i].clone()).collect();
(u2, s2, v2)
}
/// [`jacobi_svd`] run to convergence: a pair of columns is rotated until
/// their correlation is at rounding level, and the sweeps stop when one
/// rotates nothing. The original keeps its looser stopping rule (columns
/// orthogonal to about 1e-7 relative) because the MPS backend's results are
/// pinned to it.
#[cfg(any(feature = "quantum_bptn", feature = "quantum_tdvp"))]
pub(crate) fn jacobi_svd_strict(mut m: Vec<Vec<C>>, rows: usize, cols: usize) -> (Vec<Vec<C>>, Vec<f64>, Vec<Vec<C>>) {
// V starts as identity (cols × cols), stored by columns.
let mut v: Vec<Vec<C>> = (0..cols)
.map(|j| (0..cols).map(|i| if i == j { C::ONE } else { C::ZERO }).collect())
.collect();
// |⟨c_p,c_q⟩|² ≤ eps·‖c_p‖²‖c_q‖²: columns orthogonal to rounding.
let eps = 1e-30;
// A column this small against the whole matrix is numerically null: it
// holds no singular value anyone keeps, and rotating it only feeds
// rounding noise into V.
let null = 1e-30 * m.iter().flatten().map(|x| x.norm2()).sum::<f64>();
for _sweep in 0..100 {
let mut rotated = false;
for p in 0..cols {
for q in (p + 1)..cols {
// gram entries of columns p,q
let mut alpha = 0.0; // <c_p,c_p>
let mut beta = 0.0; // <c_q,c_q>
let mut gamma = C::ZERO; // <c_p,c_q> = sum conj(c_p)·c_q
for k in 0..rows {
alpha += m[p][k].norm2();
beta += m[q][k].norm2();
gamma = gamma.add(m[p][k].conj().mul(m[q][k]));
}
if alpha <= null || beta <= null {
continue;
}
let g2 = gamma.norm2();
if g2 <= eps * alpha * beta || g2 == 0.0 {
continue;
}
rotated = true;
// |γ| scaled by its larger component, so a tiny γ keeps its
// phase at unit modulus without underflowing. Not `hypot`:
// libm's last bit differs between platforms; `/` and `√` do not.
let big = gamma.re.abs().max(gamma.im.abs());
let (r, i) = (gamma.re / big, gamma.im / big);
let gabs = big * (r * r + i * i).sqrt();
// phase-rotate column q so <c_p,c_q> becomes real +gabs
let ph = C::new(gamma.re / gabs, gamma.im / gabs); // unit
let cph = ph.conj();
for k in 0..rows {
m[q][k] = m[q][k].mul(cph);
}
for k in 0..cols {
v[q][k] = v[q][k].mul(cph);
}
// real Jacobi rotation with a=alpha,b=beta,g=gabs
let tau = (beta - alpha) / (2.0 * gabs);
let t = if tau >= 0.0 {
1.0 / (tau + (1.0 + tau * tau).sqrt())
} else {
-1.0 / (-tau + (1.0 + tau * tau).sqrt())
};
let cs = 1.0 / (1.0 + t * t).sqrt();
let sn = t * cs;
for k in 0..rows {
let x = m[p][k];
let y = m[q][k];
m[p][k] = x.scale(cs).sub(y.scale(sn));
m[q][k] = x.scale(sn).add(y.scale(cs));
}
for k in 0..cols {
let x = v[p][k];
let y = v[q][k];
v[p][k] = x.scale(cs).sub(y.scale(sn));
v[q][k] = x.scale(sn).add(y.scale(cs));
}
}
}
if !rotated {
break;
}
}
// singular values = column norms; left vectors = normalized columns
let mut s = vec![0.0; cols];
for j in 0..cols {
s[j] = (0..rows).map(|k| m[j][k].norm2()).sum::<f64>().sqrt();
}
let mut u: Vec<Vec<C>> = m;
for j in 0..cols {
if s[j] > 1e-300 {
let inv = 1.0 / s[j];
for k in 0..rows {
u[j][k] = u[j][k].scale(inv);
}
}
}
// sort by singular value descending (deterministic)
let mut order: Vec<usize> = (0..cols).collect();
order.sort_by(|&a, &b| s[b].partial_cmp(&s[a]).unwrap_or(std::cmp::Ordering::Equal));
let s2: Vec<f64> = order.iter().map(|&i| s[i]).collect();
let u2: Vec<Vec<C>> = order.iter().map(|&i| u[i].clone()).collect();
let v2: Vec<Vec<C>> = order.iter().map(|&i| v[i].clone()).collect();
(u2, s2, v2)
}
// ===========================================================================
// Householder QR (A = Q · R)
// ===========================================================================
/// Thin QR of the row-major `m × n` matrix `a`: `Q` (`m × k`, orthonormal
/// columns) and `R` (`k × n`, upper triangular), both row-major, with
/// `k = min(m, n)`. Householder reflections in a fixed order; deterministic.
#[cfg(feature = "quantum_bptn")]
pub(crate) fn qr(a: &[C], m: usize, n: usize) -> (Vec<C>, Vec<C>, usize) {
let k = m.min(n);
let mut r = a.to_vec();
let mut vs: Vec<Vec<C>> = Vec::with_capacity(k);
for j in 0..k {
let norm = (j..m).map(|i| r[i * n + j].norm2()).sum::<f64>().sqrt();
let mut v: Vec<C> = (j..m).map(|i| r[i * n + j]).collect();
if norm == 0.0 {
vs.push(vec![C::ZERO; m - j]);
continue;
}
// alpha = −(x₀/|x₀|)·‖x‖, so the reflection maps x to alpha·e₁
let x0 = v[0];
let a0 = x0.norm2().sqrt();
let phase = if a0 == 0.0 { C::ONE } else { C::new(x0.re / a0, x0.im / a0) };
let alpha = phase.scale(-norm);
v[0] = v[0].sub(alpha);
let vn = v.iter().map(|x| x.norm2()).sum::<f64>().sqrt();
if vn == 0.0 {
vs.push(vec![C::ZERO; m - j]);
continue;
}
for x in v.iter_mut() {
*x = x.scale(1.0 / vn);
}
// r[j.., j..] -= 2 v (v† r[j.., j..])
for c in j..n {
let mut dot = C::ZERO;
for (t, vi) in v.iter().enumerate() {
dot = dot.add(vi.conj().mul(r[(j + t) * n + c]));
}
let d2 = dot.scale(2.0);
for (t, vi) in v.iter().enumerate() {
r[(j + t) * n + c] = r[(j + t) * n + c].sub(vi.mul(d2));
}
}
vs.push(v);
}
// Q = H₀ H₁ … H_{k−1} applied to the first k columns of the identity.
let mut q = vec![C::ZERO; m * k];
for i in 0..k {
q[i * k + i] = C::ONE;
}
for j in (0..k).rev() {
let v = &vs[j];
for c in 0..k {
let mut dot = C::ZERO;
for (t, vi) in v.iter().enumerate() {
dot = dot.add(vi.conj().mul(q[(j + t) * k + c]));
}
let d2 = dot.scale(2.0);
for (t, vi) in v.iter().enumerate() {
q[(j + t) * k + c] = q[(j + t) * k + c].sub(vi.mul(d2));
}
}
}
let mut rr = vec![C::ZERO; k * n];
for i in 0..k {
for c in i..n {
rr[i * n + c] = r[i * n + c];
}
}
(q, rr, k)
}
#[cfg(all(test, feature = "quantum_bptn"))]
mod tests {
use super::*;
#[test]
fn qr_reconstructs_and_q_is_orthonormal() {
let mut s = 7u64;
let mut next = || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
(s >> 11) as f64 / (1u64 << 53) as f64 - 0.5
};
for (m, n) in [(5, 3), (3, 5), (8, 8), (1, 4), (6, 1)] {
let a: Vec<C> = (0..m * n).map(|_| C::new(next(), next())).collect();
let (q, r, k) = qr(&a, m, n);
for i in 0..m {
for c in 0..n {
let mut x = C::ZERO;
for t in 0..k {
x = x.add(q[i * k + t].mul(r[t * n + c]));
}
let d = x.sub(a[i * n + c]);
assert!(d.norm2() < 1e-26, "({m}x{n}) entry {i},{c}");
}
}
for a1 in 0..k {
for b1 in 0..k {
let mut x = C::ZERO;
for i in 0..m {
x = x.add(q[i * k + a1].conj().mul(q[i * k + b1]));
}
let want = if a1 == b1 { 1.0 } else { 0.0 };
assert!((x.re - want).abs() < 1e-13 && x.im.abs() < 1e-13);
}
}
for i in 0..k {
for c in 0..i.min(n) {
assert_eq!(r[i * n + c].norm2(), 0.0, "R is upper triangular");
}
}
}
}
}
#[cfg(all(test, feature = "quantum_bptn"))]
mod svd_tests {
use super::*;
/// The converged SVD reconstructs `A = U·S·V†` and returns orthonormal
/// singular vectors, both to rounding, on full-rank and rank-deficient
/// matrices. (The original's looser stopping rule leaves `U` orthonormal
/// only to about 1e-7.)
#[test]
fn the_converged_svd_reconstructs_with_orthonormal_vectors() {
let mut s = 99u64;
let mut next = || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
(s >> 11) as f64 / (1u64 << 53) as f64 - 0.5
};
for trial in 0..200 {
let rows = 2 + trial % 9;
let cols = 2 + (trial / 9) % 7;
let rank = 1 + trial % rows.min(cols);
let b: Vec<C> = (0..rows * rank).map(|_| C::new(next(), next())).collect();
let c: Vec<C> = (0..cols * rank).map(|_| C::new(next(), next())).collect();
let a: Vec<C> = (0..rows * cols)
.map(|i| {
let (r, q) = (i / cols, i % cols);
(0..rank).fold(C::ZERO, |acc, k| acc.add(b[r * rank + k].mul(c[q * rank + k])))
})
.collect();
let columns: Vec<Vec<C>> = (0..cols).map(|q| (0..rows).map(|r| a[r * cols + q]).collect()).collect();
let (u, sv, v) = jacobi_svd_strict(columns, rows, cols);
for r in 0..rows {
for q in 0..cols {
let x = (0..cols).fold(C::ZERO, |acc, k| acc.add(u[k][r].scale(sv[k]).mul(v[k][q].conj())));
assert!(x.sub(a[r * cols + q]).norm2() < 1e-26, "trial {trial}: reconstruction");
}
}
for k1 in 0..cols {
for k2 in 0..cols {
for (left, vecs, len) in [(true, &u, rows), (false, &v, cols)] {
if left && (sv[k1] < 1e-8 * sv[0] || sv[k2] < 1e-8 * sv[0]) {
continue; // a null column of U is not normalised
}
let d = (0..len).fold(C::ZERO, |acc, r| acc.add(vecs[k1][r].conj().mul(vecs[k2][r])));
let want = if k1 == k2 { 1.0 } else { 0.0 };
assert!((d.re - want).abs() < 1e-13 && d.im.abs() < 1e-13, "trial {trial}: orthonormality");
}
}
}
}
}
}