Skip to main content

ariadnetor_algorithms/krylov/
lanczos.rs

1//! Lanczos iteration for the smallest eigenvalue / eigenvector
2//! of a Hermitian linear operator, with full reorthogonalization.
3
4use ariadnetor_core::Scalar;
5use ariadnetor_tensor::{ComputeBackendTensorExt, DenseTensor, Host, linear_combine};
6use num_traits::{Float, One, ToPrimitive, Zero};
7use rand::SeedableRng;
8use rand::rngs::{StdRng, SysRng};
9
10use super::lanczos_kernels::{
11    inner, random_unit_vector, solve_tridiagonal_smallest, sub_complex_axpy, sub_real_axpy,
12    sub_two_real_axpy,
13};
14
15/// A Hermitian linear operator on a flat vector of length `dim`.
16///
17/// The Lanczos solver only ever needs to apply the operator to a
18/// vector — it never inspects matrix elements directly. Closures of
19/// type `Fn(&DenseTensor<T>) -> DenseTensor<T>` automatically
20/// implement this trait via the blanket impl.
21pub trait LinearOp<T: Scalar> {
22    /// Apply the operator to `v`, returning `H · v`.
23    fn apply(&self, v: &DenseTensor<T>) -> DenseTensor<T>;
24}
25
26impl<T, F> LinearOp<T> for F
27where
28    T: Scalar,
29    F: Fn(&DenseTensor<T>) -> DenseTensor<T>,
30{
31    fn apply(&self, v: &DenseTensor<T>) -> DenseTensor<T> {
32        self(v)
33    }
34}
35
36/// Parameters controlling the Lanczos iteration.
37#[derive(Debug, Clone)]
38pub struct LanczosParams {
39    /// Maximum number of Lanczos iterations. Capped internally at `dim`.
40    pub max_iter: usize,
41    /// Convergence tolerance, interpreted as the corresponding `T::Real`.
42    ///
43    /// Used in two places: (1) the iteration loop exits as soon as the
44    /// cheap Lanczos residual estimate `beta_j * |z[m-1]|` falls at or
45    /// below `tol`, and (2) the returned [`LanczosResult::converged`]
46    /// flag is set from the *true* residual `||H psi - lambda psi||_2`
47    /// against the same `tol`, so the flag is consistent with the
48    /// residual the caller sees.
49    pub tol: f64,
50    /// Optional seed for the initial vector. `None` draws from the OS RNG.
51    pub seed: Option<u64>,
52}
53
54impl Default for LanczosParams {
55    fn default() -> Self {
56        Self {
57            max_iter: 200,
58            tol: 1e-10,
59            seed: None,
60        }
61    }
62}
63
64/// Output of [`lanczos_smallest`].
65#[derive(Debug, Clone)]
66pub struct LanczosResult<T: Scalar> {
67    /// Smallest eigenvalue.
68    pub eigenvalue: T::Real,
69    /// Corresponding (unit-norm) eigenvector of length `dim`.
70    pub eigenvector: DenseTensor<T>,
71    /// Number of Lanczos iterations actually run.
72    pub iters: usize,
73    /// True residual `|| H v - lambda v ||_2` of the returned pair.
74    pub residual: T::Real,
75    /// `true` if the returned pair satisfies the true-residual test
76    /// `|| H v - lambda v ||_2 ≤ tol`, `false` otherwise. The cheap
77    /// Lanczos residual estimate `beta * |z[m-1]|` and the `beta == 0`
78    /// invariant-subspace check are used as early-exit heuristics
79    /// inside the iteration loop, but neither sets this flag on its
80    /// own — the flag comes from comparing the residual the caller
81    /// sees against `tol`.
82    pub converged: bool,
83}
84
85/// Errors from the native Lanczos solver.
86///
87/// This carries only genuine runtime failures the caller can recover
88/// from. Caller-side invariant violations (`dim == 0`, `max_iter == 0`,
89/// a non-finite or negative `tol`) and operator-contract violations (an
90/// [`LinearOp::apply`] returning a tensor of shape other than `[dim]`)
91/// remain panics — they are programmer errors, not recoverable
92/// conditions. Failing to reach the requested `tol` is likewise not an
93/// error: it is reported as [`LanczosResult::converged`] being `false`
94/// so the caller keeps the best Ritz pair.
95#[derive(Debug, thiserror::Error)]
96#[non_exhaustive]
97pub enum LanczosError {
98    /// The computed eigenpair is not finite (NaN/Inf), typically
99    /// because the operator emitted a non-finite vector or the 2-norm
100    /// overflowed (see the overflow note on [`lanczos_smallest`]). A
101    /// non-finite result must not flow on as a normal `LanczosResult`,
102    /// where it would silently poison every downstream computation. The
103    /// diagnostics are carried as `f64` so the error type stays
104    /// non-generic.
105    #[error(
106        "Lanczos produced a non-finite result after {iters} iterations: \
107         eigenvalue = {eigenvalue}, residual = {residual}"
108    )]
109    NonFinite {
110        /// Lanczos iterations run before the non-finite result was detected.
111        iters: usize,
112        /// Computed eigenvalue (lossily cast to `f64`).
113        eigenvalue: f64,
114        /// True residual (lossily cast to `f64`).
115        residual: f64,
116    },
117}
118
119/// Compute the smallest eigenvalue and corresponding eigenvector of a
120/// Hermitian operator using Lanczos with full reorthogonalization.
121///
122/// `dim` is the length of the flat vector the operator acts on. The
123/// initial Lanczos vector is drawn at random and normalized; pass
124/// [`LanczosParams::seed`] for reproducibility.
125///
126/// # Errors
127///
128/// Returns [`LanczosError::NonFinite`] when the computed eigenvalue or
129/// residual is not finite (NaN/Inf) — see the overflow note below and
130/// the NaN-emitting-operator case. A finite but imprecise result is
131/// **not** an error: it returns `Ok` with [`LanczosResult::converged`]
132/// set to `false`, leaving the best Ritz pair for the caller to judge.
133///
134/// # Panics
135///
136/// Panics on caller-side invariant violations: `dim == 0`,
137/// `params.max_iter == 0`, or a non-finite / negative `params.tol`.
138/// Panics if [`LinearOp::apply`] returns a tensor whose shape is not
139/// `[dim]`. These are programmer errors, kept as panics so the boundary
140/// between a programmer bug and a recoverable runtime condition stays
141/// principled.
142///
143/// Internal invariant assertions (the tridiagonal eigensolve, the basis
144/// recombination, OS RNG availability) are not part of this caller
145/// contract: they cannot fire for a valid operator and a panic there
146/// signals a bug in the solver itself, not caller misuse.
147///
148/// # Numerical preconditions
149///
150/// - `params.tol` is honored down to roughly `T::Real::epsilon()`. Asking
151///   for a tolerance below working precision (e.g. `1e-10` for `f32`,
152///   whose epsilon is `~1.2e-7`) cannot be satisfied; `converged` will
153///   reflect the achievable precision rather than the requested one.
154/// - The 2-norm used to compute β is the straightforward
155///   `sum |x|^2 -> sqrt`. Operator outputs whose elements approach
156///   `sqrt(T::Real::MAX)` (roughly `1e19` for `f32`, `1e154` for `f64`)
157///   may overflow during squaring, producing a non-finite result that is
158///   surfaced as [`LanczosError::NonFinite`]. DMRG-scale Hermitians stay
159///   far below this in practice.
160pub fn lanczos_smallest<T, Op>(
161    op: &Op,
162    dim: usize,
163    params: &LanczosParams,
164) -> Result<LanczosResult<T>, LanczosError>
165where
166    T: Scalar,
167    // The tridiagonal eigenproblem is real symmetric, so we run
168    // `eigh_with_backend::<T::Real, _>` and need the inner real type to
169    // coincide with T::Real itself. This holds for all valid `Scalar`
170    // impls (f32, f64, Complex<f32>, Complex<f64>).
171    T::Real: Scalar<Real = T::Real>,
172    Op: LinearOp<T>,
173{
174    assert!(dim >= 1, "lanczos: dim must be >= 1");
175    assert!(params.max_iter >= 1, "lanczos: max_iter must be >= 1");
176    assert!(
177        params.tol.is_finite() && params.tol >= 0.0,
178        "lanczos: tol must be a finite non-negative f64, got {}",
179        params.tol,
180    );
181    let max_iter = params.max_iter.min(dim);
182
183    let tol_real: T::Real =
184        crate::numeric::try_real_from_f64::<T>(params.tol).unwrap_or_else(|| {
185            panic!(
186                "try_real_from_f64: tol = {} is not representable in T::Real",
187                params.tol
188            )
189        });
190    // `beta_floor` is only a guard against dividing by an unrepresentably
191    // small β when normalizing v_{j+1} — it must NOT override the user's
192    // tolerance. Convergence is decided exclusively by `residual_estimate`
193    // (which is itself bounded by β, so any `tol`-meeting β is caught
194    // there first). We use the smallest normal value of T::Real so the
195    // floor is a real number in the actual scalar precision (an
196    // `f64::MIN_POSITIVE` cast underflows to zero when T::Real = f32).
197    let beta_floor: T::Real = T::Real::min_positive_value();
198
199    let mut rng = match params.seed {
200        Some(s) => StdRng::seed_from_u64(s),
201        None => StdRng::try_from_rng(&mut SysRng).expect("OS RNG must be available"),
202    };
203    let v0 = random_unit_vector::<T>(dim, &mut rng);
204
205    let mut basis: Vec<DenseTensor<T>> = vec![v0];
206    let mut alphas: Vec<T::Real> = Vec::new();
207    let mut betas: Vec<T::Real> = Vec::new();
208
209    let mut iters = 0usize;
210    let mut converged_lambda: T::Real = T::Real::zero();
211    let mut converged_z: DenseTensor<T::Real> = Host::shared().dense(vec![T::Real::one()], vec![1]);
212
213    for j in 0..max_iter {
214        iters = j + 1;
215        let v_j = basis[j].clone();
216        let w_raw = op.apply(&v_j);
217        assert_eq!(
218            w_raw.shape(),
219            &[dim],
220            "LinearOp::apply must return a rank-1 tensor of shape [dim]",
221        );
222        // Operators are not required to declare an output `order()`
223        // matching the Lanczos basis. Normalize against `v_j.order()`
224        // so the recurrence and the eventual `linear_combine(&basis_refs, ...)`
225        // see a uniform-order vector set; for 1D data this is metadata-only.
226        let mut w = if w_raw.order() == v_j.order() {
227            w_raw
228        } else {
229            w_raw.reordered(v_j.order())
230        };
231
232        // alpha_j = Re<v_j, H v_j>; the imaginary part is zero up to
233        // numerical noise for a Hermitian operator.
234        let alpha = inner(&v_j, &w).re();
235        alphas.push(alpha);
236
237        // Three-term recurrence: w <- w - alpha v_j - beta_{j-1} v_{j-1}.
238        if j == 0 {
239            w = sub_real_axpy(&w, alpha, &v_j);
240        } else {
241            let beta_prev = betas[j - 1];
242            let v_prev = &basis[j - 1];
243            w = sub_two_real_axpy(&w, alpha, &v_j, beta_prev, v_prev);
244        }
245
246        // Full reorthogonalization. Two passes of classical Gram-Schmidt
247        // ("twice is enough" — Kahan / Parlett) restores orthogonality
248        // to working precision even after substantial cancellation.
249        for _ in 0..2 {
250            for v_k in basis.iter().take(j + 1) {
251                let gamma = inner(v_k, &w);
252                w = sub_complex_axpy(&w, gamma, v_k);
253            }
254        }
255
256        let beta = w.norm();
257
258        // Solve current tridiagonal eigenproblem of size m = j + 1.
259        let m = j + 1;
260        let (lambda, z) = solve_tridiagonal_smallest::<T>(&alphas, &betas, m);
261        converged_lambda = lambda;
262        converged_z = z;
263
264        // Convergence: residual estimate = beta_j * |z[m-1]|.
265        let z_last = Float::abs(converged_z.data_slice()[m - 1]);
266        let residual_estimate = beta * z_last;
267
268        if residual_estimate <= tol_real {
269            // The Ritz residual ||(H - λ I) ψ|| in the Lanczos basis is at most
270            // beta * |z[m-1]|; with full reorthogonalization this also bounds
271            // the true residual to working precision. Eigenvalue convergence
272            // is quadratic in the residual, so an "eigenvalue stagnation"
273            // criterion (prev λ ≈ λ) would exit ~half the precision early —
274            // we deliberately do not use it.
275            //
276            // Note: `converged` is decided AFTER computing the true residual
277            // below. Asking for `tol` below working precision cannot be
278            // satisfied (Ritz says yes, true residual says no); honesty over
279            // optimism.
280            break;
281        }
282
283        if j + 1 == max_iter {
284            break;
285        }
286
287        if beta <= beta_floor {
288            // β has collapsed to the bottom of the FP range; we cannot safely
289            // form v_{j+1} = w / β. The Krylov subspace is effectively
290            // exhausted at this point — the current Ritz pair is exact in
291            // the spanned subspace.
292            break;
293        }
294        let inv = T::Real::one() / beta;
295        let v_next_data: Vec<T> = w.data_slice().iter().map(|&x| x.scale_real(inv)).collect();
296        basis.push(Host::shared().dense(v_next_data, vec![dim]));
297        betas.push(beta);
298    }
299
300    // Reconstruct the Ritz vector psi = sum_k z[k] v_k.
301    let m = converged_z.len();
302    let basis_refs: Vec<&DenseTensor<T>> = basis.iter().take(m).collect();
303    let coefs: Vec<T> = converged_z
304        .data_slice()
305        .iter()
306        .map(|&zk| T::from_real_imag(zk, T::Real::zero()))
307        .collect();
308    let mut psi = linear_combine(&basis_refs, &coefs).expect("linear_combine on Lanczos basis");
309    psi.normalize();
310
311    // True residual: ||H psi - lambda psi||.
312    let h_psi_raw = op.apply(&psi);
313    assert_eq!(
314        h_psi_raw.shape(),
315        &[dim],
316        "LinearOp::apply must return a rank-1 tensor of shape [dim]",
317    );
318    // Same rationale as the recurrence loop above: align `op.apply`'s
319    // declared order with `psi.order()` so `linear_combine` does not
320    // reject mixed-order inputs.
321    let h_psi = if h_psi_raw.order() == psi.order() {
322        h_psi_raw
323    } else {
324        h_psi_raw.reordered(psi.order())
325    };
326    let lambda_t = T::from_real_imag(converged_lambda, T::Real::zero());
327    let neg_lambda = lambda_t.scale_real(-T::Real::one());
328    let residual_vec =
329        linear_combine(&[&h_psi, &psi], &[T::one(), neg_lambda]).expect("residual lc");
330    let residual = residual_vec.norm();
331
332    // A non-finite eigenvalue or residual means the operator emitted NaN/Inf
333    // or the 2-norm overflowed; the eigenpair is meaningless. Surface it as an
334    // error instead of returning an `Ok` result whose `converged == false`
335    // would let the NaN flow on silently (e.g. into a DMRG sweep, poisoning the
336    // energy). `residual` is the norm of `H psi - lambda psi`, so it is itself
337    // non-finite whenever the eigenvector is — checking eigenvalue + residual
338    // covers every non-finite source.
339    if !converged_lambda.is_finite() || !residual.is_finite() {
340        return Err(LanczosError::NonFinite {
341            iters,
342            eigenvalue: converged_lambda.to_f64().unwrap_or(f64::NAN),
343            residual: residual.to_f64().unwrap_or(f64::NAN),
344        });
345    }
346
347    // Set `converged` from the true residual rather than the Lanczos estimate,
348    // so the flag is consistent with the residual the caller sees: the Ritz
349    // estimate can claim convergence while the true residual still exceeds
350    // `tol` (e.g. when the requested tolerance is below working precision).
351    let converged = residual <= tol_real;
352
353    Ok(LanczosResult {
354        eigenvalue: converged_lambda,
355        eigenvector: psi,
356        iters,
357        residual,
358        converged,
359    })
360}