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}