Skip to main content

nereids_pipeline/
calibration.rs

1//! Energy calibration for TOF neutron instruments.
2//!
3//! Finds the flight path length (L) and TOF delay (t₀) that best align
4//! a measured transmission spectrum with the ENDF resonance model.
5//!
6//! The energy-TOF relationship is:
7//!
8//!   E = C · (L / (t − t₀))²
9//!
10//! where C = mₙ / 2 ≈ 5.2276e-9 [eV·s²/m²].
11//!
12//! When L or t₀ differ from the values assumed during data reduction,
13//! resonance positions shift in the energy domain, causing catastrophic
14//! chi² degradation (e.g. 436 → 2.7 for a 0.3% L correction on VENUS).
15
16#[cfg(test)]
17use nereids_core::constants::{EV_TO_JOULES, NEUTRON_MASS_KG};
18use nereids_core::types::IsotopeGroup;
19use nereids_endf::resonance::ResonanceData;
20use nereids_fitting::lm::LmConfig;
21use nereids_fitting::resolution_calib::corrected_energy_grid;
22use nereids_physics::resolution::TOF_FACTOR;
23use nereids_physics::transmission::{self, InstrumentParams, SampleParams};
24
25use crate::error::PipelineError;
26use crate::pipeline::{
27    InputData, SolverConfig, SpectrumFitResult, UnifiedFitConfig, fit_spectrum_typed,
28};
29
30/// Neutron mass constant: C = m_n / (2 · eV) ≈ 5.2276e-9 eV·s²/m².
31///
32/// E [eV] = C · (L [m] / t [s])²
33///
34/// Uses the CODATA 2018 values from `nereids_core::constants` so that
35/// this calibration path, `EnergyScaleTransmissionModel`, and
36/// `core::tof_to_energy` all agree to machine precision.  Production
37/// code now routes the TOF↔energy transform through
38/// `resolution_calib::corrected_energy_grid` (issue #634); this const
39/// remains for the test fixtures that synthesize ground-truth grids.
40#[cfg(test)]
41const NEUTRON_MASS_CONSTANT: f64 = 0.5 * NEUTRON_MASS_KG / EV_TO_JOULES;
42
43/// Lower / upper bounds (log10) on the `n_total` (areal density,
44/// atoms/barn) search interval for `calibrate_energy`.  The search
45/// runs in `log10(n)` so the band is sampled with relative — rather
46/// than absolute — resolution.
47///
48/// The internal search band is `[~5e-6, ~2e-2]` atoms/barn (a third
49/// of a decade beyond each documented edge on either side).  The
50/// boundary-saturation guard (`CALIBRATION_LOG10_BOUNDARY_TOL`,
51/// ≈ 5 % in linear density) trims a sliver off each end, leaving
52/// the *documented* user-supported interval at exactly `[1e-5,
53/// 1e-2]`: the doc-stated edges are inside the tolerance window,
54/// not on it.
55///
56/// `[1e-5, 1e-2]` covers every realistic VENUS / paper-relevant
57/// density: thin diluted samples down to ~1e-5 atoms/barn (trace
58/// detectability ~ Hf in matrix), the Hf calibration foil at
59/// ~1e-4, and 1 mm metal foils (U, W, Ni) up to ~1e-2 atoms/barn.
60/// Sample densities at the exact documented edges (`1e-5` or
61/// `1e-2`) are accepted because the search band extends ~0.3
62/// decades beyond them — without the buffer, a true optimum at
63/// the documented edge would trip the boundary guard with a
64/// "lies outside the band" diagnostic that contradicted the
65/// docstring.
66const CALIBRATION_LOG10_N_LO: f64 = -5.301; // log10(5e-6)
67const CALIBRATION_LOG10_N_HI: f64 = -1.699; // log10(2e-2)
68
69/// Documented lower / upper edges of the user-supported density
70/// interval in `log10(n)` space (`1e-5` and `1e-2` atoms/barn).
71/// Used only by the error message so the diagnostic states the
72/// edges the user expects to see, not the internal buffered band.
73const CALIBRATION_LOG10_N_LO_DOC: f64 = -5.0;
74const CALIBRATION_LOG10_N_HI_DOC: f64 = -2.0;
75
76/// Tolerance (in `log10(n)` space) at which the golden-section
77/// iteration terminates.  `5e-5` ≈ 0.01 % relative resolution on
78/// `n_total`, well below the chi² landscape's per-decade curvature
79/// floor for typical SAMMY-style resonance fits.
80const CALIBRATION_LOG10_N_TOL: f64 = 5e-5;
81
82/// Tolerance (in `log10(n)` space) for the boundary-saturation
83/// guard.  An optimum within `0.02` of either bound — about 5 %
84/// in linear density — almost always means the true minimum lies
85/// outside the supported band and the user should be told rather
86/// than silently handed a railed answer.  The internal search band
87/// is widened so the *documented* edges (`1e-5`, `1e-2`) remain
88/// strictly inside the tolerance window even after this margin is
89/// applied.
90const CALIBRATION_LOG10_BOUNDARY_TOL: f64 = 0.02;
91
92/// Golden-section search for the `n_total` that minimises
93/// `chi2_of_log_n(log10(n))` on `[CALIBRATION_LOG10_N_LO,
94/// CALIBRATION_LOG10_N_HI]`.
95///
96/// Runs in `log10(n)` so the three-decade interval gets relative
97/// resolution.  Returns `(best_n, best_chi2)` — `best_n` is the
98/// linear-space optimum, not the log-space value.  Uses the
99/// standard two-point golden-section update: maintain `(a, b)`,
100/// probe at the two golden-ratio interior points `c, d`, and shrink
101/// to whichever half-interval brackets the lower value.  The non-
102/// finite case (every chi² along the search returns `+inf` — e.g.
103/// `SampleParams::new` rejects the entire density range at this
104/// (L, t₀)) returns `(best_n, +inf)` so the outer grid search can
105/// move on without latching this candidate.
106fn golden_section_n_total<F>(log_lo: f64, log_hi: f64, tol: f64, mut chi2_of_log_n: F) -> (f64, f64)
107where
108    F: FnMut(f64) -> f64,
109{
110    // Golden ratio reciprocal: (√5 − 1) / 2 ≈ 0.6180.
111    let phi: f64 = (5.0_f64.sqrt() - 1.0) / 2.0;
112
113    let mut a = log_lo;
114    let mut b = log_hi;
115    let mut c = b - phi * (b - a);
116    let mut d = a + phi * (b - a);
117    let mut fc = chi2_of_log_n(c);
118    let mut fd = chi2_of_log_n(d);
119
120    // Cap iterations defensively in case `tol` is hit by NaN
121    // arithmetic; for the canonical (log_lo = -5, log_hi = -2,
122    // tol = 5e-5) parameters, convergence is reached in ~25 steps.
123    for _ in 0..200 {
124        if (b - a) <= tol {
125            break;
126        }
127        if fc < fd {
128            b = d;
129            d = c;
130            fd = fc;
131            c = b - phi * (b - a);
132            fc = chi2_of_log_n(c);
133        } else {
134            a = c;
135            c = d;
136            fc = fd;
137            d = a + phi * (b - a);
138            fd = chi2_of_log_n(d);
139        }
140    }
141
142    // The bracket has shrunk to within `tol`; either endpoint of
143    // the inner pair is within tolerance of the optimum.  Pick the
144    // lower-chi² of the two final probes.
145    if fc <= fd {
146        (10f64.powf(c), fc)
147    } else {
148        (10f64.powf(d), fd)
149    }
150}
151
152/// Plateau-robust dip-position anchor (issue #634 review P0).
153///
154/// The dip POSITIONS carry the complete (t₀, L_scale) information: with the
155/// resonance energies known, matching each measured dip to its resonance and
156/// solving the 2-parameter affine TOF map `u_dip = t₀ + L_scale · u_res` by
157/// least squares recovers the energy scale directly — no grid search.  This
158/// is the same idea as the fitters' peak-match seed, with one critical
159/// difference: dip positions are the depth-weighted centres of contiguous
160/// below-threshold RUNS, so saturated flat-bottomed dips (transmission ≈ 0
161/// over several bins — the SoftwareX foil regime), which fail the strict
162/// local-minimum test of `detect_transmission_dips`, are located robustly.
163///
164/// Used as one CANDIDATE for the stage-2 anchor (scored by the same exact
165/// golden-section density as the lattice candidates); the coarse lattice
166/// remains the fallback when fewer than two dips are detectable.  Restricted
167/// to the plausible window (|t₀| ≤ 10 µs, L_scale ∈ [0.98, 1.02]) — wider
168/// offsets are out of this function's documented band.
169fn dip_match_anchor(
170    energies_nominal: &[f64],
171    transmission: &[f64],
172    valid: &[bool],
173    isotopes: &[ResonanceData],
174    abundances: &[f64],
175    assumed_flight_path_m: f64,
176) -> Option<(f64, f64)> {
177    let n = energies_nominal.len();
178    // Baseline = 90th percentile of valid transmission; depth threshold at
179    // 25 % of the maximum depth (matches the fitters' seed conventions).
180    let mut vals: Vec<f64> = transmission
181        .iter()
182        .zip(valid.iter())
183        .filter(|&(_, &v)| v)
184        .map(|(&t, _)| t)
185        .collect();
186    if vals.len() < 5 {
187        return None;
188    }
189    vals.sort_by(f64::total_cmp);
190    let baseline = vals[(vals.len() * 9) / 10];
191    let max_depth = baseline - vals[0];
192    if !(max_depth.is_finite() && max_depth > 1e-6) {
193        return None;
194    }
195    let threshold = baseline - 0.25 * max_depth;
196
197    // Depth-weighted centre of each contiguous below-threshold run.
198    let mut dips: Vec<f64> = Vec::new();
199    let mut i = 0;
200    while i < n {
201        if valid[i] && transmission[i] < threshold {
202            let mut wsum = 0.0_f64;
203            let mut ewsum = 0.0_f64;
204            while i < n && valid[i] && transmission[i] < threshold {
205                let w = (baseline - transmission[i]).max(0.0);
206                wsum += w;
207                ewsum += w * energies_nominal[i];
208                i += 1;
209            }
210            if wsum > 0.0 {
211                dips.push(ewsum / wsum);
212            }
213        } else {
214            i += 1;
215        }
216    }
217    if dips.len() < 2 {
218        return None;
219    }
220
221    // Resonance energies of contributing isotopes inside the window.
222    let e_lo = energies_nominal[0];
223    let e_hi = energies_nominal[n - 1];
224    let mut res_e: Vec<f64> = Vec::new();
225    for (rd, &abd) in isotopes.iter().zip(abundances.iter()) {
226        if abd <= 0.0 {
227            continue;
228        }
229        for range in &rd.ranges {
230            for lg in &range.l_groups {
231                for r in &lg.resonances {
232                    if r.energy > e_lo && r.energy < e_hi {
233                        res_e.push(r.energy);
234                    }
235                }
236            }
237        }
238    }
239    res_e.sort_by(f64::total_cmp);
240    res_e.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
241    if res_e.len() < 2 {
242        return None;
243    }
244
245    // Match resonances to dips ONE-TO-ONE within half the minimum resonance
246    // spacing (floored at twice the grid resolution).  Greedy assignment on
247    // globally ascending distance: matching each resonance to its nearest
248    // dip independently would let one dip serve several resonances,
249    // duplicating `u_dip` rows in the affine least squares below — which
250    // biases the fit and, in the two-resonance case, degenerates it toward
251    // `sxx → 0` (Copilot review, PR #644).
252    let min_spacing = res_e
253        .windows(2)
254        .map(|w| w[1] - w[0])
255        .fold(f64::INFINITY, f64::min);
256    let grid_res = (e_hi - e_lo) / (n as f64 - 1.0);
257    let tol = (0.5 * min_spacing).max(2.0 * grid_res);
258    let kl = TOF_FACTOR * assumed_flight_path_m;
259    let mut candidates: Vec<(f64, usize, usize)> = Vec::new(); // (dist, res idx, dip idx)
260    for (ri, &er) in res_e.iter().enumerate() {
261        for (di, &d) in dips.iter().enumerate() {
262            let dist = (d - er).abs();
263            if dist < tol {
264                candidates.push((dist, ri, di));
265            }
266        }
267    }
268    candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
269    let mut res_used = vec![false; res_e.len()];
270    let mut dip_used = vec![false; dips.len()];
271    let mut pairs: Vec<(f64, f64)> = Vec::new(); // (u_dip, u_res) in µs
272    for &(_, ri, di) in &candidates {
273        if res_used[ri] || dip_used[di] {
274            continue;
275        }
276        res_used[ri] = true;
277        dip_used[di] = true;
278        pairs.push((kl / dips[di].sqrt(), kl / res_e[ri].sqrt()));
279    }
280    if pairs.len() < 2 {
281        return None;
282    }
283
284    // Least squares for u_dip = t0 + ls · u_res.
285    let m = pairs.len() as f64;
286    let su: f64 = pairs.iter().map(|p| p.0).sum();
287    let sv: f64 = pairs.iter().map(|p| p.1).sum();
288    let svv: f64 = pairs.iter().map(|p| p.1 * p.1).sum();
289    let suv: f64 = pairs.iter().map(|p| p.0 * p.1).sum();
290    let sxx = svv - sv * sv / m;
291    if !(sxx.is_finite() && sxx > 0.0) {
292        return None;
293    }
294    let ls = (suv - su * sv / m) / sxx;
295    let t0 = (su - ls * sv) / m;
296    if !(t0.is_finite() && ls.is_finite()) || t0.abs() > 10.0 || !(0.98..=1.02).contains(&ls) {
297        return None;
298    }
299    Some((t0, ls))
300}
301
302/// Result of energy calibration.
303#[derive(Debug, Clone)]
304pub struct CalibrationResult {
305    /// Fitted flight path length in metres.
306    pub flight_path_m: f64,
307    /// Fitted TOF delay in microseconds.
308    pub t0_us: f64,
309    /// Fitted total areal density in atoms/barn.
310    pub total_density: f64,
311    /// Reduced chi-squared at the best (L, t₀, n) values.
312    pub reduced_chi_squared: f64,
313    /// Corrected energy grid (ascending, eV).
314    pub energies_corrected: Vec<f64>,
315}
316
317/// Calibrate the energy axis of a TOF neutron measurement.
318///
319/// Given a measured 1D transmission spectrum and known sample composition
320/// (e.g. natural Hf), finds the (L, t₀) that minimize chi² by aligning
321/// the ENDF resonance positions with the measured dips.
322///
323/// # Search strategy
324///
325/// Issue #634: the former three-phase `(L, t₀)` grid scan — ~900 (L, t₀)
326/// candidates × a ~25-evaluation golden-section density search each,
327/// i.e. ~35 000 forward evaluations, >10 min on production windows — is
328/// replaced by a staged global-then-local search built on the
329/// **`fit_energy_scale` Levenberg–Marquardt path**:
330///
331/// 1. a coarse joint `(t₀, L_scale)` scan (7 × 7 candidates, each with an
332///    exact golden-section density in `log10(n)` over the documented
333///    `[1e-5, 1e-2]` atoms/barn band — the per-candidate density is what
334///    keeps the anchor JOINT, like the old grid);
335/// 2. a direct **dip-match anchor**: measured dip positions (plateau-robust
336///    depth-weighted run centres, so saturated flat-bottom dips locate
337///    correctly) matched to the known resonance energies and solved as an
338///    affine TOF map by least squares — the discriminating anchor along
339///    the `(t₀, L_scale)` degeneracy valley;
340/// 3. a fine joint pit-scan (11 × 11 at 0.25 µs / 0.05 % steps, narrow-band
341///    golden density) around EACH anchor — the compact descendant of the
342///    old Phase-2/3 grids, needed because the chi² landscape's sub-bin
343///    aliasing pits are ~±0.3 µs wide;
344/// 4. a multi-start descent from both fine anchors (per anchor: its own
345///    density plus log-spaced starts; per start: a direct joint LM AND an
346///    exact-density ↔ alignment-only-LM alternation), with every candidate
347///    scored by the original valid-bins chi² and the argmin returned.
348///
349/// Net cost is ~4× fewer forward evaluations than the old grid on
350/// production windows, and the LM refinement removes the old grid's
351/// resolution floor (0.001 % L, 0.05 µs t₀) — the optimum is continuous.
352/// The internal LM fits disable the fitters' peak-match seed
353/// (`with_energy_scale_seed(false)`): stages 1–3 already provide a
354/// stronger anchor, and the seed's strict-local-minimum dip detector
355/// mislocates saturated flat-bottom dips.
356///
357/// Each LM fit constrains `L_scale` to ±1 % (`ENERGY_SCALE_L_SCALE_*`);
358/// when a fit rails on that box (a larger true offset), the search
359/// re-anchors on the corrected grid and composes the affine TOF maps, so
360/// the documented ±1.5 % flight-path band remains covered.
361///
362/// The golden-section seed runs on a slightly wider band (~5e-6 to
363/// ~2e-2), and if the **fitted** density optimum saturates that band —
364/// effectively at `1e-5` or `1e-2`, or anywhere beyond — the function
365/// returns `Err(PipelineError::InvalidParameter)` rather than a silent
366/// boundary-saturated answer, because a true minimum at or past the edge
367/// almost always means the real optimum lies outside the supported
368/// interval and the caller should supply a better initial estimate
369/// or check the sample composition.
370///
371/// # Arguments
372///
373/// * `energies_nominal` — Energy grid computed with assumed L (ascending, eV)
374/// * `transmission` — Measured transmission values (same length)
375/// * `uncertainty` — Per-bin uncertainty (same length)
376/// * `isotopes` — ENDF resonance data for each isotope
377/// * `abundances` — Natural abundance fractions (same length as isotopes, sum ≤ 1)
378/// * `assumed_flight_path_m` — The L used to compute `energies_nominal`
379/// * `temperature_k` — Sample temperature for Doppler broadening
380/// * `resolution` — Optional instrument resolution function.  When provided,
381///   the forward model includes Doppler + resolution broadening, producing
382///   more accurate (L, t₀) fits.  Without resolution, fitted parameters
383///   absorb the missing broadening and may be biased.
384///
385/// # Returns
386///
387/// [`CalibrationResult`] with the fitted (L, t₀, n_total) and corrected energies.
388#[allow(clippy::too_many_arguments)]
389pub fn calibrate_energy(
390    energies_nominal: &[f64],
391    transmission: &[f64],
392    uncertainty: &[f64],
393    isotopes: &[ResonanceData],
394    abundances: &[f64],
395    assumed_flight_path_m: f64,
396    temperature_k: f64,
397    resolution: Option<&InstrumentParams>,
398) -> Result<CalibrationResult, PipelineError> {
399    let n = energies_nominal.len();
400    if n == 0 {
401        return Err(PipelineError::InvalidParameter(
402            "energies_nominal must not be empty".into(),
403        ));
404    }
405    if transmission.len() != n || uncertainty.len() != n {
406        return Err(PipelineError::InvalidParameter(format!(
407            "transmission ({}) and uncertainty ({}) must match energies ({})",
408            transmission.len(),
409            uncertainty.len(),
410            n,
411        )));
412    }
413    if isotopes.len() != abundances.len() {
414        return Err(PipelineError::InvalidParameter(format!(
415            "isotopes ({}) must match abundances ({})",
416            isotopes.len(),
417            abundances.len(),
418        )));
419    }
420
421    // Validate abundance values up-front.  Without this guard, non-finite
422    // or negative entries are silently multiplied into per-isotope
423    // densities (`abd * n_total`), `SampleParams::new` rejects the
424    // non-positive thickness, `compute_chi2` returns `INFINITY` for
425    // every grid point, and the user sees "no finite chi²" or boundary
426    // saturation rather than the actual cause (a bad abundance entry).
427    // Equivalent guards already exist for `assumed_flight_path_m` and
428    // `energies_nominal`; this closes the same gap for abundances.
429    let mut total_abundance = 0.0;
430    for (i, &abn) in abundances.iter().enumerate() {
431        if !abn.is_finite() || abn < 0.0 {
432            return Err(PipelineError::InvalidParameter(format!(
433                "calibrate_energy: abundances[{i}] = {abn} is not finite and non-negative"
434            )));
435        }
436        total_abundance += abn;
437    }
438    if total_abundance <= 0.0 {
439        return Err(PipelineError::InvalidParameter(
440            "calibrate_energy: sum of abundances must be strictly positive".into(),
441        ));
442    }
443
444    // Validate scalar / array inputs up-front so the grid-search loop
445    // cannot silently produce a "perfect calibration" result from
446    // degenerate inputs.  Without these guards, all-NaN transmission
447    // combined with the dof=1 fallback below would cause
448    // chi²_reduced = 0.0 to be reported as a successful fit.
449    if !assumed_flight_path_m.is_finite() || assumed_flight_path_m <= 0.0 {
450        return Err(PipelineError::InvalidParameter(format!(
451            "assumed_flight_path_m must be finite and positive, got {assumed_flight_path_m}",
452        )));
453    }
454    // Reject an invalid temperature at the entry (issue #634 review): the
455    // sibling `UnifiedFitConfig::new` validates it, and without this guard a
456    // NaN/negative temperature is only caught deep inside the repeated
457    // search/LM stages (or silently converted to an all-INFINITY chi²).
458    if !temperature_k.is_finite() || temperature_k < 0.0 {
459        return Err(PipelineError::InvalidParameter(format!(
460            "calibrate_energy: temperature_k must be finite and non-negative, \
461             got {temperature_k}",
462        )));
463    }
464    for (i, &e) in energies_nominal.iter().enumerate() {
465        if !e.is_finite() || e <= 0.0 {
466            return Err(PipelineError::InvalidParameter(format!(
467                "energies_nominal[{i}] must be finite and positive, got {e}",
468            )));
469        }
470        if i > 0 && e <= energies_nominal[i - 1] {
471            return Err(PipelineError::InvalidParameter(format!(
472                "energies_nominal must be strictly ascending; \
473                 energies_nominal[{i}]={e} <= energies_nominal[{}]={}",
474                i - 1,
475                energies_nominal[i - 1],
476            )));
477        }
478    }
479
480    // Pre-filter valid bins (finite T, positive sigma)
481    let valid: Vec<bool> = transmission
482        .iter()
483        .zip(uncertainty.iter())
484        .map(|(&t, &s)| t.is_finite() && s.is_finite() && s > 0.0)
485        .collect();
486
487    // Require enough valid bins to constrain the three fitted
488    // parameters (L, t₀, n_total).  Previously, when every bin was
489    // invalid, `compute_chi2` returned 0.0 for every grid point, the
490    // first candidate latched as "best", and the dof=1 fallback turned
491    // that into a reported `chi²_reduced = 0.0` — i.e. a totally
492    // degenerate input was indistinguishable from a perfect calibration.
493    const N_FITTED_PARAMS: usize = 3;
494    let n_valid = valid.iter().filter(|&&v| v).count();
495    if n_valid < N_FITTED_PARAMS {
496        return Err(PipelineError::InvalidParameter(format!(
497            "calibrate_energy requires at least {N_FITTED_PARAMS} bins with finite \
498             transmission and positive uncertainty, got {n_valid} valid out of {n}",
499        )));
500    }
501
502    // ── Neutralise invalid bins for the LM ──────────────────────────────
503    // The grid search masked invalid bins out of its chi² sum; the LM cost
504    // has no per-bin validity mask (its active-mask is an energy-window
505    // mask only), and a NaN transmission at an active bin poisons the
506    // normal equations.  Replace invalid bins with a finite dummy and a
507    // huge σ (weight ~1e-60 — numerically zero) so the energy grid stays
508    // intact for resolution broadening while the bins carry no influence
509    // on the fit.  The reported chi²_r below is still computed over VALID
510    // bins only, preserving the original `dof = n_valid − 3` contract.
511    let mut t_fit = transmission.to_vec();
512    let mut sigma_fit = uncertainty.to_vec();
513    for (i, &ok) in valid.iter().enumerate() {
514        if !ok {
515            t_fit[i] = 1.0;
516            sigma_fit[i] = 1.0e30;
517        }
518    }
519
520    // ── One fitted density parameter via an isotope group ───────────────
521    // The grid fitted a single n_total with per-isotope densities
522    // `abundance_i · n_total`.  Reproduce that exactly with one group whose
523    // ratios are the normalised abundances: the fitted per-isotope density
524    // is `D · abundance_i / S`, so `n_total = D / S` (S = Σ abundances).
525    // Zero-abundance isotopes contribute nothing to the model (exactly as
526    // in `compute_chi2`) and `IsotopeGroup` rejects non-positive ratios,
527    // so they are dropped from the group.
528    let mut members = Vec::new();
529    let mut rd_list: Vec<ResonanceData> = Vec::new();
530    for (rd, &abd) in isotopes.iter().zip(abundances.iter()) {
531        if abd > 0.0 {
532            members.push((rd.isotope, abd / total_abundance));
533            rd_list.push(rd.clone());
534        }
535    }
536    let group = IsotopeGroup::custom("calibration".into(), members)
537        .map_err(|e| PipelineError::InvalidParameter(format!("calibrate_energy: {e}")))?;
538    let group_pairs: [(&IsotopeGroup, &[ResonanceData]); 1] = [(&group, rd_list.as_slice())];
539
540    // ── Local helpers ────────────────────────────────────────────────────
541    // `run_lm`: one LM energy-scale fit on `grid`, cold-seeded at
542    // `(t0, L_scale) = (0, 1)` — the fitters' internal peak-match seed is
543    // deliberately DISABLED for these fits (see the
544    // `with_energy_scale_seed(false)` call and its rationale below) — with
545    // the grouped density either free or frozen (#633) at `d_init`.
546    let run_lm = |grid: &[f64],
547                  d_init: f64,
548                  freeze_density: bool|
549     -> Result<SpectrumFitResult, PipelineError> {
550        let mut config = UnifiedFitConfig::new(
551            grid.to_vec(),
552            vec![rd_list[0].clone()],
553            vec!["calibration".into()],
554            temperature_k,
555            resolution.map(|r| r.resolution.clone()),
556            vec![d_init],
557        )
558        .map_err(|e| PipelineError::InvalidParameter(format!("calibrate_energy: {e}")))?
559        .with_groups(&group_pairs, vec![d_init])
560        .map_err(|e| PipelineError::InvalidParameter(format!("calibrate_energy: {e}")))?
561        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
562            // Small first steps (issue #634): at saturated sharp dips the
563            // true (t0, L_scale) pit is sub-0.3 µs narrow while the
564            // along-valley Jacobian is near-singular — the default
565            // lambda_init = 1e-3 lets the first LM step overshoot the
566            // basin (observed: a start 0.12 µs from truth walking to a
567            // 7 µs-wrong valley pit).  A large initial damping keeps the
568            // early steps gradient-like and short; the LM anneals lambda
569            // back down once inside the basin.
570            lambda_init: 1.0e2,
571            ..LmConfig::default()
572        }))
573        .with_energy_scale(0.0, 1.0, assumed_flight_path_m)
574        // The anchor stages already seed (t0, L_scale); the fitters' internal
575        // peak-match seed mislocates saturated flat-bottom dips and would
576        // overwrite the anchor with an in-bounds wrong seed (issue #634).
577        .with_energy_scale_seed(false);
578        if freeze_density {
579            config = config.with_fix_densities(true);
580        }
581        let input = InputData::Transmission {
582            transmission: t_fit.clone(),
583            uncertainty: sigma_fit.clone(),
584        };
585        fit_spectrum_typed(&input, &config).map_err(|e| {
586            PipelineError::InvalidParameter(format!(
587                "calibrate_energy: LM energy-scale fit failed: {e}"
588            ))
589        })
590    };
591    // `golden_at`: the ORIGINAL exact 1-D density optimisation (golden
592    // section in log10 n against `compute_chi2`) at a given corrected grid.
593    let golden_at = |e_corr: &[f64]| {
594        golden_section_n_total(
595            CALIBRATION_LOG10_N_LO,
596            CALIBRATION_LOG10_N_HI,
597            CALIBRATION_LOG10_N_TOL,
598            |log_n| {
599                compute_chi2(
600                    e_corr,
601                    transmission,
602                    uncertainty,
603                    isotopes,
604                    abundances,
605                    10f64.powf(log_n),
606                    temperature_k,
607                    &valid,
608                    resolution,
609                )
610            },
611        )
612    };
613
614    // ── Stage 1: density seed at the identity energy scale ──────────────
615    // If no candidate yields a finite chi² (wildly out-of-scale data), fall
616    // back to the band midpoint — the no-finite-chi² guard below reports
617    // the failure.
618    let (n_seed, seed_chi2) = golden_at(energies_nominal);
619    let n_seed = if seed_chi2.is_finite() {
620        n_seed
621    } else {
622        10f64.powf(0.5 * (CALIBRATION_LOG10_N_LO + CALIBRATION_LOG10_N_HI))
623    };
624
625    // ── Stage 2: coarse global JOINT alignment anchor ────────────────────
626    // A compact descendant of the old Phase-1 scan: 7 L_scale (±1.5 % in
627    // 0.5 % steps) × 7 t₀ (−5…+10 µs in 2.5 µs steps) candidates, each
628    // scored with its OWN exact golden-section density — the per-candidate
629    // density optimisation is what makes the anchor JOINT, exactly like the
630    // old grid.  Scoring all candidates at one common density is NOT
631    // sufficient (issue #634 review, empirically demonstrated): on
632    // saturated (5e-3) or trace (2e-5) landscapes with production-scale
633    // offsets (0.3–1.2 % L, the module header's own VENUS example
634    // magnitude), the identity-seeded common density corrupts the candidate
635    // ranking, every descent start then inherits a wrong-basin anchor, and
636    // the calibration returns Ok with a density up to 139× off — at trace
637    // density with chi²_r ≈ 1e-4, i.e. no user-visible failure signal.
638    // ~49 golden sections ≈ 1 200 forward evaluations, still ~30× cheaper
639    // than the old three-phase grid's ~35 000.
640    let mut t0_tot = 0.0_f64;
641    let mut ls_tot = 1.0_f64;
642    let mut anchor_n = n_seed;
643    // NaN-safe latch init (issue #634 review): a NaN identity chi² would
644    // make every `chi2_c < anchor_chi2` comparison false and silently
645    // discard the whole joint scan — the same NaN-latch pattern the winner
646    // latch below is hardened against.  The old grid was immune
647    // (`best_chi2 = INFINITY` with no privileged identity candidate).
648    let mut anchor_chi2 = if seed_chi2.is_finite() {
649        seed_chi2
650    } else {
651        f64::INFINITY
652    };
653    for i_l in -3..=3_i32 {
654        let ls = 1.0 + f64::from(i_l) * 0.005;
655        for i_t in 0..=6_i32 {
656            let t0 = -5.0 + 2.5 * f64::from(i_t);
657            if i_l == 0 && i_t == 2 {
658                continue; // the identity candidate is (n_seed, seed_chi2) above
659            }
660            let Ok(e_c) = corrected_energy_grid(energies_nominal, t0, ls, assumed_flight_path_m)
661            else {
662                continue; // degenerate candidate (t0 past the shortest TOF)
663            };
664            let (n_c, chi2_c) = golden_at(&e_c);
665            if chi2_c < anchor_chi2 {
666                anchor_chi2 = chi2_c;
667                t0_tot = t0;
668                ls_tot = ls;
669                anchor_n = n_c;
670            }
671        }
672    }
673
674    // ── Stage 2b: fine joint pit-scan around each anchor ────────────────
675    // The coarse 2.5 µs / 0.5 % cell — and the dip-match solve at saturated
676    // dips (its centroid is the bin-quantized saturation interval, biased up
677    // to ~0.5 µs) — are both coarser than the (t0, L_scale) landscape's
678    // sub-bin aliasing pits (~±0.3 µs wide, ~2 µs apart on the 0.2 eV test
679    // grid), so a descent seeded from either can converge into a
680    // NEIGHBOURING pit (observed: chi² 3.3 at 1.85 µs from the chi² ≈ 0 true
681    // pit, with the LM reporting converged).  This fine scan — 11 × 11
682    // candidates at 0.25 µs / 0.05 % steps spanning one coarse cell, each
683    // with its own golden-section density on a ±0.5-decade band around the
684    // coarse anchor density — is the compact descendant of the old
685    // Phase-2/3 refinement grids and deterministically lands inside the
686    // true pit, from which the descent below converges.  It runs around
687    // BOTH anchors (lattice winner and dip-match).
688    let fine_scan = |t0_a: f64, ls_a: f64, n_a: f64| -> (f64, f64, f64, f64) {
689        let fine_lo = (n_a.log10() - 0.5).max(CALIBRATION_LOG10_N_LO);
690        let fine_hi = (n_a.log10() + 0.5).min(CALIBRATION_LOG10_N_HI);
691        let mut best = (t0_a, ls_a, n_a, f64::INFINITY);
692        for i_l in -5..=5_i32 {
693            let ls = ls_a + f64::from(i_l) * 0.0005;
694            for i_t in -5..=5_i32 {
695                let t0 = t0_a + f64::from(i_t) * 0.25;
696                let Ok(e_c) =
697                    corrected_energy_grid(energies_nominal, t0, ls, assumed_flight_path_m)
698                else {
699                    continue;
700                };
701                let (n_c, chi2_c) =
702                    golden_section_n_total(fine_lo, fine_hi, CALIBRATION_LOG10_N_TOL, |log_n| {
703                        compute_chi2(
704                            &e_c,
705                            transmission,
706                            uncertainty,
707                            isotopes,
708                            abundances,
709                            10f64.powf(log_n),
710                            temperature_k,
711                            &valid,
712                            resolution,
713                        )
714                    });
715                if chi2_c < best.3 {
716                    best = (t0, ls, n_c, chi2_c);
717                }
718            }
719        }
720        best
721    };
722
723    // Anchor set for the descent: the fine-scanned lattice winner PLUS the
724    // fine-scanned dip-match solve (see `dip_match_anchor`).  The dip-match
725    // anchor is deliberately NOT ranked against the lattice winner by a
726    // single chi² comparison: at sub-bin misalignment of sharp saturated
727    // dips, chi²(n) is multimodal — the steep dip edges make a nearly-right
728    // deep model score WORSE than a shallow one — so a chi² ranking can
729    // discard the one anchor whose fine neighbourhood contains the true pit
730    // (observed: dip-match landed 0.12 µs from truth yet scored chi² 1 809
731    // vs the lattice winner's 124).  Running the full multi-start descent
732    // from BOTH fine anchors and letting the final argmin-chi² decide is
733    // robust to that ranking failure.
734    let lattice_fine = fine_scan(t0_tot, ls_tot, anchor_n);
735    let mut anchor_list: Vec<(f64, f64, f64)> =
736        vec![(lattice_fine.0, lattice_fine.1, lattice_fine.2)];
737    if let Some((t0_d, ls_d)) = dip_match_anchor(
738        energies_nominal,
739        transmission,
740        &valid,
741        isotopes,
742        abundances,
743        assumed_flight_path_m,
744    ) {
745        // Density context for the dip anchor's narrow golden band: the
746        // lattice anchor's density is the best available estimate.
747        let dip_fine = fine_scan(t0_d, ls_d, anchor_n);
748        // Skip a duplicate anchor (both scans converged on the same pit).
749        if (dip_fine.0 - lattice_fine.0).abs() > 0.05 || (dip_fine.1 - lattice_fine.1).abs() > 1e-4
750        {
751            anchor_list.push((dip_fine.0, dip_fine.1, dip_fine.2));
752        }
753    }
754
755    // (t0_tot, ls_tot, n_total, chi2 over valid bins, LM converged flag)
756    let mut winner: Option<(f64, f64, f64, f64, bool)> = None;
757    // Last LM error across all starts: per-seed failures are skippable, but
758    // when EVERY start fails (a config-class error — bad resolution kernel,
759    // config rejection — fails all seeds identically) the no-winner
760    // diagnostic must carry the actual root cause instead of misattributing
761    // it to non-finite residuals (issue #634 review).
762    let mut last_lm_err: Option<PipelineError> = None;
763    for &(a_t0, a_ls, a_n) in &anchor_list {
764        let anchor_grid = if a_t0 == 0.0 && a_ls == 1.0 {
765            energies_nominal.to_vec()
766        } else {
767            match corrected_energy_grid(energies_nominal, a_t0, a_ls, assumed_flight_path_m) {
768                Ok(g) => g,
769                // Degenerate anchor — the other anchor still runs; if every
770                // anchor is degenerate the no-winner guard below reports it.
771                Err(_) => continue,
772            }
773        };
774        // Density seeds: this anchor's own fine-scan density (the joint
775        // optimum of its pit) plus log-spaced starts across the band (the
776        // aliasing/multimodality guard).
777        let mut seeds = vec![a_n];
778        for exp in [-4.5_f64, -3.5, -2.5] {
779            let sd = 10f64.powf(exp);
780            // Skip seeds within 2× of an existing one — same basin.
781            if seeds.iter().all(|&e| (sd / e).log10().abs() > 0.301) {
782                seeds.push(sd);
783            }
784        }
785        for &start_n in &seeds {
786            // Two descent variants per (anchor, seed); both feed the same
787            // final argmin:
788            //
789            //  (a) DIRECT joint LM from the anchor — robust at saturated
790            //      dips, where the alternation below fails: a frozen
791            //      slightly-wrong density has its alignment optimum in a
792            //      COMPENSATING pit, so the alternation walks away from a
793            //      good anchor before its joint stage ever runs (observed:
794            //      an anchor 0.12 µs from truth descending to a chi² 3.3
795            //      pit while the direct joint fit rolls into the chi² ≈ 0
796            //      true pit).
797            //
798            //  (b) alternation (exact density ↔ alignment-only LM, then
799            //      joint LM) — robust when the density seed is decades off,
800            //      where a direct joint fit would trade density against a
801            //      sub-bin shift (the aliasing degeneracy).
802            let mut candidates: Vec<(f64, f64, SpectrumFitResult)> = Vec::new();
803
804            // (a) direct joint fit from the anchor.
805            match run_lm(&anchor_grid, start_n * total_abundance, false) {
806                Ok(fit) => {
807                    let t0_c = a_t0 + a_ls * fit.t0_us.unwrap_or(0.0);
808                    let ls_c = a_ls * fit.l_scale.unwrap_or(1.0);
809                    candidates.push((t0_c, ls_c, fit));
810                }
811                Err(e) => last_lm_err = Some(e),
812            }
813
814            // (b) alternation.
815            let mut t0_tot = a_t0;
816            let mut ls_tot = a_ls;
817            let mut grid = anchor_grid.clone();
818            let mut n_cur = start_n;
819            let mut failed = false;
820            for cycle in 0..3 {
821                // First cycle keeps the start density (the whole point of the
822                // multi-start); later cycles re-optimise it at the improved
823                // alignment.
824                if cycle > 0 {
825                    let (n_new, chi2_n) = golden_at(&grid);
826                    if chi2_n.is_finite() {
827                        n_cur = n_new;
828                    }
829                }
830                let align = match run_lm(&grid, n_cur * total_abundance, true) {
831                    Ok(a) => a,
832                    Err(e) => {
833                        last_lm_err = Some(e);
834                        failed = true;
835                        break;
836                    }
837                };
838                let t0_k = align.t0_us.unwrap_or(0.0);
839                let ls_k = align.l_scale.unwrap_or(1.0);
840                t0_tot += ls_tot * t0_k;
841                ls_tot *= ls_k;
842                // Alignment converged (no further shift found) → stop.
843                if t0_k.abs() < 1e-6 && (ls_k - 1.0).abs() < 1e-9 {
844                    break;
845                }
846                match corrected_energy_grid(energies_nominal, t0_tot, ls_tot, assumed_flight_path_m)
847                {
848                    Ok(g) => grid = g,
849                    Err(_) => {
850                        failed = true;
851                        break;
852                    }
853                }
854            }
855            if !failed {
856                // Joint refinement of the alternation result.
857                let (n_stage, chi2_stage) = golden_at(&grid);
858                if chi2_stage.is_finite() {
859                    n_cur = n_stage;
860                }
861                match run_lm(&grid, n_cur * total_abundance, false) {
862                    Ok(fit) => {
863                        let t0_c = t0_tot + ls_tot * fit.t0_us.unwrap_or(0.0);
864                        let ls_c = ls_tot * fit.l_scale.unwrap_or(1.0);
865                        candidates.push((t0_c, ls_c, fit));
866                    }
867                    Err(e) => last_lm_err = Some(e),
868                }
869            }
870
871            // Score every candidate with the ORIGINAL valid-bins objective at
872            // its solution; keep the argmin.  The latch is gated on
873            // finiteness: a bare `None => true` first-candidate arm would let
874            // a NaN chi² latch (bypassing the `<` comparison), and every
875            // later candidate would then compare `chi2 < NaN` (always false)
876            // — a valid later calibration could never displace a NaN first
877            // one and the function would return the no-finite-chi² error for
878            // a calibratable spectrum.  Mirrors the old grid's
879            // `best_chi2 = INFINITY` + `<` behaviour, where non-finite
880            // candidates could never latch (issue #634 review).
881            for (t0_c, ls_c, fit) in candidates {
882                let n_c = fit.densities.first().copied().unwrap_or(f64::NAN) / total_abundance;
883                let Ok(e_c) =
884                    corrected_energy_grid(energies_nominal, t0_c, ls_c, assumed_flight_path_m)
885                else {
886                    continue;
887                };
888                let chi2_c = compute_chi2(
889                    &e_c,
890                    transmission,
891                    uncertainty,
892                    isotopes,
893                    abundances,
894                    n_c,
895                    temperature_k,
896                    &valid,
897                    resolution,
898                );
899                let better = chi2_c.is_finite()
900                    && match &winner {
901                        None => true,
902                        Some((_, _, _, best, _)) => chi2_c < *best,
903                    };
904                if better {
905                    winner = Some((t0_c, ls_c, n_c, chi2_c, fit.converged));
906                }
907            }
908        }
909    }
910    let Some((t0_tot, ls_tot, best_n, _, lm_converged)) = winner else {
911        // Preserve the root cause: a config-class LM error fails every seed
912        // identically, and its message is the actionable diagnostic — the
913        // generic non-finite-residuals guess applies only when no LM error
914        // occurred.
915        let detail = match last_lm_err {
916            Some(e) => format!("last LM error: {e}"),
917            None => "likely cause is forward-model failure or non-finite \
918                     residuals (e.g. wildly out-of-scale transmission)"
919                .to_string(),
920        };
921        return Err(PipelineError::InvalidParameter(format!(
922            "calibrate_energy: calibration produced no finite chi² from any \
923             density start (best_chi2 = inf) — {detail}"
924        )));
925    };
926
927    // Boundary-saturation guard: if the fitted n_total lies within
928    // tolerance of either density-band edge — or anywhere beyond it (the
929    // LM density is unbounded above, unlike the old grid) — the true
930    // minimum almost certainly sits outside the supported range and the
931    // calibration is unreliable.  Returning `Ok` with `best_n` ≈
932    // boundary would silently rail the density and let the (L, t₀)
933    // parameters absorb the missing density freedom by compensating
934    // bias — exactly the silent-failure pattern the no-finite-chi²
935    // guard below also defends against, but with a boundary-specific
936    // diagnostic.
937    //
938    // The seed band is `[~5e-6, ~2e-2]`; the documented edges are
939    // `[1e-5, 1e-2]`.  Fits at the documented edges lie outside the
940    // tolerance window (a true optimum at `1e-5` sits ~0.3 decades
941    // above the internal lower bound, comfortably past the ~0.02-log10
942    // tolerance) so the guard fires only when the optimum has actually
943    // saturated against — or escaped — the wider buffer.  One-sided
944    // comparisons (≤ / ≥ rather than the former |·|) so a far
945    // out-of-band LM optimum (e.g. n ≈ 1) fires the same diagnostic; a
946    // NaN density falls through (NaN comparisons are false) to the
947    // no-finite-chi² guard below.
948    let log_best_n = best_n.log10();
949    let n_lo = 10f64.powf(CALIBRATION_LOG10_N_LO_DOC);
950    let n_hi = 10f64.powf(CALIBRATION_LOG10_N_HI_DOC);
951    if log_best_n <= CALIBRATION_LOG10_N_LO + CALIBRATION_LOG10_BOUNDARY_TOL
952        || log_best_n >= CALIBRATION_LOG10_N_HI - CALIBRATION_LOG10_BOUNDARY_TOL
953    {
954        return Err(PipelineError::InvalidParameter(format!(
955            "calibrate_energy: n_total optimum {best_n:.3e} atoms/barn is at the \
956             search boundary [{n_lo:.0e}, {n_hi:.0e}]; the true optimum likely lies \
957             outside this band.  Provide a better initial density estimate, check \
958             the sample composition / abundances, or extend the search range."
959        )));
960    }
961
962    // Corrected energy grid at the composed (t0_tot, ls_tot): the same
963    // canonical transform the LM evaluated (SAMMY −t0 convention,
964    // `resolution_calib::corrected_energy_grid`), expressed relative to
965    // the caller's ORIGINAL nominal grid and assumed flight path — the
966    // pre-#634 output convention.
967    let energies_corrected =
968        corrected_energy_grid(energies_nominal, t0_tot, ls_tot, assumed_flight_path_m).map_err(
969            |e| {
970                PipelineError::InvalidParameter(format!(
971                    "calibrate_energy: degenerate calibration: {e}"
972                ))
973            },
974        )?;
975
976    // Final chi² over VALID bins with the original grid objective
977    // (`compute_chi2`), preserving the pre-#634 chi²_r semantics exactly:
978    // invalid bins excluded, `dof = n_valid − 3` clamped to ≥ 1 for the
979    // exact-fit edge case.  If the final chi² is non-finite
980    // (forward-model failure, or residual overflow for
981    // non-finite-but-passing transmission values such as 1e308), reject
982    // explicitly so calibration failure is always an `Err`, never an
983    // `Ok` with a sentinel chi² — the same contract as the old
984    // post-grid-search guard.
985    //
986    // Note the gate is the chi², NOT the LM `converged` flag: the grid
987    // search this replaced had no convergence concept — it reported the
988    // best candidate found, quality-gated only by finite chi² and the
989    // density boundary.  The LM matches that contract; its parameters at
990    // lambda-breakout are the best point found (a stall is common for
991    // trace densities, where the energy-scale Jacobian columns are nearly
992    // zero and the step control gives up AFTER the density has already
993    // converged).  The reported chi²_r carries the fit quality either way.
994    let best_chi2 = compute_chi2(
995        &energies_corrected,
996        transmission,
997        uncertainty,
998        isotopes,
999        abundances,
1000        best_n,
1001        temperature_k,
1002        &valid,
1003        resolution,
1004    );
1005    if !best_chi2.is_finite() {
1006        return Err(PipelineError::InvalidParameter(format!(
1007            "calibrate_energy: calibration produced no finite chi² \
1008             (LM converged = {converged}, best_chi2 = {best_chi2}) — likely cause is \
1009             forward-model failure or non-finite residuals (e.g. wildly \
1010             out-of-scale transmission)",
1011            converged = lm_converged,
1012        )));
1013    }
1014
1015    // Final chi2r (reduced).  The up-front guard ensures
1016    // `n_valid >= N_FITTED_PARAMS`, so we always have a non-negative
1017    // dof.  We still clamp to `max(1)` so that the exact-fit edge case
1018    // (n_valid == N_FITTED_PARAMS, dof = 0) reports a finite value
1019    // instead of dividing by zero.
1020    let dof = n_valid.saturating_sub(N_FITTED_PARAMS).max(1);
1021    let chi2r = best_chi2 / dof as f64;
1022
1023    Ok(CalibrationResult {
1024        flight_path_m: assumed_flight_path_m * ls_tot,
1025        t0_us: t0_tot,
1026        total_density: best_n,
1027        reduced_chi_squared: chi2r,
1028        energies_corrected,
1029    })
1030}
1031
1032/// Compute total chi² for a given (E_corrected, n_total) against measured data.
1033#[allow(clippy::too_many_arguments)]
1034fn compute_chi2(
1035    energies: &[f64],
1036    transmission: &[f64],
1037    uncertainty: &[f64],
1038    isotopes: &[ResonanceData],
1039    abundances: &[f64],
1040    n_total: f64,
1041    temperature_k: f64,
1042    valid: &[bool],
1043    resolution: Option<&InstrumentParams>,
1044) -> f64 {
1045    // Build (isotope, density) pairs
1046    let pairs: Vec<(ResonanceData, f64)> = isotopes
1047        .iter()
1048        .zip(abundances.iter())
1049        .map(|(iso, &abd)| (iso.clone(), abd * n_total))
1050        .collect();
1051
1052    let sample = match SampleParams::new(temperature_k, pairs) {
1053        Ok(s) => s,
1054        Err(_) => return f64::INFINITY,
1055    };
1056
1057    // P-5: Include resolution broadening when available.
1058    // Without it, fitted L and t₀ absorb the missing broadening bias.
1059    let model = match transmission::forward_model(energies, &sample, resolution) {
1060        Ok(m) => m,
1061        Err(_) => return f64::INFINITY,
1062    };
1063
1064    // Chi²
1065    let mut chi2 = 0.0;
1066    for (i, (&t_data, &t_model)) in transmission.iter().zip(model.iter()).enumerate() {
1067        if !valid[i] {
1068            continue;
1069        }
1070        let residual = (t_data - t_model) / uncertainty[i];
1071        chi2 += residual * residual;
1072    }
1073    chi2
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079    use nereids_endf::resonance::test_support::synthetic_single_resonance;
1080
1081    /// Round-trip exercise of the public `calibrate_energy` API on a
1082    /// synthetic spectrum.  Uses `synthetic_single_resonance` from
1083    /// `nereids_endf::resonance::test_support` so the test does not require network access and
1084    /// runs in every CI invocation.
1085    ///
1086    /// Note on tolerances: the grid-search calibrator's L resolution
1087    /// is fundamentally limited by chi² curvature (broader resonances
1088    /// or sparser bins → broader minimum).  With only synthetic
1089    /// single-resonance isotopes on a sparse 0.2 eV grid, the chi²
1090    /// landscape near L=true_l is shallow on the scale of the
1091    /// 0.001 % ultra-fine step — Doppler-broadened ≈ 33 meV resonance
1092    /// width vs 200 meV grid spacing means each resonance is sampled
1093    /// by ≤ 1 bin, so (L, t₀) can drift across a wide band before chi²
1094    /// degrades enough to lock the minimum down.  We therefore (a) use
1095    /// a small true offset (0.05 % in L, 0.5 µs in t₀) that the
1096    /// calibrator can resolve, and (b) test the *physics* — a fit
1097    /// converged, the corrected energies are close to truth on the
1098    /// data-relevant range, and density recovery is in the right
1099    /// decade — rather than chasing exact ENDF-style L recovery.
1100    /// The bit-exact precision question is owned by the SAMMY parity
1101    /// tests in `nereids-physics`, not by this API smoke test.
1102    #[test]
1103    fn test_calibrate_round_trip_synthetic() {
1104        // Generate synthetic data with known L and t0, then recover them.
1105        // Small offsets (0.05 % in L, 0.5 µs in t₀) so the chi² minimum
1106        // is well inside Phase-2 fine grid (±0.05 % in L, ±2 µs in t₀).
1107        //
1108        // Setup (resonances, energy grid, forward-model transmission,
1109        // and the `calibrate_energy` call itself) is shared with the
1110        // density-band tests below via `calibrate_round_trip_at_density`;
1111        // the helper returns `(result, e_true, assumed_l)` so this
1112        // smoke test can also assert on corrected-energy accuracy.
1113        let true_n = 1.5e-4;
1114        let (result, e_true, assumed_l) =
1115            calibrate_round_trip_at_density(true_n).expect("Calibration failed");
1116
1117        // Check recovery.  Wider tolerances than the Hf-178 fixture
1118        // because the synthetic chi² minimum is broader (see the
1119        // doc comment on the test above).  These bands still
1120        // distinguish a successful fit from a degenerate one (the
1121        // zero-valid-bins failure mode would report L = assumed_l
1122        // and chi² = 0.0).
1123        //
1124        // With the n_total golden-section refactor, the previously-
1125        // narrow density grid no longer pins (L, t₀) at a single
1126        // coarse-grid point; the calibrator now expresses the
1127        // genuine (L, t₀, n) degeneracy this sparse-grid synthetic
1128        // admits — 33 meV Doppler-broadened resonances vs 200 meV
1129        // grid spacing samples each resonance with ≤ 1 bin, so any
1130        // (L, t₀) that places the resonance near the same bin gives
1131        // an indistinguishable fit.  The L and t₀ parameters are
1132        // therefore not independently identifiable from this
1133        // synthetic, but the *corrected energy grid* — the actual
1134        // downstream deliverable — is, and is the right thing to
1135        // assert on.
1136        //
1137        // L and t₀ are still required to be inside the search grid
1138        // (Phase 1 L ±1.5 %, t₀ ∈ [-5, +10] µs) and density inside
1139        // the search band — anything outside would signal a
1140        // calibrator regression, not a degeneracy.
1141        assert!(
1142            (result.flight_path_m - assumed_l).abs() / assumed_l <= 0.015,
1143            "L drift outside Phase 1 ±1.5 % grid: got {}",
1144            result.flight_path_m,
1145        );
1146        assert!(
1147            (-5.0..=10.0).contains(&result.t0_us),
1148            "t0 outside Phase 1 grid: got {}",
1149            result.t0_us,
1150        );
1151        assert!(
1152            (result.total_density - true_n).abs() / true_n < 0.5,
1153            "n: got {}, expected {}",
1154            result.total_density,
1155            true_n,
1156        );
1157
1158        // The corrected energy grid is the deliverable a downstream
1159        // fit uses; even when (L, t₀, n) drift inside the chi²
1160        // basin allowed by this sparse-grid synthetic, the corrected
1161        // energies must still track `e_true` to within ~1 % at the
1162        // resonance positions and a few % across the grid.  A
1163        // median relative error check is more robust to per-bin
1164        // scaling than max-over-bins on a 150-bin grid; we still
1165        // assert max < 5 % to catch a wholly-wrong calibration.
1166        let rel_errs: Vec<f64> = result
1167            .energies_corrected
1168            .iter()
1169            .zip(e_true.iter())
1170            .map(|(&ec, &et)| (ec - et).abs() / et)
1171            .collect();
1172        let mut sorted = rel_errs.clone();
1173        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1174        let median = sorted[sorted.len() / 2];
1175        let max_err = sorted.last().copied().unwrap_or(0.0);
1176        assert!(
1177            median < 0.02,
1178            "corrected energies median rel err {median} should be < 2 %",
1179        );
1180        assert!(
1181            max_err < 0.05,
1182            "corrected energies max rel err {max_err} should be < 5 %",
1183        );
1184        // The synthetic round-trip uses noiseless data, so a grid point
1185        // that lands on (or arbitrarily close to) the true parameters
1186        // can legitimately yield chi² = 0.  Accept `>= 0.0` (the
1187        // physically valid range) rather than `> 0.0`, which was a
1188        // flake-prone strict-inequality.  The zero-valid-bins
1189        // regression that previously reported chi² = 0.0 is now
1190        // rejected up-front by the `n_valid >= N_FITTED_PARAMS` guard,
1191        // and the all-infinity grid-search case is rejected by the
1192        // post-search `best_chi2.is_finite()` guard — so finiteness
1193        // alone is the meaningful check here.
1194        assert!(
1195            result.reduced_chi_squared.is_finite() && result.reduced_chi_squared >= 0.0,
1196            "chi²_reduced must be finite and >= 0 (degenerate-input regressions \
1197             are caught up-front and post-search; this assertion guards against \
1198             chi² leaking as inf or NaN); got {}",
1199            result.reduced_chi_squared,
1200        );
1201    }
1202
1203    // ── Degenerate-input guards ────────────────────────────────────────
1204    //
1205    // Before these guards, `compute_chi2` returned 0.0 when every bin
1206    // was skipped by the `valid` mask, the grid search latched the
1207    // first candidate as "best", and the dof=1 fallback at the end
1208    // turned that into a reported `chi²_reduced = 0.0` — a totally
1209    // degenerate input was indistinguishable from a perfect
1210    // calibration.
1211
1212    /// `(energies_nominal, transmission, uncertainty, isotopes, abundances)`
1213    /// — the five array arguments to `calibrate_energy`.
1214    type CalibrationInputs = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<ResonanceData>, Vec<f64>);
1215
1216    /// Build a minimal valid input set for `calibrate_energy`, then let
1217    /// the caller mutate one field to drive a specific error path.
1218    fn minimal_calibration_inputs() -> CalibrationInputs {
1219        let iso = synthetic_single_resonance(72, 178, 176.4, 7.8);
1220        let energies: Vec<f64> = (0..50).map(|i| 5.0 + i as f64 * 0.4).collect();
1221        let transmission = vec![0.95; energies.len()];
1222        let uncertainty = vec![0.01; energies.len()];
1223        (energies, transmission, uncertainty, vec![iso], vec![1.0])
1224    }
1225
1226    #[test]
1227    fn test_calibrate_all_nan_transmission_rejected() {
1228        // All-NaN transmission would previously yield zero valid bins,
1229        // compute_chi2() returned 0.0 for every grid point, and the
1230        // dof=1 fallback reported chi²_reduced = 0.0 as success.
1231        let (energies, mut transmission, uncertainty, isotopes, abundances) =
1232            minimal_calibration_inputs();
1233        for t in transmission.iter_mut() {
1234            *t = f64::NAN;
1235        }
1236        let err = calibrate_energy(
1237            &energies,
1238            &transmission,
1239            &uncertainty,
1240            &isotopes,
1241            &abundances,
1242            25.0,
1243            293.6,
1244            None,
1245        )
1246        .expect_err("all-NaN transmission must be rejected");
1247        match err {
1248            PipelineError::InvalidParameter(msg) => {
1249                assert!(
1250                    msg.contains("valid"),
1251                    "error message should mention valid-bin count, got: {msg}"
1252                );
1253            }
1254            other => panic!("expected InvalidParameter, got {other:?}"),
1255        }
1256    }
1257
1258    #[test]
1259    fn test_calibrate_all_zero_uncertainty_rejected() {
1260        // All-zero uncertainty is the other path to zero valid bins
1261        // (sigma > 0 is required by the valid mask).
1262        let (energies, transmission, mut uncertainty, isotopes, abundances) =
1263            minimal_calibration_inputs();
1264        for s in uncertainty.iter_mut() {
1265            *s = 0.0;
1266        }
1267        let err = calibrate_energy(
1268            &energies,
1269            &transmission,
1270            &uncertainty,
1271            &isotopes,
1272            &abundances,
1273            25.0,
1274            293.6,
1275            None,
1276        )
1277        .expect_err("all-zero uncertainty must be rejected");
1278        assert!(
1279            matches!(err, PipelineError::InvalidParameter(_)),
1280            "expected InvalidParameter, got {err:?}"
1281        );
1282    }
1283
1284    #[test]
1285    fn test_calibrate_nonfinite_flight_path_rejected() {
1286        // All non-finite or non-positive flight paths must produce
1287        // InvalidParameter, naming the offending field so the caller
1288        // can diagnose the source.
1289        let (energies, transmission, uncertainty, isotopes, abundances) =
1290            minimal_calibration_inputs();
1291        for bad_l in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -1.0] {
1292            let result = calibrate_energy(
1293                &energies,
1294                &transmission,
1295                &uncertainty,
1296                &isotopes,
1297                &abundances,
1298                bad_l,
1299                293.6,
1300                None,
1301            );
1302            match result {
1303                Ok(_) => panic!("expected Err for L={bad_l}, got Ok"),
1304                Err(PipelineError::InvalidParameter(msg)) => {
1305                    assert!(
1306                        msg.contains("assumed_flight_path_m"),
1307                        "error message should name the offending field for L={bad_l}, got: {msg}"
1308                    );
1309                }
1310                Err(other) => panic!("expected InvalidParameter for L={bad_l}, got {other:?}"),
1311            }
1312        }
1313    }
1314
1315    #[test]
1316    fn test_calibrate_nonascending_energies_rejected() {
1317        let (mut energies, transmission, uncertainty, isotopes, abundances) =
1318            minimal_calibration_inputs();
1319        // Introduce a non-ascending pair.
1320        energies[10] = energies[9];
1321        let err = calibrate_energy(
1322            &energies,
1323            &transmission,
1324            &uncertainty,
1325            &isotopes,
1326            &abundances,
1327            25.0,
1328            293.6,
1329            None,
1330        )
1331        .expect_err("non-ascending energies must be rejected");
1332        match err {
1333            PipelineError::InvalidParameter(msg) => {
1334                assert!(
1335                    msg.contains("ascending"),
1336                    "error message should mention ascending, got: {msg}"
1337                );
1338            }
1339            other => panic!("expected InvalidParameter, got {other:?}"),
1340        }
1341    }
1342
1343    #[test]
1344    fn test_calibrate_all_infinite_chi2_rejected() {
1345        // Regression for the post-grid-search `best_chi2.is_finite()`
1346        // guard.  Before the guard, an input whose every `compute_chi2`
1347        // evaluation returned `f64::INFINITY` left `best_chi2`
1348        // initialised at `INFINITY`, no candidate ever beat it, and the
1349        // function returned `Ok(CalibrationResult { reduced_chi_squared:
1350        // inf, .. })` — the same silent-failure surface as the
1351        // zero-valid-bins case, just with infinity instead of zero.
1352        //
1353        // Driving the all-infinity path: feed finite but wildly
1354        // out-of-scale transmission (1e308).  Each finite uncertainty
1355        // (0.01) makes the residual ((1e308 − T_model) / 0.01) overflow
1356        // to `+inf`, residual² is `inf`, the per-bin sum is `inf`, and
1357        // every grid candidate returns `inf`.  Crucially, the
1358        // transmission values stay `finite()` so they pass the up-front
1359        // `t.is_finite()` mask and the new post-search guard is the
1360        // only line of defence.
1361        let (energies, _transmission, uncertainty, isotopes, abundances) =
1362            minimal_calibration_inputs();
1363        let transmission = vec![1e308; energies.len()];
1364        let err = calibrate_energy(
1365            &energies,
1366            &transmission,
1367            &uncertainty,
1368            &isotopes,
1369            &abundances,
1370            25.0,
1371            293.6,
1372            None,
1373        )
1374        .expect_err("all-infinity chi² across the grid must be rejected");
1375        match err {
1376            PipelineError::InvalidParameter(msg) => {
1377                assert!(
1378                    msg.contains("finite chi²") || msg.contains("best_chi2"),
1379                    "error message should explain the all-infinity grid-search failure, got: {msg}"
1380                );
1381            }
1382            other => panic!("expected InvalidParameter, got {other:?}"),
1383        }
1384    }
1385
1386    #[test]
1387    fn test_calibrate_nonfinite_energy_rejected() {
1388        let (mut energies, transmission, uncertainty, isotopes, abundances) =
1389            minimal_calibration_inputs();
1390        energies[7] = f64::NAN;
1391        let err = calibrate_energy(
1392            &energies,
1393            &transmission,
1394            &uncertainty,
1395            &isotopes,
1396            &abundances,
1397            25.0,
1398            293.6,
1399            None,
1400        )
1401        .expect_err("NaN energy must be rejected");
1402        assert!(
1403            matches!(err, PipelineError::InvalidParameter(_)),
1404            "expected InvalidParameter, got {err:?}"
1405        );
1406    }
1407
1408    #[test]
1409    fn test_calibrate_energy_rejects_negative_abundance() {
1410        // A negative abundance silently flips the sign of `abd * n_total`
1411        // and `SampleParams::new` rejects the non-positive thickness;
1412        // every grid point then returns chi² = INFINITY and the user
1413        // sees a "no finite chi²" boundary error.  The up-front guard
1414        // converts this to an actionable diagnostic that names the
1415        // offending index.
1416        let (energies, transmission, uncertainty, isotopes, mut abundances) =
1417            minimal_calibration_inputs();
1418        abundances[0] = -0.5;
1419        let err = calibrate_energy(
1420            &energies,
1421            &transmission,
1422            &uncertainty,
1423            &isotopes,
1424            &abundances,
1425            25.0,
1426            293.6,
1427            None,
1428        )
1429        .expect_err("negative abundance must be rejected");
1430        match err {
1431            PipelineError::InvalidParameter(msg) => {
1432                assert!(
1433                    msg.contains("abundances[0]"),
1434                    "error message should name the offending index, got: {msg}"
1435                );
1436            }
1437            other => panic!("expected InvalidParameter, got {other:?}"),
1438        }
1439    }
1440
1441    #[test]
1442    fn test_calibrate_energy_rejects_nan_abundance() {
1443        // NaN bypasses naive `< 0.0` guards (NaN comparisons are always
1444        // false), so the up-front check must pair `is_finite()` with the
1445        // sign predicate.  Without this guard, `abd * n_total` is NaN
1446        // for every density, every chi² is NaN, and the user sees a
1447        // confusing boundary-saturation message.
1448        let (energies, transmission, uncertainty, isotopes, mut abundances) =
1449            minimal_calibration_inputs();
1450        abundances[0] = f64::NAN;
1451        let err = calibrate_energy(
1452            &energies,
1453            &transmission,
1454            &uncertainty,
1455            &isotopes,
1456            &abundances,
1457            25.0,
1458            293.6,
1459            None,
1460        )
1461        .expect_err("NaN abundance must be rejected");
1462        match err {
1463            PipelineError::InvalidParameter(msg) => {
1464                assert!(
1465                    msg.contains("abundances[0]"),
1466                    "error message should name the offending index, got: {msg}"
1467                );
1468            }
1469            other => panic!("expected InvalidParameter, got {other:?}"),
1470        }
1471    }
1472
1473    #[test]
1474    fn test_calibrate_energy_rejects_all_zero_abundances() {
1475        // Each individual zero abundance is legal (the isotope is simply
1476        // not present in this sample), but the sum being zero means
1477        // every per-isotope density is zero and the transmission model
1478        // collapses to T == 1 — the calibrator has no signal to fit
1479        // (L, t₀, n_total) against.  Reject up-front rather than letting
1480        // the search bottom out at the band boundary.
1481        let (energies, transmission, uncertainty, isotopes, _) = minimal_calibration_inputs();
1482        let abundances = vec![0.0; isotopes.len()];
1483        let err = calibrate_energy(
1484            &energies,
1485            &transmission,
1486            &uncertainty,
1487            &isotopes,
1488            &abundances,
1489            25.0,
1490            293.6,
1491            None,
1492        )
1493        .expect_err("all-zero abundances must be rejected");
1494        match err {
1495            PipelineError::InvalidParameter(msg) => {
1496                assert!(
1497                    msg.contains("sum of abundances"),
1498                    "error message should mention the zero-sum cause, got: {msg}"
1499                );
1500            }
1501            other => panic!("expected InvalidParameter, got {other:?}"),
1502        }
1503    }
1504
1505    // ── n_total search-band regression tests ──────────────────────────
1506    //
1507    // Before the golden-section refactor, `calibrate_energy` scanned
1508    // n_total at a hard-coded 5-point linear grid `{5e-5, 1e-4,
1509    // 1.5e-4, 2e-4, 3e-4}` and then refined multiplicatively, leaving
1510    // the final density anchored inside `[2.25e-5, 4.95e-4]`
1511    // atoms/barn — incompatible with every realistic VENUS /
1512    // SoftwareX paper density (1 mm metal foils at ~5e-3, trace
1513    // matrix densities up to ~1e-2).  The refactor replaces the
1514    // multi-stage density refinement with a true golden-section
1515    // search in log10(n) on `[1e-5, 1e-2]`, and adds a boundary-
1516    // saturation guard for the (now possible) case where the
1517    // optimum lies outside that band.
1518    //
1519    // The tests below verify recovery at three representative
1520    // densities that span the search range (1e-5 lower edge,
1521    // 1e-3 middle of band that the old code could not reach,
1522    // 5e-3 SoftwareX U-238 density that was a factor-10× outside
1523    // the old reachable max) plus the explicit boundary-failure
1524    // diagnostic at densities outside the band.
1525
1526    /// Fully-parameterised synthetic round-trip helper.  Builds data with
1527    /// two well-separated Hf-style resonances at the given true density and
1528    /// injected `(true_l = assumed_l · true_l_factor, true_t0_us)` offsets,
1529    /// runs `calibrate_energy`, and on success returns
1530    /// `(result, e_true, assumed_l)` so tests can assert on parameter
1531    /// recovery and corrected-energy accuracy against `e_true`.
1532    fn calibrate_round_trip(
1533        true_l_factor: f64,
1534        true_t0_us: f64,
1535        true_n: f64,
1536    ) -> Result<(CalibrationResult, Vec<f64>, f64), PipelineError> {
1537        let assumed_l = 25.0;
1538        let true_l = assumed_l * true_l_factor;
1539        let temperature_k = 293.6;
1540
1541        let iso_a = synthetic_single_resonance(72, 178, 176.4, 7.8);
1542        let iso_b = synthetic_single_resonance(72, 178, 176.4, 22.0);
1543        let isotopes = vec![iso_a, iso_b];
1544        let abundances = vec![0.5, 0.5];
1545
1546        let e_nominal: Vec<f64> = (0..150).map(|i| 5.0 + i as f64 * 0.2).collect();
1547        let tof_s: Vec<f64> = e_nominal
1548            .iter()
1549            .map(|&e| assumed_l * (NEUTRON_MASS_CONSTANT / e).sqrt())
1550            .collect();
1551        let true_t0_s = true_t0_us * 1e-6;
1552        let e_true: Vec<f64> = tof_s
1553            .iter()
1554            .map(|&t| NEUTRON_MASS_CONSTANT * (true_l / (t - true_t0_s)).powi(2))
1555            .collect();
1556
1557        let pairs: Vec<_> = isotopes
1558            .iter()
1559            .zip(abundances.iter())
1560            .map(|(iso, &abd)| (iso.clone(), abd * true_n))
1561            .collect();
1562        let sample = SampleParams::new(temperature_k, pairs).expect("SampleParams creation failed");
1563        let t_model =
1564            transmission::forward_model(&e_true, &sample, None).expect("forward_model failed");
1565        let sigma = vec![0.01; e_nominal.len()];
1566
1567        let result = calibrate_energy(
1568            &e_nominal,
1569            &t_model,
1570            &sigma,
1571            &isotopes,
1572            &abundances,
1573            assumed_l,
1574            temperature_k,
1575            None,
1576        )?;
1577        Ok((result, e_true, assumed_l))
1578    }
1579
1580    /// Synthetic round-trip helper parameterised on `true_n` at the small
1581    /// legacy offsets (`+0.05 % L`, `+0.5 µs`) — the easy regime.  The
1582    /// wide-offset production regime is covered separately by
1583    /// `assert_wide_offset_recovery` (issue #634 review P0).
1584    fn calibrate_round_trip_at_density(
1585        true_n: f64,
1586    ) -> Result<(CalibrationResult, Vec<f64>, f64), PipelineError> {
1587        calibrate_round_trip(25.0125 / 25.0, 0.5, true_n)
1588    }
1589
1590    /// Issue #634 review P0 regression: production-scale offsets — the
1591    /// module header's own VENUS correction magnitude (0.3 % L) and
1592    /// beyond-one-±1 %-L_scale-box offsets (1.2 % L, 6 µs, which also pin
1593    /// the affine re-anchoring composition `t0 ← t0 + ls·t0_k, ls ← ls·ls_k`)
1594    /// — must recover `(L, t0, n)` and the corrected energy grid.  The
1595    /// single-common-density anchor variant returned Ok in the WRONG basin
1596    /// here (density up to 139× off; at trace density with chi²_r ≈ 1e-4,
1597    /// no visible failure signal); the per-candidate golden-section density
1598    /// in the stage-2 anchor is what defeats it.
1599    fn assert_wide_offset_recovery(
1600        true_l_factor: f64,
1601        true_t0_us: f64,
1602        true_n: f64,
1603        n_rel_tol: f64,
1604    ) {
1605        let (result, e_true, assumed_l) = calibrate_round_trip(true_l_factor, true_t0_us, true_n)
1606            .expect("wide-offset calibration must succeed");
1607        let true_l = assumed_l * true_l_factor;
1608        assert!(
1609            (result.flight_path_m - true_l).abs() / true_l < 2e-3,
1610            "L: got {}, expected {true_l}",
1611            result.flight_path_m,
1612        );
1613        assert!(
1614            (result.t0_us - true_t0_us).abs() < 0.5,
1615            "t0: got {}, expected {true_t0_us}",
1616            result.t0_us,
1617        );
1618        assert!(
1619            (result.total_density - true_n).abs() / true_n < n_rel_tol,
1620            "n: got {}, expected {true_n}",
1621            result.total_density,
1622        );
1623        // The deliverable: the corrected energy grid tracks the truth.
1624        let mut rel: Vec<f64> = result
1625            .energies_corrected
1626            .iter()
1627            .zip(e_true.iter())
1628            .map(|(&c, &t)| (c - t).abs() / t)
1629            .collect();
1630        rel.sort_by(f64::total_cmp);
1631        let med = rel[rel.len() / 2];
1632        assert!(
1633            med < 5e-3,
1634            "median corrected-energy rel err {med:.3e} exceeds 5e-3"
1635        );
1636    }
1637
1638    #[test]
1639    fn test_calibrate_wide_offset_venus_scale_at_paper_density() {
1640        // 0.3 % L + 1 µs at the SoftwareX U-238 foil density.
1641        assert_wide_offset_recovery(1.003, 1.0, 5e-3, 0.15);
1642    }
1643
1644    #[test]
1645    fn test_calibrate_wide_offset_venus_scale_at_trace_density() {
1646        // Same offsets at trace density — the silent-failure regime
1647        // (wrong answer previously carried chi²_r ≈ 1e-4).
1648        assert_wide_offset_recovery(1.003, 1.0, 2e-5, 0.3);
1649    }
1650
1651    #[test]
1652    fn test_calibrate_wide_offset_beyond_one_box_at_paper_density() {
1653        // 1.2 % L exceeds the per-fit ±1 % L_scale box → exercises the
1654        // re-anchoring composition; 6 µs t0 is mid coarse-grid.
1655        assert_wide_offset_recovery(1.012, 6.0, 5e-3, 0.15);
1656    }
1657
1658    #[test]
1659    fn test_calibrate_wide_offset_beyond_one_box_at_midband_density() {
1660        assert_wide_offset_recovery(1.012, 6.0, 1.5e-4, 0.15);
1661    }
1662
1663    /// Near-lower-edge density: `true_n = 2e-5` sits just inside the
1664    /// `[1e-5, 1e-2]` documented user-supported interval — approximately
1665    /// 0.3 decades (a factor of 2) above the lower documented edge,
1666    /// comfortably outside the boundary guard's `5 %`-linear tolerance
1667    /// window.  Recovery must succeed.  Note the 30 % relative tolerance —
1668    /// at this low density the chi² landscape is shallow (single-resonance
1669    /// synthetic, weak signal), so the recovered density can drift further
1670    /// from the true value than at mid-band; the test still meaningfully
1671    /// distinguishes "we found roughly the right decade" from the old
1672    /// behaviour of being unable to reach the value at all.  The
1673    /// `test_calibrate_energy_boundary_saturation_error` test separately
1674    /// verifies the guard fires for genuinely-out-of-band densities.
1675    #[test]
1676    fn test_calibrate_energy_recovers_density_1e_5() {
1677        let true_n = 2e-5;
1678        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1679            .expect("calibration at true_n=2e-5 must succeed");
1680        assert!(
1681            (result.total_density - true_n).abs() / true_n < 0.3,
1682            "n: got {}, expected {}",
1683            result.total_density,
1684            true_n,
1685        );
1686        assert!(result.reduced_chi_squared.is_finite());
1687    }
1688
1689    /// Documented lower bound: `true_n = 1.0e-5` atoms/barn is exactly
1690    /// the lower edge promised by the `calibrate_energy` rustdoc.  Before
1691    /// the search-band widening the boundary-saturation guard's
1692    /// `~5 %`-linear tolerance trimmed a sliver off either side and
1693    /// rejected truly-at-the-edge optima with a "true optimum likely
1694    /// lies outside this band" diagnostic that contradicted the docs.
1695    /// With the internal band widened to `[~5e-6, ~2e-2]`, an optimum
1696    /// at the documented edge sits ~0.3 decades inside the buffer and
1697    /// is accepted — the user-facing contract here is that the call
1698    /// returns `Ok(_)` (no boundary-saturation error) and recovers a
1699    /// density close to truth in log-space, not that the recovered
1700    /// value is bit-exactly bounded by the documented interval: chi²
1701    /// minimisation can land slightly outside `[1e-5, 1e-2]` even when
1702    /// the true density sits at the edge, and that is correct
1703    /// behaviour for a smooth optimisation landscape.
1704    #[test]
1705    fn test_calibrate_energy_accepts_density_at_documented_lower_bound() {
1706        let true_n = 1.0e-5;
1707        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1708            .expect("calibration at the documented lower edge 1e-5 must succeed");
1709        // Log-space tolerance because the chi² landscape is shallow at
1710        // this trace density (single-resonance synthetic, weak signal):
1711        // a recovered-vs-truth ratio of 2× corresponds to 0.3 in
1712        // log10(n) and is the empirically reasonable precision floor.
1713        let log_err = (result.total_density.log10() - true_n.log10()).abs();
1714        assert!(
1715            log_err < 0.3,
1716            "log10(n) error {log_err} too large; recovered {} vs truth {true_n}",
1717            result.total_density,
1718        );
1719        assert!(result.reduced_chi_squared.is_finite());
1720    }
1721
1722    /// Documented upper bound: `true_n = 1.0e-2` atoms/barn is exactly
1723    /// the upper edge promised by `calibrate_energy`'s rustdoc — the
1724    /// `1 mm metal foil` use case that drives the SoftwareX paper's
1725    /// calibration narrative.  Sister test to
1726    /// `test_calibrate_energy_accepts_density_at_documented_lower_bound`;
1727    /// the search-band widening keeps the upper documented edge inside
1728    /// the boundary guard's tolerance buffer.  As with the lower-bound
1729    /// test, the assertion is on chi²-resolution recovery (log-space
1730    /// proximity to truth), not on hard-bounding the result inside
1731    /// `[1e-5, 1e-2]` — a smooth optimum at the edge can land just
1732    /// outside without indicating any defect.
1733    #[test]
1734    fn test_calibrate_energy_accepts_density_at_documented_upper_bound() {
1735        let true_n = 1.0e-2;
1736        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1737            .expect("calibration at the documented upper edge 1e-2 must succeed");
1738        // Tighter log-space tolerance than the lower edge: at this
1739        // high density the resonance is saturated, the chi²
1740        // landscape is sharp and the (L, t₀, n) trade-off basin is
1741        // narrow.  log_err < 0.05 ≈ 12 % linear is comfortable.
1742        let log_err = (result.total_density.log10() - true_n.log10()).abs();
1743        assert!(
1744            log_err < 0.05,
1745            "log10(n) error {log_err} too large; recovered {} vs truth {true_n}",
1746            result.total_density,
1747        );
1748        assert!(result.reduced_chi_squared.is_finite());
1749    }
1750
1751    /// Phase-1-grid-point density: `true_n = 1e-4` was the historical
1752    /// reachable-band centre.  Recovery is the easiest case for the
1753    /// chi² landscape and tightens the tolerance accordingly.
1754    #[test]
1755    fn test_calibrate_energy_recovers_density_1e_4() {
1756        let true_n = 1e-4;
1757        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1758            .expect("calibration at true_n=1e-4 must succeed");
1759        assert!(
1760            (result.total_density - true_n).abs() / true_n < 0.1,
1761            "n: got {}, expected {}",
1762            result.total_density,
1763            true_n,
1764        );
1765        assert!(result.reduced_chi_squared.is_finite());
1766    }
1767
1768    /// Mid-band density: `true_n = 1e-3` was **unreachable** under
1769    /// the previous 5-point scan + multiplicative refinement; the
1770    /// best the old code could return was ~4.95e-4.  After the
1771    /// log-space golden-section refactor this density must round-
1772    /// trip with full Phase-3 precision.
1773    #[test]
1774    fn test_calibrate_energy_recovers_density_1e_3() {
1775        let true_n = 1e-3;
1776        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1777            .expect("calibration at true_n=1e-3 must succeed");
1778        assert!(
1779            (result.total_density - true_n).abs() / true_n < 0.1,
1780            "n: got {}, expected {} — old code saturated at ~4.95e-4",
1781            result.total_density,
1782            true_n,
1783        );
1784        assert!(result.reduced_chi_squared.is_finite());
1785    }
1786
1787    /// SoftwareX U-238 reference density: 1 mm metal foil at ~5e-3
1788    /// atoms/barn.  This is the density the paper figure scripts
1789    /// (`gen_fig_physics.py`, `gen_fig_closed_loop.py`) use and
1790    /// that the paper's calibration narrative relies on; it sits a
1791    /// factor 10× above the old reachable maximum.
1792    #[test]
1793    fn test_calibrate_energy_recovers_density_5e_3() {
1794        let true_n = 5e-3;
1795        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1796            .expect("calibration at true_n=5e-3 must succeed");
1797        assert!(
1798            (result.total_density - true_n).abs() / true_n < 0.1,
1799            "n: got {}, expected {} — old code saturated at ~4.95e-4 (10× too low)",
1800            result.total_density,
1801            true_n,
1802        );
1803        assert!(result.reduced_chi_squared.is_finite());
1804    }
1805
1806    /// Out-of-band saturation: `true_n = 1.0` atoms/barn is two
1807    /// orders of magnitude above the upper search bound (`1e-2`).
1808    /// The golden-section minimum must land on the upper bound,
1809    /// and the boundary-saturation guard must turn that into an
1810    /// `Err(InvalidParameter)` rather than the silent railed answer
1811    /// the old 5-point scan would have returned (the old code
1812    /// would have railed to `4.95e-4`, six orders of magnitude
1813    /// below truth, with no diagnostic).
1814    #[test]
1815    fn test_calibrate_energy_boundary_saturation_error() {
1816        let true_n = 1.0;
1817        let err = calibrate_round_trip_at_density(true_n)
1818            .expect_err("density outside search band must trigger boundary guard");
1819        match err {
1820            PipelineError::InvalidParameter(msg) => {
1821                assert!(
1822                    msg.contains("search boundary") || msg.contains("boundary"),
1823                    "error must explain boundary saturation, got: {msg}"
1824                );
1825            }
1826            other => panic!("expected InvalidParameter, got {other:?}"),
1827        }
1828    }
1829}