Skip to main content

spectrafit_varpro/
solver.rs

1//! VarPro solver: runs `SeparableProblemBuilder` + `LevMarSolver` and maps
2//! results back to `FitResultSpec`.
3
4use std::collections::HashMap;
5
6use nalgebra::DVector;
7use spectrafit_graph::{
8    evaluate_compiled, evaluate_components_compiled, jacobian_compiled, CompiledGraph,
9};
10use spectrafit_types::{
11    CoreError, DatasetSliceSpec, FitGraphSpec, FitOptionsSpec, FitResultSpec, MeasurementSpec,
12    ParameterResultSpec, ParameterSpec, TerminationReason,
13};
14use varpro::{problem::SeparableProblemBuilder, solvers::levmar::LevMarSolver};
15
16use crate::model::GraphSeparableModel;
17
18/// Run the varpro solver on one or more datasets sharing the same separable model.
19///
20/// For multi-dataset fits all datasets are concatenated vertically before
21/// building the basis matrix, so nonlinear shape parameters (α) are shared
22/// across all datasets while amplitudes are solved jointly.
23///
24/// # Errors
25/// - [`CoreError::Eval`] if the model construction or solve fails.
26pub fn solve_varpro(
27    graph: &FitGraphSpec,
28    datasets: &[MeasurementSpec],
29    all_params: &HashMap<String, ParameterSpec>,
30    _options: &FitOptionsSpec,
31) -> Result<FitResultSpec, CoreError> {
32    if datasets.is_empty() {
33        return Err(CoreError::Eval("VarPro: no datasets provided".into()));
34    }
35
36    // ── 0. Stack all datasets into a single synthetic MeasurementSpec ─────────
37    // Shared nonlinear params (α) apply to the concatenated problem; amplitudes
38    // (linear coefficients) are solved jointly across the stacked data.
39    let (stacked, x_concat, y_concat) = stack_datasets(datasets);
40
41    // ── 1. Build the separable model ─────────────────────────────────────────
42    let model = build_model(graph, &stacked, all_params)?;
43    let alpha_keys = model.alpha_keys.clone();
44
45    // ── 2+3. Build y/weights, build the fitting problem, and solve ────────────
46    let (alpha, coefficients) =
47        solve_separable_problem(model, &y_concat, stacked.sigma.as_deref())?;
48
49    // ── 4. Reconstruct parameters ─────────────────────────────────────────────
50    let compiled = CompiledGraph::compile(graph).map_err(|e| CoreError::Eval(format!("{e}")))?;
51    let mut parameters =
52        build_parameter_results(&alpha_keys, &alpha, &coefficients, &compiled, all_params);
53
54    // ── 5. Compute best-fit, residuals, statistics ────────────────────────────
55    let stats = compute_fit_statistics(
56        &compiled,
57        &parameters,
58        all_params,
59        &x_concat,
60        &y_concat,
61        &alpha_keys,
62        &coefficients,
63    )?;
64
65    // ── 6. Stderr from analytical Jacobian at solution ────────────────────────
66    // Compute Σ = (JᵀJ)⁻¹ · (χ²/DOF) for all free parameters.
67    // Use the sigma-weighted Jacobian if sigma was provided.
68    fill_parameter_stderr(
69        &mut parameters,
70        &compiled,
71        &stats.flat_params,
72        &x_concat,
73        datasets,
74        stats.chi2,
75        stats.dof,
76    );
77
78    // ── 7. Per-dataset slices (only populated for multi-dataset fits) ─────────
79    let dataset_slices = build_dataset_slices(datasets, &stats.best_fit);
80
81    // The covariance/Jacobian column order is `compiled.free_keys` — the same
82    // order `jacobian_compiled` emits and the same convention the LM/TRF path
83    // reports (see `spectrafit-solver::postfit`). VarPro's own α-then-amplitude
84    // ordering is an *internal* solve basis and must never leak here: reporting
85    // it as the column order is exactly the permutation bug this replaced.
86    let covariance_param_order = compiled.free_keys.clone();
87
88    Ok(FitResultSpec {
89        schema_version: "0.1".into(),
90        parameters,
91        covariance: None,
92        covariance_param_order,
93        chi2: stats.chi2,
94        reduced_chi2: stats.reduced_chi2,
95        r_squared: stats.r_squared,
96        dof: stats.dof,
97        aic: stats.aic,
98        bic: stats.bic,
99        n_iter: 0,
100        n_func_evals: None,
101        n_jac_evals: None,
102        success: true,
103        message: TerminationReason::Converged.as_str().to_string(),
104        best_fit: stats.best_fit,
105        residuals: stats.residuals,
106        init_fit: stats.init_fit,
107        components: stats.components,
108        dataset_slices,
109        // VarPro does not form the full JᵀJ over all parameters, so the
110        // condition number is not computed on this path.
111        condition_number: None,
112        // VarPro is not a DE/global path.
113        n_de_generations: None,
114        // VarPro's separable projection has no per-iteration LM trajectory; the
115        // benchmark layer reconstructs a labelled proxy from initial/final cost.
116        cost_history: Vec::new(),
117        gradient_norm_history: Vec::new(),
118        params_history: Vec::new(),
119    })
120}
121
122// ---------------------------------------------------------------------------
123// solve_varpro helpers (pure structural extraction — see git log for the
124// pre-extraction monolithic body if a behavior question ever arises)
125// ---------------------------------------------------------------------------
126
127/// Stack all datasets into a single synthetic `MeasurementSpec`. Shared
128/// nonlinear params (α) apply to the concatenated problem; amplitudes (linear
129/// coefficients) are solved jointly across the stacked data. Returns the
130/// stacked spec together with the concatenated x/y vectors, both of which are
131/// used directly (not re-derived) by callers.
132fn stack_datasets(datasets: &[MeasurementSpec]) -> (MeasurementSpec, Vec<f64>, Vec<f64>) {
133    let x_concat: Vec<f64> = datasets
134        .iter()
135        .flat_map(|ds| ds.x.first().cloned().unwrap_or_default())
136        .collect();
137    let y_concat: Vec<f64> = datasets
138        .iter()
139        .flat_map(|ds| ds.y.iter().copied())
140        .collect();
141    let sigma_concat: Option<Vec<f64>> = if datasets.iter().any(|ds| ds.sigma.is_some()) {
142        Some(
143            datasets
144                .iter()
145                .flat_map(|ds| {
146                    let n = ds.y.len();
147                    match &ds.sigma {
148                        Some(s) => s.clone(),
149                        None => vec![1.0_f64; n],
150                    }
151                })
152                .collect(),
153        )
154    } else {
155        None
156    };
157    let stacked = MeasurementSpec {
158        schema_version: None,
159        x: vec![x_concat.clone()],
160        y: y_concat.clone(),
161        sigma: sigma_concat,
162        label: None,
163    };
164    (stacked, x_concat, y_concat)
165}
166
167/// Build the `GraphSeparableModel` for the stacked measurement.
168fn build_model(
169    graph: &FitGraphSpec,
170    stacked: &MeasurementSpec,
171    all_params: &HashMap<String, ParameterSpec>,
172) -> Result<GraphSeparableModel, CoreError> {
173    GraphSeparableModel::new(graph, stacked, all_params)
174        .map_err(|e| CoreError::Eval(format!("VarPro model build error: {e}")))
175}
176
177/// Build the observation vector + optional weights, build the VarPro
178/// `SeparableProblem`, and run `LevMarSolver`. Returns the solved nonlinear
179/// parameters (α) and linear coefficients as owned `Vec<f64>` — the VarPro fit
180/// result's `linear_coefficients()` is a view borrowed from the (function-
181/// local) fit result, so it cannot outlive this function; copying to owned
182/// vectors here is required, not a style choice.
183fn solve_separable_problem(
184    model: GraphSeparableModel,
185    y_concat: &[f64],
186    sigma: Option<&[f64]>,
187) -> Result<(Vec<f64>, Vec<f64>), CoreError> {
188    // ── Build y and optional weights ──────────────────────────────────────
189    let y = DVector::from_vec(y_concat.to_vec());
190    let weights_opt = sigma.map(|s| {
191        DVector::from_vec(
192            s.iter()
193                .map(|&si| if si > 0.0 { 1.0 / si } else { 1.0 })
194                .collect(),
195        )
196    });
197
198    // ── Build the fitting problem and solve ────────────────────────────────
199    let fit_result = if let Some(weights) = weights_opt {
200        let problem = SeparableProblemBuilder::new(model)
201            .observations(y)
202            .weights(weights)
203            .build()
204            .map_err(|e| CoreError::Eval(format!("VarPro problem build error: {e}")))?;
205        LevMarSolver::default()
206            .solve(problem)
207            // varpro::solvers::levmar::FitResult does not implement Display; use Debug format.
208            .map_err(|e| CoreError::Eval(format!("VarPro solve failed: {e:?}")))?
209    } else {
210        let problem = SeparableProblemBuilder::new(model)
211            .observations(y)
212            .build()
213            .map_err(|e| CoreError::Eval(format!("VarPro problem build error: {e}")))?;
214        LevMarSolver::default()
215            .solve(problem)
216            // varpro::solvers::levmar::FitResult does not implement Display; use Debug format.
217            .map_err(|e| CoreError::Eval(format!("VarPro solve failed: {e:?}")))?
218    };
219
220    let alpha: Vec<f64> = fit_result.nonlinear_parameters().iter().copied().collect();
221    let coefficients: Vec<f64> = fit_result
222        .linear_coefficients()
223        .ok_or_else(|| CoreError::Eval("VarPro: linear coefficients unavailable".into()))?
224        .iter()
225        .copied()
226        .collect();
227
228    Ok((alpha, coefficients))
229}
230
231/// Reconstruct the `ParameterResultSpec` map from the solved α (nonlinear) and
232/// amplitude (linear) values, filling in any parameter VarPro did not touch
233/// (e.g. fixed/expression-bound parameters) from `all_params`.
234fn build_parameter_results(
235    alpha_keys: &[String],
236    alpha: &[f64],
237    coefficients: &[f64],
238    compiled: &CompiledGraph,
239    all_params: &HashMap<String, ParameterSpec>,
240) -> HashMap<String, ParameterResultSpec> {
241    let mut parameters: HashMap<String, ParameterResultSpec> = HashMap::new();
242
243    for (i, key) in alpha_keys.iter().enumerate() {
244        let init_spec = all_params.get(key).cloned().unwrap_or(ParameterSpec {
245            value: 0.0,
246            min: f64::NEG_INFINITY,
247            max: f64::INFINITY,
248            vary: true,
249            expr: None,
250            scale: None,
251        });
252        parameters.insert(
253            key.clone(),
254            ParameterResultSpec {
255                name: key.clone(),
256                value: alpha[i],
257                min: if init_spec.min.is_infinite() {
258                    None
259                } else {
260                    Some(init_spec.min)
261                },
262                max: if init_spec.max.is_infinite() {
263                    None
264                } else {
265                    Some(init_spec.max)
266                },
267                vary: true,
268                expr: None,
269                scale: init_spec.scale,
270                stderr: None, // filled below from Jacobian
271            },
272        );
273    }
274
275    for (j, node_entry) in compiled.nodes.iter().enumerate() {
276        let amp_key = format!("{}.amplitude", node_entry.id);
277        if let Some(init_spec) = all_params.get(&amp_key) {
278            let value = if j < coefficients.len() {
279                coefficients[j]
280            } else {
281                1.0
282            };
283            parameters.insert(
284                amp_key.clone(),
285                ParameterResultSpec {
286                    name: amp_key,
287                    value,
288                    min: if init_spec.min.is_infinite() {
289                        None
290                    } else {
291                        Some(init_spec.min)
292                    },
293                    max: if init_spec.max.is_infinite() {
294                        None
295                    } else {
296                        Some(init_spec.max)
297                    },
298                    vary: init_spec.vary,
299                    expr: None,
300                    scale: init_spec.scale,
301                    stderr: None, // filled below
302                },
303            );
304        }
305    }
306
307    for (key, spec) in all_params {
308        if !parameters.contains_key(key) {
309            parameters.insert(
310                key.clone(),
311                ParameterResultSpec {
312                    name: key.clone(),
313                    value: spec.value,
314                    min: if spec.min.is_infinite() {
315                        None
316                    } else {
317                        Some(spec.min)
318                    },
319                    max: if spec.max.is_infinite() {
320                        None
321                    } else {
322                        Some(spec.max)
323                    },
324                    vary: spec.vary,
325                    expr: spec.expr.clone(),
326                    scale: spec.scale,
327                    stderr: None,
328                },
329            );
330        }
331    }
332
333    parameters
334}
335
336/// Statistics + evaluated curves produced from the reconstructed parameters,
337/// bundled for return from [`compute_fit_statistics`]. `flat_params` is kept
338/// here (rather than recomputed) because [`fill_parameter_stderr`] needs the
339/// exact same map to evaluate the Jacobian at the solution.
340struct FitStatistics {
341    flat_params: HashMap<String, f64>,
342    best_fit: Vec<f64>,
343    init_fit: Vec<f64>,
344    components: HashMap<String, Vec<f64>>,
345    dof: i64,
346    residuals: Vec<f64>,
347    chi2: f64,
348    reduced_chi2: f64,
349    r_squared: f64,
350    aic: f64,
351    bic: f64,
352}
353
354/// Compute best-fit, residuals, and fit statistics (χ², R², AIC/BIC, …) from
355/// the reconstructed parameters.
356// Allowed: the compiled graph, the reconstructed (post-VarPro) parameters
357// alongside the original spec map, the concatenated data, and the VarPro
358// linear/nonlinear split (alpha_keys/coefficients) are each read
359// independently below to assemble best-fit, init-fit, and the scalar
360// diagnostics; bundling them into a struct would just rename this same
361// parameter list.
362#[allow(clippy::too_many_arguments)]
363fn compute_fit_statistics(
364    compiled: &CompiledGraph,
365    parameters: &HashMap<String, ParameterResultSpec>,
366    all_params: &HashMap<String, ParameterSpec>,
367    x_concat: &[f64],
368    y_concat: &[f64],
369    alpha_keys: &[String],
370    coefficients: &[f64],
371) -> Result<FitStatistics, CoreError> {
372    let flat_params: HashMap<String, f64> = parameters
373        .iter()
374        .map(|(k, v)| (k.clone(), v.value))
375        .collect();
376
377    let best_fit = evaluate_compiled(compiled, &flat_params, x_concat)
378        .map_err(|e| CoreError::Eval(format!("{e}")))?;
379
380    let init_flat: HashMap<String, f64> = all_params
381        .iter()
382        .map(|(k, s)| (k.clone(), s.value))
383        .collect();
384    let init_fit = evaluate_compiled(compiled, &init_flat, x_concat)
385        .unwrap_or_else(|_| vec![0.0; y_concat.len()]);
386
387    // Per-component contributions evaluated at the stacked x
388    let components =
389        evaluate_components_compiled(compiled, &flat_params, x_concat).unwrap_or_default();
390
391    let n_total = y_concat.len();
392    let n_free = alpha_keys.len() + coefficients.len();
393    let dof = (n_total as i64 - n_free as i64).max(1);
394
395    let residuals: Vec<f64> = y_concat
396        .iter()
397        .zip(best_fit.iter())
398        .map(|(yi, fi)| yi - fi)
399        .collect();
400    let chi2: f64 = residuals.iter().map(|r| r * r).sum();
401    let y_mean = y_concat.iter().sum::<f64>() / n_total.max(1) as f64;
402    let ss_tot: f64 = y_concat.iter().map(|yi| (yi - y_mean).powi(2)).sum();
403    let r_squared = if ss_tot > 0.0 {
404        1.0 - chi2 / ss_tot
405    } else {
406        1.0
407    };
408    let reduced_chi2 = chi2 / dof as f64;
409    let aic = chi2 + 2.0 * n_free as f64;
410    let bic = chi2 + n_free as f64 * (n_total as f64).ln();
411
412    Ok(FitStatistics {
413        flat_params,
414        best_fit,
415        init_fit,
416        components,
417        dof,
418        residuals,
419        chi2,
420        reduced_chi2,
421        r_squared,
422        aic,
423        bic,
424    })
425}
426
427/// Fill in `stderr` on `parameters` from Σ = (JᵀJ)⁻¹ · (χ²/DOF), using the
428/// analytical Jacobian at the solution (sigma-weighted when sigma was
429/// supplied). Leaves `stderr: None` wherever the Jacobian or the inversion is
430/// unavailable, mirroring the original best-effort (non-erroring) behavior.
431///
432/// Column indexing is keyed off `compiled.free_keys`, which is the order
433/// `jacobian_compiled` actually emits (node-id sorted, then model-param order
434/// within a node). It is *not* VarPro's internal α-then-linear-coefficient
435/// basis; conflating the two silently permutes the reported uncertainties.
436fn fill_parameter_stderr(
437    parameters: &mut HashMap<String, ParameterResultSpec>,
438    compiled: &CompiledGraph,
439    flat_params: &HashMap<String, f64>,
440    x_concat: &[f64],
441    datasets: &[MeasurementSpec],
442    chi2: f64,
443    dof: i64,
444) {
445    if let Ok(j_final) = jacobian_compiled(compiled, flat_params, x_concat) {
446        let sigma_provided = datasets.iter().any(|ds| ds.sigma.is_some());
447        let cov_opt = if sigma_provided {
448            let sigma_vec: Vec<f64> = datasets
449                .iter()
450                .flat_map(|ds| {
451                    let n = ds.y.len();
452                    match &ds.sigma {
453                        Some(s) => s.clone(),
454                        None => vec![1.0_f64; n],
455                    }
456                })
457                .collect();
458            let mut j_w = j_final.clone();
459            for (i, &s) in sigma_vec.iter().enumerate() {
460                let w = if s > 0.0 { 1.0 / s } else { 1.0 };
461                for col in 0..j_w.ncols() {
462                    j_w[(i, col)] *= w;
463                }
464            }
465            (j_w.transpose() * &j_w).try_inverse()
466        } else {
467            (j_final.transpose() * &j_final)
468                .try_inverse()
469                .map(|inv| inv * (chi2 / dof as f64))
470        };
471
472        if let Some(cov) = cov_opt {
473            // Column `col` of J (and hence row/col `col` of Σ) belongs to
474            // `compiled.free_keys[col]` — assign by name, never positionally
475            // against some other ordering.
476            for (col, key) in compiled.free_keys.iter().enumerate() {
477                if col >= cov.nrows() {
478                    break;
479                }
480                let v = cov[(col, col)];
481                if v >= 0.0 {
482                    if let Some(p) = parameters.get_mut(key) {
483                        p.stderr = Some(v.sqrt());
484                    }
485                }
486            }
487        }
488    }
489}
490
491/// Per-dataset best-fit/residual/χ² slices, populated only for multi-dataset
492/// fits (`None` for a single dataset).
493fn build_dataset_slices(
494    datasets: &[MeasurementSpec],
495    best_fit: &[f64],
496) -> Option<Vec<DatasetSliceSpec>> {
497    if datasets.len() > 1 {
498        let mut offset = 0usize;
499        let slices: Vec<DatasetSliceSpec> = datasets
500            .iter()
501            .map(|ds| {
502                let n = ds.y.len();
503                let bf_slice = best_fit[offset..offset + n].to_vec();
504                let ds_chi2: f64 =
505                    ds.y.iter()
506                        .zip(bf_slice.iter())
507                        .map(|(obs, pred)| (obs - pred).powi(2))
508                        .sum();
509                let res_slice: Vec<f64> =
510                    ds.y.iter()
511                        .zip(bf_slice.iter())
512                        .map(|(obs, pred)| obs - pred)
513                        .collect();
514                offset += n;
515                DatasetSliceSpec {
516                    label: ds.label.clone(),
517                    n_points: n,
518                    best_fit: bf_slice,
519                    residuals: res_slice,
520                    chi2: ds_chi2,
521                }
522            })
523            .collect();
524        Some(slices)
525    } else {
526        None
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Tests
532// ---------------------------------------------------------------------------
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use approx::assert_relative_eq;
538    use spectrafit_types::{
539        FitGraphSpec, FitOptionsSpec, MeasurementSpec, ModelNodeSpec, ModelTypeStr, ParameterSpec,
540    };
541
542    // ── Fixture helpers ───────────────────────────────────────────────────────
543
544    fn free_param(value: f64) -> ParameterSpec {
545        ParameterSpec {
546            value,
547            min: f64::NEG_INFINITY,
548            max: f64::INFINITY,
549            vary: true,
550            expr: None,
551            scale: None,
552        }
553    }
554
555    /// Generate noiseless Gaussian data: y = amp * exp(-(x-center)^2 / (2*sigma^2))
556    fn gaussian_data(x: &[f64], amp: f64, center: f64, sigma: f64) -> Vec<f64> {
557        x.iter()
558            .map(|&xi| amp * (-(xi - center).powi(2) / (2.0 * sigma * sigma)).exp())
559            .collect()
560    }
561
562    // ── R1c: solve_varpro recovers known amplitude + sigma from 1-Gaussian ───
563    //
564    // Truth: amplitude = 3.5, center = 0.0 (fixed), sigma = 1.2.
565    // Initial guess: amplitude = 1.0, sigma = 0.8 (20% off truth).
566    // Tolerance: 1e-3 relative — loose enough for VarPro convergence on a clean
567    // noiseless dataset with a 20%-off starting point, tight enough to be useful.
568    // center is fixed (vary=false) because VarPro only optimises the nonlinear
569    // free parameters (alpha); a known-good center allows amplitude+sigma recovery
570    // to be tested in isolation.
571    #[test]
572    fn solve_varpro_recovers_gaussian_amplitude_and_sigma() {
573        // ── 1. Truth ──────────────────────────────────────────────────────────
574        let true_amp = 3.5_f64;
575        let true_center = 0.0_f64;
576        let true_sigma = 1.2_f64;
577
578        // 100 points from -4 to +4
579        let n = 100usize;
580        let x: Vec<f64> = (0..n)
581            .map(|i| -4.0 + 8.0 * i as f64 / (n - 1) as f64)
582            .collect();
583        let y = gaussian_data(&x, true_amp, true_center, true_sigma);
584
585        // ── 2. Spec: center fixed; amplitude + sigma free ─────────────────────
586        let node_id = "g0";
587        let mut params_map: HashMap<String, ParameterSpec> = HashMap::new();
588        // Amplitude: initial guess 1.0 (will be solved as a linear coefficient)
589        params_map.insert(format!("{node_id}.amplitude"), free_param(1.0));
590        // Center: fixed at truth so only sigma is the nonlinear unknown
591        params_map.insert(
592            format!("{node_id}.center"),
593            ParameterSpec {
594                value: true_center,
595                min: f64::NEG_INFINITY,
596                max: f64::INFINITY,
597                vary: false,
598                expr: None,
599                scale: None,
600            },
601        );
602        // Sigma: initial guess 20% off truth
603        params_map.insert(format!("{node_id}.sigma"), free_param(0.8));
604
605        let mut node_parameters = HashMap::new();
606        node_parameters.insert("amplitude".into(), free_param(1.0));
607        node_parameters.insert(
608            "center".into(),
609            ParameterSpec {
610                value: true_center,
611                min: f64::NEG_INFINITY,
612                max: f64::INFINITY,
613                vary: false,
614                expr: None,
615                scale: None,
616            },
617        );
618        node_parameters.insert("sigma".into(), free_param(0.8));
619
620        let graph = FitGraphSpec {
621            schema_version: "0.1".into(),
622            nodes: vec![ModelNodeSpec {
623                id: node_id.into(),
624                model_type: ModelTypeStr::Gaussian,
625                parameters: node_parameters,
626                dataset_index: None,
627            }],
628            expr_edges: vec![],
629        };
630
631        let dataset = MeasurementSpec {
632            schema_version: None,
633            x: vec![x],
634            y,
635            sigma: None,
636            label: None,
637        };
638
639        let options = FitOptionsSpec::default();
640
641        // ── 3. Solve ──────────────────────────────────────────────────────────
642        let result = solve_varpro(&graph, &[dataset], &params_map, &options)
643            .expect("VarPro must succeed on a clean 1-Gaussian dataset");
644
645        // ── 4. Assert recovered parameters ───────────────────────────────────
646        let amp_key = format!("{node_id}.amplitude");
647        let sigma_key = format!("{node_id}.sigma");
648
649        let recovered_amp = result
650            .parameters
651            .get(&amp_key)
652            .unwrap_or_else(|| panic!("Missing parameter {amp_key}"))
653            .value;
654        let recovered_sigma = result
655            .parameters
656            .get(&sigma_key)
657            .unwrap_or_else(|| panic!("Missing parameter {sigma_key}"))
658            .value;
659
660        // Relative tolerance of 1e-3 (0.1%) — noiseless data, clean Gaussian,
661        // VarPro eliminates the linear dimension so sigma convergence is robust.
662        assert_relative_eq!(
663            recovered_amp,
664            true_amp,
665            max_relative = 1e-3,
666            epsilon = 1e-10
667        );
668        assert_relative_eq!(
669            recovered_sigma,
670            true_sigma,
671            max_relative = 1e-3,
672            epsilon = 1e-10
673        );
674
675        // Also verify R² is near 1.0 (sanity check on best_fit quality)
676        assert!(
677            result.r_squared > 0.9999,
678            "R² should be near 1.0 on a noiseless fit, got {}",
679            result.r_squared
680        );
681    }
682
683    // ── Error path: no datasets ───────────────────────────────────────────────
684
685    #[test]
686    fn solve_varpro_errors_on_empty_datasets() {
687        let graph = FitGraphSpec {
688            schema_version: "0.1".into(),
689            nodes: vec![],
690            expr_edges: vec![],
691        };
692        let options = FitOptionsSpec::default();
693        let result = solve_varpro(&graph, &[], &HashMap::new(), &options);
694        assert!(result.is_err(), "VarPro with no datasets must return Err");
695    }
696
697    // ── Weighted path: dataset with sigma exercises lines 48-97 ──────────────
698
699    #[test]
700    fn solve_varpro_weighted_dataset_sigma_path() {
701        let true_amp = 2.5_f64;
702        let true_center = 0.0_f64;
703        let true_sigma = 1.0_f64;
704        let n = 80usize;
705        let x: Vec<f64> = (0..n)
706            .map(|i| -4.0 + 8.0 * i as f64 / (n - 1) as f64)
707            .collect();
708        let y = gaussian_data(&x, true_amp, true_center, true_sigma);
709        let sigma_vec = vec![0.05_f64; n];
710
711        let node_id = "g0";
712        let mut params_map: HashMap<String, ParameterSpec> = HashMap::new();
713        params_map.insert(format!("{node_id}.amplitude"), free_param(1.0));
714        params_map.insert(
715            format!("{node_id}.center"),
716            ParameterSpec {
717                value: true_center,
718                min: f64::NEG_INFINITY,
719                max: f64::INFINITY,
720                vary: false,
721                expr: None,
722                scale: None,
723            },
724        );
725        params_map.insert(format!("{node_id}.sigma"), free_param(0.8));
726
727        let mut node_parameters = HashMap::new();
728        node_parameters.insert("amplitude".into(), free_param(1.0));
729        node_parameters.insert(
730            "center".into(),
731            ParameterSpec {
732                value: true_center,
733                min: f64::NEG_INFINITY,
734                max: f64::INFINITY,
735                vary: false,
736                expr: None,
737                scale: None,
738            },
739        );
740        node_parameters.insert("sigma".into(), free_param(0.8));
741
742        let graph = FitGraphSpec {
743            schema_version: "0.1".into(),
744            nodes: vec![ModelNodeSpec {
745                id: node_id.into(),
746                model_type: ModelTypeStr::Gaussian,
747                parameters: node_parameters,
748                dataset_index: None,
749            }],
750            expr_edges: vec![],
751        };
752
753        let dataset = MeasurementSpec {
754            schema_version: None,
755            x: vec![x],
756            y,
757            sigma: Some(sigma_vec),
758            label: None,
759        };
760
761        let options = FitOptionsSpec::default();
762        let result = solve_varpro(&graph, &[dataset], &params_map, &options)
763            .expect("weighted VarPro must succeed on clean noiseless data");
764
765        assert_relative_eq!(
766            result.parameters[&format!("{node_id}.amplitude")].value,
767            true_amp,
768            max_relative = 5e-2,
769            epsilon = 1e-10
770        );
771        assert_relative_eq!(
772            result.parameters[&format!("{node_id}.sigma")].value,
773            true_sigma,
774            max_relative = 5e-2,
775            epsilon = 1e-10
776        );
777    }
778
779    // ── Multi-dataset path: exercises dataset_slices population (lines 306-336)
780
781    #[test]
782    fn solve_varpro_multi_dataset_produces_slices() {
783        let true_amp = 2.0_f64;
784        let true_center = 0.0_f64;
785        let true_sigma = 1.0_f64;
786        let n = 40usize;
787        let x: Vec<f64> = (0..n)
788            .map(|i| -3.0 + 6.0 * i as f64 / (n - 1) as f64)
789            .collect();
790        let y = gaussian_data(&x, true_amp, true_center, true_sigma);
791
792        let node_id = "g0";
793        let mut params_map: HashMap<String, ParameterSpec> = HashMap::new();
794        params_map.insert(format!("{node_id}.amplitude"), free_param(1.0));
795        params_map.insert(
796            format!("{node_id}.center"),
797            ParameterSpec {
798                value: true_center,
799                min: f64::NEG_INFINITY,
800                max: f64::INFINITY,
801                vary: false,
802                expr: None,
803                scale: None,
804            },
805        );
806        params_map.insert(format!("{node_id}.sigma"), free_param(0.8));
807
808        let mut node_parameters = HashMap::new();
809        node_parameters.insert("amplitude".into(), free_param(1.0));
810        node_parameters.insert(
811            "center".into(),
812            ParameterSpec {
813                value: true_center,
814                min: f64::NEG_INFINITY,
815                max: f64::INFINITY,
816                vary: false,
817                expr: None,
818                scale: None,
819            },
820        );
821        node_parameters.insert("sigma".into(), free_param(0.8));
822
823        let graph = FitGraphSpec {
824            schema_version: "0.1".into(),
825            nodes: vec![ModelNodeSpec {
826                id: node_id.into(),
827                model_type: ModelTypeStr::Gaussian,
828                parameters: node_parameters,
829                dataset_index: None,
830            }],
831            expr_edges: vec![],
832        };
833
834        let make_ds = |label: &str| MeasurementSpec {
835            schema_version: None,
836            x: vec![x.clone()],
837            y: y.clone(),
838            sigma: None,
839            label: Some(label.into()),
840        };
841
842        let options = FitOptionsSpec::default();
843        let result = solve_varpro(
844            &graph,
845            &[make_ds("ds1"), make_ds("ds2")],
846            &params_map,
847            &options,
848        )
849        .expect("multi-dataset VarPro must succeed");
850
851        let slices = result
852            .dataset_slices
853            .expect("dataset_slices must be Some for a 2-dataset fit");
854        assert_eq!(slices.len(), 2, "expected 2 slices, one per dataset");
855        assert_eq!(slices[0].n_points, n);
856        assert_eq!(slices[1].n_points, n);
857    }
858
859    // ── Regression: stderr must land on the parameter it belongs to ─────────
860    //
861    // VarPro optimises in an internal basis (nonlinear α first, then the linear
862    // amplitude coefficients). The reported covariance, however, comes from
863    // `jacobian_compiled`, whose columns follow `CompiledGraph::free_keys`
864    // (node-id sorted, then model-param order → amplitude, center, sigma).
865    // Writing the covariance diagonal back in α-then-amplitude order permuted
866    // every reported uncertainty; this test pins the correct mapping against a
867    // finite-difference covariance built independently, keyed by name.
868    #[test]
869    fn solve_varpro_stderr_is_not_permuted() {
870        use nalgebra::DMatrix;
871
872        let true_amp = 5.0_f64;
873        let true_center = 2.0_f64;
874        let true_sigma = 0.7_f64;
875        let n = 400usize;
876        let x: Vec<f64> = (0..n).map(|i| 4.0 * i as f64 / (n - 1) as f64).collect();
877
878        // Deterministic pseudo-noise (LCG) — real residuals are required or
879        // χ²/DOF collapses to zero and every stderr degenerates to ~0.
880        let mut seed = 12_345_u64;
881        let y: Vec<f64> = gaussian_data(&x, true_amp, true_center, true_sigma)
882            .into_iter()
883            .map(|yi| {
884                seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
885                let u = (seed >> 11) as f64 / (1_u64 << 53) as f64; // [0,1)
886                yi + 0.08 * (u - 0.5)
887            })
888            .collect();
889
890        let node_id = "g0";
891        let mut node_parameters = HashMap::new();
892        node_parameters.insert("amplitude".to_string(), free_param(1.0));
893        node_parameters.insert("center".to_string(), free_param(1.8));
894        node_parameters.insert("sigma".to_string(), free_param(0.5));
895
896        let params_map: HashMap<String, ParameterSpec> = node_parameters
897            .iter()
898            .map(|(k, v)| (format!("{node_id}.{k}"), v.clone()))
899            .collect();
900
901        let graph = FitGraphSpec {
902            schema_version: "0.1".into(),
903            nodes: vec![ModelNodeSpec {
904                id: node_id.into(),
905                model_type: ModelTypeStr::Gaussian,
906                parameters: node_parameters,
907                dataset_index: None,
908            }],
909            expr_edges: vec![],
910        };
911
912        let dataset = MeasurementSpec {
913            schema_version: None,
914            x: vec![x.clone()],
915            y,
916            sigma: None,
917            label: None,
918        };
919
920        let result = solve_varpro(&graph, &[dataset], &params_map, &FitOptionsSpec::default())
921            .expect("VarPro must succeed on a clean 1-Gaussian dataset");
922
923        let compiled = CompiledGraph::compile(&graph).expect("graph must compile");
924        assert_eq!(
925            result.covariance_param_order, compiled.free_keys,
926            "reported column order must be the Jacobian's own free_keys order"
927        );
928
929        // Independent reference: central-difference Jacobian, columns keyed by
930        // name in `free_keys` order, then Σ = (JᵀJ)⁻¹ · (χ²/DOF).
931        let flat: HashMap<String, f64> = result
932            .parameters
933            .iter()
934            .map(|(k, v)| (k.clone(), v.value))
935            .collect();
936        let keys = &compiled.free_keys;
937        let mut j = DMatrix::<f64>::zeros(n, keys.len());
938        for (col, key) in keys.iter().enumerate() {
939            let v0 = flat[key];
940            let h = 1e-6 * v0.abs().max(1.0);
941            let mut up = flat.clone();
942            up.insert(key.clone(), v0 + h);
943            let mut dn = flat.clone();
944            dn.insert(key.clone(), v0 - h);
945            let fu = evaluate_compiled(&compiled, &up, &x).expect("eval up");
946            let fd = evaluate_compiled(&compiled, &dn, &x).expect("eval down");
947            for row in 0..n {
948                j[(row, col)] = (fu[row] - fd[row]) / (2.0 * h);
949            }
950        }
951        let cov = (j.transpose() * &j)
952            .try_inverse()
953            .expect("JᵀJ must be invertible")
954            * (result.chi2 / result.dof as f64);
955
956        for (col, key) in keys.iter().enumerate() {
957            let expected = cov[(col, col)].sqrt();
958            let got = result.parameters[key]
959                .stderr
960                .unwrap_or_else(|| panic!("{key} must carry a stderr"));
961            assert_relative_eq!(got, expected, max_relative = 1e-4, epsilon = 1e-12);
962        }
963
964        // Guard the specific symptom: amplitude and center had swapped errors,
965        // and on this fixture their magnitudes differ by ~2.6x.
966        let amp_err = result.parameters["g0.amplitude"]
967            .stderr
968            .expect("amp stderr");
969        let ctr_err = result.parameters["g0.center"]
970            .stderr
971            .expect("center stderr");
972        assert!(
973            amp_err > ctr_err,
974            "amplitude stderr ({amp_err}) must exceed center stderr ({ctr_err}) here"
975        );
976    }
977}