1use 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
18pub 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 let (stacked, x_concat, y_concat) = stack_datasets(datasets);
40
41 let model = build_model(graph, &stacked, all_params)?;
43 let alpha_keys = model.alpha_keys.clone();
44
45 let (alpha, coefficients) =
47 solve_separable_problem(model, &y_concat, stacked.sigma.as_deref())?;
48
49 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 let stats = compute_fit_statistics(
56 &compiled,
57 ¶meters,
58 all_params,
59 &x_concat,
60 &y_concat,
61 &alpha_keys,
62 &coefficients,
63 )?;
64
65 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 let dataset_slices = build_dataset_slices(datasets, &stats.best_fit);
80
81 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 condition_number: None,
112 n_de_generations: None,
114 cost_history: Vec::new(),
117 gradient_norm_history: Vec::new(),
118 params_history: Vec::new(),
119 })
120}
121
122fn 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
167fn 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
177fn solve_separable_problem(
184 model: GraphSeparableModel,
185 y_concat: &[f64],
186 sigma: Option<&[f64]>,
187) -> Result<(Vec<f64>, Vec<f64>), CoreError> {
188 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 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 .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 .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
231fn 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, },
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(&_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, },
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
336struct 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#[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 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
427fn 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 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
491fn 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#[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 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 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 #[test]
572 fn solve_varpro_recovers_gaussian_amplitude_and_sigma() {
573 let true_amp = 3.5_f64;
575 let true_center = 0.0_f64;
576 let true_sigma = 1.2_f64;
577
578 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 let node_id = "g0";
587 let mut params_map: HashMap<String, ParameterSpec> = HashMap::new();
588 params_map.insert(format!("{node_id}.amplitude"), free_param(1.0));
590 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 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 let result = solve_varpro(&graph, &[dataset], ¶ms_map, &options)
643 .expect("VarPro must succeed on a clean 1-Gaussian dataset");
644
645 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(&_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 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 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 #[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 #[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], ¶ms_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 #[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 ¶ms_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 #[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 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; 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], ¶ms_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 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 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}