Skip to main content

spectrafit_solver/
dispatch.rs

1//! `fit()` — the public solver entry point and dispatcher.
2//!
3//! Parses [`FitOptionsSpec::solver`] into a [`Solver`] and routes it: `irls`,
4//! `global` and `varpro` dispatch early to their own module/crate, while the
5//! Levenberg–Marquardt family (`lm`/`trf`/`geodesic`/`lm-legacy`, and `auto`'s
6//! non-VarPro case) runs the shared pre-solve, solve and post-fit path here.
7//!
8//! Steps (LM-family path):
9//!   1. Compile the graph → get `free_keys`
10//!   2. Extract initial parameter vector + bounds
11//!   3. Evaluate the model *before* optimisation (`init_fit`)
12//!   4. Run `LevenbergMarquardt::minimize`
13//!   5. Compute post-fit statistics: χ², reduced-χ², R², DOF, AIC, BIC
14//!   6. Compute covariance matrix: (J_wᵀ J_w)⁻¹ when σ is user-supplied, else (JᵀJ)⁻¹ · (χ²/DOF)
15//!   7. Assemble and return `FitResultSpec`
16
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::ops::ControlFlow;
20use std::str::FromStr;
21
22use levenberg_marquardt::LevenbergMarquardt;
23use nalgebra::DVector;
24use spectrafit_graph::{evaluate_compiled, CompiledGraph};
25use spectrafit_types::{
26    CoreError, FitGraphSpec, FitOptionsSpec, FitResultSpec, MeasurementSpec, TerminationReason,
27};
28
29use crate::error::SolverError;
30use crate::global::{solve_global, DeConfig};
31use crate::irls::{solve_irls, WeightFn, DEFAULT_MAX_OUTER_ITER, DEFAULT_TOL_WEIGHTS};
32use crate::lm_problem::LmProblem;
33use crate::postfit::{self, LmSolveOutcome};
34
35/// Flatten one dataset's `dims × points` coordinates into the **point-major**
36/// (stride = n_dims) layout the executor's `coord_layout` expects: point `i`
37/// occupies `x[i*n_dims .. (i+1)*n_dims]`. For the 1-D case this is exactly
38/// `ds.x[0]`, so existing behaviour is unchanged; for n-D it interleaves the
39/// per-dimension coordinate rows so a Gaussian2D node receives `[xᵢ, yᵢ]`.
40pub(crate) fn point_major_x(ds: &MeasurementSpec) -> Vec<f64> {
41    let n_dims = ds.x.len();
42    if n_dims == 0 {
43        return Vec::new();
44    }
45    let n_points = ds.x[0].len();
46    let mut out = Vec::with_capacity(n_points * n_dims);
47    for i in 0..n_points {
48        for dim in &ds.x {
49            out.push(dim[i]);
50        }
51    }
52    out
53}
54
55// ---------------------------------------------------------------------------
56// Solver dispatch
57// ---------------------------------------------------------------------------
58
59/// Every solver [`fit`] can dispatch to, parsed once from
60/// [`FitOptionsSpec::solver`]. This enum + [`Solver::parse`] + the single match
61/// in [`fit`] is the *one place* that answers "which solvers exist and where
62/// does each one run":
63///
64/// * [`Lm`](Solver::Lm) / [`Trf`](Solver::Trf) / [`Geodesic`](Solver::Geodesic) /
65///   [`LmLegacy`](Solver::LmLegacy) — the Levenberg–Marquardt family. All run the
66///   shared pre-solve + solve below: the first three on the faer
67///   `spectrafit-levenberg-marquardt` core (TRF = Coleman–Li bound scaling,
68///   geodesic = Transtrum acceleration), `LmLegacy` on the nalgebra oracle.
69/// * [`Irls`](Solver::Irls) / [`Global`](Solver::Global) / [`Varpro`](Solver::Varpro)
70///   — distinct algorithms that dispatch early to their own module/crate.
71/// * [`Auto`](Solver::Auto) — VarPro when the graph is separable, unconstrained
72///   and untied (see [`graph_prefers_varpro`]), otherwise the faer LM family.
73#[derive(Debug, Clone, Copy, PartialEq)]
74enum Solver {
75    /// Default faer Levenberg–Marquardt.
76    Lm,
77    /// nalgebra `levenberg-marquardt` parity oracle.
78    LmLegacy,
79    /// Trust-Region-Reflective (Coleman–Li bound scaling on the faer LM core).
80    Trf,
81    /// Geodesic acceleration on the faer LM core.
82    Geodesic,
83    /// Powell's dogleg trust-region method.
84    Dogleg,
85    /// Matrix-free Newton-CG (Steihaug–Toint) trust-region method.
86    NewtonCg,
87    /// Iteratively-reweighted least squares (robust loss).
88    Irls(WeightFn),
89    /// Differential Evolution (global search).
90    Global,
91    /// Variable Projection (separable nonlinear least squares).
92    Varpro,
93    /// Choose from the graph shape at run time.
94    Auto,
95}
96
97impl Solver {
98    /// Parse the `FitOptionsSpec.solver` string.
99    ///
100    /// Unrecognised strings are rejected rather than silently defaulting to
101    /// LM — previously a typo (e.g. `"lmm"`, wrong-case `"Trf"`) silently ran
102    /// the LM solver instead of erroring, which could mask a misconfigured
103    /// fit with a plausible-looking but unintended result.
104    fn parse(s: &str) -> Result<Self, SolverError> {
105        match s {
106            "lm" => Ok(Solver::Lm),
107            "lm-legacy" => Ok(Solver::LmLegacy),
108            "trf" => Ok(Solver::Trf),
109            "geodesic" | "lm-geodesic" => Ok(Solver::Geodesic),
110            "dogleg" => Ok(Solver::Dogleg),
111            "newton-cg" | "newton_cg" | "newtoncg" | "steihaug" => Ok(Solver::NewtonCg),
112            "global" => Ok(Solver::Global),
113            "varpro" => Ok(Solver::Varpro),
114            "auto" => Ok(Solver::Auto),
115            _ if s.starts_with("irls") => {
116                let name = s.split_once(':').map(|(_, name)| name).unwrap_or("huber");
117                Ok(Solver::Irls(WeightFn::from_str(name)?))
118            }
119            other => Err(SolverError::UnrecognisedSolver(other.to_string())),
120        }
121    }
122}
123
124/// Whether a graph carries any tied parameters — a constraint expression from a
125/// graph `expr_edge` OR a per-parameter `Parameter.expr`. VarPro reconstructs
126/// parameters from the linear/nonlinear split and never applies the `TiedPlan`,
127/// so a tied graph must never be routed to / accepted by VarPro on EITHER surface
128/// (CX-VPE-01: checking `expr_edges` alone silently dropped `Parameter.expr` ties).
129fn graph_has_tied_params(graph: &FitGraphSpec) -> bool {
130    !graph.expr_edges.is_empty()
131        || graph
132            .nodes
133            .iter()
134            .any(|n| n.parameters.values().any(|p| p.expr.is_some()))
135}
136
137/// Whether `solver="auto"` should choose VarPro: the graph is separable, every
138/// nonlinear parameter is unconstrained (VarPro ignores bounds), and there are no
139/// tied parameters (via `expr_edges` or `Parameter.expr`; VarPro cannot honour them).
140fn graph_prefers_varpro(graph: &FitGraphSpec) -> bool {
141    !graph_has_tied_params(graph)
142        // VarPro stacks all datasets and is not dataset_index-aware, so it must
143        // not be auto-selected for simultaneous multi-dataset ("global analysis")
144        // graphs — those need the LM-family scoped executor path.
145        && graph.nodes.iter().all(|n| n.dataset_index.is_none())
146        && spectrafit_varpro::is_separable(graph)
147        && graph.nodes.iter().all(|n| {
148            n.parameters
149                .iter()
150                // amplitude is the linear coeff VarPro projects out; skip it BY NAME
151                // (HashMap order is non-deterministic, so positional `.skip(1)` could
152                // drop an arbitrary nonlinear param and miss its bounds — silent bound
153                // violation when that param is bounded but amplitude is not).
154                .filter(|(name, _)| name.as_str() != "amplitude")
155                .filter(|(_, p)| p.vary)
156                .all(|(_, p)| p.min.is_infinite() && p.max.is_infinite())
157        })
158}
159
160/// Build the `ParameterSpec` map and run VarPro. Shared by the explicit
161/// `solver="varpro"` arm and the `solver="auto"`→VarPro arm.
162fn solve_varpro_path(
163    graph: &FitGraphSpec,
164    datasets: &[MeasurementSpec],
165    options: &FitOptionsSpec,
166) -> Result<FitResultSpec, CoreError> {
167    let mut param_specs: HashMap<String, spectrafit_types::ParameterSpec> = HashMap::new();
168    for node in &graph.nodes {
169        for (pname, pspec) in &node.parameters {
170            param_specs.insert(format!("{}.{}", node.id, pname), pspec.clone());
171        }
172    }
173    spectrafit_varpro::solve_varpro(graph, datasets, &param_specs, options)
174}
175
176// ---------------------------------------------------------------------------
177// Public API
178// ---------------------------------------------------------------------------
179
180/// Run a fit of `graph` against `datasets` using the solver named by
181/// `options.solver` (see `Solver::parse` for the accepted strings: `lm`,
182/// `lm-legacy`, `trf`, `geodesic`, `dogleg`, `newton-cg`, `irls[:weight]`,
183/// `global`, `varpro`, `auto`).
184///
185/// The LM family (`lm`, `lm-legacy`, `trf`, `geodesic`) plus the
186/// trust-region methods (`dogleg`, `newton-cg`) and `auto`'s non-VarPro
187/// fallback share one pre-solve/solve/post-fit path through this function.
188/// `irls`, `global`, and `varpro` are routed to their own modules/crates in
189/// `dispatch_solver` and return before that shared path runs.
190///
191/// Returns a fully populated [`FitResultSpec`] including parameter values,
192/// standard errors, covariance matrix, and goodness-of-fit statistics.
193///
194/// # Errors
195/// Returns [`CoreError`] when:
196/// - `options.solver` does not parse (unrecognised solver name, or an
197///   unrecognised `irls:<weight>` weight-function name) — `Solver::parse`.
198/// - The routed non-LM-family solver fails: `irls` (`crate::irls::solve_irls`),
199///   `global` (differential evolution), or `varpro` (not separable, tied
200///   parameters present, or per-dataset scoping unsupported).
201/// - Graph compilation or the initial model evaluation fails (malformed
202///   graph, a free-key/parameter lookup miss, or a model evaluation error) —
203///   `prepare_lm_pre_solve`.
204/// - The LM-family solve itself fails or produces a numerically unusable
205///   result (e.g. ill-conditioned Jacobian) that maps to `CoreError::Solver`.
206/// - `options.eta` is set alongside `solver="dogleg"` / `"newton-cg"` but is
207///   not finite and in `[0.0, 0.25)` — `SolverError::InvalidEta`.
208/// - Post-fit assembly fails while re-evaluating the model or its Jacobian
209///   at the solution — `postfit::assemble_result`.
210pub fn fit(
211    graph: &FitGraphSpec,
212    datasets: Vec<MeasurementSpec>,
213    options: &FitOptionsSpec,
214) -> Result<FitResultSpec, CoreError> {
215    // Run faer's dense kernels serially for the whole fit (solve loop AND the
216    // post-fit covariance/condition-number). Our matrices are skinny (p ≲ 150),
217    // so faer's default Rayon dispatch costs more in thread-pool wake-up
218    // (~30–50 µs per matmul) than the arithmetic itself — measured ~4–6×
219    // slowdown overall, ~4.7× on a 50k×3 fit, including the n×p post-fit
220    // Gram/SVD. The data-parallelism that pays off lives one level down in the
221    // graph executor's own Rayon path (over points), not in these
222    // per-iteration p×p factorizations.
223    //
224    // THIS IS THE ONLY SITE that sets faer's global parallelism. The LM and
225    // trust-region drivers each used to set it again on entry to their own
226    // `minimize`/`minimize_tr`; setting one process-global three times from
227    // three crates made the effective policy depend on call order and left the
228    // drivers quietly mutating global state their doc comments never mentioned.
229    // Ownership now sits here, at the one place that already owns the whole
230    // fit — faer is solver-internal in this workspace, so a single global
231    // policy set at the fit boundary is safe.
232    faer::set_global_parallelism(faer::Par::Seq);
233
234    // ── 0. Solver dispatch ───────────────────────────────────────────────────
235    // Route the non-LM-family solvers to their own module/crate; the LM family
236    // (Lm/LmLegacy/Trf/Geodesic, and Auto's non-VarPro case) falls through to the
237    // shared pre-solve + solve below.
238
239    let solver = Solver::parse(&options.solver)?;
240    let datasets = match dispatch_solver(solver, graph, datasets, options) {
241        ControlFlow::Break(result) => return result,
242        ControlFlow::Continue(datasets) => datasets,
243    };
244
245    // ── 1/2/3/6/7. Compile the graph, collect free-param specs, evaluate the
246    // initial model, and build the index-based node-param buffers ───────────
247    let LmPreSolve {
248        cg,
249        free_keys,
250        all_params,
251        bounds,
252        scales,
253        sigma,
254        x_all,
255        y_all,
256        init_fit,
257        init_params,
258        node_param_bufs,
259        free_to_node_param,
260        tied_to_node_param,
261    } = prepare_lm_pre_solve(graph, &datasets)?;
262
263    // ── 8. Construct problem ─────────────────────────────────────────────────
264    let problem = LmProblem {
265        compiled: &cg,
266        free_keys: free_keys.clone(),
267        bounds: bounds.clone(),
268        all_params: all_params.clone(),
269        node_param_bufs,
270        free_to_node_param,
271        tied_to_node_param,
272        x_concat: x_all.clone(),
273        y_concat: y_all.clone(),
274        params: init_params,
275        scales: scales.clone(),
276        sigma,
277        residual_buf: RefCell::new(vec![0.0; y_all.len()]),
278        // One Jacobian row per data point (residual), not per coordinate: size by
279        // the point count (y_all.len()), which equals x_all.len() only in 1-D.
280        jacobian_buf: RefCell::new(vec![0.0; y_all.len() * free_keys.len()]),
281        residual_count: RefCell::new(0),
282        jacobian_count: RefCell::new(0),
283        residual_time_ns: RefCell::new(0),
284        jacobian_time_ns: RefCell::new(0),
285    };
286
287    // ── 4/8. Initialise tied parameters and run the LM-family solve ──────────
288    let outcome = run_lm_solve(problem, solver, options, free_keys.len(), y_all.len())?;
289
290    // ── 5/6/7. Post-fit statistics, covariance, and result assembly ──────────
291    postfit::assemble_result(
292        outcome,
293        postfit::PostfitInputs {
294            cg: &cg,
295            graph,
296            datasets: &datasets,
297            x_all: &x_all,
298            y_all: &y_all,
299        },
300        init_fit,
301    )
302}
303
304// ---------------------------------------------------------------------------
305// Private helpers
306// ---------------------------------------------------------------------------
307
308/// Route `solver` to its own module/crate when it is not part of the LM
309/// family, otherwise hand `datasets` back unchanged. Step 0 of [`fit`]'s
310/// sequence, extracted verbatim from the original inline `match`.
311///
312/// Returns [`ControlFlow::Break`] carrying the already-final
313/// `Result<FitResultSpec, CoreError>` for `Irls` / `Global` / `Varpro` / an
314/// `Auto` that prefers VarPro — `fit` returns this immediately. Returns
315/// [`ControlFlow::Continue`] carrying `datasets` handed back unchanged for the
316/// LM family (`Lm` / `LmLegacy` / `Trf` / `Geodesic` / `Dogleg` / `NewtonCg`,
317/// and an `Auto` that does not prefer VarPro) — `fit` falls through to the
318/// shared pre-solve + solve path for these.
319fn dispatch_solver(
320    solver: Solver,
321    graph: &FitGraphSpec,
322    datasets: Vec<MeasurementSpec>,
323    options: &FitOptionsSpec,
324) -> ControlFlow<Result<FitResultSpec, CoreError>, Vec<MeasurementSpec>> {
325    match solver {
326        Solver::Irls(weight) => ControlFlow::Break(solve_irls(
327            graph,
328            datasets,
329            options,
330            weight,
331            DEFAULT_MAX_OUTER_ITER,
332            // The IRLS weight-convergence tolerance is its own constant, not
333            // `options.tolerance` (the LM cost/step/gradient tolerance) — see
334            // `DEFAULT_TOL_WEIGHTS`.
335            DEFAULT_TOL_WEIGHTS,
336        )),
337
338        Solver::Global => {
339            // Honour the caller's iteration budget: map `max_iterations` onto the
340            // number of DE generations. Without this the 500-generation budget the
341            // benchmark passes for pathological cases was silently dropped and DE
342            // ran with the 100-generation default.
343            let config = DeConfig {
344                max_gen: if options.max_iterations > 0 {
345                    options.max_iterations as usize
346                } else {
347                    DeConfig::default().max_gen
348                },
349                ..DeConfig::default()
350            };
351            ControlFlow::Break(solve_global(graph, datasets, options, config))
352        }
353        Solver::Varpro => {
354            // Explicit VarPro: require separability, and reject tied params from
355            // EITHER surface (expr_edges or Parameter.expr) — VarPro reconstructs
356            // parameters from the linear/nonlinear split and never applies the
357            // tied-plan, so a tie would be silently dropped (CX-VPE-01).
358            if !spectrafit_varpro::is_separable(graph) {
359                return ControlFlow::Break(Err(SolverError::VarproNotSeparable.into()));
360            }
361            if graph_has_tied_params(graph) {
362                return ControlFlow::Break(Err(SolverError::VarproExprEdgesUnsupported.into()));
363            }
364            // VarPro stacks all datasets into one synthetic spec and is not
365            // dataset_index-aware, so a per-dataset (local) node would silently
366            // contribute to every dataset. Reject rather than mis-scope; the
367            // LM-family solvers honour dataset_index (simultaneous global analysis).
368            if graph.nodes.iter().any(|n| n.dataset_index.is_some()) {
369                return ControlFlow::Break(
370                    Err(SolverError::VarproDatasetScopingUnsupported.into()),
371                );
372            }
373            // Falls through to the shared VarPro tail below, which applies the
374            // post-fit success guards. Do not call `solve_varpro_path` directly
375            // here — see the note on that tail.
376            ControlFlow::Break(run_varpro_guarded(graph, &datasets, options))
377        }
378        Solver::Auto if graph_prefers_varpro(graph) => {
379            // MUST use the same guarded tail as the explicit `Solver::Varpro`
380            // arm above. This arm previously called `solve_varpro_path` raw,
381            // skipping `finalize_varpro_result` — so on a graph where automatic
382            // routing chooses VarPro, `solver="auto"` reported success on
383            // exactly the degenerate fits `solver="varpro"` reported as failed,
384            // on identical data. `auto` is the documented default, which made
385            // it a silent wrong answer on the recommended path.
386            ControlFlow::Break(run_varpro_guarded(graph, &datasets, options))
387        }
388        // LM family + the Δ-radius methods (and Auto without VarPro) flow through
389        // to the shared pre-solve + solve below.
390        Solver::Lm
391        | Solver::LmLegacy
392        | Solver::Trf
393        | Solver::Geodesic
394        | Solver::Dogleg
395        | Solver::NewtonCg
396        | Solver::Auto => ControlFlow::Continue(datasets),
397    }
398}
399
400/// Bundle of everything the shared LM-family pre-solve step produces (steps
401/// 1/2/3/6/7 of [`fit`]'s sequence: compile the graph, collect free-parameter
402/// specs, evaluate the initial model, and build the index-based node-param
403/// buffers). [`fit`] destructures this into the same flat local bindings the
404/// original inline code used, so field names mirror the original variable
405/// names exactly.
406struct LmPreSolve {
407    /// Compiled DAG (step 1), including `dataset_offsets` for simultaneous
408    /// multi-dataset ("global analysis") fits.
409    cg: CompiledGraph,
410    /// `"node_id.param_name"` for every free parameter, canonical order.
411    free_keys: Vec<String>,
412    /// All parameters (free + fixed), keyed by `"node_id.param_name"`.
413    all_params: HashMap<String, f64>,
414    /// `(min, max)` bounds per free parameter.
415    bounds: Vec<(f64, f64)>,
416    /// Per-free-parameter `Parameter.scale` factors (identity when unset).
417    scales: Vec<f64>,
418    /// Per-point σ (default 1.0 where `MeasurementSpec.sigma` is `None`).
419    sigma: Vec<f64>,
420    /// Point-major concatenated x across all datasets.
421    x_all: Vec<f64>,
422    /// Concatenated y across all datasets.
423    y_all: Vec<f64>,
424    /// The model evaluated at the initial parameters, before optimisation.
425    init_fit: Vec<f64>,
426    /// Initial free-parameter vector in the optimiser's scaled working units.
427    init_params: DVector<f64>,
428    /// Per-node parameter buffers (fixed pre-filled, free updated in-place).
429    node_param_bufs: Vec<Vec<f64>>,
430    /// free-param index → `(node_idx, param_pos)`.
431    free_to_node_param: Vec<(usize, usize)>,
432    /// tied-target index → `(node_idx, param_pos)`.
433    tied_to_node_param: Vec<(usize, usize)>,
434}
435
436/// Compile `graph`, collect free-parameter specs, evaluate the initial model,
437/// and build the index-based node-param buffers — steps 1/2/3/6/7 of [`fit`]'s
438/// sequence, extracted verbatim from the original inline code (including the
439/// `Parameter.scale` wiring doc comment and its `debug_assert_eq!`).
440fn prepare_lm_pre_solve(
441    graph: &FitGraphSpec,
442    datasets: &[MeasurementSpec],
443) -> Result<LmPreSolve, CoreError> {
444    // ── 1. Compile graph ────────────────────────────────────────────────────
445    let mut cg = CompiledGraph::compile(graph)?;
446    // Per-dataset point boundaries (cumulative, len = n_datasets + 1) so the
447    // executor can scope a node with `dataset_index = Some(i)` to dataset i's
448    // contiguous point-range — the primitive for simultaneous multi-dataset
449    // ("global analysis") fits. Left as the single-range no-op for one dataset.
450    cg.dataset_offsets = {
451        let mut offs = Vec::with_capacity(datasets.len() + 1);
452        let mut acc = 0usize;
453        offs.push(0);
454        for ds in datasets {
455            acc += ds.y.len();
456            offs.push(acc);
457        }
458        offs
459    };
460    let free_keys = cg.free_keys.clone();
461
462    // ── 2/3. All-param flat map + initial values/bounds/scales for free params ─
463    let (all_params, init_vals, bounds, scales) = collect_free_param_specs(graph, &free_keys)?;
464
465    let init_vals_clone = init_vals.clone();
466    // The solver optimises the scaled working variable θ'_i = θ_i / s_i, so its
467    // starting point is the physical initial value divided by the scale factor
468    // (a no-op when s_i = 1.0). `node_param_bufs` is still seeded from the
469    // physical `init_vals_clone` below.
470    let init_params = DVector::from_vec(
471        init_vals
472            .iter()
473            .zip(scales.iter())
474            .map(|(&v, &s)| v / s)
475            .collect::<Vec<f64>>(),
476    );
477
478    // ── 4. Build per-point sigma vector (default = 1.0) ─────────────────────
479    let sigma: Vec<f64> = datasets
480        .iter()
481        .flat_map(|ds| {
482            let n = ds.y.len();
483            match &ds.sigma {
484                Some(s) => s.clone(),
485                None => vec![1.0_f64; n],
486            }
487        })
488        .collect();
489
490    // ── 5. Concatenated x and y for all datasets (cached in the problem) ────
491    // Point-major (stride = n_dims) so the executor reads each point's full
492    // coordinate; 1-D reduces to the old per-dataset `ds.x[0]` concatenation.
493    let x_all: Vec<f64> = datasets.iter().flat_map(point_major_x).collect();
494
495    let y_all: Vec<f64> = datasets
496        .iter()
497        .flat_map(|ds| ds.y.iter().copied())
498        .collect();
499
500    // ── 6. Evaluate initial model (before optimisation) ─────────────────────
501    let init_fit = evaluate_compiled(&cg, &all_params, &x_all)?;
502
503    // ── 7. Build index-based node param buffers ──────────────────────────────
504    let (node_param_bufs, free_to_node_param, tied_to_node_param) =
505        build_node_param_buffers(&cg, &all_params, &bounds, &init_vals_clone)?;
506
507    // ── 7b. Per-parameter scale normalization (Parameter.scale → LM step) ─────
508    //
509    // `Parameter.scale` lets a caller request that a parameter be optimised in
510    // rescaled units (e.g. an amplitude ~1e6 alongside a width ~1e-3). The solver
511    // operates on the rescaled variable `θ'_i = θ_i / s_i`, which equalises the
512    // columns of the Jacobian and thereby reshapes — and typically improves — the
513    // conditioning of `JᵀJ` (see the condition-number computation in §11). The
514    // wiring is now end-to-end:
515    //   1. `init_params` (above) is the physical start divided by `scales[i]`,
516    //   2. `LmProblem` carries `scales`; `set_params`/`params` translate between
517    //      scaled (optimiser) and physical (`node_param_bufs`) coordinates and the
518    //      analytic/FD Jacobian columns are multiplied by `scales[i]` (chain rule),
519    //   3. `node_param_bufs` — and hence `to_flat` / the reported result — is
520    //      always physical, so the converged values come back in real units.
521    // The post-fit condition number (§11 of postfit) applies the same column
522    // scaling so it reflects the optimiser's effective conditioning.
523    //
524    // With all-default (None ⇒ 1.0) scales every step above is an exact
525    // arithmetic no-op, so a fit without any `scale` set is byte-for-byte
526    // unchanged — the parity oracle (`tests/parity.rs`) pins this.
527    debug_assert_eq!(
528        scales.len(),
529        free_keys.len(),
530        "one scale factor per free parameter"
531    );
532
533    Ok(LmPreSolve {
534        cg,
535        free_keys,
536        all_params,
537        bounds,
538        scales,
539        sigma,
540        x_all,
541        y_all,
542        init_fit,
543        init_params,
544        node_param_bufs,
545        free_to_node_param,
546        tied_to_node_param,
547    })
548}
549
550/// Initialise tied parameters from the starting free values, then dispatch
551/// `solver` to the matching LM-family engine — extracted verbatim from the
552/// original inline code: `lm-legacy` on the retained nalgebra
553/// `levenberg-marquardt` crate (the parity oracle); `dogleg` / `newton-cg` on
554/// the Δ-radius trust-region drivers; everything else (`lm` / `trf` /
555/// `geodesic` / `auto`) on the faer-native LM core.
556fn run_lm_solve<'a>(
557    mut problem: LmProblem<'a>,
558    solver: Solver,
559    options: &FitOptionsSpec,
560    free_keys_len: usize,
561    y_all_len: usize,
562) -> Result<LmSolveOutcome<'a>, CoreError> {
563    // Initialise tied parameters from the starting free values. `node_param_bufs`
564    // was seeded with the tied-target *placeholders* (their spec `value`, e.g.
565    // 0.0); without this the first residual/Jacobian would evaluate the model
566    // with stale tied values. No-op for graphs without ties (`expr_edges` or `Parameter.expr`).
567    if problem.has_tied() {
568        let p0: Vec<f64> = problem.params.iter().copied().collect();
569        problem.set_free_and_tied(&p0);
570    }
571
572    // `patience` controls max function evaluations = patience * (n_params + 1)
573    let patience = (options.max_iterations as usize).max(1);
574
575    // Solver engine selection. The faer-native trust-region core is the default
576    // for every LM-family solver string (`"lm"`, `"auto"`, `"trf"`, `"geodesic"`);
577    // the proven `levenberg-marquardt` (nalgebra) crate is retained as the
578    // `"lm-legacy"` oracle for parity testing until it is removed.
579    let tol = if options.tolerance > 0.0 {
580        options.tolerance
581    } else {
582        1e-8
583    };
584    let max_nfev = patience.saturating_mul(free_keys_len + 1);
585    let _solve_t0 = std::time::Instant::now();
586    let (
587        result_problem,
588        n_iter_val,
589        success_val,
590        message_val,
591        cost_history,
592        gradient_norm_history,
593        params_history,
594    ) = match solver {
595        Solver::LmLegacy => {
596            // Retained nalgebra `levenberg-marquardt` crate — the parity oracle.
597            // It does not expose a per-iteration trajectory, so the history stays
598            // empty (the benchmark layer reconstructs a labelled proxy).
599            let lm = LevenbergMarquardt::new().with_patience(patience);
600            let lm = if options.tolerance > 0.0 {
601                lm.with_tol(options.tolerance)
602            } else {
603                lm
604            };
605            let (rp, report) = lm.minimize(problem);
606            (
607                rp,
608                report.number_of_evaluations as u64,
609                report.termination.was_successful(),
610                map_termination(&report.termination).as_str().to_string(),
611                Vec::new(),
612                Vec::new(),
613                Vec::new(),
614            )
615        }
616        Solver::Dogleg | Solver::NewtonCg => {
617            // Explicit Δ-radius trust-region methods on the shared framework loop.
618            // `Report`/`Termination` are the same types the faer LM core returns.
619            // Power-user knobs for the TR core: each `Option<f64>` on
620            // `FitOptionsSpec` keeps the library default when `None`, so the wire
621            // surface remains backward-compatible. The TR driver clamps `delta0`
622            // and `max_delta` internally; we just pass through what the caller set.
623            let mut cfg = spectrafit_dogleg::TrustRegionConfig {
624                ftol: tol,
625                xtol: tol,
626                gtol: tol,
627                max_nfev,
628                ..Default::default()
629            };
630            if let Some(d0) = options.delta0 {
631                cfg.delta0 = d0;
632            }
633            if let Some(md) = options.max_delta {
634                cfg.max_delta = md;
635            }
636            if let Some(e) = options.eta {
637                // `TrustRegionConfig::eta` is only `debug_assert!`-checked by
638                // the driver, which compiles out in release builds — this is
639                // the check that actually enforces the bound for callers that
640                // reach the Rust core directly (the JSON `fit` entrypoint),
641                // not just the Pydantic `FitOptions` validator.
642                if !(e.is_finite() && (0.0..0.25).contains(&e)) {
643                    return Err(SolverError::InvalidEta(e).into());
644                }
645                cfg.eta = e;
646            }
647            let report = if solver == Solver::Dogleg {
648                spectrafit_dogleg::minimize(&mut problem, &cfg)
649            } else {
650                spectrafit_newton_cg::minimize(&mut problem, &cfg)
651            };
652            let msg = faer_termination_str(report.termination).to_string();
653            (
654                problem,
655                report.n_iter as u64,
656                report.termination.was_successful(),
657                msg,
658                report.cost_history,
659                report.gradient_norm_history,
660                report.params_history,
661            )
662        }
663        // LM family: lm / trf / geodesic / auto on the faer LM core. Regime-adaptive
664        // (normal equations for tall-skinny, SVD/secular for many-parameter), with
665        // Moré column scaling applied inside the driver.
666        _ => {
667            let cfg = spectrafit_levenberg_marquardt::StrategyConfig {
668                kind: spectrafit_levenberg_marquardt::select_regime(y_all_len, free_keys_len),
669                ftol: tol,
670                xtol: tol,
671                gtol: tol,
672                max_nfev,
673                // `solver="geodesic"` (Transtrum acceleration) — faster on sloppy /
674                // degenerate multi-peak surfaces; otherwise plain faer LM.
675                geodesic: solver == Solver::Geodesic,
676                // `solver="trf"` (Trust-Region-Reflective) — Coleman–Li bound scaling
677                // so steps shrink near active bounds. `solver="auto"` that did not
678                // route to VarPro also lands here as TRF: across a representative
679                // case per scenario family, TRF is the fastest LM-family strategy at
680                // top accuracy in most problem classes. The TRF-over-VarPro speed
681                // result may invert for very large separable problems, which is why
682                // VarPro routing is retained for the fully-unconstrained separable
683                // case.
684                bound_scaling: solver == Solver::Trf || solver == Solver::Auto,
685                ..Default::default()
686            };
687            let report = spectrafit_levenberg_marquardt::minimize(&mut problem, &cfg);
688            let msg = faer_termination_str(report.termination).to_string();
689            (
690                problem,
691                report.n_iter as u64,
692                report.termination.was_successful(),
693                msg,
694                report.cost_history,
695                report.gradient_norm_history,
696                report.params_history,
697            )
698        }
699    };
700    let solve_ns = _solve_t0.elapsed().as_nanos();
701
702    Ok(LmSolveOutcome {
703        result_problem,
704        n_iter_val,
705        success_val,
706        message_val,
707        solve_ns,
708        cost_history,
709        gradient_norm_history,
710        params_history,
711    })
712}
713
714/// `(all_params, init_vals, bounds, scales)` — see [`collect_free_param_specs`].
715type FreeParamSpecs = (HashMap<String, f64>, Vec<f64>, Vec<(f64, f64)>, Vec<f64>);
716
717/// `(node_param_bufs, free_to_node_param, tied_to_node_param)` — see
718/// [`build_node_param_buffers`].
719type NodeParamBuffers = (Vec<Vec<f64>>, Vec<(usize, usize)>, Vec<(usize, usize)>);
720
721/// Build the flat all-parameters map (free + fixed) plus per-free-parameter
722/// initial values, bounds, and scale factors.
723///
724/// Returns `(all_params, init_vals, bounds, scales)`, all indexed in `free_keys`
725/// order for the latter three. `scales[i]` defaults to 1.0 (identity — a
726/// non-finite or non-positive `Parameter.scale` is meaningless for
727/// normalization and falls back to the identity too).
728fn collect_free_param_specs(
729    graph: &FitGraphSpec,
730    free_keys: &[String],
731) -> Result<FreeParamSpecs, CoreError> {
732    let mut all_params: HashMap<String, f64> = HashMap::new();
733    for node in &graph.nodes {
734        for (pname, pspec) in &node.parameters {
735            let key = format!("{}.{}", node.id, pname);
736            all_params.insert(key, pspec.value);
737        }
738    }
739
740    let mut init_vals: Vec<f64> = Vec::with_capacity(free_keys.len());
741    let mut bounds: Vec<(f64, f64)> = Vec::with_capacity(free_keys.len());
742    let mut scales: Vec<f64> = Vec::with_capacity(free_keys.len());
743
744    for key in free_keys {
745        // key = "node_id.param_name"
746        let (node_id, param_name) = key
747            .split_once('.')
748            .ok_or_else(|| SolverError::Dispatch(format!("malformed free key: '{}'", key)))?;
749
750        let node = graph
751            .nodes
752            .iter()
753            .find(|n| n.id == node_id)
754            .ok_or_else(|| SolverError::Dispatch(format!("node '{}' not found", node_id)))?;
755
756        let pspec = node.parameters.get(param_name).ok_or_else(|| {
757            SolverError::Dispatch(format!(
758                "param '{}' not found in node '{}'",
759                param_name, node_id
760            ))
761        })?;
762
763        init_vals.push(pspec.value);
764        bounds.push((pspec.min, pspec.max));
765        // A non-finite or non-positive scale is meaningless for normalization;
766        // fall back to the identity (1.0) so it is a no-op.
767        let s = pspec.scale.unwrap_or(1.0);
768        scales.push(if s.is_finite() && s > 0.0 { s } else { 1.0 });
769    }
770
771    Ok((all_params, init_vals, bounds, scales))
772}
773
774/// Build index-based node param buffers, the free→node/param index mapping,
775/// and the tied-target→node/param index mapping.
776///
777/// `node_param_bufs[i]` holds `cg.nodes[i]`'s current param values in model
778/// `param_names()` order — fixed params are pre-filled and never changed;
779/// free params are updated in-place by `LmProblem::set_params()`.
780/// `free_to_node_param` inverts `cg.node_free_cols` (built during `compile()`)
781/// so free-key index → `(node_idx, param_pos_in_node)`, and is used here to
782/// apply the initial bounds-clamp to `node_param_bufs`. `tied_to_node_param`
783/// maps each tied target (`tied_plan.order`) to its `(node_idx, param_pos)`
784/// slot so the solver can write each recomputed tied value back — empty when
785/// the graph declares no ties (`expr_edges` or `Parameter.expr`).
786fn build_node_param_buffers(
787    cg: &CompiledGraph,
788    all_params: &HashMap<String, f64>,
789    bounds: &[(f64, f64)],
790    init_vals_clone: &[f64],
791) -> Result<NodeParamBuffers, CoreError> {
792    let mut node_param_bufs: Vec<Vec<f64>> = (0..cg.nodes.len())
793        .map(|i| {
794            cg.node_params(i, all_params)
795                .unwrap_or_else(|_| vec![0.0; cg.nodes[i].param_names.len()])
796        })
797        .collect();
798
799    let free_to_node_param: Vec<(usize, usize)> = {
800        // Invert node_free_cols: for each free_key index (col), find the
801        // (node_idx, local_param_idx) pair.
802        let mut mapping = vec![(0usize, 0usize); cg.free_keys.len()];
803        for (node_idx, pairs) in cg.node_free_cols.iter().enumerate() {
804            for &(local_idx, col) in pairs {
805                mapping[col] = (node_idx, local_idx);
806            }
807        }
808        mapping
809    };
810
811    // Apply initial bounds clamping to node_param_bufs for free params.
812    for (i, &(node_idx, param_pos)) in free_to_node_param.iter().enumerate() {
813        let (lo, hi) = bounds[i];
814        node_param_bufs[node_idx][param_pos] = init_vals_clone[i].clamp(lo, hi);
815    }
816
817    let tied_to_node_param: Vec<(usize, usize)> = cg
818        .tied_plan
819        .order
820        .iter()
821        .map(|tp| {
822            let (nid, pname) = tp
823                .target
824                .split_once('.')
825                .ok_or_else(|| SolverError::MalformedTiedTarget(tp.target.clone()))?;
826            let ni = cg
827                .nodes
828                .iter()
829                .position(|n| n.id == nid)
830                .ok_or_else(|| SolverError::TiedTargetNodeMissing(nid.to_string()))?;
831            let pos = cg.nodes[ni]
832                .param_names
833                .iter()
834                .position(|p| p == pname)
835                .ok_or_else(|| SolverError::TiedTargetParamMissing(pname.to_string()))?;
836            Ok::<(usize, usize), CoreError>((ni, pos))
837        })
838        .collect::<Result<_, _>>()?;
839
840    Ok((node_param_bufs, free_to_node_param, tied_to_node_param))
841}
842
843/// Run the VarPro path and apply the shared post-fit success guards.
844///
845/// Both VarPro entry points — the explicit `Solver::Varpro` arm and the
846/// `Solver::Auto` arm when `graph_prefers_varpro` — must route through here.
847/// They are separate match arms because they have different *preconditions*
848/// (explicit VarPro rejects non-separable/tied/dataset-scoped graphs with a
849/// specific error; automatic routing only reaches VarPro when the graph already
850/// qualifies), but they must share the same *postcondition*. Keeping the guard
851/// in one function is what makes "the two solver strings agree on `success`"
852/// structural rather than a thing to remember.
853fn run_varpro_guarded(
854    graph: &FitGraphSpec,
855    datasets: &[MeasurementSpec],
856    options: &FitOptionsSpec,
857) -> Result<FitResultSpec, CoreError> {
858    let result = solve_varpro_path(graph, datasets, options)?;
859    Ok(finalize_varpro_result(graph, datasets, result))
860}
861
862/// Apply the shared post-fit success guards (off-domain runaway + degenerate
863/// peak-collapse) to a VarPro result.
864///
865/// VarPro builds its own [`FitResultSpec`] outside `postfit::assemble_result`
866/// (it has no `LmProblem`/covariance path to hang off), so this re-derives the
867/// inputs `apply_postfit_guards` needs — free-key list, flat parameter map,
868/// concatenated x/y — from the already-assembled `result` and applies the same
869/// guard the LM-family path gets for free. Without this a degenerate VarPro
870/// fit would report false success.
871fn finalize_varpro_result(
872    graph: &FitGraphSpec,
873    datasets: &[MeasurementSpec],
874    mut result: FitResultSpec,
875) -> FitResultSpec {
876    let x_all: Vec<f64> = datasets.iter().flat_map(point_major_x).collect();
877    let y_all: Vec<f64> = datasets
878        .iter()
879        .flat_map(|ds| ds.y.iter().copied())
880        .collect();
881    let final_flat: HashMap<String, f64> = result
882        .parameters
883        .iter()
884        .map(|(k, p)| (k.clone(), p.value))
885        .collect();
886    let vp_free_keys: Vec<String> = result
887        .parameters
888        .iter()
889        .filter(|(_, p)| p.vary)
890        .map(|(k, _)| k.clone())
891        .collect();
892    let (success, message) = postfit::apply_postfit_guards(
893        graph,
894        &vp_free_keys,
895        &final_flat,
896        &x_all,
897        &y_all,
898        result.r_squared,
899        vp_free_keys.len(),
900        result.success,
901        result.message.clone(),
902    );
903    result.success = success;
904    result.message = message;
905    result
906}
907
908/// Stable snake_case message for a faer-native [`spectrafit_levenberg_marquardt::Termination`].
909fn faer_termination_str(t: spectrafit_levenberg_marquardt::Termination) -> &'static str {
910    use spectrafit_levenberg_marquardt::Termination as T;
911    match t {
912        T::Gtol => "converged_gtol",
913        T::Ftol => "converged_ftol",
914        T::Xtol => "converged_xtol",
915        T::ResidualsZero => "residuals_zero",
916        T::MaxEval => "max_iterations",
917        T::NoImprovement => "no_improvement_possible",
918        T::NumericalError => "numerical_error",
919    }
920}
921
922/// Map the `levenberg_marquardt` crate's `TerminationReason` onto our own
923/// stable enum so callers get a deterministic snake_case string, not a
924/// debug-format blob that changes with crate versions.
925fn map_termination(lm: &levenberg_marquardt::TerminationReason) -> TerminationReason {
926    use levenberg_marquardt::TerminationReason as L;
927    match lm {
928        L::ResidualsZero => TerminationReason::ResidualsZero,
929        L::Orthogonal => TerminationReason::Orthogonal,
930        L::Converged { .. } => TerminationReason::Converged,
931        L::LostPatience => TerminationReason::MaxIterations,
932        L::NoImprovementPossible(_) => TerminationReason::NoImprovementPossible,
933        L::NoParameters => TerminationReason::NoParameters,
934        L::NoResiduals => TerminationReason::NoResiduals,
935        L::WrongDimensions(_) => TerminationReason::WrongDimensions,
936        L::Numerical(_) => TerminationReason::NumericalError,
937        L::User(_) => TerminationReason::UserCancelled,
938    }
939}
940
941// ---------------------------------------------------------------------------
942// Tests
943// ---------------------------------------------------------------------------
944#[cfg(test)]
945mod tests {
946    use super::*;
947    use approx::assert_relative_eq;
948    use spectrafit_types::{
949        FitGraphSpec, FitOptionsSpec, MeasurementSpec, ModelNodeSpec, ModelTypeStr, ParameterSpec,
950    };
951    use std::collections::HashMap;
952
953    // ── helpers ──────────────────────────────────────────────────────────────
954
955    fn make_param(value: f64, vary: bool) -> ParameterSpec {
956        ParameterSpec {
957            value,
958            min: f64::NEG_INFINITY,
959            max: f64::INFINITY,
960            vary,
961            expr: None,
962            scale: None,
963        }
964    }
965
966    fn default_options() -> FitOptionsSpec {
967        FitOptionsSpec {
968            schema_version: None,
969            solver: "lm".to_string(),
970            max_iterations: 200,
971            tolerance: 1e-8,
972            delta0: None,
973            max_delta: None,
974            eta: None,
975        }
976    }
977
978    /// Analytical Gaussian: A · exp(−(x−c)² / (2σ²))
979    fn gaussian(x: f64, amplitude: f64, center: f64, sigma: f64) -> f64 {
980        amplitude * (-(x - center).powi(2) / (2.0 * sigma * sigma)).exp()
981    }
982
983    // ── Test 1: Gaussian parameter recovery ──────────────────────────────────
984
985    #[test]
986    fn test_gaussian_recovery() {
987        // True parameters
988        let (true_a, true_c, true_s) = (5.0_f64, 2.0_f64, 0.5_f64);
989
990        // x grid: 50 points in [−1, 5]
991        let n = 50usize;
992        let x: Vec<f64> = (0..n)
993            .map(|i| -1.0 + 6.0 * i as f64 / (n - 1) as f64)
994            .collect();
995        let y: Vec<f64> = x
996            .iter()
997            .map(|&xi| gaussian(xi, true_a, true_c, true_s))
998            .collect();
999
1000        // Graph with perturbed initial params
1001        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1002        params.insert("amplitude".into(), make_param(4.0, true));
1003        params.insert("center".into(), make_param(1.8, true));
1004        params.insert("sigma".into(), make_param(0.6, true));
1005
1006        let graph = FitGraphSpec {
1007            schema_version: "0.1".into(),
1008            nodes: vec![ModelNodeSpec {
1009                id: "g1".into(),
1010                model_type: ModelTypeStr::Gaussian,
1011                dataset_index: None,
1012                parameters: params,
1013            }],
1014            expr_edges: vec![],
1015        };
1016
1017        let dataset = MeasurementSpec {
1018            schema_version: None,
1019            x: vec![x],
1020            y,
1021            sigma: None,
1022            label: None,
1023        };
1024
1025        let result = fit(&graph, vec![dataset], &default_options()).expect("fit should not error");
1026
1027        assert!(
1028            result.success,
1029            "LM should converge; message: {}",
1030            result.message
1031        );
1032        assert!(
1033            result.n_iter > 0,
1034            "should have done at least one evaluation"
1035        );
1036        assert!(
1037            result.chi2 < 1e-10,
1038            "chi2 = {} should be near zero",
1039            result.chi2
1040        );
1041
1042        let a = result.parameters["g1.amplitude"].value;
1043        let c = result.parameters["g1.center"].value;
1044        let s = result.parameters["g1.sigma"].value;
1045
1046        assert_relative_eq!(a, true_a, max_relative = 0.01);
1047        assert_relative_eq!(c, true_c, max_relative = 0.01);
1048        assert_relative_eq!(s, true_s, max_relative = 0.01);
1049    }
1050
1051    // ── Test: unrecognised `solver`/weight-fn strings are rejected, not
1052    //    silently defaulted ────────────────────────────────────────────────
1053    //
1054    // Before this fix, `Solver::parse` silently mapped ANY unrecognised
1055    // string to `Solver::Lm` (and `WeightFn::from_str` silently mapped any
1056    // unrecognised name to Huber) — a typo like `solver="lmm"` ran a fit
1057    // with the LM solver instead of erroring, which could hide a
1058    // misconfigured request behind a plausible-looking successful result.
1059
1060    #[test]
1061    fn fit_rejects_unrecognised_solver_string_instead_of_defaulting_to_lm() {
1062        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1063        params.insert("amplitude".into(), make_param(4.0, true));
1064        params.insert("center".into(), make_param(1.8, true));
1065        params.insert("sigma".into(), make_param(0.6, true));
1066        let graph = FitGraphSpec {
1067            schema_version: "0.1".into(),
1068            nodes: vec![ModelNodeSpec {
1069                id: "g1".into(),
1070                model_type: ModelTypeStr::Gaussian,
1071                dataset_index: None,
1072                parameters: params,
1073            }],
1074            expr_edges: vec![],
1075        };
1076        let dataset = MeasurementSpec {
1077            schema_version: None,
1078            x: vec![vec![0.0, 1.0, 2.0]],
1079            y: vec![1.0, 2.0, 1.0],
1080            sigma: None,
1081            label: None,
1082        };
1083        let opts = FitOptionsSpec {
1084            solver: "lmm".to_string(), // typo for "lm"
1085            ..default_options()
1086        };
1087
1088        let err = fit(&graph, vec![dataset], &opts)
1089            .expect_err("a typo'd solver name must error, not silently run LM");
1090        let msg = err.to_string();
1091        assert!(
1092            msg.contains("lmm"),
1093            "error should name the offending string, got: {msg}"
1094        );
1095        assert!(
1096            msg.contains("expected one of"),
1097            "error should list valid solver names, got: {msg}"
1098        );
1099    }
1100
1101    // ── Test: `eta >= 0.25` is rejected on the JSON/Rust path, not just by
1102    //    the Pydantic `Field(ge=0.0, lt=0.25)` validator ─────────────────────
1103    //
1104    // Before this fix, `FitOptionsSpec.eta` was only bounds-checked in
1105    // `python/spectrafit_core/options.py`; a caller reaching the Rust core
1106    // directly (the JSON `fit` entrypoint, or any non-Python consumer) could
1107    // set `eta >= 0.25`, which the trust-region driver only caught with a
1108    // `debug_assert!` that compiles out of release builds — so a release
1109    // build silently ran with the invalid ratio.
1110
1111    #[test]
1112    fn fit_rejects_eta_at_or_above_a_quarter_on_dogleg() {
1113        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1114        params.insert("amplitude".into(), make_param(4.0, true));
1115        params.insert("center".into(), make_param(1.8, true));
1116        params.insert("sigma".into(), make_param(0.6, true));
1117        let graph = FitGraphSpec {
1118            schema_version: "0.1".into(),
1119            nodes: vec![ModelNodeSpec {
1120                id: "g1".into(),
1121                model_type: ModelTypeStr::Gaussian,
1122                dataset_index: None,
1123                parameters: params,
1124            }],
1125            expr_edges: vec![],
1126        };
1127        let dataset = MeasurementSpec {
1128            schema_version: None,
1129            x: vec![vec![0.0, 1.0, 2.0]],
1130            y: vec![1.0, 2.0, 1.0],
1131            sigma: None,
1132            label: None,
1133        };
1134        let opts = FitOptionsSpec {
1135            solver: "dogleg".to_string(),
1136            eta: Some(0.5),
1137            ..default_options()
1138        };
1139
1140        let err = fit(&graph, vec![dataset], &opts)
1141            .expect_err("eta >= 0.25 must error, not silently run with an invalid ratio");
1142        let msg = err.to_string();
1143        assert!(
1144            msg.contains("eta"),
1145            "error should name the offending option, got: {msg}"
1146        );
1147    }
1148
1149    #[test]
1150    fn fit_rejects_unrecognised_irls_weight_fn_instead_of_defaulting_to_huber() {
1151        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1152        params.insert("amplitude".into(), make_param(4.0, true));
1153        params.insert("center".into(), make_param(1.8, true));
1154        params.insert("sigma".into(), make_param(0.6, true));
1155        let graph = FitGraphSpec {
1156            schema_version: "0.1".into(),
1157            nodes: vec![ModelNodeSpec {
1158                id: "g1".into(),
1159                model_type: ModelTypeStr::Gaussian,
1160                dataset_index: None,
1161                parameters: params,
1162            }],
1163            expr_edges: vec![],
1164        };
1165        let dataset = MeasurementSpec {
1166            schema_version: None,
1167            x: vec![vec![0.0, 1.0, 2.0]],
1168            y: vec![1.0, 2.0, 1.0],
1169            sigma: None,
1170            label: None,
1171        };
1172        let opts = FitOptionsSpec {
1173            solver: "irls:buisquare".to_string(), // typo for "bisquare"
1174            ..default_options()
1175        };
1176
1177        let err = fit(&graph, vec![dataset], &opts)
1178            .expect_err("a typo'd irls weight-fn name must error, not silently run Huber");
1179        let msg = err.to_string();
1180        assert!(
1181            msg.contains("buisquare"),
1182            "error should name the offending string, got: {msg}"
1183        );
1184    }
1185
1186    // ── Test 2: Constant model trivial fit ────────────────────────────────────
1187
1188    #[test]
1189    fn test_constant_recovery() {
1190        let n = 20usize;
1191        let x: Vec<f64> = (0..n).map(|i| i as f64 / (n - 1) as f64).collect();
1192        // Tiny deterministic "noise" to avoid a perfectly flat problem
1193        let y: Vec<f64> = x
1194            .iter()
1195            .enumerate()
1196            .map(|(i, _)| 3.0 + 1e-6 * (i as f64 * 0.1).sin())
1197            .collect();
1198
1199        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1200        params.insert("c".into(), make_param(0.0, true)); // start far from 3
1201
1202        let graph = FitGraphSpec {
1203            schema_version: "0.1".into(),
1204            nodes: vec![ModelNodeSpec {
1205                id: "const1".into(),
1206                model_type: ModelTypeStr::Constant,
1207                dataset_index: None,
1208                parameters: params,
1209            }],
1210            expr_edges: vec![],
1211        };
1212
1213        let dataset = MeasurementSpec {
1214            schema_version: None,
1215            x: vec![x],
1216            y,
1217            sigma: None,
1218            label: None,
1219        };
1220
1221        let result = fit(&graph, vec![dataset], &default_options()).expect("fit should not error");
1222
1223        assert!(
1224            result.success,
1225            "LM should converge; message: {}",
1226            result.message
1227        );
1228
1229        let c_val = result.parameters["const1.c"].value;
1230        assert!(
1231            (c_val - 3.0).abs() < 1e-4,
1232            "recovered constant = {}, expected ≈ 3.0",
1233            c_val
1234        );
1235    }
1236
1237    // ── SP-2: N-D (gaussian_nd) end-to-end fits ───────────────────────────────
1238    //
1239    // The compiler infers D from the node's `center_<i>` parameters and builds a
1240    // `GaussianND` of that dimensionality; the N-D-general executor strides the
1241    // coordinate buffer at stride D and the LM solver recovers every parameter.
1242    // Both 3-D and 5-D run, so "arbitrary N" is exercised, not extrapolated.
1243    fn run_gaussian_nd_recovery(d: usize) {
1244        let amp = 3.0_f64;
1245        let centers: Vec<f64> = (0..d).map(|i| -1.0 + 0.5 * i as f64).collect();
1246        let sigmas: Vec<f64> = (0..d).map(|i| 0.8 + 0.1 * i as f64).collect();
1247
1248        // Keep the grid tiny so d=5 stays cheap (5^5 = 3125 points).
1249        let axis_n = if d <= 3 { 7 } else { 5 };
1250        let axis: Vec<f64> = (0..axis_n)
1251            .map(|i| -3.0 + 6.0 * i as f64 / (axis_n - 1) as f64)
1252            .collect();
1253        // Cartesian product of the axis over d dimensions → point coordinates.
1254        let mut coords: Vec<Vec<f64>> = vec![vec![]];
1255        for _dim in 0..d {
1256            let mut next = Vec::with_capacity(coords.len() * axis.len());
1257            for prefix in &coords {
1258                for &a in &axis {
1259                    let mut p = prefix.clone();
1260                    p.push(a);
1261                    next.push(p);
1262                }
1263            }
1264            coords = next;
1265        }
1266        let g = |pt: &[f64]| -> f64 {
1267            let mut z = 0.0;
1268            for i in 0..d {
1269                let dx = pt[i] - centers[i];
1270                z -= dx * dx / (2.0 * sigmas[i] * sigmas[i]);
1271            }
1272            amp * z.exp()
1273        };
1274        let y: Vec<f64> = coords.iter().map(|pt| g(pt)).collect();
1275        let n_points = coords.len();
1276        // Dimension-major x: x[dim] is the coordinate array for axis `dim`.
1277        let x: Vec<Vec<f64>> = (0..d)
1278            .map(|dim| (0..n_points).map(|p| coords[p][dim]).collect())
1279            .collect();
1280
1281        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1282        params.insert("amplitude".into(), make_param(2.0, true));
1283        for (i, &center) in centers.iter().enumerate() {
1284            params.insert(format!("center_{i}"), make_param(center + 0.3, true));
1285            params.insert(format!("sigma_{i}"), make_param(1.0, true));
1286        }
1287        let graph = FitGraphSpec {
1288            schema_version: "0.1".into(),
1289            nodes: vec![ModelNodeSpec {
1290                id: "gnd".into(),
1291                model_type: ModelTypeStr::GaussianNd,
1292                dataset_index: None,
1293                parameters: params,
1294            }],
1295            expr_edges: vec![],
1296        };
1297        let dataset = MeasurementSpec {
1298            schema_version: None,
1299            x,
1300            y,
1301            sigma: None,
1302            label: None,
1303        };
1304        let result =
1305            fit(&graph, vec![dataset], &default_options()).expect("N-D fit should not error");
1306        assert!(
1307            result.success,
1308            "LM should converge on {d}-D; msg: {}",
1309            result.message
1310        );
1311        let p = &result.parameters;
1312        assert_relative_eq!(p["gnd.amplitude"].value, amp, max_relative = 0.02);
1313        for i in 0..d {
1314            assert_relative_eq!(
1315                p[&format!("gnd.center_{i}")].value,
1316                centers[i],
1317                epsilon = 0.05
1318            );
1319            assert_relative_eq!(
1320                p[&format!("gnd.sigma_{i}")].value,
1321                sigmas[i],
1322                epsilon = 0.05
1323            );
1324        }
1325    }
1326
1327    #[test]
1328    fn gaussian_nd_fit_recovers_3d() {
1329        run_gaussian_nd_recovery(3);
1330    }
1331
1332    #[test]
1333    fn gaussian_nd_fit_recovers_5d_arbitrary_n() {
1334        run_gaussian_nd_recovery(5);
1335    }
1336
1337    // ── TDD: tied-parameter (expr_edge) end-to-end fit ────────────────────────
1338    //
1339    // The compiler builds a dependency-ordered `tied_plan` (parse +
1340    // topo-order + cycle-detection). The LM solver loop calls `TiedPlan::apply`
1341    // on every iteration, so tied parameters are recomputed from `expr_edges` and
1342    // `Parameter.expr` at each step. These tests run unconditionally and pass.
1343
1344    /// Build a two-Gaussian graph where `g2.amplitude = k * g1.amplitude`.
1345    ///
1346    /// The tie is expressed through the top-level `expr_edge` list only.
1347    /// `ParameterSpec.expr` is intentionally `None` on the tied amplitude so
1348    /// this helper exercises the legacy `expr_edge` path without triggering the
1349    /// `DuplicateExprTarget` error that T1 introduced when both routes target
1350    /// the same parameter.
1351    fn tied_two_gaussian_graph(k: f64) -> FitGraphSpec {
1352        let mut g1: HashMap<String, ParameterSpec> = HashMap::new();
1353        g1.insert("amplitude".into(), make_param(4.0, true));
1354        g1.insert("center".into(), make_param(-1.0, true));
1355        g1.insert("sigma".into(), make_param(0.5, true));
1356
1357        let mut g2: HashMap<String, ParameterSpec> = HashMap::new();
1358        // `vary = false`, `expr = None` — the tie lives in expr_edges below.
1359        g2.insert("amplitude".into(), make_param(0.0, false));
1360        g2.insert("center".into(), make_param(1.0, true));
1361        g2.insert("sigma".into(), make_param(0.5, true));
1362
1363        FitGraphSpec {
1364            schema_version: "0.1".into(),
1365            nodes: vec![
1366                ModelNodeSpec {
1367                    id: "g1".into(),
1368                    model_type: ModelTypeStr::Gaussian,
1369                    dataset_index: None,
1370                    parameters: g1,
1371                },
1372                ModelNodeSpec {
1373                    id: "g2".into(),
1374                    model_type: ModelTypeStr::Gaussian,
1375                    dataset_index: None,
1376                    parameters: g2,
1377                },
1378            ],
1379            expr_edges: vec![spectrafit_types::ExprEdge {
1380                target_node: "g2".into(),
1381                target_param: "amplitude".into(),
1382                expression: format!("{} * g1.amplitude", k),
1383            }],
1384        }
1385    }
1386
1387    /// A tied fit recovers `g2.amplitude == k * g1.amplitude` (wired in M6).
1388    #[test]
1389    fn test_tied_amplitude_fit_recovers_ratio() {
1390        let k = 0.5_f64;
1391        let (true_a, true_s) = (5.0_f64, 0.5_f64);
1392        let n = 80usize;
1393        let x: Vec<f64> = (0..n)
1394            .map(|i| -3.0 + 6.0 * i as f64 / (n - 1) as f64)
1395            .collect();
1396        let y: Vec<f64> = x
1397            .iter()
1398            .map(|&xi| gaussian(xi, true_a, -1.0, true_s) + gaussian(xi, k * true_a, 1.0, true_s))
1399            .collect();
1400
1401        let graph = tied_two_gaussian_graph(k);
1402        let dataset = MeasurementSpec {
1403            schema_version: None,
1404            x: vec![x],
1405            y,
1406            sigma: None,
1407            label: None,
1408        };
1409        let result = fit(&graph, vec![dataset], &default_options()).unwrap();
1410
1411        assert!(
1412            result.success,
1413            "tied fit should converge: {}",
1414            result.message
1415        );
1416        let a1 = result.parameters["g1.amplitude"].value;
1417        let a2 = result.parameters["g2.amplitude"].value;
1418        // The tied plan enforces the ratio exactly each iteration…
1419        assert_relative_eq!(a2, k * a1, epsilon = 1e-9);
1420        // …and the free amplitude is recovered.
1421        assert_relative_eq!(a1, true_a, max_relative = 1e-3);
1422    }
1423
1424    /// End-to-end proof that a `Parameter.expr`-only tie (no `expr_edge`) is
1425    /// honoured by the solver — the tied parameter tracks its derived value at
1426    /// the converged solution, not its initial placeholder.
1427    ///
1428    /// The graph expresses `g2.amplitude = 0.5 * g1.amplitude` purely through
1429    /// `ParameterSpec.expr`; the `expr_edges` list is intentionally **empty**.
1430    /// T1 folds `Parameter.expr` into the compiled `TiedPlan`, and the solver
1431    /// calls `set_free_and_tied` each iteration, so `g2.amplitude` is recomputed
1432    /// from the current `g1.amplitude` on every step.
1433    #[test]
1434    fn test_param_expr_fit_recovers_derived_value() {
1435        let k = 0.5_f64;
1436        let (true_a, true_s) = (5.0_f64, 0.5_f64);
1437        let n = 80usize;
1438        let x: Vec<f64> = (0..n)
1439            .map(|i| -3.0 + 6.0 * i as f64 / (n - 1) as f64)
1440            .collect();
1441        // Synthesise data from the known-true parameters consistent with the tie.
1442        let y: Vec<f64> = x
1443            .iter()
1444            .map(|&xi| gaussian(xi, true_a, -1.0, true_s) + gaussian(xi, k * true_a, 1.0, true_s))
1445            .collect();
1446
1447        // Build a graph identical to `tied_two_gaussian_graph(k)` **except** the
1448        // `expr_edges` list is empty — the tie lives solely in `Parameter.expr`.
1449        let mut g1: HashMap<String, ParameterSpec> = HashMap::new();
1450        g1.insert("amplitude".into(), make_param(4.0, true));
1451        g1.insert("center".into(), make_param(-1.0, true));
1452        g1.insert("sigma".into(), make_param(0.5, true));
1453
1454        let mut g2: HashMap<String, ParameterSpec> = HashMap::new();
1455        let mut tied_amp = make_param(0.0, false);
1456        tied_amp.expr = Some(format!("{} * g1.amplitude", k));
1457        g2.insert("amplitude".into(), tied_amp);
1458        g2.insert("center".into(), make_param(1.0, true));
1459        g2.insert("sigma".into(), make_param(0.5, true));
1460
1461        let graph = FitGraphSpec {
1462            schema_version: "0.1".into(),
1463            nodes: vec![
1464                ModelNodeSpec {
1465                    id: "g1".into(),
1466                    model_type: ModelTypeStr::Gaussian,
1467                    dataset_index: None,
1468                    parameters: g1,
1469                },
1470                ModelNodeSpec {
1471                    id: "g2".into(),
1472                    model_type: ModelTypeStr::Gaussian,
1473                    dataset_index: None,
1474                    parameters: g2,
1475                },
1476            ],
1477            // Intentionally empty — the tie is expressed only via Parameter.expr.
1478            expr_edges: vec![],
1479        };
1480
1481        let dataset = MeasurementSpec {
1482            schema_version: None,
1483            x: vec![x],
1484            y,
1485            sigma: None,
1486            label: None,
1487        };
1488        let result = fit(&graph, vec![dataset], &default_options()).unwrap();
1489
1490        assert!(
1491            result.success,
1492            "param-expr tied fit should converge: {}",
1493            result.message
1494        );
1495        let a1 = result.parameters["g1.amplitude"].value;
1496        let a2 = result.parameters["g2.amplitude"].value;
1497        // `g2.amplitude` must equal `k * g1.amplitude` at the solution — not
1498        // the initial placeholder (0.0).
1499        assert!(
1500            a2.abs() > 1e-6,
1501            "tied param must not be frozen at placeholder 0.0"
1502        );
1503        assert_relative_eq!(a2, k * a1, epsilon = 1e-9);
1504        // The free amplitude must recover the ground truth.
1505        assert_relative_eq!(a1, true_a, max_relative = 1e-3);
1506    }
1507
1508    /// The tied parameter is excluded from the free set, so the solver reports
1509    /// DOF = n_points − n_free with n_free reduced by the tied count.
1510    #[test]
1511    fn test_tied_fit_reduces_free_param_count() {
1512        let graph = tied_two_gaussian_graph(0.5);
1513        let cg = CompiledGraph::compile(&graph).unwrap();
1514        // 6 params total, 1 tied (g2.amplitude) → 5 free.
1515        assert_eq!(cg.free_keys.len(), 5);
1516        assert_eq!(cg.tied_plan.len(), 1);
1517    }
1518
1519    // ── Helper: build a well-conditioned single-Gaussian fit problem ──────────
1520
1521    /// Returns a graph + dataset that recovers a clean Gaussian. `scale` is a
1522    /// base `Parameter.scale` factor (`None` ⇒ all parameters unset, i.e. 1.0).
1523    ///
1524    /// When `Some(base)`, a deliberately **non-uniform** scale is applied across
1525    /// the three parameters — `amplitude·base`, `center` unscaled, `sigma/base`.
1526    /// This is intentional: a *uniform* column scaling `J → J·(s·I)` multiplies
1527    /// `JᵀJ` by `s²` and leaves `κ(JᵀJ) = σ_max/σ_min` exactly invariant, so it
1528    /// could never change the reported conditioning. Spreading the scale across
1529    /// the columns changes their relative norms, which is what actually reshapes
1530    /// `κ(JᵀJ)` (see `parameter_scale_changes_effective_conditioning`).
1531    fn gaussian_fit_inputs(scale: Option<f64>) -> (FitGraphSpec, MeasurementSpec) {
1532        let (true_a, true_c, true_s) = (5.0_f64, 2.0_f64, 0.5_f64);
1533        let n = 50usize;
1534        let x: Vec<f64> = (0..n)
1535            .map(|i| -1.0 + 6.0 * i as f64 / (n - 1) as f64)
1536            .collect();
1537        let y: Vec<f64> = x
1538            .iter()
1539            .map(|&xi| gaussian(xi, true_a, true_c, true_s))
1540            .collect();
1541
1542        let mk = |value: f64, scale: Option<f64>| ParameterSpec {
1543            value,
1544            min: f64::NEG_INFINITY,
1545            max: f64::INFINITY,
1546            vary: true,
1547            expr: None,
1548            scale,
1549        };
1550        // Non-uniform scale spread (see doc comment) so κ(JᵀJ) genuinely changes.
1551        let (sa, sc, ss) = match scale {
1552            None => (None, None, None),
1553            Some(base) => (Some(base), Some(1.0), Some(1.0 / base)),
1554        };
1555        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1556        params.insert("amplitude".into(), mk(4.0, sa));
1557        params.insert("center".into(), mk(1.8, sc));
1558        params.insert("sigma".into(), mk(0.6, ss));
1559
1560        let graph = FitGraphSpec {
1561            schema_version: "0.1".into(),
1562            nodes: vec![ModelNodeSpec {
1563                id: "g1".into(),
1564                model_type: ModelTypeStr::Gaussian,
1565                dataset_index: None,
1566                parameters: params,
1567            }],
1568            expr_edges: vec![],
1569        };
1570        let dataset = MeasurementSpec {
1571            schema_version: None,
1572            x: vec![x],
1573            y,
1574            sigma: None,
1575            label: None,
1576        };
1577        (graph, dataset)
1578    }
1579
1580    // ── GREEN: condition number is computed end-to-end through the LM path ────
1581
1582    #[test]
1583    fn condition_number_is_some_and_finite_after_gaussian_fit() {
1584        let (graph, dataset) = gaussian_fit_inputs(None);
1585        let result = fit(&graph, vec![dataset], &default_options()).expect("fit should not error");
1586        let cond = result
1587            .condition_number
1588            .expect("well-conditioned Gaussian fit should report a condition number");
1589        assert!(cond.is_finite(), "condition number must be finite: {cond}");
1590        assert!(cond >= 1.0, "condition number must be ≥ 1.0: {cond}");
1591    }
1592
1593    // ── U3: Parameter.scale is wired into the LM step / Jacobian ──────────────
1594
1595    #[test]
1596    fn parameter_scale_changes_effective_conditioning() {
1597        // Same problem, once unscaled and once with a deliberately non-uniform
1598        // `Parameter.scale` spread across the free parameters (see
1599        // `gaussian_fit_inputs`). With scale wired into the LM step and Jacobian,
1600        // the reported condition number must differ — the per-column scaling
1601        // reshapes the columns of J and hence κ(JᵀJ). (A *uniform* scale would be
1602        // κ-invariant, which is why the helper spreads it across columns.)
1603        let (g_unscaled, d_unscaled) = gaussian_fit_inputs(None);
1604        let (g_scaled, d_scaled) = gaussian_fit_inputs(Some(1000.0));
1605
1606        let r_unscaled =
1607            fit(&g_unscaled, vec![d_unscaled], &default_options()).expect("unscaled fit");
1608        let r_scaled = fit(&g_scaled, vec![d_scaled], &default_options()).expect("scaled fit");
1609
1610        let c0 = r_unscaled
1611            .condition_number
1612            .expect("unscaled fit should report κ");
1613        let c1 = r_scaled
1614            .condition_number
1615            .expect("scaled fit should report κ");
1616        assert!(
1617            (c0 - c1).abs() > 1e-6,
1618            "Parameter.scale should change effective conditioning: κ_unscaled={c0}, κ_scaled={c1}"
1619        );
1620    }
1621
1622    #[test]
1623    fn parameter_scale_of_one_is_a_bitwise_no_op() {
1624        // The parity contract: `scale = Some(1.0)` on every free parameter must be
1625        // byte-for-byte identical to `scale = None` (the un-scaled path). Every
1626        // scaling operation reduces to multiply/divide by 1.0, which is exact in
1627        // IEEE-754, so the reported κ — and the recovered parameters — must match
1628        // to the last bit, not merely within a tolerance.
1629        let (g_none, d_none) = gaussian_fit_inputs(None);
1630        let g_one = {
1631            let mut g = g_none.clone();
1632            for p in g.nodes[0].parameters.values_mut() {
1633                p.scale = Some(1.0);
1634            }
1635            g
1636        };
1637
1638        let r_none = fit(&g_none, vec![d_none.clone()], &default_options()).expect("none fit");
1639        let r_one = fit(&g_one, vec![d_none], &default_options()).expect("scale=1 fit");
1640
1641        assert_eq!(
1642            r_none.condition_number, r_one.condition_number,
1643            "scale=Some(1.0) must reproduce the un-scaled κ bit-for-bit"
1644        );
1645        for (key, p_none) in &r_none.parameters {
1646            let p_one = &r_one.parameters[key];
1647            assert_eq!(
1648                p_none.value, p_one.value,
1649                "scale=1 changed converged value of {key}: {} vs {}",
1650                p_none.value, p_one.value
1651            );
1652        }
1653    }
1654
1655    // ── auto-routing: non-VarPro graph → TRF ─────────────────────────────────
1656
1657    #[test]
1658    fn auto_routes_to_trf_for_bounded_graph() {
1659        // A bounded sigma disqualifies the graph from VarPro (graph_prefers_varpro
1660        // requires unconstrained nonlinear params), so `solver="auto"` must fall
1661        // through to TRF. Assert auto's result is identical to an explicit trf fit
1662        // (and that graph_prefers_varpro is indeed false for this graph).
1663        let (true_a, true_c, true_s) = (5.0_f64, 2.0_f64, 0.5_f64);
1664        let n = 60usize;
1665        let x: Vec<f64> = (0..n)
1666            .map(|i| -1.0 + 6.0 * i as f64 / (n - 1) as f64)
1667            .collect();
1668        let y: Vec<f64> = x
1669            .iter()
1670            .map(|&xi| gaussian(xi, true_a, true_c, true_s))
1671            .collect();
1672
1673        let bounded = |value: f64, min: f64, max: f64| ParameterSpec {
1674            value,
1675            min,
1676            max,
1677            vary: true,
1678            expr: None,
1679            scale: None,
1680        };
1681        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1682        params.insert("amplitude".into(), bounded(4.0, 0.0, f64::INFINITY));
1683        params.insert("center".into(), make_param(1.8, true));
1684        params.insert("sigma".into(), bounded(0.6, 1e-6, 10.0)); // finite → not varpro-eligible
1685        let graph = FitGraphSpec {
1686            schema_version: "0.1".into(),
1687            nodes: vec![ModelNodeSpec {
1688                id: "g1".into(),
1689                model_type: ModelTypeStr::Gaussian,
1690                dataset_index: None,
1691                parameters: params,
1692            }],
1693            expr_edges: vec![],
1694        };
1695        assert!(
1696            !graph_prefers_varpro(&graph),
1697            "bounded sigma must disqualify the graph from VarPro"
1698        );
1699        let data = MeasurementSpec {
1700            schema_version: None,
1701            x: vec![x],
1702            y,
1703            sigma: None,
1704            label: None,
1705        };
1706        let opts = |s: &str| FitOptionsSpec {
1707            solver: s.to_string(),
1708            ..default_options()
1709        };
1710        let r_auto = fit(&graph, vec![data.clone()], &opts("auto")).expect("auto fit");
1711        let r_trf = fit(&graph, vec![data], &opts("trf")).expect("trf fit");
1712        // auto routed to TRF → bit-for-bit the same outcome.
1713        assert_eq!(
1714            r_auto.n_iter, r_trf.n_iter,
1715            "auto should match trf iterations"
1716        );
1717        assert_relative_eq!(r_auto.chi2, r_trf.chi2, max_relative = 1e-12);
1718        for key in ["g1.amplitude", "g1.center", "g1.sigma"] {
1719            assert_relative_eq!(
1720                r_auto.parameters[key].value,
1721                r_trf.parameters[key].value,
1722                max_relative = 1e-12
1723            );
1724        }
1725    }
1726
1727    // ── degenerate peak-collapse guard ───────────────────────────────────────
1728
1729    #[test]
1730    fn degenerate_peak_collapse_is_flagged_unsuccessful() {
1731        // Narrow Gaussian (true centre 7) fit from centre=0 on the flat tail:
1732        // local LM stalls and collapses the amplitude to ~0 (R² < 0). The
1733        // degenerate-fit guard must downgrade success to false.
1734        let n = 200usize;
1735        let x: Vec<f64> = (0..n).map(|i| 10.0 * i as f64 / (n - 1) as f64).collect();
1736        let y: Vec<f64> = x.iter().map(|&xi| gaussian(xi, 3.0, 7.0, 0.3)).collect();
1737
1738        let bounded = |value: f64, min: f64, max: f64| ParameterSpec {
1739            value,
1740            min,
1741            max,
1742            vary: true,
1743            expr: None,
1744            scale: None,
1745        };
1746        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1747        params.insert("amplitude".into(), bounded(1.0, 0.0, f64::INFINITY));
1748        params.insert("center".into(), make_param(0.0, true)); // far from 7, flat region
1749        params.insert("sigma".into(), bounded(0.3, 1e-6, f64::INFINITY));
1750        let graph = FitGraphSpec {
1751            schema_version: "0.1".into(),
1752            nodes: vec![ModelNodeSpec {
1753                id: "g1".into(),
1754                model_type: ModelTypeStr::Gaussian,
1755                dataset_index: None,
1756                parameters: params,
1757            }],
1758            expr_edges: vec![],
1759        };
1760        let data = MeasurementSpec {
1761            schema_version: None,
1762            x: vec![x],
1763            y,
1764            sigma: None,
1765            label: None,
1766        };
1767        let r = fit(&graph, vec![data], &default_options()).expect("fit runs");
1768        assert!(
1769            !r.success,
1770            "a collapsed-peak fit (R²<0, amplitude≈0) must be flagged unsuccessful, got success=true r2={}",
1771            r.r_squared
1772        );
1773        assert!(
1774            r.message.contains("degenerate_fit"),
1775            "expected degenerate_fit message, got {:?}",
1776            r.message
1777        );
1778    }
1779
1780    // ── VarPro rejects dataset_index scoping (it is not scope-aware) ──────────
1781
1782    #[test]
1783    fn varpro_rejects_dataset_index_scoped_graph() {
1784        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1785        params.insert("amplitude".into(), make_param(1.0, true));
1786        params.insert("center".into(), make_param(0.0, true));
1787        params.insert("sigma".into(), make_param(1.0, true));
1788        let graph = FitGraphSpec {
1789            schema_version: "0.1".into(),
1790            nodes: vec![ModelNodeSpec {
1791                id: "g1".into(),
1792                model_type: ModelTypeStr::Gaussian,
1793                dataset_index: Some(0), // per-dataset local node
1794                parameters: params,
1795            }],
1796            expr_edges: vec![],
1797        };
1798        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1799        let data = MeasurementSpec {
1800            schema_version: None,
1801            x: vec![x],
1802            y: vec![0.0; 10],
1803            sigma: None,
1804            label: None,
1805        };
1806        let opts = FitOptionsSpec {
1807            solver: "varpro".into(),
1808            ..default_options()
1809        };
1810        let err = fit(&graph, vec![data], &opts);
1811        assert!(
1812            err.is_err(),
1813            "varpro must reject dataset_index-scoped graphs rather than mis-scope them"
1814        );
1815        let msg = format!("{:?}", err.unwrap_err());
1816        assert!(
1817            msg.contains("dataset_index"),
1818            "error should mention dataset_index, got {msg}"
1819        );
1820    }
1821
1822    // ── CX-VPE-01: VarPro routing must honour `Parameter.expr` ties ───────────
1823
1824    /// Two unbounded, separable Gaussians. When `tie` is set, `g2.sigma` is tied
1825    /// to `g1.sigma` via `Parameter.expr` (no `expr_edge`), so the graph is
1826    /// VarPro-eligible on every axis EXCEPT the tie — isolating the tie check.
1827    fn two_gaussian_param_expr_graph(tie: bool) -> FitGraphSpec {
1828        let mut g1: HashMap<String, ParameterSpec> = HashMap::new();
1829        g1.insert("amplitude".into(), make_param(5.0, true));
1830        g1.insert("center".into(), make_param(-1.0, true));
1831        g1.insert("sigma".into(), make_param(0.5, true));
1832
1833        let mut g2: HashMap<String, ParameterSpec> = HashMap::new();
1834        g2.insert("amplitude".into(), make_param(3.0, true));
1835        g2.insert("center".into(), make_param(1.5, true));
1836        // tied => vary=false and value derived from g1.sigma via Parameter.expr.
1837        let mut sig2 = make_param(0.5, !tie);
1838        if tie {
1839            sig2.expr = Some("g1.sigma".into());
1840        }
1841        g2.insert("sigma".into(), sig2);
1842
1843        FitGraphSpec {
1844            schema_version: "0.1".into(),
1845            nodes: vec![
1846                ModelNodeSpec {
1847                    id: "g1".into(),
1848                    model_type: ModelTypeStr::Gaussian,
1849                    dataset_index: None,
1850                    parameters: g1,
1851                },
1852                ModelNodeSpec {
1853                    id: "g2".into(),
1854                    model_type: ModelTypeStr::Gaussian,
1855                    dataset_index: None,
1856                    parameters: g2,
1857                },
1858            ],
1859            expr_edges: vec![],
1860        }
1861    }
1862
1863    #[test]
1864    fn graph_prefers_varpro_false_for_param_expr_tie() {
1865        // CX-VPE-01 regression: an otherwise VarPro-eligible (separable, single
1866        // dataset, all nonlinear params unbounded) graph whose ONLY tie lives in
1867        // `Parameter.expr` must NOT auto-route to VarPro (which would silently drop
1868        // the tie). Before the fix this returned true (only `expr_edges` checked).
1869        let graph = two_gaussian_param_expr_graph(true);
1870        assert!(
1871            !graph_prefers_varpro(&graph),
1872            "a Parameter.expr tie must disqualify the graph from VarPro auto-routing"
1873        );
1874    }
1875
1876    #[test]
1877    fn graph_prefers_varpro_true_for_untied_unbounded_separable() {
1878        // Positive control: the SAME unbounded separable graph WITHOUT a tie stays
1879        // VarPro-eligible — the fix must not over-reject untied graphs.
1880        let graph = two_gaussian_param_expr_graph(false);
1881        assert!(
1882            graph_prefers_varpro(&graph),
1883            "an untied, unbounded, separable graph must remain VarPro-eligible"
1884        );
1885    }
1886
1887    #[test]
1888    fn auto_and_explicit_varpro_agree_on_success() {
1889        // REGRESSION. The `Solver::Auto if graph_prefers_varpro` arm used to call
1890        // `solve_varpro_path` raw while `Solver::Varpro` wrapped it in
1891        // `finalize_varpro_result`. The guards live in that wrapper, so on a
1892        // VarPro-eligible graph the two solver strings could disagree about
1893        // `success` for identical data — with `auto`, the documented default,
1894        // being the one that reported the false success.
1895        //
1896        // The assertion is deliberately on AGREEMENT rather than on a specific
1897        // verdict: whether this particular fit converges is not the point, and
1898        // pinning it would make the test brittle to solver tuning. What must
1899        // hold is that routing does not change the answer.
1900        let graph = two_gaussian_param_expr_graph(false);
1901        assert!(
1902            graph_prefers_varpro(&graph),
1903            "fixture must actually route auto -> varpro, or this proves nothing"
1904        );
1905
1906        let n = 64usize;
1907        let x: Vec<f64> = (0..n)
1908            .map(|i| -3.0 + 6.0 * i as f64 / (n - 1) as f64)
1909            .collect();
1910        // Data two Gaussians cannot represent: a high-frequency oscillation.
1911        // The fit converges but to a poor optimum, which is what puts r² under
1912        // OFF_DOMAIN_R2_FLOOR (0.5) so `apply_postfit_guards` actually engages.
1913        // A clean, well-fit dataset makes this test VACUOUS — the guard's own
1914        // r²-quality escape short-circuits it and both paths trivially agree.
1915        let y: Vec<f64> = x.iter().map(|xi| (20.0 * xi).sin()).collect();
1916        let data = MeasurementSpec {
1917            schema_version: None,
1918            x: vec![x],
1919            y,
1920            sigma: None,
1921            label: None,
1922        };
1923        let opts = |s: &str| FitOptionsSpec {
1924            solver: s.to_string(),
1925            ..default_options()
1926        };
1927
1928        let r_auto = fit(&graph, vec![data.clone()], &opts("auto")).expect("auto fit");
1929        let r_varpro = fit(&graph, vec![data], &opts("varpro")).expect("varpro fit");
1930
1931        assert_eq!(
1932            r_auto.success, r_varpro.success,
1933            "auto and explicit varpro must reach the same success verdict on the \
1934             same graph and data; a difference means one path skipped the \
1935             post-fit guards in `finalize_varpro_result`"
1936        );
1937        assert_relative_eq!(r_auto.chi2, r_varpro.chi2, max_relative = 1e-12);
1938    }
1939
1940    #[test]
1941    fn varpro_explicit_rejects_param_expr_tie() {
1942        // Explicit solver="varpro" on a `Parameter.expr`-tied graph must return the
1943        // tied-params-unsupported error, NOT a silently-wrong success.
1944        let graph = two_gaussian_param_expr_graph(true);
1945        let n = 64usize;
1946        let x: Vec<f64> = (0..n)
1947            .map(|i| -3.0 + 6.0 * i as f64 / (n - 1) as f64)
1948            .collect();
1949        let y: Vec<f64> = x
1950            .iter()
1951            .map(|&xi| gaussian(xi, 5.0, -1.0, 0.5) + gaussian(xi, 3.0, 1.5, 0.5))
1952            .collect();
1953        let data = MeasurementSpec {
1954            schema_version: None,
1955            x: vec![x],
1956            y,
1957            sigma: None,
1958            label: None,
1959        };
1960        let opts = FitOptionsSpec {
1961            solver: "varpro".into(),
1962            ..default_options()
1963        };
1964        let msg = format!(
1965            "{}",
1966            fit(&graph, vec![data], &opts).expect_err(
1967                "solver='varpro' with a Parameter.expr tie must error, not drop the tie"
1968            )
1969        );
1970        assert!(
1971            msg.contains("tied parameters") || msg.contains("Parameter.expr"),
1972            "error should name the tied-params limitation, got: {msg}"
1973        );
1974    }
1975
1976    /// S1 regression: a node with an UNBOUNDED amplitude but a BOUNDED nonlinear
1977    /// param (sigma) must NOT auto-route to VarPro (which ignores bounds). The old
1978    /// positional `.skip(1)` over a HashMap could skip sigma instead of amplitude
1979    /// and wrongly green-light VarPro — a silent bound violation.
1980    #[test]
1981    fn graph_with_unbounded_amplitude_but_bounded_sigma_is_not_varpro() {
1982        let bounded_sigma = ParameterSpec {
1983            value: 0.6,
1984            min: 0.1,
1985            max: 2.0,
1986            vary: true,
1987            expr: None,
1988            scale: None,
1989        };
1990        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
1991        params.insert("amplitude".into(), make_param(4.0, true)); // unbounded linear coeff
1992        params.insert("center".into(), make_param(0.0, true)); // unbounded
1993        params.insert("sigma".into(), bounded_sigma); // BOUNDED nonlinear param
1994        let graph = FitGraphSpec {
1995            schema_version: "0.1".into(),
1996            nodes: vec![ModelNodeSpec {
1997                id: "g1".into(),
1998                model_type: ModelTypeStr::Gaussian,
1999                dataset_index: None,
2000                parameters: params,
2001            }],
2002            expr_edges: vec![],
2003        };
2004        assert!(
2005            !graph_prefers_varpro(&graph),
2006            "a bounded nonlinear param must block VarPro auto-routing (bounds would be ignored)"
2007        );
2008    }
2009
2010    // ------------------------------------------------------------------
2011    // A2 follow-up: typed SolverError variants reach the boundary
2012    // ------------------------------------------------------------------
2013
2014    /// Build a minimal 1-D Gaussian graph + dataset for VarPro-rejection tests.
2015    fn make_varpro_inputs() -> (FitGraphSpec, MeasurementSpec) {
2016        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
2017        params.insert("amplitude".into(), make_param(1.0, true));
2018        params.insert("center".into(), make_param(0.0, true));
2019        params.insert("sigma".into(), make_param(1.0, true));
2020        let graph = FitGraphSpec {
2021            schema_version: "0.1".into(),
2022            nodes: vec![ModelNodeSpec {
2023                id: "g1".into(),
2024                model_type: ModelTypeStr::Gaussian,
2025                dataset_index: None,
2026                parameters: params,
2027            }],
2028            expr_edges: vec![],
2029        };
2030        let x: Vec<f64> = (0..10).map(|i| i as f64 * 0.1).collect();
2031        let y: Vec<f64> = x.iter().map(|&xi| gaussian(xi, 1.0, 0.0, 1.0)).collect();
2032        let dataset = MeasurementSpec {
2033            schema_version: None,
2034            x: vec![x],
2035            y,
2036            sigma: None,
2037            label: None,
2038        };
2039        (graph, dataset)
2040    }
2041
2042    /// VarPro with expr_edges must surface the typed
2043    /// `SolverError::VarproExprEdgesUnsupported` variant via the CoreError
2044    /// boundary conversion.
2045    #[test]
2046    fn varpro_with_expr_edges_emits_solver_error_variant() {
2047        use spectrafit_types::ExprEdge;
2048
2049        let (mut graph, dataset) = make_varpro_inputs();
2050        // Tie one parameter to force the expr_edge check to fail.
2051        graph.nodes[0]
2052            .parameters
2053            .insert("amplitude".to_string(), make_param(1.0, false));
2054        graph.expr_edges.push(ExprEdge {
2055            target_node: "g1".to_string(),
2056            target_param: "amplitude".to_string(),
2057            expression: "2.0".to_string(),
2058        });
2059
2060        let mut options = default_options();
2061        options.solver = "varpro".to_string();
2062        let err = fit(&graph, vec![dataset], &options).unwrap_err();
2063        let expected: CoreError = SolverError::VarproExprEdgesUnsupported.into();
2064        assert_eq!(format!("{err}"), format!("{expected}"));
2065    }
2066
2067    /// VarPro with a `dataset_index`-scoped node must surface the typed
2068    /// `SolverError::VarproDatasetScopingUnsupported` variant.
2069    #[test]
2070    fn varpro_with_dataset_index_emits_solver_error_variant() {
2071        let (mut graph, dataset) = make_varpro_inputs();
2072        graph.nodes[0].dataset_index = Some(0);
2073
2074        let mut options = default_options();
2075        options.solver = "varpro".to_string();
2076        let err = fit(&graph, vec![dataset], &options).unwrap_err();
2077        let expected: CoreError = SolverError::VarproDatasetScopingUnsupported.into();
2078        assert_eq!(format!("{err}"), format!("{expected}"));
2079    }
2080
2081    // ------------------------------------------------------------------
2082    // Coverage-gap follow-up: `Solver::Varpro` on a non-separable graph
2083    // ------------------------------------------------------------------
2084
2085    /// Explicit `solver="varpro"` on a graph that `spectrafit_varpro::is_separable`
2086    /// rejects (e.g. `true_voigt`, which is not in `SEPARABLE_MODEL_TYPES`) must
2087    /// surface `SolverError::VarproNotSeparable`, not silently fall back to
2088    /// another solver or panic.
2089    #[test]
2090    fn fit_rejects_varpro_on_non_separable_graph() {
2091        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
2092        params.insert("amplitude".into(), make_param(1.0, true));
2093        params.insert("center".into(), make_param(0.0, true));
2094        params.insert("sigma".into(), make_param(1.0, true));
2095        params.insert("gamma".into(), make_param(1.0, true));
2096        let graph = FitGraphSpec {
2097            schema_version: "0.1".into(),
2098            nodes: vec![ModelNodeSpec {
2099                id: "v1".into(),
2100                model_type: ModelTypeStr::TrueVoigt,
2101                dataset_index: None,
2102                parameters: params,
2103            }],
2104            expr_edges: vec![],
2105        };
2106        let x: Vec<f64> = (0..10).map(|i| i as f64 * 0.1).collect();
2107        let dataset = MeasurementSpec {
2108            schema_version: None,
2109            x: vec![x],
2110            y: vec![0.0; 10],
2111            sigma: None,
2112            label: None,
2113        };
2114        let opts = FitOptionsSpec {
2115            solver: "varpro".to_string(),
2116            ..default_options()
2117        };
2118        let err = fit(&graph, vec![dataset], &opts).expect_err(
2119            "solver='varpro' on a non-separable graph (true_voigt) must error, not fall back",
2120        );
2121        let expected: CoreError = SolverError::VarproNotSeparable.into();
2122        assert_eq!(format!("{err}"), format!("{expected}"));
2123    }
2124
2125    // ------------------------------------------------------------------
2126    // Coverage-gap follow-up: `Solver::Global`'s max_iterations → DE-generation
2127    // budget mapping in `dispatch_solver`
2128    // ------------------------------------------------------------------
2129
2130    /// A cheap single-parameter constant fit through `solver="global"`, used to
2131    /// exercise `dispatch_solver`'s DE-generation-budget mapping without the
2132    /// cost of a multi-peak DE search.
2133    fn constant_global_fit_inputs() -> (FitGraphSpec, MeasurementSpec) {
2134        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
2135        params.insert(
2136            "c".into(),
2137            ParameterSpec {
2138                value: 0.0,
2139                min: -10.0,
2140                max: 10.0,
2141                vary: true,
2142                expr: None,
2143                scale: None,
2144            },
2145        );
2146        let graph = FitGraphSpec {
2147            schema_version: "0.1".into(),
2148            nodes: vec![ModelNodeSpec {
2149                id: "const1".into(),
2150                model_type: ModelTypeStr::Constant,
2151                dataset_index: None,
2152                parameters: params,
2153            }],
2154            expr_edges: vec![],
2155        };
2156        let n = 10usize;
2157        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
2158        let y = vec![3.0; n];
2159        let dataset = MeasurementSpec {
2160            schema_version: None,
2161            x: vec![x],
2162            y,
2163            sigma: None,
2164            label: None,
2165        };
2166        (graph, dataset)
2167    }
2168
2169    /// `options.max_iterations > 0` must map onto `DeConfig::max_gen`, capping
2170    /// the DE search at that many generations — see the comment on
2171    /// `Solver::Global` in `dispatch_solver`.
2172    #[test]
2173    fn dispatch_global_solver_uses_max_iterations_as_de_generation_budget() {
2174        let (graph, dataset) = constant_global_fit_inputs();
2175        let opts = FitOptionsSpec {
2176            solver: "global".to_string(),
2177            max_iterations: 7,
2178            ..default_options()
2179        };
2180        let result = fit(&graph, vec![dataset], &opts).expect("global fit should not error");
2181        let gens = result
2182            .n_de_generations
2183            .expect("global solver must report n_de_generations");
2184        assert!(
2185            gens <= 7,
2186            "max_iterations=7 must cap the DE generation budget at 7, got {gens}"
2187        );
2188    }
2189
2190    /// `options.max_iterations == 0` must fall back to `DeConfig::default().max_gen`
2191    /// (100) rather than passing a zero-generation budget through to DE.
2192    #[test]
2193    fn dispatch_global_solver_falls_back_to_default_generation_budget_when_max_iterations_is_zero()
2194    {
2195        let (graph, dataset) = constant_global_fit_inputs();
2196        let opts = FitOptionsSpec {
2197            solver: "global".to_string(),
2198            max_iterations: 0,
2199            ..default_options()
2200        };
2201        let result = fit(&graph, vec![dataset], &opts)
2202            .expect("global fit with max_iterations=0 must still run, using the DE default");
2203        let gens = result
2204            .n_de_generations
2205            .expect("global solver must report n_de_generations");
2206        assert!(
2207            gens <= DeConfig::default().max_gen as u64,
2208            "max_iterations=0 must fall back to DeConfig::default().max_gen (100), got {gens}"
2209        );
2210    }
2211
2212    // ------------------------------------------------------------------
2213    // Coverage-gap follow-up: `run_lm_solve`'s tolerance/delta0/max_delta
2214    // fallback and override branches
2215    // ------------------------------------------------------------------
2216
2217    /// `options.tolerance <= 0.0` must fall back to the built-in `1e-8` default
2218    /// for the shared `tol` used by the faer/trust-region drivers (the `else`
2219    /// arm of the top-level `tol` binding), AND must skip `LevenbergMarquardt::with_tol`
2220    /// for the `lm-legacy` oracle (its own, separate `options.tolerance > 0.0`
2221    /// check) rather than calling it with a non-positive tolerance.
2222    #[test]
2223    fn run_lm_solve_falls_back_to_default_tolerance_when_non_positive() {
2224        let (graph, dataset) = gaussian_fit_inputs(None);
2225        let opts = FitOptionsSpec {
2226            solver: "lm-legacy".to_string(),
2227            tolerance: 0.0,
2228            ..default_options()
2229        };
2230        let result = fit(&graph, vec![dataset], &opts)
2231            .expect("lm-legacy fit with tolerance<=0.0 should still run on the 1e-8 default");
2232        assert!(
2233            result.success,
2234            "lm-legacy should converge on a clean Gaussian even via the default tolerance; \
2235             message: {}",
2236            result.message
2237        );
2238    }
2239
2240    /// An explicit `delta0`/`max_delta` must override the trust-region driver's
2241    /// own defaults (the `if let Some(..)` bodies in `run_lm_solve`), not be
2242    /// silently ignored.
2243    #[test]
2244    fn run_lm_solve_applies_explicit_delta0_and_max_delta_overrides() {
2245        let (graph, dataset) = gaussian_fit_inputs(None);
2246        let opts = FitOptionsSpec {
2247            solver: "dogleg".to_string(),
2248            delta0: Some(0.5),
2249            max_delta: Some(50.0),
2250            ..default_options()
2251        };
2252        let result = fit(&graph, vec![dataset], &opts)
2253            .expect("dogleg fit with explicit delta0/max_delta should not error");
2254        assert!(
2255            result.success,
2256            "dogleg should converge on a clean Gaussian with an explicit \
2257             delta0/max_delta override; message: {}",
2258            result.message
2259        );
2260    }
2261
2262    // ------------------------------------------------------------------
2263    // Coverage-gap follow-up: `collect_free_param_specs`'s malformed-lookup
2264    // error path
2265    // ------------------------------------------------------------------
2266
2267    /// A `free_keys` entry naming a parameter the node itself does not carry
2268    /// (e.g. a stale/malformed key) must surface a `SolverError::Dispatch`
2269    /// naming the missing parameter and node, not panic on an unwrap.
2270    #[test]
2271    fn collect_free_param_specs_errors_when_free_key_param_missing_from_node() {
2272        let mut params: HashMap<String, ParameterSpec> = HashMap::new();
2273        params.insert("amplitude".into(), make_param(1.0, true));
2274        let graph = FitGraphSpec {
2275            schema_version: "0.1".into(),
2276            nodes: vec![ModelNodeSpec {
2277                id: "g1".into(),
2278                model_type: ModelTypeStr::Gaussian,
2279                dataset_index: None,
2280                parameters: params,
2281            }],
2282            expr_edges: vec![],
2283        };
2284        // "sigma" is a plausible free key for a Gaussian node, but this node's
2285        // own parameter map only has "amplitude" — collect_free_param_specs must
2286        // report the mismatch rather than panicking on a missing-key unwrap.
2287        let free_keys = vec!["g1.sigma".to_string()];
2288        let err = collect_free_param_specs(&graph, &free_keys)
2289            .expect_err("a free key naming a param absent from its node must error");
2290        let msg = format!("{err}");
2291        assert!(
2292            msg.contains("sigma") && msg.contains("g1"),
2293            "error should name the missing param and its node, got: {msg}"
2294        );
2295    }
2296
2297    // ------------------------------------------------------------------
2298    // Coverage-gap follow-up: termination-reason mapping helpers
2299    // ------------------------------------------------------------------
2300
2301    /// `faer_termination_str` must map every `spectrafit_levenberg_marquardt::Termination`
2302    /// variant to its stable snake_case string — including `NumericalError`,
2303    /// which no end-to-end fit in this suite naturally triggers.
2304    #[test]
2305    fn faer_termination_str_maps_numerical_error() {
2306        assert_eq!(
2307            faer_termination_str(spectrafit_levenberg_marquardt::Termination::NumericalError),
2308            "numerical_error"
2309        );
2310    }
2311
2312    /// `map_termination` must translate every `levenberg_marquardt::TerminationReason`
2313    /// variant (the `lm-legacy` oracle's own type) onto our stable
2314    /// `spectrafit_types::TerminationReason`, independent of any particular fit
2315    /// reaching that termination naturally.
2316    #[test]
2317    fn map_termination_covers_every_lm_legacy_variant() {
2318        use levenberg_marquardt::TerminationReason as L;
2319
2320        let cases: Vec<(L, TerminationReason)> = vec![
2321            (L::ResidualsZero, TerminationReason::ResidualsZero),
2322            (L::Orthogonal, TerminationReason::Orthogonal),
2323            (L::LostPatience, TerminationReason::MaxIterations),
2324            (
2325                L::NoImprovementPossible("test"),
2326                TerminationReason::NoImprovementPossible,
2327            ),
2328            (L::NoParameters, TerminationReason::NoParameters),
2329            (L::NoResiduals, TerminationReason::NoResiduals),
2330            (
2331                L::WrongDimensions("test"),
2332                TerminationReason::WrongDimensions,
2333            ),
2334            (L::Numerical("test"), TerminationReason::NumericalError),
2335            (L::User("test"), TerminationReason::UserCancelled),
2336        ];
2337        for (input, expected) in cases {
2338            let mapped = map_termination(&input);
2339            assert_eq!(
2340                mapped, expected,
2341                "map_termination({input:?}) should map to {expected:?}, got {mapped:?}"
2342            );
2343        }
2344    }
2345}