Skip to main content

nereids_fitting/
resolution_calib.rs

1//! Instrument-resolution calibration.
2//!
3//! Fits the **instrument-resolution parameters** of a chosen model family to a
4//! known-(ρ, T) calibrant, holding the sample density and temperature FIXED.
5//! This is the calibrate step of the standard calibrate→pin→fit procedure:
6//! characterize the beamline resolution once on a known standard, pin it, then
7//! fit unknown samples ([`crate::transmission_model`] / the typed fitters).
8//!
9//! Mechanism: an outer [`crate::nelder_mead`] optimizer over the few resolution
10//! parameters; each evaluation builds a [`ResolutionFunction`] from the
11//! parameter vector, runs the existing [`forward_model`] at the fixed
12//! [`SampleParams`], and returns χ²/dof after analytically fitting a
13//! normalization (`anorm`) and optional low-order baseline (so a baseline offset
14//! does not leak into the resolution). Calibration is once-per-experiment, so a
15//! derivative-free outer loop — not LM resolution-Jacobians — is the right tool;
16//! this mirrors the established outer-loop pattern in
17//! [`crate::joint_poisson`]'s polish stage.
18//!
19//! Families ([`ResolutionFamily`]):
20//! - **Gaussian** — fit `(Δt, ΔL)`.
21//! - **UdrCorr** — fit a shape-preserving width correction `s(E)=s0·(E/Eref)^p`
22//!   on a base tabulated UDR ([`TabulatedResolution::width_corrected`]); trusts
23//!   the Monte-Carlo shape, calibrates its width/energy-dependence. **UDR** =
24//!   *User-Defined Resolution*, SAMMY's term for a numerical (table-supplied)
25//!   resolution function.
26//! - **IkedaCarpenter** — physics-complete bounded moderator fit (#642):
27//!   `α(E) = e^{θ0}·√E + e^{θ1}` (positive at every energy by construction),
28//!   `β = e^{θ2}` (bounded), scalar storage fraction `R = θ3 ∈ [0, 1]`, all
29//!   folded with the SNS PSR channel triangle
30//!   ([`CalibrationConfig::psr_fwhm_ns`], default 350 ns; optionally fitted
31//!   via `fit_psr`). Beware the β↔R ridge: as `R → 0` the storage term
32//!   vanishes and β is unconstrained — such a fit reports `"r:lower"` in
33//!   [`CalibrationResult::bounds_hit`] and its β carries no information.
34
35use std::sync::Arc;
36
37use nereids_physics::ikeda_carpenter::{
38    EnergyLaw, IkedaCarpenter, IkedaCarpenterParams, SynthesisGrid,
39};
40use nereids_physics::resolution::{
41    ResolutionFunction, ResolutionParams, TOF_FACTOR, TabulatedResolution,
42};
43use nereids_physics::transmission::{InstrumentParams, SampleParams, forward_model};
44
45use crate::error::FittingError;
46use crate::nelder_mead::{NelderMeadConfig, NelderMeadResult, nelder_mead_minimize};
47
48/// Reference energy (eV) for the UDR width-correction power law `s(E)`.
49const UDR_E_REF: f64 = 10.0;
50/// Width-scale clamp for the UDR correction (`s0 = clamp(exp(log_s0), …)`).
51/// `pub` so the Python binding decodes the reported `s0` against the *same*
52/// bounds the optimizer used, rather than duplicating the literals.
53pub const UDR_S0_MIN: f64 = 0.2;
54/// Upper width-scale clamp; see [`UDR_S0_MIN`].
55pub const UDR_S0_MAX: f64 = 5.0;
56// --- Ikeda–Carpenter calibration family: θ encoding and physics bounds (#642) ---
57//
58// θ = [ln a0, ln a1, ln β, R, (PSR FWHM µs iff fit_psr)]. The rates are
59// exp-encoded so that `α(E) = e^{θ0}·√E + e^{θ1} > 0` at EVERY energy — on the
60// calibration grid and on any production grid the pinned kernel is later
61// applied to — by construction (a real calibration under the old plain-(a0,a1)
62// box once returned a1 = −0.396, which drives α(E) < 0 at low energy and makes
63// the pulse unphysical). β and R are FREE: the 2-parameter predecessor (β
64// pinned, R ≡ exp(−E_meV/25) ≈ 0 in the eV regime) lacked the storage-shape
65// freedom, which re-expressed as a ~90 K temperature degeneracy on real data.
66
67/// Lower box bound on the prompt-rate coefficient `a0` (µs⁻¹ per √eV) of
68/// `α(E) = a0·√E + a1`; same span as the previous plain box `(0.01, 5.0)`.
69/// α ≈ 1–3 µs⁻¹ in the eV regime (Ikeda & Carpenter, NIM A239 (1985) 536)
70/// gives a0 ≈ 0.2–0.5; the decade of head-room on each side is deliberate.
71const IC_A0_MIN: f64 = 0.01;
72/// Upper box bound on `a0`; see [`IC_A0_MIN`].
73const IC_A0_MAX: f64 = 5.0;
74/// Optimizer start for `a0` — the VENUS-scale prompt slope used since the
75/// family was introduced (matches the previous start).
76const IC_A0_X0: f64 = 0.30;
77/// Lower box bound on the energy-independent prompt offset `a1` (µs⁻¹).
78/// Strictly positive (exp-encoded) so `α(E) → a1 > 0` as `E → 0`: the offset
79/// can no longer flip α negative below the calibration window.
80const IC_A1_MIN: f64 = 1e-3;
81/// Upper box bound on `a1`: 2 µs⁻¹ already exceeds the whole prompt rate at
82/// 1 eV for any plausible a0, so the bound is not physically restrictive.
83const IC_A1_MAX: f64 = 2.0;
84/// Optimizer start for `a1` — small positive (a mostly-√E law).
85const IC_A1_X0: f64 = 0.05;
86/// Lower box bound on the storage (slow) rate `β` (µs⁻¹). Covers the
87/// canonical Ikeda–Carpenter ambient-moderator value β ≈ 0.031 µs⁻¹
88/// (NIM A239 (1985) 536; also Mantid `IkedaCarpenterPV`'s β default) with
89/// margin below it. The τ-grid is prompt-anchored and capped (nereids-physics
90/// `MAX_TAU_SAMPLES`), so the 16/β ≈ 800 µs tail at this bound stays sampled
91/// at a ≈ 0.098 µs capped step — fine enough for the default 0.35 µs (and
92/// any ≥ ~0.3 µs) PSR triangle and for prompt rates up to α ≈ 26 µs⁻¹. A
93/// fitted PSR near its 0.05 µs floor combined with β near this bound is
94/// unresolvable within the cap; such θ are treated as infeasible points
95/// (∞ objective) during the search, never as a calibration abort — see
96/// `ic_box_worst_corner_synthesizes_within_tau_cap` /
97/// `ic_unresolvable_theta_errs_in_build_resolution` /
98/// `ic_infeasible_pocket_inside_box_completes_calibration`.
99const IC_BETA_MIN: f64 = 0.02;
100/// Upper box bound on `β`: at 5 µs⁻¹ the storage tail is as fast as the
101/// prompt core itself (α range), beyond which β↔α are indistinguishable.
102const IC_BETA_MAX: f64 = 5.0;
103/// Optimizer start for `β` — the value the retired fixed-β family pinned.
104const IC_BETA_X0: f64 = 0.10;
105/// Lower box bound on the storage mixing fraction `R` (physical: a fraction).
106const IC_R_MIN: f64 = 0.0;
107/// Upper box bound on `R`; see [`IC_R_MIN`].
108const IC_R_MAX: f64 = 1.0;
109/// Optimizer start for `R`. A scalar `R` replaces the retired
110/// `ExpMilliEv{κ=25}` law: a free κ is unidentifiable in the eV regime
111/// (R ≡ 0 across 1–200 eV for ANY plausible κ), whereas a scalar R lets the
112/// data decide whether a storage tail is present at all.
113const IC_R_X0: f64 = 0.1;
114/// Default SNS PSR (proton-storage-ring / accumulator) channel-triangle FWHM
115/// in **ns**, folded into the IC family's kernel. The SNS proton pulse is
116/// shaped by the accumulator ring into an ~triangular ~700 ns base (FWHM ≈
117/// 350 ns) — the VENUS tabulated FTS kernel header records exactly this
118/// ("folded triang FWHM 350 ns PSR"). SAMMY's analog is the Gaussian-burst
119/// FWHM `DELTAG` (Manual Sec. III.C.1.a, eq. III C1 a.12) or square `BURST`
120/// width (Sec. III.C.2.a). `pub` so the Python binding's default and the Rust
121/// default cannot drift apart.
122pub const DEFAULT_PSR_FWHM_NS: f64 = 350.0;
123/// Lower box bound (µs) on a FITTED PSR triangle FWHM (`fit_psr = true`):
124/// below 50 ns the triangle is far under the IC prompt width in the eV
125/// regime and unidentifiable.
126const PSR_FWHM_US_MIN: f64 = 0.05;
127/// Upper box bound (µs) on a fitted PSR FWHM: 1 µs is ~3× the physical SNS
128/// pulse base — anything larger is the moderator's job (α, β), not the burst.
129const PSR_FWHM_US_MAX: f64 = 1.0;
130/// Sanity ceiling (µs) on the configured PSR triangle FWHM: one decade above
131/// the `PSR_FWHM_US_MAX` fit bound. [`CalibrationConfig::psr_fwhm_ns`] is in
132/// NANOSECONDS (the VENUS FTS header convention: "folded triang FWHM 350 ns
133/// PSR"), and kernel-synthesis cost grows QUADRATICALLY with a wide fold's
134/// width. The mechanism is NOT τ-step refinement — that applies only to
135/// folds FINER than the prompt design step, and a 50–350 µs fold's FWHM/3
136/// resolution floor is far coarser, leaving the step unchanged — it is the
137/// convolution itself: the ±FWHM fold-reach margin adds O(FWHM/step)
138/// τ-samples, each folded in `convolve_same` against a sampled triangle
139/// itself O(FWHM/step) long. Measured ~12 ms at 0.35 µs but ~1.3 s at 50 µs
140/// and ~28 s at 350 µs per single kernel-table synthesis at the default
141/// grid. A µs-as-ns unit slip
142/// (passing `350` meaning µs → interpreted as a 350 µs pin) would therefore
143/// turn a calibration into a multi-hour silent hang behind a physically
144/// fictitious fold. Any genuine width sits inside the fitted box; one decade
145/// of headroom keeps deliberate sensitivity studies possible while still
146/// catching the 1000× ns↔µs slip. `0.0` (fold disabled) is always accepted.
147/// `pub` for parity with the Python binding's mirrored validation.
148pub const PSR_FWHM_PIN_CEILING_US: f64 = 10.0 * PSR_FWHM_US_MAX;
149/// Nanoseconds → microseconds ([`CalibrationConfig::psr_fwhm_ns`] is in ns to
150/// match the FTS header convention; the kernel synthesis takes µs). `pub` so
151/// the Python binding's mirrored [`PSR_FWHM_PIN_CEILING_US`] check converts
152/// with the identical factor.
153pub const NS_TO_US: f64 = 1e-3;
154/// A coordinate within this fraction of its box range of a bound is reported
155/// in [`CalibrationResult::bounds_hit`] as pinned.
156const BOUND_HIT_REL_TOL: f64 = 1e-3;
157/// Cap on per-restart Nelder–Mead simplex re-inflations (fresh simplex
158/// restarted at the incumbent while it keeps improving by more than `fatol`).
159/// Guards against premature simplex collapse — see the re-inflation comment
160/// in [`calibrate_resolution`] — while guaranteeing termination.
161const MAX_SIMPLEX_REINFLATIONS: usize = 5;
162/// Initial-step fraction for the RE-INFLATED simplex (vs the default 0.05 of
163/// the first descent). A collapsed simplex rebuilt at the same 5 % scale
164/// deterministically re-collapses to the same trap (observed on the IC
165/// family's curved α↔β↔R valley); a 25 % edge straddles the valley and lets
166/// the restarted simplex see the descent direction.
167const REINFLATE_STEP_FRAC: f64 = 0.25;
168/// Absolute re-inflation step for near-zero coordinates (`|x| < 1e-8`),
169/// matching the box scale of the bounded coordinates (R ∈ [0, 1]).
170const REINFLATE_STEP_ABS: f64 = 0.1;
171/// Guard-rail bound (µs) on the optional fitted TOF zero `t0`. `t0` and `L_scale`
172/// are the SAMMY *energy-scale* parameters; resolution calibration pins them by
173/// default and fits them only as an explicit, prior-constrained opt-in (see
174/// [`CalibrationConfig::with_position_prior`]). ±5 µs is a guard rail far inside
175/// the feasible `|t0| < min(TOF)`; the *real* constraint on `t0` is the metrology
176/// prior, not this bound.
177///
178/// History: a previous design fit a *free per-family* constant `t0` and discarded
179/// it ("position nuisance") to make the cross-family χ² compare shape/width. That
180/// was wrong — the asymmetric-kernel mode→centroid lag is `≈1/√E` (exact for the
181/// `a1=0` prompt law `α=a0√E`, leading-order otherwise), the SAME basis as an
182/// `L_scale` error, so a free per-family `t0`/`L_scale` lets a wrong (symmetric)
183/// family imitate the lag and buy back the strongest evidence against it (χ² 6.0 →
184/// 1.3 in the Hf-177 study). Position is now a SHARED energy-scale parameter with a
185/// metrology prior, never a free per-family knob.
186const POSITION_T0_US_MAX: f64 = 5.0;
187/// Guard-rail bounds (±2%) on the optional fitted flight-path scale `L_scale`.
188/// The IC mode→centroid lag needs only `ΔL/L ≈ 0.22%` to be mimicked, so a *free*
189/// `L_scale` absorbs the lag and corrupts the calibrated width — fit it only under
190/// a prior, for an explicit energy-scale / identifiability study.
191const POSITION_L_SCALE_MIN: f64 = 0.98;
192/// Upper guard-rail bound on `L_scale`; see [`POSITION_L_SCALE_MIN`].
193const POSITION_L_SCALE_MAX: f64 = 1.02;
194
195/// Map an energy grid through the SAMMY energy-scale `(t0, L_scale)`, using the
196/// SAME convention as `EnergyScaleTransmissionModel::corrected_energies`: with
197/// nominal `tof(E) = TOF_FACTOR·L/√E`, the corrected energy is
198/// `E' = (TOF_FACTOR·L·L_scale / (tof − t0))²`. Identity at `(t0, L_scale) = (0, 1)`.
199///
200/// Note the `−t0` sign (a positive `t0` is *subtracted* from the measured TOF, so
201/// it raises the corrected energy) — this is the shipped energy-scale convention,
202/// opposite to the `+t0` form used by the retired position nuisance. Errors if any
203/// corrected TOF `tof − t0 ≤ 0` (a `t0` past the shortest flight time).
204///
205/// SAMMY reference for the `−t0` sign convention: `dat/mdat0.f90:189`
206/// (`etzero = ee*(Elzero/(1−Tzero·√ee/Tttzzz))²`) — the measured TOF has TZERO
207/// subtracted before the energy conversion, so a positive `t0` raises the
208/// corrected energy. This is the canonical NEREIDS implementation of the
209/// formula; the runtime
210/// `EnergyScaleTransmissionModel::corrected_energies` is pinned bit-for-bit
211/// to it by `corrected_energy_grid_matches_energy_scale_model`, and
212/// `SpectrumFitResult::corrected_energies` (issue #634) reuses it so callers
213/// never re-derive the transform (a `+t0` slip caused a silent +400 K bias).
214pub fn corrected_energy_grid(
215    energies: &[f64],
216    t0_us: f64,
217    l_scale: f64,
218    flight_path_m: f64,
219) -> Result<Vec<f64>, FittingError> {
220    // Validate scale inputs up front (issue #634 review): the transform is
221    // EVEN in `l_scale` (`(kl·l_scale/denom)²`), so a negative `l_scale`
222    // would silently return the identical plausible grid as its positive
223    // counterpart, a NaN `l_scale` would pass the denominator-only guard and
224    // return `Ok(vec![NaN])` ("NaN bypasses guards"), and
225    // `flight_path_m = 0` with a negative fitted `t0` would return an
226    // all-zeros grid as `Ok`.  Matches the sibling fit entry points'
227    // `validate_energy_scale_params` rejection (issue #458) — this is the
228    // canonical public transform, so it must not hand back plausible
229    // garbage for invalid inputs.
230    if !t0_us.is_finite() {
231        return Err(FittingError::EvaluationFailed(format!(
232            "corrected_energy_grid: t0_us must be finite, got {t0_us}"
233        )));
234    }
235    if !l_scale.is_finite() || l_scale <= 0.0 {
236        return Err(FittingError::EvaluationFailed(format!(
237            "corrected_energy_grid: l_scale must be finite and positive, got {l_scale}"
238        )));
239    }
240    if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
241        return Err(FittingError::EvaluationFailed(format!(
242            "corrected_energy_grid: flight_path_m must be finite and positive, \
243             got {flight_path_m}"
244        )));
245    }
246    // Per-bin grid validation runs BEFORE the identity shortcut so the
247    // Ok/Err contract is uniform: previously (t0=0, l_scale=1) returned a
248    // NaN/non-positive grid verbatim while any other transform rejected the
249    // same grid via the denominator guard (#634 review).  Empty grids are
250    // rejected for the same uniformity (the Python binding already errors
251    // on them; a per-bin loop is vacuous on an empty slice).
252    if energies.is_empty() {
253        return Err(FittingError::EvaluationFailed(
254            "corrected_energy_grid: energies must not be empty".into(),
255        ));
256    }
257    for (i, &e) in energies.iter().enumerate() {
258        if !e.is_finite() || e <= 0.0 {
259            return Err(FittingError::EvaluationFailed(format!(
260                "corrected_energy_grid: energies[{i}] must be finite and positive, got {e}"
261            )));
262        }
263        // Strict ascending order, matching the Python binding's standard
264        // energy-grid validation (issue #634 review): a non-monotone input
265        // would otherwise map to a plausible but non-monotone corrected axis.
266        if i > 0 && e <= energies[i - 1] {
267            return Err(FittingError::EvaluationFailed(format!(
268                "corrected_energy_grid: energies must be strictly ascending; \
269                 energies[{i}] = {e} <= energies[{}] = {}",
270                i - 1,
271                energies[i - 1],
272            )));
273        }
274    }
275    if t0_us == 0.0 && l_scale == 1.0 {
276        return Ok(energies.to_vec());
277    }
278    let kl = TOF_FACTOR * flight_path_m;
279    energies
280        .iter()
281        .map(|&e| {
282            let tof = kl / e.sqrt();
283            let denom = tof - t0_us;
284            if denom <= 0.0 || !denom.is_finite() {
285                return Err(FittingError::EvaluationFailed(
286                    "corrected TOF ≤ 0: t0 exceeds the shortest flight time".into(),
287                ));
288            }
289            Ok((kl * l_scale / denom).powi(2))
290        })
291        .collect()
292}
293
294/// The resolution-model family to calibrate.
295#[derive(Debug, Clone)]
296pub enum ResolutionFamily {
297    /// Gaussian `(Δt_µs, ΔL_m)`.
298    Gaussian,
299    /// Width-corrected tabulated UDR: fit `(log s0, p)` against `base`.
300    UdrCorr {
301        /// Base Monte-Carlo kernel to correct.
302        base: Arc<TabulatedResolution>,
303    },
304    /// Ikeda–Carpenter, physics-complete and bounded (#642):
305    /// `θ = [ln a0, ln a1, ln β, R]` with `α(E) = e^{θ0}√E + e^{θ1}` (positive
306    /// by construction), `β = e^{θ2}` bounded, scalar `R = θ3 ∈ [0, 1]`, all
307    /// folded with the SNS PSR channel triangle
308    /// ([`CalibrationConfig::psr_fwhm_ns`], default [`DEFAULT_PSR_FWHM_NS`]).
309    IkedaCarpenter {
310        /// Also fit the PSR triangle FWHM: appends `θ4` (µs, box-bounded
311        /// 0.05–1.0 µs, started at [`CalibrationConfig::psr_fwhm_ns`]
312        /// clamped into that box). A positive starting width outside the box
313        /// — legal as a pin up to [`PSR_FWHM_PIN_CEILING_US`] — starts at
314        /// the nearer box edge with a stderr warning; a fit that stays there
315        /// reports `psr_fwhm_us:lower` / `:upper` in
316        /// [`CalibrationResult::bounds_hit`]. Off by default — the 350 ns
317        /// SNS PSR width is machine metrology, not a per-experiment unknown.
318        fit_psr: bool,
319    },
320}
321
322impl ResolutionFamily {
323    /// Number of free parameters.
324    #[must_use]
325    pub fn n_params(&self) -> usize {
326        match self {
327            ResolutionFamily::Gaussian | ResolutionFamily::UdrCorr { .. } => 2,
328            ResolutionFamily::IkedaCarpenter { fit_psr } => 4 + usize::from(*fit_psr),
329        }
330    }
331
332    /// Names of the raw optimizer coordinates, in [`CalibrationResult::theta`]
333    /// order. Used to label [`CalibrationResult::bounds_hit`]; the `ln_*`
334    /// prefixes flag exp-encoded coordinates (decode via
335    /// [`CalibrationResult::resolution`] rather than by hand).
336    #[must_use]
337    pub fn param_names(&self) -> Vec<&'static str> {
338        match self {
339            ResolutionFamily::Gaussian => vec!["delta_t_us", "delta_l_m"],
340            ResolutionFamily::UdrCorr { .. } => vec!["log_s0", "p"],
341            ResolutionFamily::IkedaCarpenter { fit_psr } => {
342                let mut names = vec!["ln_a0", "ln_a1", "ln_beta", "r"];
343                if *fit_psr {
344                    names.push("psr_fwhm_us");
345                }
346                names
347            }
348        }
349    }
350
351    fn label(&self) -> &'static str {
352        match self {
353            ResolutionFamily::Gaussian => "gaussian",
354            ResolutionFamily::UdrCorr { .. } => "udr_corr",
355            ResolutionFamily::IkedaCarpenter { .. } => "ic",
356        }
357    }
358
359    /// `(start vector, box bounds)` for the optimizer (mirrors the validated
360    /// Python reference: `udr_corr` uses log-`s0`; bounds keep widths positive).
361    /// `cfg` supplies the starting PSR FWHM when the IC family fits it.
362    fn x0_bounds(&self, cfg: &CalibrationConfig) -> (Vec<f64>, Vec<(f64, f64)>) {
363        match self {
364            ResolutionFamily::Gaussian => (
365                vec![2.0, 1e-3],
366                vec![GAUSSIAN_DELTA_T_BOUNDS_US, GAUSSIAN_DELTA_L_BOUNDS_M],
367            ),
368            ResolutionFamily::UdrCorr { .. } => {
369                // (log s0, p): s0 = exp(log_s0) clamped to [0.2, 5].
370                (
371                    vec![0.0, 0.0],
372                    vec![(UDR_S0_MIN.ln(), UDR_S0_MAX.ln()), (-4.0, 4.0)],
373                )
374            }
375            ResolutionFamily::IkedaCarpenter { fit_psr } => {
376                let mut x0 = vec![IC_A0_X0.ln(), IC_A1_X0.ln(), IC_BETA_X0.ln(), IC_R_X0];
377                let mut bounds = vec![
378                    (IC_A0_MIN.ln(), IC_A0_MAX.ln()),
379                    (IC_A1_MIN.ln(), IC_A1_MAX.ln()),
380                    (IC_BETA_MIN.ln(), IC_BETA_MAX.ln()),
381                    (IC_R_MIN, IC_R_MAX),
382                ];
383                if *fit_psr {
384                    // cfg.psr_fwhm_ns > 0 is guaranteed here (fit_psr with a
385                    // zero width is rejected up front — "0 disables" cannot
386                    // silently become a fitted 0.05 µs). A positive start
387                    // outside the fit box — legal as a PIN up to
388                    // PSR_FWHM_PIN_CEILING_US — is CLAMPED to the nearer box
389                    // edge, not rejected (#645 round 4, F3), and the clamp is
390                    // announced on stderr: a clamped start that never leaves
391                    // its edge additionally surfaces as "psr_fwhm_us:lower" /
392                    // ":upper" in `CalibrationResult::bounds_hit`.
393                    let start_us = cfg.psr_fwhm_ns * NS_TO_US;
394                    let clamped_us = start_us.clamp(PSR_FWHM_US_MIN, PSR_FWHM_US_MAX);
395                    if clamped_us != start_us {
396                        eprintln!(
397                            "warning: fit_psr starting width psr_fwhm_ns = {} ns lies \
398                             outside the PSR fit box [{PSR_FWHM_US_MIN}, {PSR_FWHM_US_MAX}] µs; \
399                             starting the fit at the nearer box edge ({clamped_us} µs). A fit \
400                             that stays there reports \"psr_fwhm_us:lower\" / \":upper\" in \
401                             bounds_hit.",
402                            cfg.psr_fwhm_ns
403                        );
404                    }
405                    x0.push(clamped_us);
406                    bounds.push((PSR_FWHM_US_MIN, PSR_FWHM_US_MAX));
407                }
408                (x0, bounds)
409            }
410        }
411    }
412}
413
414/// Configuration for [`calibrate_resolution`].
415#[derive(Debug, Clone)]
416pub struct CalibrationConfig {
417    /// Flight-path length (m).
418    pub flight_path_m: f64,
419    /// Fit a low-order baseline (anorm + constant + linear) instead of anorm only.
420    pub fit_background: bool,
421    /// Number of optimizer restarts (perturbed starts; keep the best).
422    pub restarts: usize,
423    /// Nelder–Mead simplex-spread tolerance.
424    pub xatol: f64,
425    /// Nelder–Mead objective-range tolerance.
426    pub fatol: f64,
427    /// Nelder–Mead maximum iterations.
428    pub max_iter: usize,
429    /// IC synthesis grid resolution (energies × τ-samples per kernel).
430    pub ic_n_energies: usize,
431    pub ic_n_tau: usize,
432    /// SNS PSR (accumulator-ring) channel-triangle FWHM in **ns**, folded into
433    /// the **IC family only** (default [`DEFAULT_PSR_FWHM_NS`]; `0.0`
434    /// disables the fold). Tabulated/UDR (FTS) kernels already carry the fold
435    /// in the file itself (header: "folded triang FWHM 350 ns PSR") and are
436    /// structurally never re-folded here — applying it twice would
437    /// double-count the burst. When the family is
438    /// `IkedaCarpenter { fit_psr: true }` this value is the fit's starting
439    /// point instead of a pin, clamped into the 0.05–1 µs fit box: a width
440    /// in (1, 10] µs is a legal pin but an out-of-box start — the fit then
441    /// starts at the box top (announced by a stderr warning), and if it
442    /// stays there it reports `psr_fwhm_us:upper` in
443    /// [`CalibrationResult::bounds_hit`]. Nonzero widths above
444    /// [`PSR_FWHM_PIN_CEILING_US`] (10 µs = 10 000 ns) are rejected as a
445    /// ns↔µs unit slip — see that constant for the quadratic-cost rationale.
446    pub psr_fwhm_ns: f64,
447    /// Fit the SAMMY TOF-zero `t0` (µs) as a SHARED energy-scale parameter.
448    /// **Default `false`** — position is pinned at
449    /// [`position_t0_center_us`](Self::position_t0_center_us) so calibration is a
450    /// pure shape/width fit (matching SAMMY, where `t0`/`L` are a separate
451    /// energy-scale calibration). Opt in only *with* a metrology prior; see
452    /// [`with_position_prior`](CalibrationConfig::with_position_prior).
453    pub fit_t0: bool,
454    /// Fit the flight-path scale `L_scale` as a shared energy-scale parameter.
455    /// **Default `false`.** A free `L_scale` shares the asymmetric-kernel lag's
456    /// `1/√E` basis and corrupts the calibrated width — fit it only under a prior.
457    pub fit_l_scale: bool,
458    /// Prior mean (and pinned value when [`fit_t0`](Self::fit_t0) is false) of the
459    /// TOF zero `t0` (µs). Default `0.0`. Lets a caller inject a pre-calibrated `t0`.
460    pub position_t0_center_us: f64,
461    /// Prior mean (and pinned value when [`fit_l_scale`](Self::fit_l_scale) is
462    /// false) of `L_scale`. Default `1.0`.
463    pub position_l_scale_center: f64,
464    /// Gaussian prior σ on `t0` (µs); `None` = flat (bounded only). When set, adds
465    /// `((t0 − center)/σ)²` to the data χ² (a metrology penalty, *not* part of the
466    /// reported `chi2_dof`).
467    pub position_t0_prior_us: Option<f64>,
468    /// Gaussian prior σ on `L_scale`; `None` = flat. See [`position_t0_prior_us`](Self::position_t0_prior_us).
469    pub position_l_scale_prior: Option<f64>,
470    /// Also measure [`CalibrationResult::intervals`]. Default `false`.
471    ///
472    /// Finding the solution and measuring how well it is determined are two
473    /// separate measurements, and the second costs several times the first:
474    /// every trial point along a coordinate re-minimizes the others. A caller
475    /// that only needs the calibrated resolution to pin into a sample fit
476    /// does not pay for it.
477    pub intervals: bool,
478}
479
480impl Default for CalibrationConfig {
481    fn default() -> Self {
482        // Matches the validated Python calibrator (fatol=1e-3, not the
483        // NelderMeadConfig default 1e-4). The IC synthesis grid is DELIBERATELY
484        // lighter than the standalone IkedaCarpenter default (64×500 here vs the
485        // DEFAULT_N_ENERGIES×DEFAULT_N_TAU = 64×600 synthesis default): the outer
486        // loop re-synthesizes the kernel on every evaluation, and 500 τ-samples is
487        // ample for χ²/dof comparison.
488        Self {
489            flight_path_m: 25.0,
490            fit_background: false,
491            restarts: 1,
492            xatol: 1e-4,
493            fatol: 1e-3,
494            max_iter: 800,
495            ic_n_energies: 64,
496            ic_n_tau: 500,
497            psr_fwhm_ns: DEFAULT_PSR_FWHM_NS,
498            // Position is PINNED by default: pure shape/width calibration on the
499            // (already energy-calibrated) grid. Energy-scale fitting is an explicit
500            // opt-in via `with_position_prior`.
501            fit_t0: false,
502            fit_l_scale: false,
503            position_t0_center_us: 0.0,
504            position_l_scale_center: 1.0,
505            position_t0_prior_us: None,
506            position_l_scale_prior: None,
507            intervals: false,
508        }
509    }
510}
511
512impl CalibrationConfig {
513    /// Enable a SHARED, metrology-priored energy-scale `(t0, L_scale)` fit: sets
514    /// [`fit_t0`](Self::fit_t0)/[`fit_l_scale`](Self::fit_l_scale), the prior means
515    /// (`*_center`), and the Gaussian prior σ. Use this for joint energy-scale or
516    /// cross-family identifiability work; the default config pins position (pure
517    /// shape/width calibration). Pass the prior σ from the instrument's independent
518    /// flight-path / timing metrology — a loose σ marginalizes position (weak,
519    /// honest shape-only discrimination), a tight σ pins it.
520    #[must_use]
521    pub fn with_position_prior(
522        mut self,
523        t0_center_us: f64,
524        l_scale_center: f64,
525        sigma_t0_us: f64,
526        sigma_l_scale: f64,
527    ) -> Self {
528        self.fit_t0 = true;
529        self.fit_l_scale = true;
530        self.position_t0_center_us = t0_center_us;
531        self.position_l_scale_center = l_scale_center;
532        self.position_t0_prior_us = Some(sigma_t0_us);
533        self.position_l_scale_prior = Some(sigma_l_scale);
534        self
535    }
536}
537
538/// Result of a resolution calibration.
539#[derive(Debug, Clone)]
540pub struct CalibrationResult {
541    /// Family label (`"gaussian"` | `"udr_corr"` | `"ic"`).
542    pub family: String,
543    /// Fitted parameter vector (raw optimizer space; see [`ResolutionFamily`]
544    /// and [`ResolutionFamily::param_names`]). For the IC family these are
545    /// ln/box-encoded — read decoded physical values off
546    /// [`resolution`](Self::resolution) instead of exponentiating by hand.
547    pub theta: Vec<f64>,
548    /// Reduced **data** χ²/dof of the best fit (after anorm/baseline). The
549    /// energy-scale prior penalty is *excluded* — it is reported separately as
550    /// [`prior_penalty`](Self::prior_penalty).
551    pub chi2_dof: f64,
552    /// The calibrated resolution, ready to pin into a sample fit.
553    pub resolution: ResolutionFunction,
554    /// Optimizer iterations of the winning restart.
555    pub iterations: usize,
556    /// Whether the winning restart self-converged.
557    pub converged: bool,
558    /// Fitted (or pinned) SAMMY energy-scale TOF zero `t0` (µs). Equals
559    /// `config.position_t0_center_us` when `fit_t0` is false (pinned). When fit, it
560    /// is a SHARED energy-scale parameter (not a per-family nuisance): the resonance
561    /// dip position is confounded with flight-path geometry (the asymmetric-kernel
562    /// lag is the same `1/√E` basis as `L_scale`), so `t0`/`L_scale` are constrained
563    /// by the metrology prior, not free.
564    pub position_t0_us: f64,
565    /// Fitted (or pinned) flight-path scale `L_scale`. Equals
566    /// `config.position_l_scale_center` when `fit_l_scale` is false.
567    pub position_l_scale: f64,
568    /// Gaussian-prior penalty `Σ((θ−center)/σ)²` on the fitted `(t0, L_scale)` at the
569    /// solution (0 when no position prior is active). `objective = χ²_data +
570    /// prior_penalty`; report it alongside `chi2_dof` so a large position move
571    /// (e.g. a wrong family needing ΔL/L ≫ the metrology σ) is visible, not hidden.
572    pub prior_penalty: f64,
573    /// Total outer-loop free parameters: resolution θ plus any FITTED position
574    /// coordinates (`t0`, `L_scale`). Makes cross-family χ² comparisons and
575    /// dof bookkeeping explicit now that families differ in size (IC is 4–5
576    /// parameters, Gaussian/UdrCorr are 2).
577    pub n_free_params: usize,
578    /// One-sigma interval of each fitted coordinate as absolute `(lower,
579    /// upper)` bounds, in the same raw optimizer space as
580    /// [`theta`](Self::theta) (plus any fitted `t0` / `L_scale`), one entry
581    /// per [`n_free_params`](Self::n_free_params).
582    ///
583    /// Each bound is where the objective, minimized over the other
584    /// coordinates, rises by one above its floor. The two sides are
585    /// independent numbers because the interval is genuinely asymmetric: a
586    /// kernel narrower than the line it broadens stops being visible, so the
587    /// objective is nearly flat below the intrinsic width and steep above it.
588    ///
589    /// A bound equal to the coordinate's box edge means the data does not
590    /// constrain that side at all.
591    ///
592    /// `None` when [`CalibrationConfig::intervals`] is off, or when the run
593    /// exhausted its iteration budget without self-converging (which
594    /// [`converged`](Self::converged) reports): an interval about a point
595    /// that was never shown to be a minimum is not an uncertainty.
596    ///
597    /// This is what a sample fit needs in order to carry the calibrated
598    /// resolution as a prior instead of pinning it. Pinning does not bias the
599    /// fitted temperature much, but it reports it as more certain than it is:
600    /// resolution width and temperature both broaden the line, so the
601    /// uncertainty that belongs to their degeneracy is dropped.
602    pub intervals: Option<Vec<(f64, f64)>>,
603    /// Coordinates that finished within `BOUND_HIT_REL_TOL·(hi−lo)` of a box
604    /// bound, as `"name:lower"` / `"name:upper"` (names from
605    /// [`ResolutionFamily::param_names`], plus `"t0_us"` / `"l_scale"` when
606    /// position is fitted). Empty = interior solution. A pinned bound makes a
607    /// degenerate calibration visible instead of silent: e.g. an eV-regime
608    /// calibrant with no storage tail drives `R → 0` (`"r:lower"`) — on that
609    /// β↔R ridge the storage term vanishes and β is unconstrained, so the
610    /// reported β must not be physically interpreted.
611    pub bounds_hit: Vec<String>,
612}
613
614fn build_resolution(
615    family: &ResolutionFamily,
616    theta: &[f64],
617    e_min: f64,
618    e_max: f64,
619    cfg: &CalibrationConfig,
620) -> Result<ResolutionFunction, FittingError> {
621    match family {
622        ResolutionFamily::Gaussian => {
623            let params =
624                ResolutionParams::new(cfg.flight_path_m, theta[0].abs(), theta[1].abs(), 0.0)
625                    .map_err(|e| FittingError::EvaluationFailed(format!("gaussian res: {e:?}")))?;
626            Ok(ResolutionFunction::Gaussian(params))
627        }
628        ResolutionFamily::UdrCorr { base } => {
629            let s0 = theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
630            let corrected = base
631                .width_corrected(s0, theta[1], UDR_E_REF)
632                .map_err(|e| FittingError::EvaluationFailed(format!("udr_corr width: {e}")))?;
633            Ok(ResolutionFunction::Tabulated(Arc::new(corrected)))
634        }
635        ResolutionFamily::IkedaCarpenter { fit_psr } => {
636            // θ = [ln a0, ln a1, ln β, R, (PSR FWHM µs iff fit_psr)] — see the
637            // IC_* constants for the bounds and their physics. Decoding the
638            // exp-encoded coordinates here (not in a new EnergyLaw variant)
639            // keeps the kernel physics in nereids-physics untouched: the
640            // optimizer space guarantees α(E) > 0 and β > 0 by construction.
641            let psr_us = if *fit_psr {
642                theta[4]
643            } else {
644                cfg.psr_fwhm_ns * NS_TO_US
645            };
646            let params = IkedaCarpenterParams {
647                alpha: EnergyLaw::SqrtE {
648                    a0: theta[0].exp(),
649                    a1: theta[1].exp(),
650                },
651                beta: EnergyLaw::Const(theta[2].exp()),
652                // Scalar R: a free κ in ExpMilliEv is unidentifiable in the eV
653                // regime (R ≡ 0 across 1–200 eV for ANY plausible κ); a scalar
654                // lets the calibrant decide whether a storage tail is present.
655                r: EnergyLaw::Const(theta[3]),
656                burst_sigma_us: None,
657                // SNS PSR channel-triangle fold (0 disables). IC family only —
658                // tabulated/UDR kernels already carry the fold in the file.
659                channel_fwhm_us: (psr_us > 0.0).then_some(psr_us),
660            };
661            let grid = SynthesisGrid {
662                e_min_ev: (e_min * 0.5).max(1e-3),
663                e_max_ev: e_max * 2.0,
664                n_energies: cfg.ic_n_energies,
665                n_tau: cfg.ic_n_tau,
666            };
667            let ic = IkedaCarpenter::new(params, cfg.flight_path_m, &grid)
668                .map_err(|e| FittingError::EvaluationFailed(format!("ic res: {e:?}")))?;
669            Ok(ResolutionFunction::IkedaCarpenter(Arc::new(ic)))
670        }
671    }
672}
673
674/// Weighted residual sum of squares after analytically profiling out `anorm`
675/// (+ optional constant+linear baseline): `data ≈ a·model (+ b0 + b1·x)`, weighted
676/// by `1/unc²`. Returns `(ssr, k)` where `k` is the number of linear nuisance
677/// columns (1 = anorm only, 3 = anorm+const+linear). This is the **raw** χ² (not
678/// divided by dof) so an energy-scale **prior penalty** can be added to it in the
679/// same units before the optimizer minimizes — adding a penalty to a *reduced* χ²
680/// would silently rescale the prior by the dof. Returns `None` on a singular
681/// normal-equations system (a degenerate/constant model column), so the caller can
682/// treat the point as infeasible rather than as a spuriously zeroed fit.
683fn inner_ssr(data: &[f64], unc: &[f64], model: &[f64], fit_bg: bool) -> Option<(f64, usize)> {
684    let n = data.len();
685    let k = if fit_bg { 3 } else { 1 };
686    let mut ata = vec![0.0f64; k * k];
687    let mut atb = vec![0.0f64; k];
688    for i in 0..n {
689        let w2 = 1.0 / unc[i].max(1e-9).powi(2);
690        let x = if n > 1 {
691            -1.0 + 2.0 * (i as f64) / ((n - 1) as f64)
692        } else {
693            0.0
694        };
695        let col = [model[i], 1.0, x];
696        for a in 0..k {
697            atb[a] += w2 * col[a] * data[i];
698            for b in 0..k {
699                ata[a * k + b] += w2 * col[a] * col[b];
700            }
701        }
702    }
703    let coef = solve_small(&ata, &atb, k)?;
704    let mut ssr = 0.0;
705    for i in 0..n {
706        let w2 = 1.0 / unc[i].max(1e-9).powi(2);
707        let x = if n > 1 {
708            -1.0 + 2.0 * (i as f64) / ((n - 1) as f64)
709        } else {
710            0.0
711        };
712        let pred = if fit_bg {
713            coef[0] * model[i] + coef[1] + coef[2] * x
714        } else {
715            coef[0] * model[i]
716        };
717        ssr += (data[i] - pred).powi(2) * w2;
718    }
719    Some((ssr, k))
720}
721
722/// Reduced χ²/dof = [`inner_ssr`] `/ (n − k − n_res_params)`, ∞ on a singular
723/// system. `n_res_params` counts the outer-loop parameters (resolution + any free
724/// position) which are not in the linear system but still consume dof. Test-only:
725/// the calibrator minimizes raw `inner_ssr` (+ prior) and reduces at the solution.
726#[cfg(test)]
727fn inner_chi2(data: &[f64], unc: &[f64], model: &[f64], fit_bg: bool, n_res_params: usize) -> f64 {
728    match inner_ssr(data, unc, model, fit_bg) {
729        Some((ssr, k)) => {
730            let dof = data.len().saturating_sub(k + n_res_params).max(1) as f64;
731            ssr / dof
732        }
733        None => f64::INFINITY,
734    }
735}
736
737/// Gaussian-prior penalty `Σ((θ − center)/σ)²` on the fitted energy-scale
738/// `(t0, L_scale)`. Only active coordinates (fit + prior σ set) contribute; a flat
739/// (σ = `None`) or pinned coordinate contributes 0.
740fn position_prior_penalty(t0_us: f64, l_scale: f64, cfg: &CalibrationConfig) -> f64 {
741    let mut penalty = 0.0;
742    if cfg.fit_t0
743        && let Some(sigma) = cfg.position_t0_prior_us
744    {
745        penalty += ((t0_us - cfg.position_t0_center_us) / sigma).powi(2);
746    }
747    if cfg.fit_l_scale
748        && let Some(sigma) = cfg.position_l_scale_prior
749    {
750        penalty += ((l_scale - cfg.position_l_scale_center) / sigma).powi(2);
751    }
752    penalty
753}
754
755/// Optimizer box for the Gaussian timing width `delta_t_us`.
756///
757/// The upper edge is what the auxiliary grid can carry: the Gaussian
758/// broadening grid is extended by five sigma at each boundary, so its point
759/// count grows with the width, and a forward model at 50 µs already costs two
760/// orders of magnitude more than one at 1 µs on a typical eV-range grid. Any
761/// fit that frees this width uses this box, so none of them can wander into a
762/// grid the machine cannot hold.
763pub const GAUSSIAN_DELTA_T_BOUNDS_US: (f64, f64) = (1.0e-3, 50.0);
764
765/// Optimizer box for the Gaussian flight-path width `delta_l_m`.
766///
767/// Zero is a real value here — a beamline with no measurable path spread —
768/// and the upper edge bounds the same grid growth as
769/// [`GAUSSIAN_DELTA_T_BOUNDS_US`].
770pub const GAUSSIAN_DELTA_L_BOUNDS_M: (f64, f64) = (0.0, 0.5);
771
772/// Rise in the objective that marks one sigma of a single coordinate, the
773/// others minimized over. `chi^2 = -2 ln L` up to a constant, so one sigma is
774/// a rise of one.
775const PROFILE_DELTA_CHI2: f64 = 1.0;
776
777/// First trial displacement of the bracketing search, as a fraction of the
778/// coordinate's own magnitude. It doubles from there until it crosses the
779/// target or reaches the box edge.
780const PROFILE_BRACKET_START_FRACTION: f64 = 1.0e-2;
781
782/// Floor on the magnitude the first displacement is taken from, so a
783/// coordinate sitting near zero still gets a finite one.
784const PROFILE_MIN_SCALE: f64 = 1.0e-3;
785
786/// Bisections inside the bracket. The bracket is a factor of two wide, so
787/// this locates the crossing to about a percent of it.
788const PROFILE_BISECTION_STEPS: usize = 5;
789
790/// Iteration cap for the minimization over the other coordinates at each
791/// trial point.
792const PROFILE_INNER_MAX_ITER: usize = 60;
793
794/// One-sigma interval for each fitted coordinate, from the rise of the
795/// objective rather than its curvature at the minimum.
796///
797/// For a Gaussian likelihood `chi^2 = -2 ln L` up to a constant, so the
798/// one-sigma interval of a coordinate is where the objective, minimized over
799/// every other coordinate, rises by one above its floor. Each bound is found
800/// by bisecting on that crossing.
801///
802/// The interval is followed rather than inferred from the curvature at the
803/// minimum, because the objective is not quadratic out to one sigma: a kernel
804/// narrower than the line it broadens stops being visible, so the objective
805/// flattens below the intrinsic width, while above it the dip smears and the
806/// objective climbs steeply. Across that turn the two sides differ by more
807/// than an order of magnitude.
808///
809/// Bounds are absolute values in the same raw optimizer space as
810/// [`CalibrationResult::theta`]. A side whose crossing lies outside the box
811/// is reported as the box edge: the data does not bound the coordinate there,
812/// and the interval touching an edge is how the caller sees it.
813///
814/// Returns `None` when the floor cannot be evaluated or a profile
815/// minimization fails.
816fn profile_intervals<F>(
817    objective: &mut F,
818    theta: &[f64],
819    bounds: &[(f64, f64)],
820    floor: f64,
821    nm: &NelderMeadConfig,
822) -> Option<Vec<(f64, f64)>>
823where
824    F: FnMut(&[f64]) -> Result<f64, FittingError>,
825{
826    let k = theta.len();
827    if k == 0 || bounds.len() != k || !floor.is_finite() {
828        return None;
829    }
830    let target = floor + PROFILE_DELTA_CHI2;
831    // The minimization at each trial point starts from the solution and only
832    // has to slide along the valley, so it is capped well below the search
833    // that found the solution.
834    let inner_nm = NelderMeadConfig {
835        max_iter: nm.max_iter.min(PROFILE_INNER_MAX_ITER),
836        ..nm.clone()
837    };
838
839    let mut intervals = Vec::with_capacity(k);
840    for i in 0..k {
841        let free_bounds: Vec<(f64, f64)> = (0..k).filter(|&j| j != i).map(|j| bounds[j]).collect();
842
843        // The objective at `theta[i] = fixed`, minimized over the rest. The
844        // minimizer starts from where the previous trial point left it: the
845        // trials walk along one valley, so its solution is the next one's
846        // neighbourhood.
847        let profiled = |fixed: f64, start: &mut Vec<f64>, objective: &mut F| -> Option<f64> {
848            let mut at = theta.to_vec();
849            at[i] = fixed;
850            if k == 1 {
851                return objective(&at).ok().filter(|v| v.is_finite());
852            }
853            let mut inner = |x: &[f64]| -> Result<f64, FittingError> {
854                let mut full = at.clone();
855                for (slot, &v) in (0..k).filter(|&j| j != i).zip(x) {
856                    full[slot] = v;
857                }
858                objective(&full)
859            };
860            let res =
861                nelder_mead_minimize(&mut inner, start, Some(&free_bounds), &inner_nm).ok()?;
862            if !res.fun.is_finite() {
863                return None;
864            }
865            *start = res.x;
866            Some(res.fun)
867        };
868
869        // Walk outward from the solution, doubling the displacement, until
870        // the profile clears the target; then bisect the last bracket. An
871        // edge reached without clearing it means the data does not bound that
872        // side, and the edge is the answer.
873        let side = |edge: f64, objective: &mut F| -> Option<f64> {
874            let reach = (edge - theta[i]).abs();
875            if reach == 0.0 {
876                return Some(edge);
877            }
878            let direction = (edge - theta[i]).signum();
879            let at = |d: f64| theta[i] + direction * d;
880
881            let mut start: Vec<f64> = (0..k).filter(|&j| j != i).map(|j| theta[j]).collect();
882            let mut inside = 0.0_f64;
883            let mut step =
884                (PROFILE_BRACKET_START_FRACTION * theta[i].abs().max(PROFILE_MIN_SCALE)).min(reach);
885            let mut outside = loop {
886                if profiled(at(step), &mut start, objective)? > target {
887                    break step;
888                }
889                inside = step;
890                // The edge itself was the last trial and the objective has
891                // still not risen: the data does not bound this side.
892                if step >= reach {
893                    return Some(edge);
894                }
895                step = (step * 2.0).min(reach);
896            };
897            for _ in 0..PROFILE_BISECTION_STEPS {
898                let mid = 0.5 * (inside + outside);
899                if profiled(at(mid), &mut start, objective)? <= target {
900                    inside = mid;
901                } else {
902                    outside = mid;
903                }
904            }
905            Some(at(0.5 * (inside + outside)))
906        };
907
908        intervals.push((side(bounds[i].0, objective)?, side(bounds[i].1, objective)?));
909    }
910    Some(intervals)
911}
912
913/// Solve a small `k×k` linear system `A x = b` (k ≤ 3) by Gaussian elimination
914/// with partial pivoting. Returns `None` on a singular system.
915fn solve_small(a: &[f64], b: &[f64], k: usize) -> Option<Vec<f64>> {
916    // Relative pivot threshold scaled by the matrix norm, so ill-conditioned
917    // systems (not just exactly-singular ones) are reported infeasible.
918    let scale = a
919        .iter()
920        .fold(0.0_f64, |m, &v| m.max(v.abs()))
921        .max(f64::MIN_POSITIVE);
922    let mut m = a.to_vec();
923    let mut y = b.to_vec();
924    for col in 0..k {
925        let mut piv = col;
926        for r in (col + 1)..k {
927            if m[r * k + col].abs() > m[piv * k + col].abs() {
928                piv = r;
929            }
930        }
931        if m[piv * k + col].abs() < 1e-12 * scale {
932            return None;
933        }
934        if piv != col {
935            for c in 0..k {
936                m.swap(piv * k + c, col * k + c);
937            }
938            y.swap(piv, col);
939        }
940        for r in (col + 1)..k {
941            let f = m[r * k + col] / m[col * k + col];
942            for c in col..k {
943                m[r * k + c] -= f * m[col * k + c];
944            }
945            y[r] -= f * y[col];
946        }
947    }
948    let mut x = vec![0.0; k];
949    for col in (0..k).rev() {
950        let mut s = y[col];
951        for c in (col + 1)..k {
952            s -= m[col * k + c] * x[c];
953        }
954        x[col] = s / m[col * k + col];
955    }
956    Some(x)
957}
958
959/// Calibrate the resolution parameters of `family` against a known-(ρ,T)
960/// calibrant.
961///
962/// `sample` carries the FIXED density and temperature (and isotopes/groups). By
963/// default **only the resolution shape/width is optimized**, at the pinned energy
964/// scale `(t0, L_scale) = (center, center)` — a pure broadening calibration on an
965/// already energy-calibrated grid (this is the SAMMY split: resolution is a
966/// broadening kernel; `t0`/`L` are a *separate* energy-scale calibration).
967///
968/// Set [`CalibrationConfig::fit_t0`]/[`fit_l_scale`](CalibrationConfig::fit_l_scale)
969/// (e.g. via [`CalibrationConfig::with_position_prior`]) to *also* fit the SHARED
970/// energy-scale `(t0, L_scale)` under a Gaussian metrology prior — for joint
971/// energy-scale work or a cross-family identifiability study. Do **not** fit
972/// position with a flat prior in production: the asymmetric-kernel mode→centroid
973/// lag is the same `1/√E` basis as `L_scale`, so a free `L_scale` absorbs the lag
974/// and corrupts the calibrated width.
975///
976/// The IC family fits the full bounded moderator shape (#642): `α(E) =
977/// e^{θ0}√E + e^{θ1}` — positive at every energy by construction — plus free
978/// bounded `β = e^{θ2}` and scalar storage fraction `R = θ3 ∈ [0, 1]`, folded
979/// with the SNS PSR channel triangle
980/// ([`CalibrationConfig::psr_fwhm_ns`], default 350 ns; `0` disables;
981/// optionally fitted via `IkedaCarpenter { fit_psr: true }` — a zero width
982/// combined with `fit_psr` contradicts "0 disables" and is rejected).
983///
984/// Returns the fitted shape parameters, the reduced **data** χ²/dof, the fitted (or
985/// pinned) `(t0, L_scale)`, the prior penalty, the calibrated
986/// [`ResolutionFunction`] (ready to pin), the free-parameter count
987/// ([`CalibrationResult::n_free_params`]), and the pinned-bound report
988/// ([`CalibrationResult::bounds_hit`]).
989///
990/// # Errors
991/// [`FittingError::EmptyData`] / [`FittingError::LengthMismatch`] for bad
992/// inputs; [`FittingError::InvalidConfig`] for a bad grid or position config;
993/// propagates optimizer errors.
994pub fn calibrate_resolution(
995    family: ResolutionFamily,
996    energies: &[f64],
997    data: &[f64],
998    unc: &[f64],
999    sample: &SampleParams,
1000    config: &CalibrationConfig,
1001) -> Result<CalibrationResult, FittingError> {
1002    if data.is_empty() {
1003        return Err(FittingError::EmptyData);
1004    }
1005    if energies.len() != data.len() || unc.len() != data.len() {
1006        return Err(FittingError::LengthMismatch {
1007            expected: data.len(),
1008            actual: energies.len().min(unc.len()),
1009            field: "energies/unc vs data",
1010        });
1011    }
1012    // Reject non-finite inputs up front: a NaN datum would otherwise propagate
1013    // to a NaN χ², and since `NaN < x` is false the optimizer could retain it as
1014    // "best" and return a NaN-objective fit silently.
1015    if !energies.iter().all(|v| v.is_finite())
1016        || !data.iter().all(|v| v.is_finite())
1017        || !unc.iter().all(|v| v.is_finite() && *v > 0.0)
1018    {
1019        return Err(FittingError::InvalidConfig(
1020            "energies, data must be finite and uncertainty finite and > 0".into(),
1021        ));
1022    }
1023    // Energy grid must be strictly positive and strictly ascending — mirror the
1024    // Python entry point's `validate_energy_grid` so both public APIs reject the
1025    // same inputs up front. Without this, a zero/negative energy panics deep in
1026    // the Reich–Moore cross-section assert, a descending grid errors late as a
1027    // generic "forward model failed", and duplicate energies are silently
1028    // accepted (the recurring NEREIDS sibling-path validation gap).
1029    if energies[0] <= 0.0 {
1030        return Err(FittingError::InvalidConfig(
1031            "energies must be strictly positive".into(),
1032        ));
1033    }
1034    if !energies.windows(2).all(|w| w[1] > w[0]) {
1035        return Err(FittingError::InvalidConfig(
1036            "energies must be strictly ascending (no duplicates)".into(),
1037        ));
1038    }
1039    // The calibrant must have at least one isotope with a finite, positive areal
1040    // density. Otherwise `forward_model` skips every isotope (thickness ≤ 0) and
1041    // returns a flat T≡1 that is independent of the resolution parameters, so the
1042    // optimizer would converge to a finite but physically meaningless result —
1043    // silently masking a whole-config error. Mirrors the Python wrapper's guard.
1044    if !sample
1045        .isotopes()
1046        .iter()
1047        .any(|(_, density)| density.is_finite() && *density > 0.0)
1048    {
1049        return Err(FittingError::InvalidConfig(
1050            "calibrant must have at least one isotope with a finite, positive density".into(),
1051        ));
1052    }
1053    // Reject under-determined calibrants: need strictly more data points than the
1054    // total free parameters (resolution + any *fitted* position + anorm/baseline).
1055    let n_res = family.n_params();
1056    let n_pos = usize::from(config.fit_t0) + usize::from(config.fit_l_scale);
1057    let baseline_cols = if config.fit_background { 3 } else { 1 };
1058    if data.len() <= n_res + n_pos + baseline_cols {
1059        return Err(FittingError::InvalidConfig(format!(
1060            "calibrant has {} points but the model has {} resolution + {} position + {} baseline \
1061             parameters; need strictly more data points than parameters",
1062            data.len(),
1063            n_res,
1064            n_pos,
1065            baseline_cols,
1066        )));
1067    }
1068    // Flight path is a physical positive length. A non-positive / non-finite value
1069    // would invert the t0 feasibility bound below (min_tof < 0 ⇒ t0_hi < t0_lo ⇒
1070    // `clamp(lo, hi)` panic) as soon as `fit_t0` appends a bounded coordinate, and
1071    // otherwise only surfaces as a generic "no finite-objective" error. Reject it
1072    // precisely up front (covers every family and the fit/pin paths alike).
1073    if !(config.flight_path_m.is_finite() && config.flight_path_m > 0.0) {
1074        return Err(FittingError::InvalidConfig(
1075            "flight_path_m must be finite and > 0".into(),
1076        ));
1077    }
1078    // PSR triangle width: finite and >= 0 (0.0 disables the fold). A NaN or
1079    // negative width would otherwise flow into IkedaCarpenter::new on every
1080    // evaluation and only surface as the generic "no finite-objective
1081    // resolution" error. Validated for every family (it is inert outside IC)
1082    // so a mis-set config is caught regardless of the family under test.
1083    if !config.psr_fwhm_ns.is_finite() || config.psr_fwhm_ns < 0.0 {
1084        return Err(FittingError::InvalidConfig(format!(
1085            "psr_fwhm_ns must be finite and >= 0 (0 disables the PSR fold), got {}",
1086            config.psr_fwhm_ns
1087        )));
1088    }
1089    // Sanity ceiling on the width itself (see PSR_FWHM_PIN_CEILING_US):
1090    // psr_fwhm_ns is NANOSECONDS and synthesis cost is quadratic in the fold
1091    // width, so a µs-as-ns unit slip pins a fictitious multi-hundred-µs fold
1092    // that hangs the calibration for hours. Reject loudly, up front, for
1093    // every family (inert outside IC, same rationale as the checks above).
1094    if config.psr_fwhm_ns * NS_TO_US > PSR_FWHM_PIN_CEILING_US {
1095        return Err(FittingError::InvalidConfig(format!(
1096            "psr_fwhm_ns = {} ns (= {} µs) exceeds the {PSR_FWHM_PIN_CEILING_US} µs sanity \
1097             ceiling (10x the {PSR_FWHM_US_MAX} µs fit bound). psr_fwhm_ns is in NANOSECONDS \
1098             — the SNS/VENUS FTS convention is 350 ns — and kernel-synthesis cost grows \
1099             quadratically with the fold width, so a µs-as-ns unit slip would hang the \
1100             calibration behind a fictitious fold. Pass the width in ns, or 0 to disable \
1101             the PSR fold",
1102            config.psr_fwhm_ns,
1103            config.psr_fwhm_ns * NS_TO_US
1104        )));
1105    }
1106    // fit_psr fits the PSR FWHM from the psr_fwhm_ns starting value, but 0 is
1107    // documented as "no fold": a zero start would be silently clamped into the
1108    // [PSR_FWHM_US_MIN, PSR_FWHM_US_MAX] fit box, contradicting the "0
1109    // disables" contract. Reject the contradiction loudly.
1110    if matches!(family, ResolutionFamily::IkedaCarpenter { fit_psr: true })
1111        && config.psr_fwhm_ns == 0.0
1112    {
1113        return Err(FittingError::InvalidConfig(
1114            "fit_psr requires a positive psr_fwhm_ns starting value (psr_fwhm_ns = 0 disables \
1115             the PSR fold; use fit_psr = false to calibrate without one)"
1116                .into(),
1117        ));
1118    }
1119    // IC synthesis-grid resolution: validate up front for the IC family (inert
1120    // for the others) so an out-of-range value gives this precise error instead
1121    // of every IkedaCarpenter::new evaluation failing into the generic late
1122    // "no finite-objective resolution" error. Thresholds mirror both
1123    // IkedaCarpenter::new (n_energies >= 2, n_tau >= 8) and the Python
1124    // binding's sibling validation, so the two public entry points reject the
1125    // same inputs.
1126    if matches!(family, ResolutionFamily::IkedaCarpenter { .. }) {
1127        if config.ic_n_energies < 2 {
1128            return Err(FittingError::InvalidConfig(format!(
1129                "ic_n_energies must be >= 2 for the IC family, got {}",
1130                config.ic_n_energies
1131            )));
1132        }
1133        if config.ic_n_tau < 8 {
1134            return Err(FittingError::InvalidConfig(format!(
1135                "ic_n_tau must be >= 8 for the IC family, got {}",
1136                config.ic_n_tau
1137            )));
1138        }
1139    }
1140    // Validate the energy-scale (t0, L_scale) prior/center configuration up front.
1141    if !config.position_t0_center_us.is_finite()
1142        || !config.position_l_scale_center.is_finite()
1143        || config.position_l_scale_center <= 0.0
1144    {
1145        return Err(FittingError::InvalidConfig(
1146            "position centers must be finite and the L_scale center > 0".into(),
1147        ));
1148    }
1149    if config.position_t0_center_us.abs() >= POSITION_T0_US_MAX {
1150        return Err(FittingError::InvalidConfig(format!(
1151            "position_t0_center_us must lie within ±{POSITION_T0_US_MAX} µs"
1152        )));
1153    }
1154    if config.position_l_scale_center < POSITION_L_SCALE_MIN
1155        || config.position_l_scale_center > POSITION_L_SCALE_MAX
1156    {
1157        return Err(FittingError::InvalidConfig(format!(
1158            "position_l_scale_center must lie within [{POSITION_L_SCALE_MIN}, {POSITION_L_SCALE_MAX}]"
1159        )));
1160    }
1161    for (sigma, name) in [
1162        (config.position_t0_prior_us, "position_t0_prior_us"),
1163        (config.position_l_scale_prior, "position_l_scale_prior"),
1164    ] {
1165        if let Some(s) = sigma
1166            && !(s.is_finite() && s > 0.0)
1167        {
1168            return Err(FittingError::InvalidConfig(format!(
1169                "{name} must be finite and > 0 when set"
1170            )));
1171        }
1172    }
1173
1174    let e_min = energies.first().copied().unwrap_or(1.0);
1175    let e_max = energies.last().copied().unwrap_or(1.0);
1176    // Feasible t0 upper bound: the corrected TOF `tof − t0` must stay positive for
1177    // every energy, i.e. `t0 < min(tof) = TOF_FACTOR·L/√E_max`. Far outside ±5 µs in
1178    // the eV regime, but clamp defensively so a wide window can never make it bite.
1179    let min_tof = TOF_FACTOR * config.flight_path_m / e_max.max(1e-12).sqrt();
1180    // The (pinned or prior-mean) t0 center must itself be feasible: corrected_energy_grid
1181    // needs `t0 < min(tof)` for every energy. In the eV regime min_tof ≫ 5 µs, but a
1182    // short flight path or very high E_max can shrink it — reject up front with a precise
1183    // message instead of a late, generic "corrected TOF ≤ 0" from the final recompute.
1184    if config.position_t0_center_us >= min_tof {
1185        return Err(FittingError::InvalidConfig(format!(
1186            "position_t0_center_us ({:.3} µs) must be below the shortest flight time \
1187             min_tof = TOF_FACTOR·L/√E_max = {min_tof:.3} µs",
1188            config.position_t0_center_us
1189        )));
1190    }
1191    let t0_lo = -POSITION_T0_US_MAX;
1192    let t0_hi = POSITION_T0_US_MAX.min(min_tof - 1e-6);
1193
1194    // Optimizer coordinates: [resolution params (n_res)..., t0?, L_scale?]. A
1195    // position coordinate is appended only when fit; otherwise it is pinned at its
1196    // center. (Position is a SHARED energy-scale parameter, not a per-family
1197    // nuisance — fitting it is an explicit, prior-constrained opt-in.)
1198    let (mut x0, mut bounds) = family.x0_bounds(config);
1199    if config.fit_t0 {
1200        x0.push(config.position_t0_center_us.clamp(t0_lo, t0_hi));
1201        bounds.push((t0_lo, t0_hi));
1202    }
1203    if config.fit_l_scale {
1204        x0.push(
1205            config
1206                .position_l_scale_center
1207                .clamp(POSITION_L_SCALE_MIN, POSITION_L_SCALE_MAX),
1208        );
1209        bounds.push((POSITION_L_SCALE_MIN, POSITION_L_SCALE_MAX));
1210    }
1211    // PRE-FLIGHT the start (#645 round 3, F1): synthesize the resolution once
1212    // at x0 before any optimization. A start whose kernel cannot be
1213    // synthesized — e.g. any PSR triangle under ~58.6 ns: the default β/R
1214    // start (β = 0.1, R = 0.1) spans a 16/β = 160 µs storage tail, capping
1215    // the τ-step at 160/8191 ≈ 19.53 ns, above such a triangle's FWHM/3
1216    // resolution floor (note the PSR fit-box floor 0.05 µs = 50 ns is ITSELF
1217    // in this class, so a `>= PSR_FWHM_US_MIN` value check could not cover
1218    // it) — passes every value-level config check above yet makes EVERY
1219    // initial-simplex vertex infeasible (∞ objective): the Nelder–Mead
1220    // objective range is then ∞ − ∞ = NaN, so it can never self-converge,
1221    // burns max_iter, and used to die late with the generic "no
1222    // finite-objective" error blaming the forward model. Reject the START
1223    // precisely instead, surfacing the τ-geometry/synthesis diagnosis. A θ
1224    // that becomes infeasible only DURING the search remains an ∞ point the
1225    // simplex steps away from (see the objective below) — this pre-flight
1226    // rejects only an infeasible start.
1227    if let Err(synth_err) = build_resolution(&family, &x0, e_min, e_max, config) {
1228        let psr_note = if matches!(family, ResolutionFamily::IkedaCarpenter { .. }) {
1229            format!(
1230                " The starting PSR width comes from psr_fwhm_ns = {} ns — widen the \
1231                 triangle (the SNS/VENUS FTS convention is 350 ns), or pass 0 to \
1232                 disable the fold when not fitting it.",
1233                config.psr_fwhm_ns
1234            )
1235        } else {
1236            String::new()
1237        };
1238        return Err(FittingError::InvalidConfig(format!(
1239            "resolution kernel synthesis is infeasible at the starting parameter \
1240             vector, so every optimizer restart would begin from an all-infeasible \
1241             simplex: {synth_err}.{psr_note}"
1242        )));
1243    }
1244    let nm = NelderMeadConfig {
1245        xatol: config.xatol,
1246        fatol: config.fatol,
1247        max_iter: config.max_iter,
1248        ..Default::default()
1249    };
1250
1251    // Read the (possibly pinned) position coordinates out of an optimizer vector.
1252    let unpack_position = |theta: &[f64]| -> (f64, f64) {
1253        let mut idx = n_res;
1254        let t0 = if config.fit_t0 {
1255            let v = theta[idx];
1256            idx += 1;
1257            v
1258        } else {
1259            config.position_t0_center_us
1260        };
1261        let l_scale = if config.fit_l_scale {
1262            theta[idx]
1263        } else {
1264            config.position_l_scale_center
1265        };
1266        (t0, l_scale)
1267    };
1268
1269    let mut best: Option<NelderMeadResult> = None;
1270    // Hoisted out of the restart loop: the objective does not depend on the
1271    // restart, and the covariance at the end needs the same function that was
1272    // minimized rather than a rebuilt copy of it.
1273    let mut obj = |theta: &[f64]| -> Result<f64, FittingError> {
1274        // theta = [resolution params (n_res)..., t0?, L_scale?]. The resolution
1275        // kernel uses only the first n_res; (t0, L_scale) set the energy scale.
1276        // An UNRESOLVABLE θ — nereids-physics rejects a kernel whose τ-grid
1277        // cannot resolve the requested fold / prompt core within the
1278        // MAX_TAU_SAMPLES cap (e.g. a fitted PSR at its 0.05 µs floor
1279        // against β at its own floor) — is an infeasible POINT of the
1280        // search, not a broken calibration: step away (mirrors the
1281        // corrected-TOF ≤ 0 guard below). Config-level failures cannot
1282        // reach here: they are rejected up front by calibrate_resolution.
1283        let Ok(res) = build_resolution(&family, theta, e_min, e_max, config) else {
1284            return Ok(f64::INFINITY);
1285        };
1286        let inst = InstrumentParams { resolution: res };
1287        let (t0, l_scale) = unpack_position(theta);
1288        // Infeasible energy scale (corrected TOF ≤ 0) → step away.
1289        let Ok(grid) = corrected_energy_grid(energies, t0, l_scale, config.flight_path_m) else {
1290            return Ok(f64::INFINITY);
1291        };
1292        let model = forward_model(&grid, sample, Some(&inst))
1293            .map_err(|e| FittingError::EvaluationFailed(format!("forward: {e:?}")))?;
1294        if !model.iter().all(|v| v.is_finite()) {
1295            return Err(FittingError::EvaluationFailed("non-finite model".into()));
1296        }
1297        // Minimize RAW χ²_data + metrology prior penalty (same units — adding
1298        // the penalty to a reduced χ² would rescale the prior by the dof).
1299        let Some((ssr, _k)) = inner_ssr(data, unc, &model, config.fit_background) else {
1300            return Ok(f64::INFINITY);
1301        };
1302        Ok(ssr + position_prior_penalty(t0, l_scale, config))
1303    };
1304    for r in 0..config.restarts.max(1) {
1305        // Additive perturbation (a fraction of each parameter's bound range) so
1306        // restarts move even for zero-valued start components — a multiplicative
1307        // `x0·(1+0.1r)` left `udr_corr`'s `[0, 0]` start identical every restart.
1308        let start: Vec<f64> = x0
1309            .iter()
1310            .zip(&bounds)
1311            .map(|(&v, &(lo, hi))| (v + 0.1 * r as f64 * (hi - lo)).clamp(lo, hi))
1312            .collect();
1313        let mut res = nelder_mead_minimize(&mut obj, &start, Some(&bounds), &nm)?;
1314        // Simplex RE-INFLATION: Nelder–Mead's known failure mode is premature
1315        // simplex collapse — the spread criteria are met (`self_converged`)
1316        // at a point that is NOT the basin minimum. Observed on the
1317        // 4-parameter IC family: a 300 K synthetic calibrant stalled at
1318        // Δχ² ≈ +130 above the noise floor in the curved α↔β↔R valley, and
1319        // the ~1.5 % kernel-width error re-expressed as a ~23 K temperature
1320        // bias in the downstream pinned fit. Standard cure: restart a FRESH,
1321        // *larger* simplex at the incumbent (same 5 % edge re-collapses to
1322        // the same trap deterministically) and keep the improvement, until
1323        // it stops helping (bounded by MAX_SIMPLEX_REINFLATIONS). A
1324        // re-inflation from a true minimum re-contracts quickly, so the
1325        // extra cost there is small.
1326        let reinflate_nm = NelderMeadConfig {
1327            initial_step_frac: REINFLATE_STEP_FRAC,
1328            initial_step_abs: REINFLATE_STEP_ABS,
1329            ..nm.clone()
1330        };
1331        for _ in 0..MAX_SIMPLEX_REINFLATIONS {
1332            let again = nelder_mead_minimize(&mut obj, &res.x, Some(&bounds), &reinflate_nm)?;
1333            let improved = again.fun + nm.fatol < res.fun;
1334            res.iterations += again.iterations;
1335            res.n_evals += again.n_evals;
1336            if improved {
1337                res.x = again.x;
1338                res.fun = again.fun;
1339                res.self_converged = again.self_converged;
1340            } else {
1341                break;
1342            }
1343        }
1344        if best.as_ref().is_none_or(|b| res.fun < b.fun) {
1345            best = Some(res);
1346        }
1347    }
1348    let best = best.expect("at least one restart runs");
1349    if !best.fun.is_finite() {
1350        // Every ∞ source of the objective (#645 round 3 F1, round 4 F1):
1351        // `nelder_mead_minimize` maps every objective `Err` — forward-model
1352        // failures included — to an infeasible +∞ point rather than aborting
1353        // (see the `eval` closure in `nelder_mead.rs`), so no failure class
1354        // raises its own error during the search. Reaching here means every
1355        // vector tried hit one of them — kernel synthesis rejected (τ-grid
1356        // cap vs fold/prompt geometry), an invalid energy scale (corrected
1357        // TOF ≤ 0), a singular anorm/baseline system, or a forward-model
1358        // (transmission) failure. The start itself synthesized (pre-flighted
1359        // above), so the infeasibility arose during the search.
1360        return Err(FittingError::EvaluationFailed(
1361            "calibration found no finite-objective resolution: every parameter vector \
1362             tried was infeasible — kernel synthesis rejected it (τ-grid cap vs \
1363             fold/prompt geometry), the energy scale was invalid (corrected TOF ≤ 0), \
1364             the anorm/baseline system was singular, or the forward model failed"
1365                .into(),
1366        ));
1367    }
1368    let (position_t0_us, position_l_scale) = unpack_position(&best.x);
1369    let prior_penalty = position_prior_penalty(position_t0_us, position_l_scale, config);
1370    // Recompute the reduced DATA χ²/dof at the solution: the objective carries the
1371    // prior penalty, so `best.fun` is the penalized objective, not the data χ². dof
1372    // subtracts the linear anorm/baseline columns AND the outer-loop params
1373    // (resolution + any fitted position).
1374    let resolution = build_resolution(&family, &best.x, e_min, e_max, config)?;
1375    let grid = corrected_energy_grid(
1376        energies,
1377        position_t0_us,
1378        position_l_scale,
1379        config.flight_path_m,
1380    )?;
1381    let inst = InstrumentParams { resolution };
1382    let model = forward_model(&grid, sample, Some(&inst))
1383        .map_err(|e| FittingError::EvaluationFailed(format!("forward: {e:?}")))?;
1384    let (ssr, k) = inner_ssr(data, unc, &model, config.fit_background).ok_or_else(|| {
1385        FittingError::EvaluationFailed("singular anorm/baseline at the solution".into())
1386    })?;
1387    let dof = data.len().saturating_sub(k + n_res + n_pos).max(1) as f64;
1388    let chi2_dof = ssr / dof;
1389    let theta = best.x[..n_res].to_vec();
1390    // Bound-pinning report: label every optimizer coordinate that finished
1391    // within BOUND_HIT_REL_TOL·(hi−lo) of its box bound. This is the
1392    // degeneracy flag for the wider IC family (e.g. "r:lower" ⇒ the β↔R ridge:
1393    // no storage tail in the data, β unconstrained).
1394    let mut coord_names = family.param_names();
1395    if config.fit_t0 {
1396        coord_names.push("t0_us");
1397    }
1398    if config.fit_l_scale {
1399        coord_names.push("l_scale");
1400    }
1401    let bounds_hit: Vec<String> = best
1402        .x
1403        .iter()
1404        .zip(&bounds)
1405        .zip(&coord_names)
1406        .flat_map(|((&v, &(lo, hi)), name)| {
1407            let tol = BOUND_HIT_REL_TOL * (hi - lo);
1408            let mut hits = Vec::new();
1409            if v - lo <= tol {
1410                hits.push(format!("{name}:lower"));
1411            }
1412            if hi - v <= tol {
1413                hits.push(format!("{name}:upper"));
1414            }
1415            hits
1416        })
1417        .collect();
1418    // Interval in the same coordinates the optimizer used. `obj` is the raw
1419    // chi-squared plus the position prior, so this is the interval of exactly
1420    // what was minimized.
1421    //
1422    // A run that exhausted its iteration budget has not shown it reached a
1423    // minimum — Nelder-Mead stops wherever it happens to be, and a rise
1424    // measured from an unverified floor is not a calibrated uncertainty.
1425    let intervals = if config.intervals && best.self_converged {
1426        profile_intervals(&mut obj, &best.x, &bounds, best.fun, &nm)
1427    } else {
1428        None
1429    };
1430
1431    Ok(CalibrationResult {
1432        family: family.label().to_string(),
1433        theta,
1434        chi2_dof,
1435        intervals,
1436        resolution: inst.resolution,
1437        iterations: best.iterations,
1438        converged: best.self_converged,
1439        position_t0_us,
1440        position_l_scale,
1441        prior_penalty,
1442        n_free_params: n_res + n_pos,
1443        bounds_hit,
1444    })
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450    use nereids_endf::resonance::test_support::synthetic_isotope;
1451
1452    /// Decode the calibrated IC parameters `(a0, a1, β, R, psr_fwhm_us)` off
1453    /// the result's resolution — the single source of truth (raw `theta` is
1454    /// ln/box-encoded).
1455    fn decoded_ic(r: &CalibrationResult) -> (f64, f64, f64, f64, f64) {
1456        let ResolutionFunction::IkedaCarpenter(ic) = &r.resolution else {
1457            panic!("expected an IC resolution for family {}", r.family);
1458        };
1459        let p = ic.params();
1460        let EnergyLaw::SqrtE { a0, a1 } = p.alpha else {
1461            panic!("expected a SqrtE alpha law");
1462        };
1463        let EnergyLaw::Const(rr) = p.r else {
1464            panic!("expected a Const R law");
1465        };
1466        let EnergyLaw::Const(beta) = p.beta else {
1467            panic!("expected a Const beta law");
1468        };
1469        (a0, a1, beta, rr, p.channel_fwhm_us.unwrap_or(0.0))
1470    }
1471
1472    fn synthetic_base_udr() -> TabulatedResolution {
1473        // Asymmetric kernel (sharp rise, +TOF tail), at two reference energies.
1474        let offs = vec![-1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5];
1475        let wts = vec![0.05, 0.3, 1.0, 0.8, 0.5, 0.3, 0.15, 0.05];
1476        TabulatedResolution::from_kernels(
1477            vec![5.0, 50.0],
1478            vec![(offs.clone(), wts.clone()), (offs, wts)],
1479            25.0,
1480        )
1481        .unwrap()
1482    }
1483
1484    #[test]
1485    fn inner_chi2_zero_on_exact_anorm() {
1486        let model = vec![0.9, 0.7, 0.5, 0.8];
1487        let data: Vec<f64> = model.iter().map(|m| 1.0 * m).collect();
1488        let unc = vec![0.01; 4];
1489        assert!(inner_chi2(&data, &unc, &model, false, 0) < 1e-18);
1490    }
1491
1492    /// #645 round 4, F3: a `fit_psr` starting width outside the 0.05–1 µs fit
1493    /// box (legal as a PIN up to [`PSR_FWHM_PIN_CEILING_US`]) is clamped to
1494    /// the nearer box edge — documented behavior, not an error.
1495    #[test]
1496    fn fit_psr_out_of_box_start_clamps_to_box_edge() {
1497        let family = ResolutionFamily::IkedaCarpenter { fit_psr: true };
1498        // 5 000 ns = 5 µs: a valid pin width, above the 1 µs fit-box top.
1499        let above = CalibrationConfig {
1500            psr_fwhm_ns: 5_000.0,
1501            ..CalibrationConfig::default()
1502        };
1503        let (x0, bounds) = family.x0_bounds(&above);
1504        assert_eq!(x0.len(), 5);
1505        assert_eq!(x0[4], PSR_FWHM_US_MAX);
1506        assert_eq!(bounds[4], (PSR_FWHM_US_MIN, PSR_FWHM_US_MAX));
1507        // 10 ns: below the 50 ns identifiability floor — clamped UP.
1508        let below = CalibrationConfig {
1509            psr_fwhm_ns: 10.0,
1510            ..CalibrationConfig::default()
1511        };
1512        let (x0, _) = family.x0_bounds(&below);
1513        assert_eq!(x0[4], PSR_FWHM_US_MIN);
1514        // The in-box default (350 ns) passes through unclamped.
1515        let inside = CalibrationConfig::default();
1516        let (x0, _) = family.x0_bounds(&inside);
1517        assert_eq!(x0[4], DEFAULT_PSR_FWHM_NS * NS_TO_US);
1518    }
1519
1520    #[test]
1521    fn udr_corr_recovers_known_width_scale() {
1522        // Loop-closure / OPTIMIZER test: truth and fit both use width_corrected, so
1523        // this checks that the calibrator finds the s0=1.5 minimum — NOT that
1524        // width_corrected itself is physically correct. The width-scale physics
1525        // (centroid invariance + std scaling) is independently verified by
1526        // `width_corrected_preserves_centroid_scales_width_and_energy_dependence`
1527        // in nereids-physics.
1528        // Two well-separated resonances (15 + 45 eV) so the width is identifiable
1529        // (a single resonance leaves a width↔position ridge). Position is pinned by
1530        // default. Calibrant generated with a UDR truth scaled by s0=1.5; udr_corr
1531        // must recover s0≈1.5 at χ²≈0.
1532        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1533        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1534        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1535        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1536        let base = synthetic_base_udr();
1537        let truth = ResolutionFunction::Tabulated(Arc::new(
1538            base.width_corrected(1.5, 0.0, UDR_E_REF).unwrap(),
1539        ));
1540        let data = forward_model(
1541            &energies,
1542            &sample,
1543            Some(&InstrumentParams { resolution: truth }),
1544        )
1545        .unwrap();
1546        let unc = vec![0.004; energies.len()];
1547
1548        let cfg = CalibrationConfig {
1549            restarts: 2,
1550            ..Default::default()
1551        };
1552        let r = calibrate_resolution(
1553            ResolutionFamily::UdrCorr {
1554                base: Arc::new(base),
1555            },
1556            &energies,
1557            &data,
1558            &unc,
1559            &sample,
1560            &cfg,
1561        )
1562        .unwrap();
1563        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1564        assert!((s0 - 1.5).abs() < 0.05, "recovered s0={s0}, expected 1.5");
1565        assert!(r.chi2_dof < 1e-2, "matched χ²/dof={} too high", r.chi2_dof);
1566        assert!(matches!(r.resolution, ResolutionFunction::Tabulated(_)));
1567    }
1568
1569    #[test]
1570    fn udr_corr_recovers_known_width_scale_and_exponent() {
1571        // Two resonances at well-separated energies make the width EXPONENT p
1572        // identifiable — a single resonance constrains only s(E) at one energy (a
1573        // ridge in (s0, p)). Truth: s0=1.3, p=-0.5; the calibrator must recover
1574        // both (the s0-only test never exercised the p knob).
1575        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1576        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1577        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1578        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1579        let base = synthetic_base_udr();
1580        let (s0_true, p_true) = (1.3, -0.5);
1581        let truth = ResolutionFunction::Tabulated(Arc::new(
1582            base.width_corrected(s0_true, p_true, UDR_E_REF).unwrap(),
1583        ));
1584        let data = forward_model(
1585            &energies,
1586            &sample,
1587            Some(&InstrumentParams { resolution: truth }),
1588        )
1589        .unwrap();
1590        let unc = vec![0.004; energies.len()];
1591        let cfg = CalibrationConfig {
1592            restarts: 3,
1593            ..Default::default()
1594        };
1595        let r = calibrate_resolution(
1596            ResolutionFamily::UdrCorr {
1597                base: Arc::new(base),
1598            },
1599            &energies,
1600            &data,
1601            &unc,
1602            &sample,
1603            &cfg,
1604        )
1605        .unwrap();
1606        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1607        let p = r.theta[1];
1608        assert!(
1609            (s0 - s0_true).abs() < 0.1,
1610            "recovered s0={s0}, expected {s0_true}"
1611        );
1612        assert!(
1613            (p - p_true).abs() < 0.2,
1614            "recovered p={p}, expected {p_true}"
1615        );
1616        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1617    }
1618
1619    #[test]
1620    fn udr_corr_recovers_independent_raw_kernel() {
1621        // External-oracle coverage: the truth resolution is the RAW hand-built UDR
1622        // kernel broadened directly — it does NOT pass through `width_corrected`,
1623        // so truth-generation no longer shares the width-correction code with the
1624        // fit. Fitting udr_corr against that base must recover the identity width
1625        // (s0≈1) at χ²≈0. (The broadening OPERATOR itself is independently
1626        // validated by this crate's bit-exact `broaden_presorted_reference` tests.)
1627        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1628        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1629        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1630        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1631        let base = synthetic_base_udr();
1632        // Truth = the RAW base kernel (no width_corrected call).
1633        let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1634        let data = forward_model(
1635            &energies,
1636            &sample,
1637            Some(&InstrumentParams { resolution: truth }),
1638        )
1639        .unwrap();
1640        let unc = vec![0.004; energies.len()];
1641        let cfg = CalibrationConfig {
1642            restarts: 3,
1643            ..Default::default()
1644        };
1645        let r = calibrate_resolution(
1646            ResolutionFamily::UdrCorr {
1647                base: Arc::new(base),
1648            },
1649            &energies,
1650            &data,
1651            &unc,
1652            &sample,
1653            &cfg,
1654        )
1655        .unwrap();
1656        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1657        assert!((s0 - 1.0).abs() < 0.1, "recovered s0={s0}, expected ~1.0");
1658        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1659    }
1660
1661    #[test]
1662    fn gaussian_recovers_known_width() {
1663        // Gaussian loop-closure: a Gaussian truth must be recovered by the gaussian
1664        // family (the smoke test only checked finiteness+convergence). Two
1665        // resonances break the Δt/ΔL degeneracy (Δt is flat in TOF; ΔL scales with
1666        // TOF ∝ 1/√E).
1667        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1668        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1669        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1670        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1671        let (dt_true, dl_true) = (1.5, 1.0e-3);
1672        let truth = ResolutionFunction::Gaussian(
1673            ResolutionParams::new(25.0, dt_true, dl_true, 0.0).unwrap(),
1674        );
1675        let data = forward_model(
1676            &energies,
1677            &sample,
1678            Some(&InstrumentParams { resolution: truth }),
1679        )
1680        .unwrap();
1681        let unc = vec![0.004; energies.len()];
1682        let cfg = CalibrationConfig {
1683            restarts: 3,
1684            ..Default::default()
1685        };
1686        let r = calibrate_resolution(
1687            ResolutionFamily::Gaussian,
1688            &energies,
1689            &data,
1690            &unc,
1691            &sample,
1692            &cfg,
1693        )
1694        .unwrap();
1695        let (dt, dl) = (r.theta[0].abs(), r.theta[1].abs());
1696        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1697        assert!(
1698            (dt - dt_true).abs() < 0.2,
1699            "recovered Δt={dt}, expected {dt_true}"
1700        );
1701        assert!(
1702            (dl - dl_true).abs() < 1.0e-3,
1703            "recovered ΔL={dl}, expected {dl_true}"
1704        );
1705    }
1706
1707    #[test]
1708    fn fit_t0_recovers_injected_energy_scale_shift() {
1709        // With fit_t0 enabled, an injected TOF-zero offset in the calibrant is
1710        // recovered as the SHARED energy-scale t0 while the width is still
1711        // recovered — position is a fitted energy-scale parameter (−t0 convention),
1712        // not folded into the resolution. (Default config pins position; this test
1713        // opts in.) L_scale stays pinned at 1.
1714        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1715        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1716        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1717        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1718        let base = synthetic_base_udr();
1719        let (s0_true, t0_inject) = (1.4, 1.5_f64); // µs (energy-scale −t0 convention)
1720        let truth = ResolutionFunction::Tabulated(Arc::new(
1721            base.width_corrected(s0_true, 0.0, UDR_E_REF).unwrap(),
1722        ));
1723        // Calibrant generated on a grid displaced by the energy-scale t0.
1724        let shifted = corrected_energy_grid(&energies, t0_inject, 1.0, 25.0).unwrap();
1725        let data = forward_model(
1726            &shifted,
1727            &sample,
1728            Some(&InstrumentParams { resolution: truth }),
1729        )
1730        .unwrap();
1731        let unc = vec![0.004; energies.len()];
1732        // Opt into fitting t0 (flat prior); L_scale stays pinned at 1.
1733        let cfg = CalibrationConfig {
1734            restarts: 3,
1735            fit_t0: true,
1736            ..Default::default()
1737        };
1738        let r = calibrate_resolution(
1739            ResolutionFamily::UdrCorr {
1740                base: Arc::new(base),
1741            },
1742            &energies,
1743            &data,
1744            &unc,
1745            &sample,
1746            &cfg,
1747        )
1748        .unwrap();
1749        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1750        assert!(
1751            (s0 - s0_true).abs() < 0.1,
1752            "recovered s0={s0}, expected {s0_true}"
1753        );
1754        assert!(
1755            (r.position_t0_us - t0_inject).abs() < 0.3,
1756            "recovered t0={}, expected {t0_inject}",
1757            r.position_t0_us
1758        );
1759        assert!(
1760            (r.position_l_scale - 1.0).abs() < 1e-9,
1761            "L_scale should stay pinned at 1, got {}",
1762            r.position_l_scale
1763        );
1764        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1765    }
1766
1767    #[test]
1768    fn pinned_position_is_the_default_and_works_for_udr() {
1769        // The default config pins position (fit_t0/fit_l_scale = false) — a pure
1770        // shape/width fit. This is the no-position reference that the retired design
1771        // could NOT construct for the UDR family in Python (the width-correction was
1772        // Rust-internal). Self-fit must recover s0≈1 with position reported at its
1773        // pinned center and zero prior penalty.
1774        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1775        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1776        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1777        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1778        let base = synthetic_base_udr();
1779        let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1780        let data = forward_model(
1781            &energies,
1782            &sample,
1783            Some(&InstrumentParams { resolution: truth }),
1784        )
1785        .unwrap();
1786        let unc = vec![0.004; energies.len()];
1787        let cfg = CalibrationConfig::default();
1788        assert!(!cfg.fit_t0 && !cfg.fit_l_scale, "default must pin position");
1789        let r = calibrate_resolution(
1790            ResolutionFamily::UdrCorr {
1791                base: Arc::new(base),
1792            },
1793            &energies,
1794            &data,
1795            &unc,
1796            &sample,
1797            &cfg,
1798        )
1799        .unwrap();
1800        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1801        assert!((s0 - 1.0).abs() < 0.1, "recovered s0={s0}, expected ~1.0");
1802        assert_eq!(r.position_t0_us, 0.0, "t0 pinned at center 0");
1803        assert_eq!(r.position_l_scale, 1.0, "L_scale pinned at center 1");
1804        assert_eq!(r.prior_penalty, 0.0, "no prior active when pinned");
1805        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1806    }
1807
1808    #[test]
1809    #[ignore = "slow; runs nightly"]
1810    fn free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination() {
1811        // The asymmetric IC mode→centroid lag is pure 1/√E — the SAME basis as an
1812        // L_scale error. So a Gaussian fitting an IC-broadened calibrant fits much
1813        // BETTER when L_scale is free than when position is pinned: a free physical
1814        // position lets the wrong (symmetric) family buy back the position evidence.
1815        // This is exactly why fitting position with a flat prior is unsafe for
1816        // family discrimination (and why the default pins it).
1817        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1818        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1819        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1820        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1821        let ic = IkedaCarpenter::new(
1822            IkedaCarpenterParams {
1823                alpha: EnergyLaw::SqrtE { a0: 0.30, a1: 0.0 },
1824                beta: EnergyLaw::Const(0.1),
1825                r: EnergyLaw::ExpMilliEv { kappa: 25.0 },
1826                burst_sigma_us: None,
1827                channel_fwhm_us: None,
1828            },
1829            25.0,
1830            &SynthesisGrid {
1831                e_min_ev: 4.0,
1832                e_max_ev: 100.0,
1833                n_energies: 64,
1834                n_tau: 500,
1835            },
1836        )
1837        .unwrap();
1838        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1839        let data = forward_model(
1840            &energies,
1841            &sample,
1842            Some(&InstrumentParams { resolution: truth }),
1843        )
1844        .unwrap();
1845        let unc = vec![0.004; energies.len()];
1846        let pinned = CalibrationConfig {
1847            restarts: 3,
1848            ..Default::default()
1849        };
1850        // Free physical position (flat priors): fit_t0 + fit_l_scale, sigmas None.
1851        let free_pos = CalibrationConfig {
1852            restarts: 3,
1853            fit_t0: true,
1854            fit_l_scale: true,
1855            ..Default::default()
1856        };
1857        let gau = |cfg: &CalibrationConfig| {
1858            calibrate_resolution(
1859                ResolutionFamily::Gaussian,
1860                &energies,
1861                &data,
1862                &unc,
1863                &sample,
1864                cfg,
1865            )
1866            .unwrap()
1867            .chi2_dof
1868        };
1869        let gau_pinned = gau(&pinned);
1870        let gau_free = gau(&free_pos);
1871        assert!(
1872            gau_free < 0.5 * gau_pinned,
1873            "free (t0,L_scale) should sharply erode the wrong-family penalty: \
1874             pinned χ²={gau_pinned}, free χ²={gau_free}"
1875        );
1876    }
1877
1878    #[test]
1879    fn position_prior_penalizes_displacement() {
1880        // A tight prior on t0 (center 0) penalizes a calibrant whose true t0 is
1881        // displaced: the fit cannot freely move to the displacement, so it pays a
1882        // prior penalty and leaves residual data χ². A loose prior recovers the
1883        // displacement with ~zero penalty. (Demonstrates the prior is the real
1884        // constraint on position, per the metrology-prior design.)
1885        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1886        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1887        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1888        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1889        let base = synthetic_base_udr();
1890        let t0_inject = 1.5_f64;
1891        let truth = ResolutionFunction::Tabulated(Arc::new(
1892            base.width_corrected(1.0, 0.0, UDR_E_REF).unwrap(),
1893        ));
1894        let shifted = corrected_energy_grid(&energies, t0_inject, 1.0, 25.0).unwrap();
1895        let data = forward_model(
1896            &shifted,
1897            &sample,
1898            Some(&InstrumentParams { resolution: truth }),
1899        )
1900        .unwrap();
1901        let unc = vec![0.004; energies.len()];
1902        let mk = |sigma_t0: f64| CalibrationConfig {
1903            restarts: 3,
1904            fit_t0: true,
1905            position_t0_prior_us: Some(sigma_t0),
1906            ..Default::default()
1907        };
1908        let mkbase = || ResolutionFamily::UdrCorr {
1909            base: Arc::new(base.clone()),
1910        };
1911        // Tight prior: σ must be small enough that the quadratic prior
1912        // curvature rivals the data-χ² curvature in t0, or the optimum
1913        // sits at the displacement and the pull is invisible. The
1914        // width-correct kernel interpolation sharpened the data term
1915        // (narrower between-reference kernels carry more positional
1916        // information than the over-wide chord blend used to), so the
1917        // binding regime needs a tighter σ than it once did.
1918        let tight =
1919            calibrate_resolution(mkbase(), &energies, &data, &unc, &sample, &mk(0.005)).unwrap();
1920        // Loose prior (σ=100 µs): recovers the displacement, ~no penalty.
1921        let loose =
1922            calibrate_resolution(mkbase(), &energies, &data, &unc, &sample, &mk(100.0)).unwrap();
1923        assert!(
1924            tight.prior_penalty > 1.0,
1925            "tight prior should incur a real penalty, got {}",
1926            tight.prior_penalty
1927        );
1928        assert!(
1929            tight.position_t0_us.abs() < t0_inject,
1930            "tight prior should pull t0 toward the center, got {}",
1931            tight.position_t0_us
1932        );
1933        assert!(
1934            (loose.position_t0_us - t0_inject).abs() < 0.3,
1935            "loose prior should recover the displacement, got {}",
1936            loose.position_t0_us
1937        );
1938        assert!(
1939            loose.prior_penalty < tight.prior_penalty,
1940            "loose penalty {} should be below tight penalty {}",
1941            loose.prior_penalty,
1942            tight.prior_penalty
1943        );
1944        assert!(
1945            loose.chi2_dof < tight.chi2_dof,
1946            "loose data χ² {} should beat tight data χ² {} (tight can't reach t0)",
1947            loose.chi2_dof,
1948            tight.chi2_dof
1949        );
1950    }
1951
1952    #[test]
1953    fn with_position_prior_builder_sets_fields() {
1954        let cfg = CalibrationConfig::default().with_position_prior(0.5, 1.001, 0.3, 0.002);
1955        assert!(cfg.fit_t0 && cfg.fit_l_scale);
1956        assert_eq!(cfg.position_t0_center_us, 0.5);
1957        assert_eq!(cfg.position_l_scale_center, 1.001);
1958        assert_eq!(cfg.position_t0_prior_us, Some(0.3));
1959        assert_eq!(cfg.position_l_scale_prior, Some(0.002));
1960    }
1961
1962    #[test]
1963    fn fit_l_scale_only_pins_t0() {
1964        // Per-coordinate control: fitting ONLY L_scale (fit_t0=false) must fit
1965        // position_l_scale while pinning t0 at its center — locks the
1966        // `unpack_position` indexing when only the SECOND position coordinate is
1967        // active (the single-coordinate path the round-2 review flagged).
1968        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1969        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1970        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1971        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1972        let base = synthetic_base_udr();
1973        let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1974        let data = forward_model(
1975            &energies,
1976            &sample,
1977            Some(&InstrumentParams { resolution: truth }),
1978        )
1979        .unwrap();
1980        let unc = vec![0.004; energies.len()];
1981        let cfg = CalibrationConfig {
1982            restarts: 2,
1983            fit_l_scale: true,
1984            ..Default::default()
1985        };
1986        let r = calibrate_resolution(
1987            ResolutionFamily::UdrCorr {
1988                base: Arc::new(base),
1989            },
1990            &energies,
1991            &data,
1992            &unc,
1993            &sample,
1994            &cfg,
1995        )
1996        .unwrap();
1997        assert_eq!(
1998            r.position_t0_us, 0.0,
1999            "t0 must stay pinned when fit_t0=false"
2000        );
2001        assert!(
2002            (r.position_l_scale - 1.0).abs() < 0.02,
2003            "L_scale fit within bound (~1 for a self-fit), got {}",
2004            r.position_l_scale
2005        );
2006        assert!(r.chi2_dof < 1e-1, "self-fit χ²/dof={} too high", r.chi2_dof);
2007    }
2008
2009    #[test]
2010    #[ignore = "slow; runs nightly"]
2011    fn cross_family_chi2_selects_the_true_shape() {
2012        // Model-family discrimination at a KNOWN (pinned) energy scale: an
2013        // asymmetric IC-broadened calibrant generated at the nominal position
2014        // (t0=0, L_scale=1) must be best-fit by the IC family and clearly worse by
2015        // the symmetric Gaussian. With position pinned (the default), the Gaussian
2016        // is penalized for both shape AND the asymmetry-induced dip shift it cannot
2017        // reproduce — legitimate here because the truth's position is known exactly.
2018        // (When position is uncertain, that shift is confounded with flight-path L —
2019        // see `free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination`.)
2020        // Truth has NO width-correction/Gaussian generator, so the Gaussian arm is a
2021        // genuinely different shape (not loop-closure).
2022        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
2023        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
2024        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
2025        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
2026        let ic = IkedaCarpenter::new(
2027            IkedaCarpenterParams {
2028                alpha: EnergyLaw::SqrtE { a0: 0.30, a1: 0.0 },
2029                beta: EnergyLaw::Const(0.1),
2030                r: EnergyLaw::ExpMilliEv { kappa: 25.0 },
2031                burst_sigma_us: None,
2032                channel_fwhm_us: None,
2033            },
2034            25.0,
2035            &SynthesisGrid {
2036                e_min_ev: 4.0,
2037                e_max_ev: 100.0,
2038                n_energies: 64,
2039                n_tau: 500,
2040            },
2041        )
2042        .unwrap();
2043        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
2044        let data = forward_model(
2045            &energies,
2046            &sample,
2047            Some(&InstrumentParams { resolution: truth }),
2048        )
2049        .unwrap();
2050        let unc = vec![0.004; energies.len()];
2051        // The truth kernel carries NO PSR fold, so disable the calibrator's
2052        // default 350 ns fold — otherwise the IC family could not close.
2053        let cfg = CalibrationConfig {
2054            restarts: 3,
2055            psr_fwhm_ns: 0.0,
2056            ..Default::default()
2057        };
2058        let chi2 = |fam| {
2059            calibrate_resolution(fam, &energies, &data, &unc, &sample, &cfg)
2060                .unwrap()
2061                .chi2_dof
2062        };
2063        let ic_chi2 = chi2(ResolutionFamily::IkedaCarpenter { fit_psr: false });
2064        let gau_chi2 = chi2(ResolutionFamily::Gaussian);
2065        assert!(
2066            ic_chi2 < gau_chi2,
2067            "true (IC) shape χ²={ic_chi2} should beat the Gaussian χ²={gau_chi2}"
2068        );
2069        assert!(
2070            ic_chi2 < 1.0,
2071            "IC (true shape) should fit well: χ²={ic_chi2}"
2072        );
2073    }
2074
2075    #[test]
2076    #[ignore = "slow; runs nightly"]
2077    fn gaussian_and_ic_families_run_and_converge() {
2078        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2079        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2080        let energies: Vec<f64> = (0..300).map(|i| 12.0 + i as f64 * 0.05).collect();
2081        let base = synthetic_base_udr();
2082        let truth = ResolutionFunction::Tabulated(Arc::new(
2083            base.width_corrected(1.2, 0.0, UDR_E_REF).unwrap(),
2084        ));
2085        let data = forward_model(
2086            &energies,
2087            &sample,
2088            Some(&InstrumentParams { resolution: truth }),
2089        )
2090        .unwrap();
2091        let unc = vec![0.004; energies.len()];
2092        let cfg = CalibrationConfig::default();
2093        for (fam, n_expected) in [
2094            (ResolutionFamily::Gaussian, 2),
2095            (ResolutionFamily::IkedaCarpenter { fit_psr: false }, 4),
2096        ] {
2097            let label = fam.label().to_string();
2098            let r = calibrate_resolution(fam, &energies, &data, &unc, &sample, &cfg).unwrap();
2099            assert!(r.chi2_dof.is_finite(), "{label} χ² not finite");
2100            assert_eq!(
2101                r.theta.len(),
2102                n_expected,
2103                "{label} should fit {n_expected} params"
2104            );
2105            assert_eq!(r.n_free_params, n_expected, "{label} n_free_params");
2106            // The objective is smooth and noise-free, so Nelder–Mead reaches its
2107            // tolerance well within max_iter — guard the "_and_converge" promise.
2108            assert!(r.converged, "{label} did not self-converge");
2109        }
2110    }
2111
2112    #[test]
2113    fn n_params_matches_family() {
2114        assert_eq!(ResolutionFamily::Gaussian.n_params(), 2);
2115        assert_eq!(
2116            ResolutionFamily::UdrCorr {
2117                base: Arc::new(synthetic_base_udr())
2118            }
2119            .n_params(),
2120            2
2121        );
2122        // IC fits θ = [ln a0, ln a1, ln β, R] (+ PSR FWHM iff fit_psr).
2123        assert_eq!(
2124            ResolutionFamily::IkedaCarpenter { fit_psr: false }.n_params(),
2125            4
2126        );
2127        assert_eq!(
2128            ResolutionFamily::IkedaCarpenter { fit_psr: true }.n_params(),
2129            5
2130        );
2131        // param_names track n_params, coordinate for coordinate.
2132        for fam in [
2133            ResolutionFamily::Gaussian,
2134            ResolutionFamily::IkedaCarpenter { fit_psr: false },
2135            ResolutionFamily::IkedaCarpenter { fit_psr: true },
2136        ] {
2137            assert_eq!(fam.param_names().len(), fam.n_params());
2138        }
2139        assert_eq!(
2140            ResolutionFamily::IkedaCarpenter { fit_psr: true }
2141                .param_names()
2142                .last()
2143                .copied(),
2144            Some("psr_fwhm_us")
2145        );
2146    }
2147
2148    #[test]
2149    fn rejects_empty_mismatched_and_non_finite_inputs() {
2150        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2151        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2152        let cfg = CalibrationConfig::default();
2153        assert!(matches!(
2154            calibrate_resolution(ResolutionFamily::Gaussian, &[], &[], &[], &sample, &cfg),
2155            Err(FittingError::EmptyData)
2156        ));
2157        let e = vec![1.0, 2.0, 3.0];
2158        let d = vec![0.5, 0.5];
2159        let u = vec![0.1, 0.1];
2160        assert!(matches!(
2161            calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg),
2162            Err(FittingError::LengthMismatch { .. })
2163        ));
2164        // Non-finite data and non-positive uncertainty are rejected up front.
2165        let e = vec![1.0, 2.0, 3.0];
2166        assert!(matches!(
2167            calibrate_resolution(
2168                ResolutionFamily::Gaussian,
2169                &e,
2170                &[0.5, f64::NAN, 0.7],
2171                &[0.1; 3],
2172                &sample,
2173                &cfg
2174            ),
2175            Err(FittingError::InvalidConfig(_))
2176        ));
2177        assert!(matches!(
2178            calibrate_resolution(
2179                ResolutionFamily::Gaussian,
2180                &e,
2181                &[0.5; 3],
2182                &[0.1, 0.0, 0.1],
2183                &sample,
2184                &cfg
2185            ),
2186            Err(FittingError::InvalidConfig(_))
2187        ));
2188    }
2189
2190    #[test]
2191    fn rejects_nonascending_and_nonpositive_energy_grid() {
2192        // Sibling-path parity with the Python `validate_energy_grid`: descending,
2193        // duplicate, zero, and negative energy grids must be rejected up front
2194        // rather than panicking deep in the cross-section assert or erroring late.
2195        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2196        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2197        let cfg = CalibrationConfig::default();
2198        for grid in [
2199            vec![4.0, 3.0, 2.0, 1.0],  // descending
2200            vec![1.0, 2.0, 2.0, 3.0],  // duplicate
2201            vec![0.0, 1.0, 2.0, 3.0],  // zero
2202            vec![-1.0, 1.0, 2.0, 3.0], // negative
2203        ] {
2204            let n = grid.len();
2205            assert!(
2206                matches!(
2207                    calibrate_resolution(
2208                        ResolutionFamily::Gaussian,
2209                        &grid,
2210                        &vec![0.5; n],
2211                        &vec![0.1; n],
2212                        &sample,
2213                        &cfg
2214                    ),
2215                    Err(FittingError::InvalidConfig(_))
2216                ),
2217                "expected InvalidConfig for grid {grid:?}"
2218            );
2219        }
2220    }
2221
2222    #[test]
2223    fn rejects_degenerate_calibrant_composition() {
2224        // A calibrant with no isotopes, or only zero/negative densities, yields a
2225        // flat (resolution-independent) forward model; the optimizer would return
2226        // a finite but meaningless result. Reject up front (Python-sibling parity).
2227        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2228        let energies: Vec<f64> = (0..64).map(|i| 5.0 + i as f64 * 0.4).collect();
2229        let data = vec![0.8; energies.len()];
2230        let unc = vec![0.01; energies.len()];
2231        let cfg = CalibrationConfig::default();
2232        for bad_sample in [
2233            SampleParams::new(300.0, vec![]).unwrap(),
2234            SampleParams::new(300.0, vec![(iso.clone(), 0.0)]).unwrap(),
2235            SampleParams::new(300.0, vec![(iso, -1.0e-3)]).unwrap(),
2236        ] {
2237            assert!(
2238                matches!(
2239                    calibrate_resolution(
2240                        ResolutionFamily::Gaussian,
2241                        &energies,
2242                        &data,
2243                        &unc,
2244                        &bad_sample,
2245                        &cfg
2246                    ),
2247                    Err(FittingError::InvalidConfig(_))
2248                ),
2249                "degenerate calibrant composition should be rejected"
2250            );
2251        }
2252    }
2253
2254    #[test]
2255    fn invalid_flight_path_propagates_build_error() {
2256        // flight_path <= 0 makes ResolutionParams::new fail on every eval, so the
2257        // calibration cannot build a resolution and returns an error.
2258        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2259        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2260        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2261        let d = vec![0.9; 60];
2262        let u = vec![0.01; 60];
2263        let cfg = CalibrationConfig {
2264            flight_path_m: -1.0,
2265            ..Default::default()
2266        };
2267        assert!(
2268            calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg).is_err()
2269        );
2270    }
2271
2272    #[test]
2273    fn inner_chi2_background_path_and_degenerate_model() {
2274        // 3-column baseline fit (anorm + const + linear) recovers an offset exactly.
2275        let model = vec![0.9, 0.7, 0.5, 0.8, 0.6];
2276        let data: Vec<f64> = model.iter().map(|m| 0.5 * m + 0.1).collect();
2277        let unc = vec![0.01; 5];
2278        assert!(inner_chi2(&data, &unc, &model, true, 0) < 1e-12);
2279        // all-zero model -> singular normal equations -> infeasible (χ²=∞), so the
2280        // optimizer steps away rather than seeing a spuriously inflated finite χ².
2281        let v = inner_chi2(&data, &unc, &[0.0; 5], false, 0);
2282        assert_eq!(v, f64::INFINITY);
2283    }
2284
2285    #[test]
2286    fn calibrate_with_background_runs() {
2287        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2288        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2289        let energies: Vec<f64> = (0..200).map(|i| 14.0 + i as f64 * 0.06).collect();
2290        let base = synthetic_base_udr();
2291        let truth = ResolutionFunction::Tabulated(Arc::new(
2292            base.width_corrected(1.3, 0.0, UDR_E_REF).unwrap(),
2293        ));
2294        let data = forward_model(
2295            &energies,
2296            &sample,
2297            Some(&InstrumentParams { resolution: truth }),
2298        )
2299        .unwrap();
2300        let unc = vec![0.004; energies.len()];
2301        let cfg = CalibrationConfig {
2302            fit_background: true,
2303            ..Default::default()
2304        };
2305        let r = calibrate_resolution(
2306            ResolutionFamily::UdrCorr {
2307                base: Arc::new(base),
2308            },
2309            &energies,
2310            &data,
2311            &unc,
2312            &sample,
2313            &cfg,
2314        )
2315        .unwrap();
2316        assert!(r.chi2_dof.is_finite());
2317    }
2318
2319    #[test]
2320    #[ignore = "slow; runs nightly"]
2321    fn ic_recovers_known_alpha() {
2322        // Loop-closure / optimizer test (same caveat as udr_corr): truth and fit
2323        // both use the IC synthesis, so this checks the optimizer recovers the
2324        // full bounded 4-parameter family (#642) — the IC pulse physics is
2325        // independently covered by the ic_pulse tests in nereids-physics.
2326        // Truth, all interior to the new boxes: a0=0.35, a1=0.05, β=0.1, R=0.1,
2327        // PSR triangle 0.35 µs (= the calibrator's default 350 ns pin).
2328        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2329        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2330        let energies: Vec<f64> = (0..400).map(|i| 12.0 + i as f64 * 0.04).collect();
2331        let cfg = CalibrationConfig {
2332            restarts: 2,
2333            ..Default::default()
2334        };
2335        // Truth kernel on the SAME derived grid the calibrator synthesizes on,
2336        // so the loop closes exactly (a grid mismatch would leak into recovery).
2337        let ic_truth = IkedaCarpenter::new(
2338            IkedaCarpenterParams {
2339                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2340                beta: EnergyLaw::Const(0.1),
2341                r: EnergyLaw::Const(0.1),
2342                burst_sigma_us: None,
2343                channel_fwhm_us: Some(0.35),
2344            },
2345            cfg.flight_path_m,
2346            &SynthesisGrid {
2347                e_min_ev: (energies[0] * 0.5).max(1e-3),
2348                e_max_ev: energies.last().unwrap() * 2.0,
2349                n_energies: cfg.ic_n_energies,
2350                n_tau: cfg.ic_n_tau,
2351            },
2352        )
2353        .unwrap();
2354        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2355        let data = forward_model(
2356            &energies,
2357            &sample,
2358            Some(&InstrumentParams { resolution: truth }),
2359        )
2360        .unwrap();
2361        let unc = vec![0.004; energies.len()];
2362        let r = calibrate_resolution(
2363            ResolutionFamily::IkedaCarpenter { fit_psr: false },
2364            &energies,
2365            &data,
2366            &unc,
2367            &sample,
2368            &cfg,
2369        )
2370        .unwrap();
2371        let (a0, a1, beta, rr, psr) = decoded_ic(&r);
2372        assert!((a0 - 0.35).abs() < 0.05, "recovered a0={a0}, expected 0.35");
2373        assert!(a1 > 0.0, "a1 positive by construction, got {a1}");
2374        // β and R shape the kernel only jointly through the storage tail
2375        // (weight R, decay 1/β), so their windows are deliberately loose.
2376        assert!(
2377            (beta - 0.1).abs() < 0.08,
2378            "recovered β={beta}, expected 0.1"
2379        );
2380        assert!((rr - 0.1).abs() < 0.08, "recovered R={rr}, expected 0.1");
2381        assert!(
2382            (psr - 0.35).abs() < 1e-12,
2383            "PSR pin {psr} µs != 0.35 µs (config default)"
2384        );
2385        assert_eq!(r.n_free_params, 4);
2386        assert!(
2387            r.bounds_hit.is_empty(),
2388            "interior truth must not pin bounds, got {:?}",
2389            r.bounds_hit
2390        );
2391        assert!(r.chi2_dof < 1.0, "matched χ²/dof={} too high", r.chi2_dof);
2392    }
2393
2394    #[test]
2395    #[ignore = "slow; runs nightly"]
2396    fn ic_recovers_known_psr_when_fit() {
2397        // Loop-closure / optimizer test for fit_psr (#645 F2, same caveat as
2398        // ic_recovers_known_alpha: truth and fit share the IC synthesis, so
2399        // this checks the 5-parameter optimizer, not the pulse physics).
2400        // Truth PSR FWHM = 0.6 µs — interior to the [0.05, 1] µs box and far
2401        // from the 0.35 µs default start — with the rest of the truth kernel
2402        // identical to ic_recovers_known_alpha. Two resonances (15 + 45 eV)
2403        // give the E-leverage that separates the E-independent triangle
2404        // width from the α(E) = a0·√E + a1 prompt law (a single resonance
2405        // probes the kernel at essentially one energy).
2406        // Full-density run (420 pts, 64×500 grid, restarts 2) recovers
2407        // psr = 0.5984 µs at χ²/dof ≈ 1e-6; this slimmed grid keeps the same
2408        // loop-closure semantics at a debug-friendly runtime.
2409        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
2410        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
2411        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
2412        let energies: Vec<f64> = (0..280).map(|i| 8.0 + i as f64 * 0.15).collect();
2413        let cfg = CalibrationConfig {
2414            restarts: 1,
2415            ic_n_energies: 32,
2416            ic_n_tau: 320,
2417            ..Default::default()
2418        };
2419        let psr_true = 0.6;
2420        let ic_truth = IkedaCarpenter::new(
2421            IkedaCarpenterParams {
2422                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2423                beta: EnergyLaw::Const(0.1),
2424                r: EnergyLaw::Const(0.1),
2425                burst_sigma_us: None,
2426                channel_fwhm_us: Some(psr_true),
2427            },
2428            cfg.flight_path_m,
2429            &SynthesisGrid {
2430                e_min_ev: (energies[0] * 0.5).max(1e-3),
2431                e_max_ev: energies.last().unwrap() * 2.0,
2432                n_energies: cfg.ic_n_energies,
2433                n_tau: cfg.ic_n_tau,
2434            },
2435        )
2436        .unwrap();
2437        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2438        let data = forward_model(
2439            &energies,
2440            &sample,
2441            Some(&InstrumentParams { resolution: truth }),
2442        )
2443        .unwrap();
2444        let unc = vec![0.004; energies.len()];
2445        let r = calibrate_resolution(
2446            ResolutionFamily::IkedaCarpenter { fit_psr: true },
2447            &energies,
2448            &data,
2449            &unc,
2450            &sample,
2451            &cfg,
2452        )
2453        .unwrap();
2454        let (a0, _a1, _beta, _rr, psr) = decoded_ic(&r);
2455        assert_eq!(r.n_free_params, 5);
2456        assert!(
2457            (psr - psr_true).abs() < 0.1,
2458            "recovered PSR FWHM {psr} µs, expected {psr_true} µs"
2459        );
2460        assert!((a0 - 0.35).abs() < 0.05, "recovered a0={a0}, expected 0.35");
2461        assert!(r.chi2_dof < 1.0, "matched χ²/dof={} too high", r.chi2_dof);
2462    }
2463
2464    #[test]
2465    #[ignore = "slow; runs nightly"]
2466    fn psr_disabled_at_zero_width() {
2467        // psr_fwhm_ns = 0.0 disables the triangle fold entirely: an UNFOLDED
2468        // truth is reproduced and the calibrated kernel carries no channel.
2469        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2470        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2471        let energies: Vec<f64> = (0..250).map(|i| 14.0 + i as f64 * 0.05).collect();
2472        let cfg = CalibrationConfig {
2473            psr_fwhm_ns: 0.0,
2474            ic_n_energies: 32,
2475            ic_n_tau: 300,
2476            ..Default::default()
2477        };
2478        let ic_truth = IkedaCarpenter::new(
2479            IkedaCarpenterParams {
2480                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2481                beta: EnergyLaw::Const(0.1),
2482                r: EnergyLaw::Const(0.1),
2483                burst_sigma_us: None,
2484                channel_fwhm_us: None, // unfolded truth
2485            },
2486            cfg.flight_path_m,
2487            &SynthesisGrid {
2488                e_min_ev: (energies[0] * 0.5).max(1e-3),
2489                e_max_ev: energies.last().unwrap() * 2.0,
2490                n_energies: cfg.ic_n_energies,
2491                n_tau: cfg.ic_n_tau,
2492            },
2493        )
2494        .unwrap();
2495        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2496        let data = forward_model(
2497            &energies,
2498            &sample,
2499            Some(&InstrumentParams { resolution: truth }),
2500        )
2501        .unwrap();
2502        let unc = vec![0.004; energies.len()];
2503        let r = calibrate_resolution(
2504            ResolutionFamily::IkedaCarpenter { fit_psr: false },
2505            &energies,
2506            &data,
2507            &unc,
2508            &sample,
2509            &cfg,
2510        )
2511        .unwrap();
2512        let ResolutionFunction::IkedaCarpenter(ic) = &r.resolution else {
2513            panic!("expected an IC resolution");
2514        };
2515        assert!(
2516            ic.params().channel_fwhm_us.is_none(),
2517            "psr_fwhm_ns = 0 must leave channel_fwhm_us = None, got {:?}",
2518            ic.params().channel_fwhm_us
2519        );
2520        assert!(
2521            r.chi2_dof < 1.0,
2522            "unfolded self-fit χ²/dof={} too high",
2523            r.chi2_dof
2524        );
2525    }
2526
2527    #[test]
2528    #[ignore = "slow; runs nightly"]
2529    fn bounds_hit_reports_pinned_parameter() {
2530        // A truth WITHOUT a storage tail (R = 0) drives the fitted R onto its
2531        // lower box bound; the result must say so ("r:lower") — the β↔R-ridge
2532        // degeneracy flag (with no tail, β is unconstrained).
2533        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2534        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2535        let energies: Vec<f64> = (0..250).map(|i| 14.0 + i as f64 * 0.05).collect();
2536        let cfg = CalibrationConfig {
2537            ic_n_energies: 32,
2538            ic_n_tau: 300,
2539            restarts: 2,
2540            ..Default::default()
2541        };
2542        let ic_truth = IkedaCarpenter::new(
2543            IkedaCarpenterParams {
2544                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2545                beta: EnergyLaw::Const(0.1), // irrelevant at R = 0 (no storage term)
2546                r: EnergyLaw::Const(0.0),
2547                burst_sigma_us: None,
2548                channel_fwhm_us: Some(0.35),
2549            },
2550            cfg.flight_path_m,
2551            &SynthesisGrid {
2552                e_min_ev: (energies[0] * 0.5).max(1e-3),
2553                e_max_ev: energies.last().unwrap() * 2.0,
2554                n_energies: cfg.ic_n_energies,
2555                n_tau: cfg.ic_n_tau,
2556            },
2557        )
2558        .unwrap();
2559        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2560        let data = forward_model(
2561            &energies,
2562            &sample,
2563            Some(&InstrumentParams { resolution: truth }),
2564        )
2565        .unwrap();
2566        let unc = vec![0.004; energies.len()];
2567        let r = calibrate_resolution(
2568            ResolutionFamily::IkedaCarpenter { fit_psr: false },
2569            &energies,
2570            &data,
2571            &unc,
2572            &sample,
2573            &cfg,
2574        )
2575        .unwrap();
2576        // At R_truth = 0 the β↔R ridge is FLAT: a fast-β storage term is
2577        // absorbable by the freed α(E) coefficients, so the optimizer's
2578        // endpoint — pinned on the r lower bound, or slightly interior —
2579        // depends on the kernel discretization. The physical contract is
2580        // that no MATERIAL storage fraction is claimed either way; the
2581        // deterministic positive pin for the bounds_hit labeling mechanism
2582        // is bounds_hit_labels_saturated_upper_bound below.
2583        let (_, _, beta_cal, r_cal, _) = decoded_ic(&r);
2584        assert!(
2585            r.bounds_hit.iter().any(|s| s == "r:lower") || (r_cal < 0.08 && beta_cal > 1.0),
2586            "R = 0 truth must not claim a material storage fraction: an \
2587             interior ridge endpoint is admissible only when the claimed \
2588             storage is small AND fast (β above the ~1.6 µs⁻¹ prompt rate, \
2589             i.e. absorbable) — a slow visible tail must fail. \
2590             bounds_hit = {:?}, decoded = {:?}",
2591            r.bounds_hit,
2592            decoded_ic(&r)
2593        );
2594    }
2595
2596    #[test]
2597    #[ignore = "slow; runs nightly"]
2598    fn bounds_hit_labels_saturated_upper_bound() {
2599        // A truth channel fold WIDER than the fitted PSR box (2.5 µs vs
2600        // PSR_FWHM_US_MAX = 1.0 µs) pulls the fitted width monotonically
2601        // into the upper bound: fold width is identifiable through the
2602        // kernel's second moment (unlike the β↔R ridge coordinates), so the
2603        // pin is deterministic — the positive test for the bounds_hit
2604        // labeling mechanism.
2605        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2606        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2607        let energies: Vec<f64> = (0..120).map(|i| 14.0 + i as f64 * 0.05).collect();
2608        let cfg = CalibrationConfig {
2609            ic_n_energies: 32,
2610            ic_n_tau: 300,
2611            restarts: 1,
2612            ..Default::default()
2613        };
2614        let ic_truth = IkedaCarpenter::new(
2615            IkedaCarpenterParams {
2616                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2617                beta: EnergyLaw::Const(0.1),
2618                r: EnergyLaw::Const(0.0),
2619                burst_sigma_us: None,
2620                channel_fwhm_us: Some(2.5),
2621            },
2622            cfg.flight_path_m,
2623            &SynthesisGrid {
2624                e_min_ev: (energies[0] * 0.5).max(1e-3),
2625                e_max_ev: energies.last().unwrap() * 2.0,
2626                n_energies: cfg.ic_n_energies,
2627                n_tau: cfg.ic_n_tau,
2628            },
2629        )
2630        .unwrap();
2631        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2632        let data = forward_model(
2633            &energies,
2634            &sample,
2635            Some(&InstrumentParams { resolution: truth }),
2636        )
2637        .unwrap();
2638        let unc = vec![0.004; energies.len()];
2639        let r = calibrate_resolution(
2640            ResolutionFamily::IkedaCarpenter { fit_psr: true },
2641            &energies,
2642            &data,
2643            &unc,
2644            &sample,
2645            &cfg,
2646        )
2647        .unwrap();
2648        assert!(
2649            r.bounds_hit.iter().any(|s| s == "psr_fwhm_us:upper"),
2650            "a truth fold wider than the PSR box must pin the upper bound: \
2651             bounds_hit = {:?}, decoded = {:?}",
2652            r.bounds_hit,
2653            decoded_ic(&r)
2654        );
2655    }
2656
2657    #[test]
2658    fn rejects_invalid_psr_fwhm_ns() {
2659        // NaN / negative / infinite PSR widths are config errors caught up
2660        // front (NaN would silently disable the `> 0.0` fold gate; a negative
2661        // width would fail deep in IkedaCarpenter::new on every evaluation).
2662        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2663        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2664        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2665        let d = vec![0.9; 60];
2666        let u = vec![0.01; 60];
2667        for bad in [f64::NAN, -1.0, f64::INFINITY] {
2668            let cfg = CalibrationConfig {
2669                psr_fwhm_ns: bad,
2670                ..Default::default()
2671            };
2672            assert!(
2673                matches!(
2674                    calibrate_resolution(
2675                        ResolutionFamily::IkedaCarpenter { fit_psr: false },
2676                        &e,
2677                        &d,
2678                        &u,
2679                        &sample,
2680                        &cfg
2681                    ),
2682                    Err(FittingError::InvalidConfig(_))
2683                ),
2684                "psr_fwhm_ns={bad} should be rejected"
2685            );
2686        }
2687    }
2688
2689    #[test]
2690    fn rejects_absurd_pinned_psr_width() {
2691        // Review #645 round 2, F1: psr_fwhm_ns is NANOSECONDS (FTS convention
2692        // 350 ns) and synthesis cost is quadratic in the fold width — a
2693        // µs-as-ns unit slip (350 meaning µs → 350_000 ns) previously passed
2694        // the finite/sign check and pinned a fictitious 350 µs fold: a
2695        // multi-hour silent hang. Widths above PSR_FWHM_PIN_CEILING_US
2696        // (10 µs = 10_000 ns) must be a loud up-front config error.
2697        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2698        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2699        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2700        let d = vec![0.9; 60];
2701        let u = vec![0.01; 60];
2702        let cfg = CalibrationConfig {
2703            psr_fwhm_ns: 350_000.0, // "350 µs" unit slip
2704            ..Default::default()
2705        };
2706        let err = calibrate_resolution(
2707            ResolutionFamily::IkedaCarpenter { fit_psr: false },
2708            &e,
2709            &d,
2710            &u,
2711            &sample,
2712            &cfg,
2713        )
2714        .expect_err("a 350_000 ns (350 µs) pinned PSR width must be rejected");
2715        assert!(
2716            matches!(
2717                &err,
2718                FittingError::InvalidConfig(msg)
2719                    if msg.contains("NANOSECONDS") && msg.contains("350 ns")
2720            ),
2721            "ceiling error must name the ns unit and the 350-ns convention, got {err:?}"
2722        );
2723
2724        // Boundary + normal pins stay valid: exactly 10_000 ns sits ON the
2725        // ceiling (rejection is strict `>`; 10_000·1e-3 rounds to exactly
2726        // 10.0) and 350 ns is the FTS default. psr_fwhm_ns = 0 (disable) is
2727        // pinned valid by rejects_fit_psr_with_zero_psr_width. Tiny
2728        // grid/iteration budget: these arms assert config validity, not fit
2729        // quality.
2730        for ok_ns in [350.0, 10_000.0] {
2731            let cheap = CalibrationConfig {
2732                psr_fwhm_ns: ok_ns,
2733                ic_n_energies: 8,
2734                ic_n_tau: 32,
2735                max_iter: 10,
2736                ..Default::default()
2737            };
2738            assert!(
2739                calibrate_resolution(
2740                    ResolutionFamily::IkedaCarpenter { fit_psr: false },
2741                    &e,
2742                    &d,
2743                    &u,
2744                    &sample,
2745                    &cheap,
2746                )
2747                .is_ok(),
2748                "psr_fwhm_ns = {ok_ns} ns must remain a valid pinned width"
2749            );
2750        }
2751    }
2752
2753    #[test]
2754    fn rejects_infeasible_psr_start_width() {
2755        // Review #645 round 3, F1: a nonzero PSR width in (0, ~58.6 ns)
2756        // passes every value-level check (finite / sign / ceiling) yet cannot
2757        // be SYNTHESIZED at the optimizer start: the default β/R start
2758        // (β = 0.1, R = 0.1 > R_NEGLIGIBLE) spans a 16/β = 160 µs storage
2759        // tail, capping the τ-step at 160/8191 ≈ 19.53 ns, and tau_geometry
2760        // rejects any triangle whose FWHM/3 floor is below that (fwhm <
2761        // ~58.6 ns). Every initial-simplex vertex was then ∞ (objective range
2762        // ∞ − ∞ = NaN — no self-convergence), so the calibration burned
2763        // max_iter and died with the generic "no finite-objective" error
2764        // blaming the forward model. The pre-flight must reject the START
2765        // precisely, surfacing the τ-geometry diagnosis and naming
2766        // psr_fwhm_ns. The fit_psr arm starts AT the fit-box floor
2767        // PSR_FWHM_US_MIN = 0.05 µs (50 ns), which is itself infeasible at
2768        // the default start — proof that a `>= PSR_FWHM_US_MIN` value check
2769        // would not be sufficient.
2770        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2771        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2772        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2773        let d = vec![0.9; 60];
2774        let u = vec![0.01; 60];
2775        for (fit_psr, psr_ns) in [(false, 55.0), (true, 50.0)] {
2776            let cfg = CalibrationConfig {
2777                psr_fwhm_ns: psr_ns,
2778                ..Default::default()
2779            };
2780            let err = calibrate_resolution(
2781                ResolutionFamily::IkedaCarpenter { fit_psr },
2782                &e,
2783                &d,
2784                &u,
2785                &sample,
2786                &cfg,
2787            )
2788            .expect_err("a sub-59-ns PSR start must be rejected up front");
2789            assert!(
2790                matches!(
2791                    &err,
2792                    FittingError::InvalidConfig(msg)
2793                        if msg.contains("starting parameter vector")
2794                            && msg.contains("psr_fwhm_ns")
2795                            && msg.contains("cannot resolve")
2796                ),
2797                "pre-flight error must name the start, psr_fwhm_ns and the τ-cap cause \
2798                 (fit_psr = {fit_psr}, psr_ns = {psr_ns}), got {err:?}"
2799            );
2800        }
2801    }
2802
2803    #[test]
2804    fn rejects_fit_psr_with_zero_psr_width() {
2805        // psr_fwhm_ns = 0 means "no PSR fold"; fit_psr = true would silently
2806        // clamp that 0 start into the [0.05, 1] µs fit box, contradicting the
2807        // documented "0 disables". The contradiction is a config error.
2808        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2809        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2810        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2811        let d = vec![0.9; 60];
2812        let u = vec![0.01; 60];
2813        let cfg = CalibrationConfig {
2814            psr_fwhm_ns: 0.0,
2815            ..Default::default()
2816        };
2817        let err = calibrate_resolution(
2818            ResolutionFamily::IkedaCarpenter { fit_psr: true },
2819            &e,
2820            &d,
2821            &u,
2822            &sample,
2823            &cfg,
2824        )
2825        .expect_err("fit_psr with psr_fwhm_ns = 0 must be rejected");
2826        assert!(
2827            matches!(&err, FittingError::InvalidConfig(msg) if msg.contains("fit_psr")),
2828            "expected an InvalidConfig naming fit_psr, got {err:?}"
2829        );
2830        // The same zero width WITHOUT fit_psr stays valid ("0 disables").
2831        // Tiny grid/iteration budget: this arm only asserts the config
2832        // passes validation, not fit quality.
2833        let cheap = CalibrationConfig {
2834            psr_fwhm_ns: 0.0,
2835            ic_n_energies: 8,
2836            ic_n_tau: 32,
2837            max_iter: 10,
2838            ..Default::default()
2839        };
2840        assert!(
2841            calibrate_resolution(
2842                ResolutionFamily::IkedaCarpenter { fit_psr: false },
2843                &e,
2844                &d,
2845                &u,
2846                &sample,
2847                &cheap,
2848            )
2849            .is_ok(),
2850            "psr_fwhm_ns = 0 with fit_psr = false must remain a valid config"
2851        );
2852    }
2853
2854    #[test]
2855    fn rejects_undersized_ic_synthesis_grid() {
2856        // ic_n_energies < 2 / ic_n_tau < 8 previously surfaced only as the
2857        // late, generic "no finite-objective resolution" error (every
2858        // IkedaCarpenter::new evaluation failed). They must be precise
2859        // up-front InvalidConfig errors for the IC family — sibling parity
2860        // with the Python binding's validation.
2861        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2862        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2863        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2864        let d = vec![0.9; 60];
2865        let u = vec![0.01; 60];
2866        for (ne, nt, what) in [(1, 500, "ic_n_energies"), (64, 7, "ic_n_tau")] {
2867            // The loose iteration/tolerance budget only cheapens the Gaussian
2868            // is_ok arm below (validation-only assertion, not fit quality);
2869            // the InvalidConfig arm rejects before any optimization runs.
2870            let cfg = CalibrationConfig {
2871                ic_n_energies: ne,
2872                ic_n_tau: nt,
2873                max_iter: 5,
2874                xatol: 1.0,
2875                fatol: 1.0,
2876                ..Default::default()
2877            };
2878            let err = calibrate_resolution(
2879                ResolutionFamily::IkedaCarpenter { fit_psr: false },
2880                &e,
2881                &d,
2882                &u,
2883                &sample,
2884                &cfg,
2885            )
2886            .expect_err("undersized IC synthesis grid must be rejected");
2887            assert!(
2888                matches!(&err, FittingError::InvalidConfig(msg) if msg.contains(what)),
2889                "expected an InvalidConfig naming {what}, got {err:?}"
2890            );
2891            // The same values are inert for a non-IC family (mirrors the
2892            // Python binding: the knobs only size the IC synthesis grid).
2893            assert!(
2894                calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg).is_ok(),
2895                "ic grid knobs must stay inert for the Gaussian family"
2896            );
2897        }
2898    }
2899
2900    #[test]
2901    fn ic_box_worst_corner_synthesizes_within_tau_cap() {
2902        // #645 F1: the calibrator's box must not abort a calibration on an
2903        // unresolvable τ-grid. Worst corner of the box in the τ-cap sense:
2904        // β at its floor (slow reach 16/β = 800 µs — the longest admitted
2905        // storage tail, so the capped step is at its widest ≈ 0.098 µs),
2906        // R = 1 (storage fully active), a1 at its ceiling and a0 at the
2907        // documented physical ceiling (α ≈ 1–3 µs⁻¹ in the eV regime ⇒
2908        // a0 ≈ 0.2–0.5, see IC_A0_MIN), at the calibrator's default
2909        // n_tau = 500 on a representative eV-regime synthesis window. The
2910        // capped step resolves both the PSR triangle box (0.35 µs default
2911        // pin up to the 1 µs fitted ceiling: ≥ 3 samples per side) and any
2912        // prompt core with α ≤ 18/(7 · 0.098) ≈ 26 µs⁻¹ — far above eV-regime
2913        // moderator physics. (The remaining unresolvable pockets — a fitted
2914        // PSR near its 0.05 µs floor together with β near its floor, or
2915        // a0 driven ~50× past the physical ceiling — are handled as
2916        // infeasible points, see the companion test below.)
2917        let cfg = CalibrationConfig::default();
2918        for fwhm_us in [DEFAULT_PSR_FWHM_NS * NS_TO_US, PSR_FWHM_US_MAX] {
2919            let corner = IkedaCarpenterParams {
2920                alpha: EnergyLaw::SqrtE {
2921                    a0: 0.5,
2922                    a1: IC_A1_MAX,
2923                },
2924                beta: EnergyLaw::Const(IC_BETA_MIN),
2925                r: EnergyLaw::Const(IC_R_MAX),
2926                burst_sigma_us: None,
2927                channel_fwhm_us: Some(fwhm_us),
2928            };
2929            let grid = SynthesisGrid {
2930                e_min_ev: 6.0,
2931                e_max_ev: 112.0,
2932                n_energies: cfg.ic_n_energies,
2933                n_tau: cfg.ic_n_tau,
2934            };
2935            assert!(
2936                IkedaCarpenter::new(corner, cfg.flight_path_m, &grid).is_ok(),
2937                "calibration-box worst corner must synthesize (fwhm = {fwhm_us} µs)"
2938            );
2939        }
2940    }
2941
2942    #[test]
2943    fn ic_unresolvable_theta_errs_in_build_resolution() {
2944        // A θ inside the box can still be unresolvable: a fitted PSR at its
2945        // 0.05 µs floor against β at its own floor needs a τ-step ≤ FWHM/3 ≈
2946        // 0.017 µs across an 800 µs storage tail — past the 8192-sample cap.
2947        // This test asserts the build_resolution half only: such θ must Err.
2948        // The calibration-level half — the objective maps that Err to an ∞
2949        // point the simplex steps away from, never aborting the calibration —
2950        // is asserted by ic_infeasible_pocket_inside_box_completes_calibration
2951        // below (#645 round 3, F4).
2952        let cfg = CalibrationConfig::default();
2953        let theta = [
2954            IC_A0_X0.ln(),
2955            IC_A1_X0.ln(),
2956            IC_BETA_MIN.ln(),
2957            0.5,
2958            PSR_FWHM_US_MIN,
2959        ];
2960        let fam = ResolutionFamily::IkedaCarpenter { fit_psr: true };
2961        assert!(
2962            build_resolution(&fam, &theta, 6.0, 112.0, &cfg).is_err(),
2963            "β at its floor + PSR at its floor must be unresolvable"
2964        );
2965    }
2966
2967    #[test]
2968    #[ignore = "slow; runs nightly"]
2969    fn ic_infeasible_pocket_inside_box_completes_calibration() {
2970        // Review #645 round 3, F4 — the calibration-level half of the claim
2971        // above: with fit_psr the box CONTAINS the unresolvable pocket (PSR
2972        // near its 0.05 µs floor against β near its own floor), and the
2973        // simplex demonstrably brushes it — the 60 ns start sits just above
2974        // the ~58.6 ns feasibility edge at the default β/R start (the
2975        // pre-flight passes: 60/3 = 20 ns floor > 19.53 ns capped step), so
2976        // the FIRST simplex already carries an ∞ vertex: the β-decreased
2977        // vertex (ln β step is negative, β 0.1 → ~0.089) widens the storage
2978        // reach to ~180 µs and the capped step to ~21.9 ns, past the 20 ns
2979        // floor. The optimizer must treat such vertices as infeasible points
2980        // and finish: Ok, finite χ², decoded resolution inside the box. Tiny
2981        // grid/iteration budget — this asserts non-abortion, not fit quality.
2982        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2983        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2984        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2985        let d = vec![0.9; 60];
2986        let u = vec![0.01; 60];
2987        let cfg = CalibrationConfig {
2988            psr_fwhm_ns: 60.0,
2989            ic_n_energies: 8,
2990            ic_n_tau: 32,
2991            max_iter: 60,
2992            ..Default::default()
2993        };
2994        let r = calibrate_resolution(
2995            ResolutionFamily::IkedaCarpenter { fit_psr: true },
2996            &e,
2997            &d,
2998            &u,
2999            &sample,
3000            &cfg,
3001        )
3002        .expect("an infeasible pocket inside the box must not abort the calibration");
3003        assert!(
3004            r.chi2_dof.is_finite(),
3005            "calibration through the infeasible pocket must return a finite χ²/dof, got {}",
3006            r.chi2_dof
3007        );
3008        let (_a0, _a1, beta, _r, psr_us) = decoded_ic(&r);
3009        assert!(
3010            (PSR_FWHM_US_MIN..=PSR_FWHM_US_MAX).contains(&psr_us)
3011                && (IC_BETA_MIN..=IC_BETA_MAX).contains(&beta),
3012            "decoded solution must be feasible and inside the box: β = {beta}, \
3013             psr = {psr_us} µs"
3014        );
3015    }
3016    /// The reported interval is the spread a repeated calibration actually
3017    /// shows.
3018    ///
3019    /// An interval checked only for shape can be any pair of numbers and
3020    /// still pass. The claim it makes is about repetition, so the oracle is
3021    /// repetition: calibrate many noise realizations of one calibrant and
3022    /// compare how far the answers scatter against what a single calibration
3023    /// said they would.
3024    ///
3025    /// Compared against the half-width, `(upper - lower) / 2`. The interval
3026    /// itself is asymmetric, and which side is the long one depends on where
3027    /// in the flat valley a realization landed, so neither side alone is the
3028    /// scatter; their average is.
3029    #[test]
3030    #[ignore = "slow; runs nightly"]
3031    fn the_reported_interval_predicts_the_scatter_of_repeated_calibrations() {
3032        use rand::SeedableRng;
3033        use rand_chacha::ChaCha12Rng;
3034        use rand_distr::{Distribution, Normal};
3035
3036        const REALIZATIONS: usize = 24;
3037        const NOISE: f64 = 0.002;
3038
3039        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
3040        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
3041        let energies: Vec<f64> = (0..120).map(|i| 18.0 + i as f64 * 0.04).collect();
3042        let cfg = CalibrationConfig {
3043            ic_n_energies: 8,
3044            ic_n_tau: 32,
3045            max_iter: 400,
3046            intervals: true,
3047            ..Default::default()
3048        };
3049        let truth = forward_model(
3050            &energies,
3051            &sample,
3052            Some(&InstrumentParams {
3053                resolution: ResolutionFunction::Gaussian(
3054                    ResolutionParams::new(cfg.flight_path_m, 0.30, 0.05, 0.0)
3055                        .expect("valid truth resolution"),
3056                ),
3057            }),
3058        )
3059        .expect("truth forward model");
3060        let unc = vec![NOISE; energies.len()];
3061
3062        let mut rng = ChaCha12Rng::seed_from_u64(20260917);
3063        let normal = Normal::new(0.0, NOISE).expect("valid noise distribution");
3064        let mut widths = Vec::new();
3065        let mut half_widths = Vec::new();
3066        for _ in 0..REALIZATIONS {
3067            let noisy: Vec<f64> = truth.iter().map(|t| t + normal.sample(&mut rng)).collect();
3068            let result = calibrate_resolution(
3069                ResolutionFamily::Gaussian,
3070                &energies,
3071                &noisy,
3072                &unc,
3073                &sample,
3074                &cfg,
3075            )
3076            .expect("calibration runs");
3077            let Some(intervals) = result.intervals.as_ref() else {
3078                assert!(
3079                    !result.converged,
3080                    "no interval was reported for a self-converged run, so its \
3081                     rejection is unaccounted for"
3082                );
3083                continue;
3084            };
3085            let width = result.theta[0].abs();
3086            let (lo, hi) = intervals[0];
3087            assert!(
3088                lo <= width && width <= hi,
3089                "the interval [{lo}, {hi}] does not bracket its own solution {width}"
3090            );
3091            widths.push(width);
3092            half_widths.push(0.5 * (hi - lo));
3093        }
3094        assert!(
3095            widths.len() >= REALIZATIONS / 2,
3096            "only {} of {REALIZATIONS} realizations reported an interval; the \
3097             comparison would be drawn from a selected subset",
3098            widths.len()
3099        );
3100
3101        let n = widths.len() as f64;
3102        let mean = widths.iter().sum::<f64>() / n;
3103        let observed = (widths.iter().map(|w| (w - mean).powi(2)).sum::<f64>() / (n - 1.0)).sqrt();
3104        let predicted = half_widths.iter().sum::<f64>() / n;
3105        let ratio = observed / predicted;
3106        assert!(
3107            (0.5..=2.0).contains(&ratio),
3108            "the calibrations scatter by {observed:.4e} while their own \
3109             intervals predict {predicted:.4e} (ratio {ratio:.2}); an interval \
3110             that misses the spread by more than a factor of two is not an \
3111             uncertainty"
3112        );
3113    }
3114
3115    /// The width has no lower bound on this calibrant, and the interval says
3116    /// so.
3117    ///
3118    /// A resolution kernel narrower than the line it broadens leaves no trace
3119    /// in the spectrum, so the objective is flat all the way down and the
3120    /// data cannot distinguish a narrow kernel from none. The interval
3121    /// reports that by reaching the box floor.
3122    ///
3123    /// The upper bound is the opposite case and must be strictly inside the
3124    /// box: past the intrinsic width the dip smears and the objective climbs,
3125    /// so that side is measured, not open.
3126    #[test]
3127    fn the_width_interval_is_open_below_and_closed_above() {
3128        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
3129        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
3130        let energies: Vec<f64> = (0..120).map(|i| 18.0 + i as f64 * 0.04).collect();
3131        let cfg = CalibrationConfig {
3132            ic_n_energies: 8,
3133            ic_n_tau: 32,
3134            max_iter: 400,
3135            intervals: true,
3136            ..Default::default()
3137        };
3138        let truth = forward_model(
3139            &energies,
3140            &sample,
3141            Some(&InstrumentParams {
3142                resolution: ResolutionFunction::Gaussian(
3143                    ResolutionParams::new(cfg.flight_path_m, 0.30, 0.05, 0.0)
3144                        .expect("valid truth resolution"),
3145                ),
3146            }),
3147        )
3148        .expect("truth forward model");
3149        let unc = vec![0.002; energies.len()];
3150
3151        let result = calibrate_resolution(
3152            ResolutionFamily::Gaussian,
3153            &energies,
3154            &truth,
3155            &unc,
3156            &sample,
3157            &cfg,
3158        )
3159        .expect("calibration runs");
3160        let intervals = result
3161            .intervals
3162            .as_ref()
3163            .expect("a self-converged calibration reports an interval");
3164        assert_eq!(
3165            intervals.len(),
3166            result.n_free_params,
3167            "one interval per fitted coordinate"
3168        );
3169
3170        let (box_lo, box_hi) = ResolutionFamily::Gaussian.x0_bounds(&cfg).1[0];
3171        let (lo, hi) = intervals[0];
3172        let width = result.theta[0].abs();
3173        assert!(
3174            lo <= box_lo + 1e-9,
3175            "the width interval starts at {lo}, inside the box floor {box_lo}; \
3176             a kernel narrower than the line leaves no trace, so the data \
3177             cannot bound the width from below"
3178        );
3179        assert!(
3180            hi > width && hi < box_hi,
3181            "the width interval ends at {hi}, outside ({width}, {box_hi}); \
3182             past the intrinsic width the dip smears, so that side is measured"
3183        );
3184    }
3185
3186    /// A crossing far from a solution that sits near its box floor is still
3187    /// found.
3188    ///
3189    /// The bracketing walk starts from the coordinate's own magnitude, so a
3190    /// solution near zero inside a wide box starts many doublings away from
3191    /// its own crossing. Against a parabola of known width the interval is
3192    /// `x0 ± sigma` exactly, and the side whose crossing lies outside the box
3193    /// is the box edge.
3194    #[test]
3195    fn a_crossing_far_from_a_small_solution_is_not_reported_as_unbounded() {
3196        const X0: f64 = 1.0e-3;
3197        const SIGMA: f64 = 20.0;
3198        let bounds = [(1.0e-4, 1.0e6)];
3199        let mut parabola =
3200            |x: &[f64]| -> Result<f64, FittingError> { Ok(((x[0] - X0) / SIGMA).powi(2)) };
3201        let nm = NelderMeadConfig::default();
3202
3203        let intervals = profile_intervals(&mut parabola, &[X0], &bounds, 0.0, &nm)
3204            .expect("a parabola has a curvature everywhere");
3205        let (lo, hi) = intervals[0];
3206        assert!(
3207            (hi - (X0 + SIGMA)).abs() < 0.05 * SIGMA,
3208            "the upper bound is {hi}, not the analytic crossing {}; a bracket \
3209             that stops short of the box reports a measured side as unbounded",
3210            X0 + SIGMA
3211        );
3212        assert!(
3213            (lo - bounds[0].0).abs() < 1e-12,
3214            "the lower crossing lies below the box floor {}, so the interval \
3215             must report the floor, not {lo}",
3216            bounds[0].0
3217        );
3218    }
3219}