ariadnetor_algorithms/dmrg/solver.rs
1//! Local-eigensolver selection for the 2-site DMRG step.
2//!
3//! [`LocalEigensolverParams`] is the runtime selector used by
4//! [`super::sweep::DmrgSweepParams`] (and through it both
5//! [`super::heff::dmrg_2site_step`] and
6//! [`super::heff_block_sparse::dmrg_2site_step_block_sparse`]) to
7//! pick which Krylov solver drives the local eigenpair extraction.
8//!
9//! Lanczos is always available. An ARPACK-backed variant lives behind
10//! the `arpack` feature gate; its presence in the enum is itself
11//! `cfg`-gated so callers cannot select it at compile time without
12//! enabling the feature.
13//!
14//! Helper functions [`validate_eigensolver_params`] and
15//! [`eigensolver_tol`] centralize the per-variant param sanity checks
16//! and tolerance extraction so [`super::sweep::sweep_2site`] and the
17//! heff entry points don't drift.
18
19use ariadnetor_core::Scalar;
20
21#[cfg(feature = "arpack")]
22use crate::krylov::ArpackParams;
23use crate::krylov::LanczosParams;
24
25/// Runtime-selectable local eigensolver for the 2-site DMRG step.
26///
27/// `Lanczos` is the default; `Arpack` requires the `arpack` Cargo
28/// feature.
29#[derive(Debug, Clone)]
30#[non_exhaustive]
31pub enum LocalEigensolverParams {
32 /// In-tree Lanczos with full reorthogonalization
33 /// ([`crate::krylov::lanczos_smallest`]).
34 Lanczos(LanczosParams),
35 /// ARPACK-NG-backed solver
36 /// ([`crate::krylov::arpack_smallest`]). Only constructible when
37 /// the `arpack` feature is enabled.
38 #[cfg(feature = "arpack")]
39 Arpack(ArpackParams),
40}
41
42impl Default for LocalEigensolverParams {
43 fn default() -> Self {
44 Self::Lanczos(LanczosParams::default())
45 }
46}
47
48impl From<LanczosParams> for LocalEigensolverParams {
49 fn from(p: LanczosParams) -> Self {
50 Self::Lanczos(p)
51 }
52}
53
54#[cfg(feature = "arpack")]
55impl From<ArpackParams> for LocalEigensolverParams {
56 fn from(p: ArpackParams) -> Self {
57 Self::Arpack(p)
58 }
59}
60
61/// Validate the per-variant solver params (max_iter, tol). The
62/// `T::Real` representability check is left to the caller because it
63/// depends on the storage's element type.
64///
65/// Returns the `&'static str` detail of the first failing constraint
66/// so the caller can wrap it into either
67/// [`super::sweep::DmrgSweepError::InvalidParams`] or
68/// [`super::heff_error::DmrgHeffError::InvalidEigensolverParams`]
69/// without duplicating the per-variant logic.
70pub(crate) fn validate_eigensolver_params(
71 params: &LocalEigensolverParams,
72) -> Result<(), &'static str> {
73 match params {
74 LocalEigensolverParams::Lanczos(p) => {
75 if p.max_iter == 0 {
76 return Err("lanczos.max_iter must be >= 1");
77 }
78 if !p.tol.is_finite() {
79 return Err("lanczos.tol must be finite");
80 }
81 if p.tol < 0.0 {
82 return Err("lanczos.tol must be non-negative");
83 }
84 Ok(())
85 }
86 #[cfg(feature = "arpack")]
87 LocalEigensolverParams::Arpack(p) => {
88 if p.max_iter == 0 {
89 return Err("arpack.max_iter must be >= 1");
90 }
91 if !p.tol.is_finite() {
92 return Err("arpack.tol must be finite");
93 }
94 // ARPACK rejects tol == 0 (would request its
95 // machine-epsilon default and silently break the
96 // converged flag).
97 if p.tol <= 0.0 {
98 return Err("arpack.tol must be strictly positive");
99 }
100 Ok(())
101 }
102 }
103}
104
105/// Extract the variant's `tol` field as `f64` for cross-variant
106/// downstream casts (e.g. `try_real_from_f64`).
107pub(crate) fn eigensolver_tol(params: &LocalEigensolverParams) -> f64 {
108 match params {
109 LocalEigensolverParams::Lanczos(p) => p.tol,
110 #[cfg(feature = "arpack")]
111 LocalEigensolverParams::Arpack(p) => p.tol,
112 }
113}
114
115/// Trait alias bundling `Scalar` with the per-feature solver bounds
116/// the heff entry points need.
117///
118/// Without the `arpack` feature this is just `Scalar`. With the
119/// feature on, it additionally requires `crate::krylov::ArpackScalar`,
120/// which `arpack_smallest` demands. Both `Scalar` and `ArpackScalar`
121/// are sealed to the same set of scalar types
122/// (`f32`/`f64`/`Complex<f32>`/`Complex<f64>`), so toggling the
123/// feature does not restrict any existing caller — every supported
124/// scalar already satisfies both.
125#[cfg(not(feature = "arpack"))]
126pub trait DmrgScalar: Scalar {}
127#[cfg(not(feature = "arpack"))]
128impl<T: Scalar> DmrgScalar for T {}
129
130#[cfg(feature = "arpack")]
131pub trait DmrgScalar: Scalar + crate::krylov::ArpackScalar {}
132#[cfg(feature = "arpack")]
133impl<T: Scalar + crate::krylov::ArpackScalar> DmrgScalar for T {}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 // The `From` conversions and `eigensolver_tol` have no caller in tests;
140 // these pin the payload-preserving behavior so conversions cannot decay
141 // to `Default::default()` and the accessor cannot decay to a constant.
142
143 #[test]
144 fn from_lanczos_preserves_payload() {
145 // Values chosen to differ from `LanczosParams::default()` (200 / 1e-10)
146 // so a `Default::default()` substitution is observable.
147 let p = LocalEigensolverParams::from(LanczosParams {
148 max_iter: 7,
149 tol: 0.5,
150 seed: None,
151 });
152 match p {
153 LocalEigensolverParams::Lanczos(q) => {
154 assert_eq!(q.max_iter, 7);
155 assert_eq!(q.tol, 0.5);
156 }
157 #[cfg(feature = "arpack")]
158 other => panic!("expected Lanczos variant, got {other:?}"),
159 }
160 }
161
162 #[test]
163 fn eigensolver_tol_reads_lanczos_tol() {
164 let p = LocalEigensolverParams::Lanczos(LanczosParams {
165 tol: 0.5,
166 ..LanczosParams::default()
167 });
168 assert_eq!(eigensolver_tol(&p), 0.5);
169 }
170
171 #[cfg(feature = "arpack")]
172 #[test]
173 fn from_arpack_preserves_payload_and_tol() {
174 let p = LocalEigensolverParams::from(ArpackParams {
175 tol: 0.5,
176 max_iter: 7,
177 ncv: None,
178 });
179 match &p {
180 LocalEigensolverParams::Arpack(q) => {
181 assert_eq!(q.max_iter, 7);
182 assert_eq!(q.tol, 0.5);
183 }
184 other => panic!("expected Arpack variant, got {other:?}"),
185 }
186 assert_eq!(eigensolver_tol(&p), 0.5);
187 }
188}