Skip to main content

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}