Skip to main content

spectrafit_builder/
lib.rs

1//! spectrafit-builder — typed Rust DSL for [`FitGraphSpec`].
2//!
3//! Opens the kernel to direct Rust users so they do not have to hand-write JSON
4//! payloads. The builder produces the exact same `FitGraphSpec` shape the JSON
5//! contract describes — see the `builder_roundtrip` integration test.
6//!
7//! # Examples
8//!
9//! ```
10//! use spectrafit_builder::FitGraphBuilder;
11//!
12//! let g = FitGraphBuilder::new()
13//!     .add_gaussian("g0", 1.0, 0.0, 1.0)
14//!     .add_linear("baseline", 0.0, 0.5)
15//!     .build();
16//!
17//! assert_eq!(g.nodes.len(), 2);
18//! assert_eq!(g.schema_version, "0.1");
19//! ```
20#![warn(missing_docs)]
21#![forbid(unsafe_code)]
22
23use std::collections::HashMap;
24
25use spectrafit_models::{model_from_str, Model};
26use spectrafit_types::types::{ExprEdge, FitGraphSpec, ModelNodeSpec, ModelTypeStr, ParameterSpec};
27
28/// Canonical schema version embedded in every builder-produced `FitGraphSpec`.
29///
30/// Mirrors the string Python writes when emitting a fresh spec; lives in one
31/// place so any future bump is a single edit.
32pub const SCHEMA_VERSION: &str = "0.1";
33
34/// Fluent builder for a [`FitGraphSpec`].
35///
36/// Internal state is just `(Vec<ModelNodeSpec>, Vec<ExprEdge>)`. Each
37/// `add_<model>` arm wires the right `ModelTypeStr` variant — derived from
38/// [`ModelTypeStr::as_str`] (no hand-written wire keys) — and constructs a
39/// `ParameterSpec` per ordered kernel parameter. Default ranges are unbounded
40/// (`±∞`) and `vary = true`, matching the JSON the Python side writes when a
41/// user does not pin bounds explicitly.
42#[derive(Debug, Clone, Default)]
43pub struct FitGraphBuilder {
44    nodes: Vec<ModelNodeSpec>,
45    expr_edges: Vec<ExprEdge>,
46}
47
48impl FitGraphBuilder {
49    /// Create an empty builder.
50    pub fn new() -> Self {
51        Self {
52            nodes: Vec::new(),
53            expr_edges: Vec::new(),
54        }
55    }
56
57    /// List every model wire-key the builder can produce.
58    ///
59    /// Mirror of the [`ModelTypeStr`] registry — the parity test in this crate
60    /// pins it against `model_from_str` so a missing variant is caught at
61    /// `cargo test` time.
62    pub fn available_models() -> Vec<&'static str> {
63        ALL_MODELS.iter().map(|m| m.as_str()).collect()
64    }
65
66    /// Add a parameter expression edge (a tied parameter).
67    ///
68    /// Equivalent to a `ExprEdge` JSON entry: the named `target_param` on
69    /// `target_node` is driven by `expression` instead of varied freely.
70    pub fn tie(
71        mut self,
72        target_node: impl Into<String>,
73        target_param: impl Into<String>,
74        expression: impl Into<String>,
75    ) -> Self {
76        self.expr_edges.push(ExprEdge {
77            target_node: target_node.into(),
78            target_param: target_param.into(),
79            expression: expression.into(),
80        });
81        self
82    }
83
84    /// Finish building and return the underlying [`FitGraphSpec`].
85    pub fn build(self) -> FitGraphSpec {
86        FitGraphSpec {
87            schema_version: SCHEMA_VERSION.to_string(),
88            nodes: self.nodes,
89            expr_edges: self.expr_edges,
90        }
91    }
92
93    // -----------------------------------------------------------------------
94    // Per-model fluent methods. Each one positionally accepts the kernel's
95    // declared `param_names()` in order — keeping the surface aligned with the
96    // Rust kernel rather than the Python alias layer.
97    // -----------------------------------------------------------------------
98
99    /// Add a Gaussian peak: `A · exp(−(x−c)² / (2σ²))`.
100    ///
101    /// Parameters (in order): `amplitude`, `center`, `sigma`.
102    pub fn add_gaussian(self, id: &str, amplitude: f64, center: f64, sigma: f64) -> Self {
103        self.add_node(id, ModelTypeStr::Gaussian, &[amplitude, center, sigma])
104    }
105
106    /// Add an axis-aligned 2-D Gaussian peak (`n_dims == 2`).
107    ///
108    /// Parameters (in order): `amplitude`, `center_x`, `center_y`,
109    /// `sigma_x`, `sigma_y`.
110    pub fn add_gaussian2d(
111        self,
112        id: &str,
113        amplitude: f64,
114        center_x: f64,
115        center_y: f64,
116        sigma_x: f64,
117        sigma_y: f64,
118    ) -> Self {
119        self.add_node(
120            id,
121            ModelTypeStr::Gaussian2D,
122            &[amplitude, center_x, center_y, sigma_x, sigma_y],
123        )
124    }
125
126    /// Add a parametric N-D Gaussian peak (`gaussian_nd`).
127    ///
128    /// This fluent helper adds the **1-D** instance (`amplitude`, `center_0`,
129    /// `sigma_0`) — the dimensionality the compiler infers from the indexed
130    /// `center_<i>` parameters. For higher D, construct the node directly with
131    /// `center_0..center_{D-1}` / `sigma_0..sigma_{D-1}` parameters; the builder
132    /// helper covers the roundtrip/registry contract for the variant.
133    pub fn add_gaussian_nd(self, id: &str, amplitude: f64, center_0: f64, sigma_0: f64) -> Self {
134        self.add_node(
135            id,
136            ModelTypeStr::GaussianNd,
137            &[amplitude, center_0, sigma_0],
138        )
139    }
140
141    /// Add a Lorentzian (Cauchy) peak.
142    ///
143    /// Parameters (in order): `amplitude`, `center`, `sigma`.
144    pub fn add_lorentzian(self, id: &str, amplitude: f64, center: f64, sigma: f64) -> Self {
145        self.add_node(id, ModelTypeStr::Lorentzian, &[amplitude, center, sigma])
146    }
147
148    /// Add the Voigt kernel.
149    ///
150    /// Note: the `voigt` wire key is the pseudo-Voigt linear mixture
151    /// (`A·(fraction·L + (1−fraction)·G)`) — its fourth parameter is the
152    /// mixing weight `fraction` (canonical name per `docs/reference/models/index.md`), not `gamma`.
153    /// Use [`Self::add_true_voigt`] for the Faddeeva-function convolution.
154    ///
155    /// Parameters (in order): `amplitude`, `center`, `sigma`, `fraction`.
156    pub fn add_voigt(
157        self,
158        id: &str,
159        amplitude: f64,
160        center: f64,
161        sigma: f64,
162        fraction: f64,
163    ) -> Self {
164        self.add_node(
165            id,
166            ModelTypeStr::Voigt,
167            &[amplitude, center, sigma, fraction],
168        )
169    }
170
171    /// Add a constant offset: `f(x) = c`.
172    pub fn add_constant(self, id: &str, c: f64) -> Self {
173        self.add_node(id, ModelTypeStr::Constant, &[c])
174    }
175
176    /// Add a linear baseline: `slope·x + intercept`.
177    pub fn add_linear(self, id: &str, slope: f64, intercept: f64) -> Self {
178        self.add_node(id, ModelTypeStr::Linear, &[slope, intercept])
179    }
180
181    /// Add a quadratic bowl: `amplitude · (x − center)² + offset`.
182    pub fn add_quadratic(self, id: &str, amplitude: f64, center: f64, offset: f64) -> Self {
183        self.add_node(id, ModelTypeStr::Quadratic, &[amplitude, center, offset])
184    }
185
186    /// Add an arctangent step.
187    ///
188    /// Parameters (in order): `amplitude`, `center`, `sigma`.
189    pub fn add_arctan_step(self, id: &str, amplitude: f64, center: f64, sigma: f64) -> Self {
190        self.add_node(id, ModelTypeStr::ArctanStep, &[amplitude, center, sigma])
191    }
192
193    /// Add a hyperbolic tangent step.
194    ///
195    /// Parameters (in order): `amplitude`, `center`, `sigma`.
196    pub fn add_tanh_step(self, id: &str, amplitude: f64, center: f64, sigma: f64) -> Self {
197        self.add_node(id, ModelTypeStr::TanhStep, &[amplitude, center, sigma])
198    }
199
200    /// Add a complementary-error-function step.
201    ///
202    /// Parameters (in order): `amplitude`, `center`, `sigma`.
203    pub fn add_erfc_step(self, id: &str, amplitude: f64, center: f64, sigma: f64) -> Self {
204        self.add_node(id, ModelTypeStr::ErfcStep, &[amplitude, center, sigma])
205    }
206
207    /// Add a pseudo-Voigt linear-mixture peak.
208    ///
209    /// Parameters (in order): `amplitude`, `center`, `sigma`, `fraction`.
210    pub fn add_pseudo_voigt(
211        self,
212        id: &str,
213        amplitude: f64,
214        center: f64,
215        sigma: f64,
216        fraction: f64,
217    ) -> Self {
218        self.add_node(
219            id,
220            ModelTypeStr::PseudoVoigt,
221            &[amplitude, center, sigma, fraction],
222        )
223    }
224
225    /// Add a Fano resonance lineshape.
226    ///
227    /// Parameters (in order): `amplitude`, `center`, `gamma`, `q`.
228    pub fn add_fano(self, id: &str, amplitude: f64, center: f64, gamma: f64, q: f64) -> Self {
229        self.add_node(id, ModelTypeStr::Fano, &[amplitude, center, gamma, q])
230    }
231
232    /// Add a double-exponential decay: `A₁·exp(−λ₁·x) + A₂·exp(−λ₂·x)`.
233    pub fn add_double_exponential(self, id: &str, a1: f64, lam1: f64, a2: f64, lam2: f64) -> Self {
234        self.add_node(id, ModelTypeStr::DoubleExponential, &[a1, lam1, a2, lam2])
235    }
236
237    /// Add a saturating exponential rise: `A·(1 − exp(−k·x))`.
238    ///
239    /// Parameters (in order): `amplitude`, `rate`.
240    pub fn add_saturating_exponential(self, id: &str, amplitude: f64, rate: f64) -> Self {
241        self.add_node(id, ModelTypeStr::SaturatingExponential, &[amplitude, rate])
242    }
243
244    /// Add a power-law saturation: `A·(1 − (1 + rate·x/2)^(−2))` (Misra1b model).
245    ///
246    /// Parameters (in order): `amplitude`, `rate`.
247    pub fn add_power_saturation(self, id: &str, amplitude: f64, rate: f64) -> Self {
248        self.add_node(id, ModelTypeStr::PowerSaturation, &[amplitude, rate])
249    }
250
251    /// Add a power-law with offset: `amplitude · (offset + x)^(−1/shape)` (Bennett5 model).
252    ///
253    /// Parameters (in order): `amplitude`, `offset`, `shape`.
254    ///
255    /// **Domain guard:** requires `offset + x > 0` for all data points; the
256    /// kernel returns `NaN` otherwise and the LM solver backs off.
257    pub fn add_power_law_offset(self, id: &str, amplitude: f64, offset: f64, shape: f64) -> Self {
258        self.add_node(
259            id,
260            ModelTypeStr::PowerLawOffset,
261            &[amplitude, offset, shape],
262        )
263    }
264
265    /// Add the Kowalik–Osborne rational function (NIST StRD MGH09).
266    ///
267    /// `amplitude · (x² + num_lin·x) / (x² + den_lin·x + den_const)`
268    ///
269    /// Parameters (in order): `amplitude`, `num_lin`, `den_lin`, `den_const`.
270    ///
271    /// **Domain guard:** requires `x² + den_lin·x + den_const ≠ 0`; the kernel
272    /// returns `NaN` otherwise. At the MGH09 certified parameters the denominator
273    /// discriminant is negative, keeping D > 0 for all x.
274    pub fn add_mgh09_rational(
275        self,
276        id: &str,
277        amplitude: f64,
278        num_lin: f64,
279        den_lin: f64,
280        den_const: f64,
281    ) -> Self {
282        self.add_node(
283            id,
284            ModelTypeStr::Mgh09Rational,
285            &[amplitude, num_lin, den_lin, den_const],
286        )
287    }
288
289    /// Add the true Voigt profile (Gaussian ⊗ Lorentzian) via the Faddeeva
290    /// function.
291    ///
292    /// Parameters (in order): `amplitude`, `center`, `sigma`, `gamma`.
293    pub fn add_true_voigt(
294        self,
295        id: &str,
296        amplitude: f64,
297        center: f64,
298        sigma: f64,
299        gamma: f64,
300    ) -> Self {
301        self.add_node(
302            id,
303            ModelTypeStr::TrueVoigt,
304            &[amplitude, center, sigma, gamma],
305        )
306    }
307
308    /// Add a skewed Gaussian (error-function-modulated asymmetric peak).
309    ///
310    /// Parameters (in order): `amplitude`, `center`, `sigma`, `gamma`.
311    pub fn add_skewed_gaussian(
312        self,
313        id: &str,
314        amplitude: f64,
315        center: f64,
316        sigma: f64,
317        gamma: f64,
318    ) -> Self {
319        self.add_node(
320            id,
321            ModelTypeStr::SkewedGaussian,
322            &[amplitude, center, sigma, gamma],
323        )
324    }
325
326    /// Add an exponentially-modified Gaussian (asymmetric tailing peak).
327    ///
328    /// Parameters (in order): `amplitude`, `center`, `sigma`, `gamma`.
329    pub fn add_exp_gaussian(
330        self,
331        id: &str,
332        amplitude: f64,
333        center: f64,
334        sigma: f64,
335        gamma: f64,
336    ) -> Self {
337        self.add_node(
338            id,
339            ModelTypeStr::ExpGaussian,
340            &[amplitude, center, sigma, gamma],
341        )
342    }
343
344    /// Add a Doniach–Šunjić asymmetric XPS core-level lineshape.
345    ///
346    /// Parameters (in order): `amplitude`, `center`, `sigma`, `gamma`.
347    pub fn add_doniach_sunjic(
348        self,
349        id: &str,
350        amplitude: f64,
351        center: f64,
352        sigma: f64,
353        gamma: f64,
354    ) -> Self {
355        self.add_node(
356            id,
357            ModelTypeStr::DoniachSunjic,
358            &[amplitude, center, sigma, gamma],
359        )
360    }
361
362    /// Add a log-normal peak (`A·exp(−(ln(x/c))²/(2σ²))` for `x > 0`).
363    ///
364    /// Parameters (in order): `amplitude`, `center`, `sigma`.
365    pub fn add_log_normal(self, id: &str, amplitude: f64, center: f64, sigma: f64) -> Self {
366        self.add_node(id, ModelTypeStr::LogNormal, &[amplitude, center, sigma])
367    }
368
369    /// Add a Pearson VII peak.
370    ///
371    /// Parameters (in order): `amplitude`, `center`, `sigma`, `m`.
372    pub fn add_pearson7(self, id: &str, amplitude: f64, center: f64, sigma: f64, m: f64) -> Self {
373        self.add_node(id, ModelTypeStr::Pearson7, &[amplitude, center, sigma, m])
374    }
375
376    /// Add a split (bi-)Gaussian with different widths each side of `center`.
377    ///
378    /// Parameters (in order): `amplitude`, `center`, `sigma_l`, `sigma_r`.
379    pub fn add_split_gaussian(
380        self,
381        id: &str,
382        amplitude: f64,
383        center: f64,
384        sigma_l: f64,
385        sigma_r: f64,
386    ) -> Self {
387        self.add_node(
388            id,
389            ModelTypeStr::SplitGaussian,
390            &[amplitude, center, sigma_l, sigma_r],
391        )
392    }
393
394    /// Add a Moffat peak.
395    ///
396    /// Parameters (in order): `amplitude`, `center`, `sigma`, `beta`.
397    pub fn add_moffat(self, id: &str, amplitude: f64, center: f64, sigma: f64, beta: f64) -> Self {
398        self.add_node(id, ModelTypeStr::Moffat, &[amplitude, center, sigma, beta])
399    }
400
401    /// Add a Student's-t peak.
402    ///
403    /// Parameters (in order): `amplitude`, `center`, `sigma`, `nu`.
404    pub fn add_students_t(
405        self,
406        id: &str,
407        amplitude: f64,
408        center: f64,
409        sigma: f64,
410        nu: f64,
411    ) -> Self {
412        self.add_node(id, ModelTypeStr::StudentsT, &[amplitude, center, sigma, nu])
413    }
414
415    /// Add a split Pearson VII (split width + exponent each side of `center`).
416    ///
417    /// Parameters (in order): `amplitude`, `center`, `sigma_l`, `sigma_r`,
418    /// `m_l`, `m_r`.
419    // Allowed: mirrors the kernel's declared `param_names()` positionally —
420    // amplitude, center, sigma_l, sigma_r, m_l, m_r — splitting into a struct
421    // would just rename the same six-parameter bundle.
422    #[allow(clippy::too_many_arguments)]
423    pub fn add_split_pearson7(
424        self,
425        id: &str,
426        amplitude: f64,
427        center: f64,
428        sigma_l: f64,
429        sigma_r: f64,
430        m_l: f64,
431        m_r: f64,
432    ) -> Self {
433        self.add_node(
434            id,
435            ModelTypeStr::SplitPearson7,
436            &[amplitude, center, sigma_l, sigma_r, m_l, m_r],
437        )
438    }
439
440    /// Add a Breit-Wigner-Fano resonance.
441    ///
442    /// Parameters (in order): `amplitude`, `center`, `sigma`, `q`.
443    pub fn add_breit_wigner(
444        self,
445        id: &str,
446        amplitude: f64,
447        center: f64,
448        sigma: f64,
449        q: f64,
450    ) -> Self {
451        self.add_node(
452            id,
453            ModelTypeStr::BreitWigner,
454            &[amplitude, center, sigma, q],
455        )
456    }
457
458    /// Add an asymmetric IR band (Gaussian × logistic sigmoid).
459    ///
460    /// Parameters (in order): `amplitude`, `center`, `sigma`, `k`.
461    pub fn add_asym_ir(self, id: &str, amplitude: f64, center: f64, sigma: f64, k: f64) -> Self {
462        self.add_node(id, ModelTypeStr::AsymIr, &[amplitude, center, sigma, k])
463    }
464
465    /// Add a driven damped harmonic-oscillator IR absorption.
466    ///
467    /// Parameters (in order): `amplitude`, `center`, `sigma`.
468    pub fn add_harmonic_ir(self, id: &str, amplitude: f64, center: f64, sigma: f64) -> Self {
469        self.add_node(id, ModelTypeStr::HarmonicIr, &[amplitude, center, sigma])
470    }
471
472    /// Add a Tauc optical band-gap edge: `A·((x−e_gap)·H(x−e_gap))^p`.
473    ///
474    /// Parameters (in order): `amplitude`, `e_gap`, `exponent`.
475    pub fn add_tauc(self, id: &str, amplitude: f64, e_gap: f64, exponent: f64) -> Self {
476        self.add_node(id, ModelTypeStr::Tauc, &[amplitude, e_gap, exponent])
477    }
478
479    /// Add a Cauchy refractive-index dispersion: `a + b/x² + c/x⁴`.
480    pub fn add_cauchy_dispersion(self, id: &str, a: f64, b: f64, c: f64) -> Self {
481        self.add_node(id, ModelTypeStr::CauchyDispersion, &[a, b, c])
482    }
483
484    /// Add a Kohlrausch–Williams–Watts stretched exponential: `A·exp(−(x/τ)^β)`.
485    ///
486    /// Parameters (in order): `amplitude`, `tau`, `beta`.
487    pub fn add_kww(self, id: &str, amplitude: f64, tau: f64, beta: f64) -> Self {
488        self.add_node(id, ModelTypeStr::Kww, &[amplitude, tau, beta])
489    }
490
491    // -----------------------------------------------------------------------
492    // Internal: build a `ModelNodeSpec` by looking up the kernel's declared
493    // parameter names and zipping them with positional values.
494    // -----------------------------------------------------------------------
495    fn add_node(mut self, id: &str, model_type: ModelTypeStr, values: &[f64]) -> Self {
496        let wire = model_type.as_str();
497        let kernel: Box<dyn Model> = model_from_str(wire).unwrap_or_else(|| {
498            // Unreachable by construction — every variant is wired into
499            // `model_from_str`, and the `available_models_matches_model_from_str`
500            // test pins this — but explicit panic beats silent UB if a future
501            // contributor adds a variant without registering the kernel.
502            panic!(
503                "spectrafit-builder: model_from_str({wire:?}) returned None — \
504                 add the kernel registration in spectrafit-models::model_from_str"
505            )
506        });
507        let names = kernel.param_names();
508        debug_assert_eq!(
509            names.len(),
510            values.len(),
511            "builder arity mismatch for {wire}: kernel expects {} params, got {}",
512            names.len(),
513            values.len()
514        );
515        let mut parameters: HashMap<String, ParameterSpec> = HashMap::with_capacity(names.len());
516        for (name, value) in names.iter().zip(values.iter()) {
517            parameters.insert((*name).to_string(), default_parameter(*value));
518        }
519        self.nodes.push(ModelNodeSpec {
520            id: id.to_string(),
521            model_type,
522            parameters,
523            dataset_index: None,
524        });
525        self
526    }
527}
528
529/// Default `ParameterSpec` for a builder-added parameter: unbounded, free.
530fn default_parameter(value: f64) -> ParameterSpec {
531    ParameterSpec {
532        value,
533        min: f64::NEG_INFINITY,
534        max: f64::INFINITY,
535        vary: true,
536        expr: None,
537        scale: None,
538    }
539}
540
541/// Every model variant the builder can emit, in declaration order.
542///
543/// Single source of truth for `available_models()` and for the roundtrip /
544/// parity tests. New `ModelTypeStr` variant → one entry here.
545const ALL_MODELS: &[ModelTypeStr] = &[
546    ModelTypeStr::Gaussian,
547    ModelTypeStr::Gaussian2D,
548    ModelTypeStr::GaussianNd,
549    ModelTypeStr::Lorentzian,
550    ModelTypeStr::Voigt,
551    ModelTypeStr::Constant,
552    ModelTypeStr::Linear,
553    ModelTypeStr::Quadratic,
554    ModelTypeStr::ArctanStep,
555    ModelTypeStr::TanhStep,
556    ModelTypeStr::ErfcStep,
557    ModelTypeStr::PseudoVoigt,
558    ModelTypeStr::Fano,
559    ModelTypeStr::DoubleExponential,
560    ModelTypeStr::SaturatingExponential,
561    ModelTypeStr::TrueVoigt,
562    ModelTypeStr::SkewedGaussian,
563    ModelTypeStr::ExpGaussian,
564    ModelTypeStr::DoniachSunjic,
565    ModelTypeStr::LogNormal,
566    ModelTypeStr::Pearson7,
567    ModelTypeStr::SplitGaussian,
568    ModelTypeStr::Moffat,
569    ModelTypeStr::StudentsT,
570    ModelTypeStr::SplitPearson7,
571    ModelTypeStr::BreitWigner,
572    ModelTypeStr::AsymIr,
573    ModelTypeStr::HarmonicIr,
574    ModelTypeStr::Tauc,
575    ModelTypeStr::CauchyDispersion,
576    ModelTypeStr::Kww,
577    ModelTypeStr::PowerSaturation,
578    ModelTypeStr::PowerLawOffset,
579    ModelTypeStr::Mgh09Rational,
580    ModelTypeStr::RationalCubic,
581    ModelTypeStr::GeneralisedLogistic,
582    ModelTypeStr::ExpOverLinear,
583];
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    /// `available_models()` must enumerate every variant `model_from_str` knows
590    /// — otherwise a builder caller would see a model the kernel cannot
591    /// instantiate, or vice versa.
592    #[test]
593    fn available_models_matches_model_from_str() {
594        for key in FitGraphBuilder::available_models() {
595            assert!(
596                model_from_str(key).is_some(),
597                "available_models() reported {key:?} but model_from_str does not know it",
598            );
599        }
600    }
601
602    /// Empty builder produces a valid, empty `FitGraphSpec`.
603    #[test]
604    fn empty_builder_produces_valid_spec() {
605        let g = FitGraphBuilder::new().build();
606        assert_eq!(g.schema_version, SCHEMA_VERSION);
607        assert!(g.nodes.is_empty());
608        assert!(g.expr_edges.is_empty());
609    }
610
611    /// Compiler-enforced exhaustiveness: every `ModelTypeStr` variant must
612    /// appear in `ALL_MODELS`. The `match` here has no wildcard arm, so adding
613    /// a new variant to `spectrafit-types::ModelTypeStr` without listing it in
614    /// `ALL_MODELS` (and adding the matching `add_<name>()` method) breaks the
615    /// build — which is the point. Vista-safety: the builder cannot silently
616    /// fall behind a new kernel.
617    #[test]
618    fn every_model_type_str_variant_is_covered_by_all_models() {
619        fn covered(variant: &ModelTypeStr) -> bool {
620            ALL_MODELS.iter().any(|m| m.as_str() == variant.as_str())
621        }
622        // No wildcard — adding a new variant forces this test to grow.
623        let representatives = [
624            ModelTypeStr::Gaussian,
625            ModelTypeStr::Gaussian2D,
626            ModelTypeStr::GaussianNd,
627            ModelTypeStr::Lorentzian,
628            ModelTypeStr::Voigt,
629            ModelTypeStr::Constant,
630            ModelTypeStr::Linear,
631            ModelTypeStr::Quadratic,
632            ModelTypeStr::ArctanStep,
633            ModelTypeStr::TanhStep,
634            ModelTypeStr::ErfcStep,
635            ModelTypeStr::PseudoVoigt,
636            ModelTypeStr::Fano,
637            ModelTypeStr::DoubleExponential,
638            ModelTypeStr::SaturatingExponential,
639            ModelTypeStr::TrueVoigt,
640            ModelTypeStr::SkewedGaussian,
641            ModelTypeStr::ExpGaussian,
642            ModelTypeStr::DoniachSunjic,
643            ModelTypeStr::LogNormal,
644            ModelTypeStr::Pearson7,
645            ModelTypeStr::SplitGaussian,
646            ModelTypeStr::Moffat,
647            ModelTypeStr::StudentsT,
648            ModelTypeStr::SplitPearson7,
649            ModelTypeStr::BreitWigner,
650            ModelTypeStr::AsymIr,
651            ModelTypeStr::HarmonicIr,
652            ModelTypeStr::Tauc,
653            ModelTypeStr::CauchyDispersion,
654            ModelTypeStr::Kww,
655            ModelTypeStr::PowerSaturation,
656            ModelTypeStr::PowerLawOffset,
657            ModelTypeStr::Mgh09Rational,
658            ModelTypeStr::RationalCubic,
659            ModelTypeStr::GeneralisedLogistic,
660            ModelTypeStr::ExpOverLinear,
661        ];
662        // The `match` below is the compile-time gate: a new `ModelTypeStr`
663        // variant is a non-exhaustive-patterns error here, forcing the
664        // contributor to wire the builder arm before the crate even compiles.
665        for v in &representatives {
666            // No wildcard arm → rustc's exhaustiveness check (E0004) fires on
667            // any new `ModelTypeStr` variant. That compile error is the gate.
668            let _exhaustive: () = match v {
669                ModelTypeStr::Gaussian
670                | ModelTypeStr::Gaussian2D
671                | ModelTypeStr::GaussianNd
672                | ModelTypeStr::Lorentzian
673                | ModelTypeStr::Voigt
674                | ModelTypeStr::Constant
675                | ModelTypeStr::Linear
676                | ModelTypeStr::Quadratic
677                | ModelTypeStr::ArctanStep
678                | ModelTypeStr::TanhStep
679                | ModelTypeStr::ErfcStep
680                | ModelTypeStr::PseudoVoigt
681                | ModelTypeStr::Fano
682                | ModelTypeStr::DoubleExponential
683                | ModelTypeStr::SaturatingExponential
684                | ModelTypeStr::TrueVoigt
685                | ModelTypeStr::SkewedGaussian
686                | ModelTypeStr::ExpGaussian
687                | ModelTypeStr::DoniachSunjic
688                | ModelTypeStr::LogNormal
689                | ModelTypeStr::Pearson7
690                | ModelTypeStr::SplitGaussian
691                | ModelTypeStr::Moffat
692                | ModelTypeStr::StudentsT
693                | ModelTypeStr::SplitPearson7
694                | ModelTypeStr::BreitWigner
695                | ModelTypeStr::AsymIr
696                | ModelTypeStr::HarmonicIr
697                | ModelTypeStr::Tauc
698                | ModelTypeStr::CauchyDispersion
699                | ModelTypeStr::Kww
700                | ModelTypeStr::PowerSaturation
701                | ModelTypeStr::PowerLawOffset
702                | ModelTypeStr::Mgh09Rational
703                | ModelTypeStr::RationalCubic
704                | ModelTypeStr::GeneralisedLogistic
705                | ModelTypeStr::ExpOverLinear => (),
706            };
707            assert!(
708                covered(v),
709                "ModelTypeStr::{v:?} is not present in ALL_MODELS — add it \
710                 alongside the corresponding `add_<name>()` fluent method",
711            );
712        }
713        assert_eq!(
714            ALL_MODELS.len(),
715            representatives.len(),
716            "ALL_MODELS length drifted from the exhaustive variant list",
717        );
718    }
719
720    /// `tie()` accumulates edges in call order.
721    #[test]
722    fn tie_accumulates_edges_in_order() {
723        let g = FitGraphBuilder::new()
724            .tie("g0", "center", "g1.center + 0.5")
725            .tie("g1", "sigma", "g0.sigma")
726            .build();
727        assert_eq!(g.expr_edges.len(), 2);
728        assert_eq!(g.expr_edges[0].target_node, "g0");
729        assert_eq!(g.expr_edges[0].target_param, "center");
730        assert_eq!(g.expr_edges[0].expression, "g1.center + 0.5");
731        assert_eq!(g.expr_edges[1].target_node, "g1");
732    }
733}