spectrafit_varpro/lib.rs
1//! spectrafit-varpro — Variable Projection (VarPro) solver for separable models.
2//!
3//! A graph is **separable** when every model node has exactly one linear
4//! parameter (`amplitude`) and the remaining parameters are nonlinear shape
5//! parameters. Examples: Gaussian, Lorentzian, Voigt, step functions, Fano.
6//!
7//! For such graphs varpro eliminates the linear amplitude dimensions from the
8//! optimisation, reducing it to a pure nonlinear problem over the shape params.
9//! This gives faster convergence and better numerical conditioning vs vanilla LM.
10//!
11//! # Bounds
12//! varpro has no native bounds support. When any nonlinear parameter has finite
13//! bounds **and** `options.solver == "varpro"`, we fall back to LM and log a
14//! warning. With `options.solver == "auto"` we transparently fall back.
15//!
16//! # Jacobian variant: Kaufman approximation, not full Golub-Pereyra
17//! The projected-residual Jacobian that drives the nonlinear optimisation
18//! step is supplied by the `varpro` crate (the `[dependencies] varpro`
19//! entry in this crate's `Cargo.toml`), not by this crate's own code. That
20//! crate implements the **Kaufman (1975)** approximation to the classic
21//! Golub-Pereyra (1973) projected Jacobian — confirmed by its own module
22//! doc comment (`varpro` v0.14.0, `src/lib.rs`, line 99): "The VarPro
23//! algorithm implemented here follows (O'Leary2013), but uses the Kaufman
24//! approximation to calculate the Jacobian." Concretely, the full
25//! Golub-Pereyra derivative of the projected residual has two terms; the
26//! Kaufman approximation drops the second one (the term involving the
27//! derivative of the projector `P = I - Φ Φ⁺` itself), keeping only the
28//! cheaper first term. This is the standard trade-off documented in the
29//! literature: fewer FLOPs per Jacobian evaluation, at the cost of a
30//! Gauss-Newton-only convergence-rate guarantee near the solution (full
31//! Golub-Pereyra retains second-order terms that can matter on
32//! ill-conditioned or rank-deficient projections) and a covariance that is
33//! only an approximation of the true joint covariance over both linear and
34//! nonlinear parameters. `tests/parity/test_varpro_equivalence.py` checks
35//! empirically that, on a well-posed separable problem, this approximation
36//! still converges to the *same* minimum as full joint LM — the
37//! approximation changes the path and the covariance, not the optimum.
38//!
39//! References:
40//! - Golub, G., Pereyra, V. (1973). "The Differentiation of Pseudo-Inverses
41//! and Nonlinear Least Squares Problems Whose Variables Separate." *SIAM
42//! J. Numer. Anal.* 10(2), 413-432.
43//! - Kaufman, L. (1975). "A variable projection method for solving
44//! separable nonlinear least squares problems." *BIT* 15, 49-57.
45#![warn(missing_docs)]
46
47// Modules are private: the crate's public surface is the curated `pub use`
48// list below (house rule 26). Nothing outside this crate reached
49// `model::`/`solver::` by module path.
50mod model;
51mod solver;
52
53pub use model::GraphSeparableModel;
54pub use solver::solve_varpro;
55
56use spectrafit_types::FitGraphSpec;
57
58/// Set of model types whose `amplitude` is the single linear coefficient.
59///
60/// Polynomial models (`constant`, `linear`) are excluded because they have no
61/// nonlinear parameters at all, so they contribute *invariant* basis functions
62/// (no gain from VarPro). Mixed graphs (e.g. Gaussian + constant) are
63/// supported via invariant basis functions; see [`model::GraphSeparableModel`].
64const SEPARABLE_MODEL_TYPES: &[&str] = &[
65 "gaussian",
66 "lorentzian",
67 "voigt",
68 "arctan_step",
69 "tanh_step",
70 "erfc_step",
71 "pseudo_voigt",
72 "fano",
73];
74
75/// Invariant (purely linear) model types — contribute a basis function that
76/// does not depend on any nonlinear parameter.
77const INVARIANT_MODEL_TYPES: &[&str] = &["constant", "linear"];
78
79/// Returns `true` when every node in the graph is either separable (has an
80/// `amplitude` linear param + nonlinear shape params) or invariant (all params
81/// linear). Returns `false` for unknown or non-conforming node types.
82///
83/// The wire-format string for each `ModelTypeStr` is read from the canonical
84/// `ModelTypeStr::as_str()` in `spectrafit-types`; VarPro separability is then
85/// a membership check against [`SEPARABLE_MODEL_TYPES`] /
86/// [`INVARIANT_MODEL_TYPES`]. Non-separable variants (true Voigt, skewed
87/// Gaussian, EMG, log-normal, Pearson VII, Tauc, Cauchy, KWW, …) fall through
88/// here untouched — they are simply absent from `SEPARABLE_MODEL_TYPES`, so
89/// callers see them as non-VarPro candidates and route to the general solver.
90pub fn is_separable(graph: &FitGraphSpec) -> bool {
91 for node in &graph.nodes {
92 let type_str = node.model_type.as_str();
93 if !SEPARABLE_MODEL_TYPES.contains(&type_str) && !INVARIANT_MODEL_TYPES.contains(&type_str)
94 {
95 return false;
96 }
97 }
98 true
99}
100
101// ---------------------------------------------------------------------------
102// Tests
103// ---------------------------------------------------------------------------
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use spectrafit_types::{FitGraphSpec, ModelNodeSpec, ModelTypeStr, ParameterSpec};
109 use std::collections::HashMap;
110
111 // ── Helpers ──────────────────────────────────────────────────────────────
112
113 fn make_param(value: f64, vary: bool) -> ParameterSpec {
114 ParameterSpec {
115 value,
116 min: f64::NEG_INFINITY,
117 max: f64::INFINITY,
118 vary,
119 expr: None,
120 scale: None,
121 }
122 }
123
124 /// Build a minimal single-node FitGraphSpec.
125 fn single_node_graph(model_type: ModelTypeStr) -> FitGraphSpec {
126 let mut parameters = HashMap::new();
127 match &model_type {
128 ModelTypeStr::Gaussian => {
129 parameters.insert("amplitude".into(), make_param(1.0, true));
130 parameters.insert("center".into(), make_param(0.0, true));
131 parameters.insert("sigma".into(), make_param(1.0, true));
132 }
133 ModelTypeStr::Constant => {
134 parameters.insert("amplitude".into(), make_param(0.0, true));
135 }
136 ModelTypeStr::TrueVoigt => {
137 parameters.insert("amplitude".into(), make_param(1.0, true));
138 parameters.insert("center".into(), make_param(0.0, true));
139 parameters.insert("sigma".into(), make_param(1.0, true));
140 parameters.insert("gamma".into(), make_param(1.0, true));
141 }
142 _ => {
143 parameters.insert("amplitude".into(), make_param(1.0, true));
144 }
145 }
146 FitGraphSpec {
147 schema_version: "0.1".into(),
148 nodes: vec![ModelNodeSpec {
149 id: "n0".into(),
150 model_type,
151 parameters,
152 dataset_index: None,
153 }],
154 expr_edges: vec![],
155 }
156 }
157
158 // ── R1a: is_separable returns true for a pure-Gaussian graph ─────────────
159
160 #[test]
161 fn is_separable_true_for_gaussian() {
162 let graph = single_node_graph(ModelTypeStr::Gaussian);
163 assert!(
164 is_separable(&graph),
165 "A pure-Gaussian graph must be separable"
166 );
167 }
168
169 // ── R1a variant: all SEPARABLE_MODEL_TYPES return true ───────────────────
170
171 #[test]
172 fn is_separable_true_for_all_declared_separable_types() {
173 for &type_str in SEPARABLE_MODEL_TYPES {
174 // Reconstruct a graph from the wire string via the ModelTypeStr list.
175 // We use serde_json to deserialise so we don't need to enumerate manually.
176 let json = format!("\"{}\"", type_str);
177 let model_type: ModelTypeStr = serde_json::from_str(&json).unwrap_or_else(|_| {
178 panic!("Could not deserialise ModelTypeStr from SEPARABLE_MODEL_TYPES entry: {type_str}")
179 });
180 let graph = single_node_graph(model_type);
181 assert!(
182 is_separable(&graph),
183 "is_separable should return true for SEPARABLE_MODEL_TYPES entry: {type_str}"
184 );
185 }
186 }
187
188 // ── R1b: is_separable returns false for a non-separable model ────────────
189
190 #[test]
191 fn is_separable_false_for_true_voigt() {
192 let graph = single_node_graph(ModelTypeStr::TrueVoigt);
193 assert!(
194 !is_separable(&graph),
195 "true_voigt is not in SEPARABLE_MODEL_TYPES and must return false"
196 );
197 }
198
199 // ── R1b variant: INVARIANT_MODEL_TYPES are separable ─────────────────────
200
201 #[test]
202 fn is_separable_true_for_invariant_types() {
203 for &type_str in INVARIANT_MODEL_TYPES {
204 let json = format!("\"{}\"", type_str);
205 let model_type: ModelTypeStr = serde_json::from_str(&json).unwrap_or_else(|_| {
206 panic!("Could not deserialise ModelTypeStr from INVARIANT_MODEL_TYPES entry: {type_str}")
207 });
208 let graph = single_node_graph(model_type);
209 assert!(
210 is_separable(&graph),
211 "is_separable should return true for INVARIANT_MODEL_TYPES entry: {type_str}"
212 );
213 }
214 }
215
216 // ── R1b variant: mixed separable+non-separable graph is non-separable ────
217
218 #[test]
219 fn is_separable_false_for_mixed_graph() {
220 let mut graph = single_node_graph(ModelTypeStr::Gaussian);
221 // Append a true_voigt node to make it non-separable
222 let mut params = HashMap::new();
223 params.insert("amplitude".into(), make_param(1.0, true));
224 params.insert("center".into(), make_param(0.0, true));
225 params.insert("sigma".into(), make_param(1.0, true));
226 params.insert("gamma".into(), make_param(1.0, true));
227 graph.nodes.push(ModelNodeSpec {
228 id: "tv0".into(),
229 model_type: ModelTypeStr::TrueVoigt,
230 parameters: params,
231 dataset_index: None,
232 });
233 assert!(
234 !is_separable(&graph),
235 "A graph containing true_voigt must not be separable"
236 );
237 }
238
239 // ── R2: every ModelTypeStr variant is classified in one of three buckets ─
240 //
241 // This test enumerates ALL ModelTypeStr variants exhaustively and asserts
242 // each is either in SEPARABLE_MODEL_TYPES, in INVARIANT_MODEL_TYPES, or
243 // explicitly in the NON_VARPRO allow-list below. Any newly-added variant
244 // that is not placed in one of the three buckets fails the test, preventing
245 // silent VarPro-miss (the model would forever be routed to the general solver
246 // without the developer noticing).
247 //
248 // Mirror of `model_type_as_str_matches_serde_wire_for_every_variant` in
249 // spectrafit-types (which tests the serde↔as_str parity); this test adds
250 // the VarPro-eligibility layer.
251 #[test]
252 fn model_type_str_varpro_parity_guard() {
253 // Variants that are intentionally absent from SEPARABLE/INVARIANT lists.
254 // Each exclusion must be explained.
255 //
256 // Non-VarPro: these models cannot be expressed as amplitude × shape(α)
257 // because their formulas are non-separable (multiple amplitude-like roles,
258 // asymmetric integrals, etc.), or they live in multi-dimensional space.
259 let non_varpro: &[&str] = &[
260 // --- N-D kernels: no 1-D VarPro basis-column concept applies ----------
261 "gaussian2d",
262 "gaussian_nd", // parametric N-D Gaussian; multi-dimensional, like gaussian2d
263 // --- Quadratic has no amplitude as a pure linear scaler ---------------
264 "quadratic",
265 // --- Non-separable asymmetric/complex lineshapes ----------------------
266 "double_exponential", // two amplitudes, not one linear coefficient
267 "true_voigt", // Faddeeva convolution — not A × f(σ,γ,x)
268 "skewed_gaussian", // error-function modulated; asymmetric shape
269 "exp_gaussian", // exponentially-modified Gaussian; EMG tail integral
270 "doniach_sunjic", // XPS asymmetric; power-law tail, not separable
271 "log_normal", // domain-restricted (x>0); shape not amplitude-linear
272 "pearson7", // exponent `m` couples with amplitude
273 "split_gaussian", // two sigmas; shape not uniform across center
274 "moffat", // beta exponent couples with amplitude
275 "students_t", // nu exponent couples with amplitude
276 "split_pearson7", // split variant; two exponents, two sigmas
277 "breit_wigner", // complex resonance; not separable
278 "asym_ir", // logistic sigmoid modulation; not amplitude-linear
279 "harmonic_ir", // driven oscillator; amplitude couples with damping
280 "tauc", // power-law edge; exponent makes it non-separable
281 "cauchy_dispersion", // multi-coefficient refractive-index; not one amplitude
282 "kww", // stretched-exponential; beta exponent non-separable
283 "rational_cubic", // 4 independent linear numerator coeffs (a0..a3), no single amplitude
284 "exp_over_linear", // lin_const/lin_slope both reshape the curve, not just scale it
285 // --- Separable-ELIGIBLE but not (yet) VarPro-enrolled -----------------
286 // These four have `amplitude` as a single linear scaler × shape(α), so
287 // they COULD join SEPARABLE_MODEL_TYPES. They are intentionally left on
288 // the general-LM path for now: enrolling them flips solver routing and
289 // shifts benchmark numbers, so it is a deliberate, benchmarked change —
290 // not a guard-coverage fix. Listed here (honestly: deferred, not
291 // "non-separable") so the parity guard covers all VARIANT_COUNT variants.
292 "saturating_exponential", // BoxBOD: amplitude·(1−exp(−rate·x)) — eligible, deferred
293 "power_saturation", // Misra1b: amplitude·(1−(1+rate·x/2)^−2) — eligible, deferred
294 "power_law_offset", // Bennett5: amplitude·(offset+x)^(−1/shape) — eligible, deferred
295 "mgh09_rational", // MGH09: amplitude·rational(x;α) — eligible, deferred
296 "generalised_logistic", // Rat43: amplitude/(1+exp(shift-rate·x))^(1/shape) — eligible, deferred
297 ];
298
299 // Iterate the manifest source of truth directly — `ModelTypeStr::ALL`
300 // (generated by `model_manifest!` in spectrafit-types). There is no
301 // hand-maintained variant list here to drift: a new manifest row is
302 // automatically classified-or-fails below, which is the exact silent
303 // drift this guard exists to prevent (it itself once suffered it —
304 // 4 NIST kernels went unchecked behind a stale hand-list).
305 for variant in ModelTypeStr::ALL {
306 let wire = variant.as_str();
307 let in_separable = SEPARABLE_MODEL_TYPES.contains(&wire);
308 let in_invariant = INVARIANT_MODEL_TYPES.contains(&wire);
309 let in_non_varpro = non_varpro.contains(&wire);
310
311 assert!(
312 in_separable || in_invariant || in_non_varpro,
313 "ModelTypeStr variant {:?} (wire=\"{}\") is not classified in \
314 SEPARABLE_MODEL_TYPES, INVARIANT_MODEL_TYPES, or the NON_VARPRO \
315 allow-list. Add it to one of the three buckets and explain why.",
316 variant,
317 wire
318 );
319
320 // Mutual exclusion: a variant must not appear in two buckets.
321 let bucket_count = [in_separable, in_invariant, in_non_varpro]
322 .iter()
323 .filter(|&&b| b)
324 .count();
325 assert_eq!(
326 bucket_count, 1,
327 "ModelTypeStr variant {:?} (wire=\"{}\") appears in more than one \
328 classification bucket (separable={}, invariant={}, non_varpro={}). \
329 Each variant must belong to exactly one bucket.",
330 variant, wire, in_separable, in_invariant, in_non_varpro
331 );
332 }
333 }
334}