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 => (vec![2.0, 1e-3], vec![(1e-3, 50.0), (0.0, 0.5)]),
365            ResolutionFamily::UdrCorr { .. } => {
366                // (log s0, p): s0 = exp(log_s0) clamped to [0.2, 5].
367                (
368                    vec![0.0, 0.0],
369                    vec![(UDR_S0_MIN.ln(), UDR_S0_MAX.ln()), (-4.0, 4.0)],
370                )
371            }
372            ResolutionFamily::IkedaCarpenter { fit_psr } => {
373                let mut x0 = vec![IC_A0_X0.ln(), IC_A1_X0.ln(), IC_BETA_X0.ln(), IC_R_X0];
374                let mut bounds = vec![
375                    (IC_A0_MIN.ln(), IC_A0_MAX.ln()),
376                    (IC_A1_MIN.ln(), IC_A1_MAX.ln()),
377                    (IC_BETA_MIN.ln(), IC_BETA_MAX.ln()),
378                    (IC_R_MIN, IC_R_MAX),
379                ];
380                if *fit_psr {
381                    // cfg.psr_fwhm_ns > 0 is guaranteed here (fit_psr with a
382                    // zero width is rejected up front — "0 disables" cannot
383                    // silently become a fitted 0.05 µs). A positive start
384                    // outside the fit box — legal as a PIN up to
385                    // PSR_FWHM_PIN_CEILING_US — is CLAMPED to the nearer box
386                    // edge, not rejected (#645 round 4, F3), and the clamp is
387                    // announced on stderr: a clamped start that never leaves
388                    // its edge additionally surfaces as "psr_fwhm_us:lower" /
389                    // ":upper" in `CalibrationResult::bounds_hit`.
390                    let start_us = cfg.psr_fwhm_ns * NS_TO_US;
391                    let clamped_us = start_us.clamp(PSR_FWHM_US_MIN, PSR_FWHM_US_MAX);
392                    if clamped_us != start_us {
393                        eprintln!(
394                            "warning: fit_psr starting width psr_fwhm_ns = {} ns lies \
395                             outside the PSR fit box [{PSR_FWHM_US_MIN}, {PSR_FWHM_US_MAX}] µs; \
396                             starting the fit at the nearer box edge ({clamped_us} µs). A fit \
397                             that stays there reports \"psr_fwhm_us:lower\" / \":upper\" in \
398                             bounds_hit.",
399                            cfg.psr_fwhm_ns
400                        );
401                    }
402                    x0.push(clamped_us);
403                    bounds.push((PSR_FWHM_US_MIN, PSR_FWHM_US_MAX));
404                }
405                (x0, bounds)
406            }
407        }
408    }
409}
410
411/// Configuration for [`calibrate_resolution`].
412#[derive(Debug, Clone)]
413pub struct CalibrationConfig {
414    /// Flight-path length (m).
415    pub flight_path_m: f64,
416    /// Fit a low-order baseline (anorm + constant + linear) instead of anorm only.
417    pub fit_background: bool,
418    /// Number of optimizer restarts (perturbed starts; keep the best).
419    pub restarts: usize,
420    /// Nelder–Mead simplex-spread tolerance.
421    pub xatol: f64,
422    /// Nelder–Mead objective-range tolerance.
423    pub fatol: f64,
424    /// Nelder–Mead maximum iterations.
425    pub max_iter: usize,
426    /// IC synthesis grid resolution (energies × τ-samples per kernel).
427    pub ic_n_energies: usize,
428    pub ic_n_tau: usize,
429    /// SNS PSR (accumulator-ring) channel-triangle FWHM in **ns**, folded into
430    /// the **IC family only** (default [`DEFAULT_PSR_FWHM_NS`]; `0.0`
431    /// disables the fold). Tabulated/UDR (FTS) kernels already carry the fold
432    /// in the file itself (header: "folded triang FWHM 350 ns PSR") and are
433    /// structurally never re-folded here — applying it twice would
434    /// double-count the burst. When the family is
435    /// `IkedaCarpenter { fit_psr: true }` this value is the fit's starting
436    /// point instead of a pin, clamped into the 0.05–1 µs fit box: a width
437    /// in (1, 10] µs is a legal pin but an out-of-box start — the fit then
438    /// starts at the box top (announced by a stderr warning), and if it
439    /// stays there it reports `psr_fwhm_us:upper` in
440    /// [`CalibrationResult::bounds_hit`]. Nonzero widths above
441    /// [`PSR_FWHM_PIN_CEILING_US`] (10 µs = 10 000 ns) are rejected as a
442    /// ns↔µs unit slip — see that constant for the quadratic-cost rationale.
443    pub psr_fwhm_ns: f64,
444    /// Fit the SAMMY TOF-zero `t0` (µs) as a SHARED energy-scale parameter.
445    /// **Default `false`** — position is pinned at
446    /// [`position_t0_center_us`](Self::position_t0_center_us) so calibration is a
447    /// pure shape/width fit (matching SAMMY, where `t0`/`L` are a separate
448    /// energy-scale calibration). Opt in only *with* a metrology prior; see
449    /// [`with_position_prior`](CalibrationConfig::with_position_prior).
450    pub fit_t0: bool,
451    /// Fit the flight-path scale `L_scale` as a shared energy-scale parameter.
452    /// **Default `false`.** A free `L_scale` shares the asymmetric-kernel lag's
453    /// `1/√E` basis and corrupts the calibrated width — fit it only under a prior.
454    pub fit_l_scale: bool,
455    /// Prior mean (and pinned value when [`fit_t0`](Self::fit_t0) is false) of the
456    /// TOF zero `t0` (µs). Default `0.0`. Lets a caller inject a pre-calibrated `t0`.
457    pub position_t0_center_us: f64,
458    /// Prior mean (and pinned value when [`fit_l_scale`](Self::fit_l_scale) is
459    /// false) of `L_scale`. Default `1.0`.
460    pub position_l_scale_center: f64,
461    /// Gaussian prior σ on `t0` (µs); `None` = flat (bounded only). When set, adds
462    /// `((t0 − center)/σ)²` to the data χ² (a metrology penalty, *not* part of the
463    /// reported `chi2_dof`).
464    pub position_t0_prior_us: Option<f64>,
465    /// Gaussian prior σ on `L_scale`; `None` = flat. See [`position_t0_prior_us`](Self::position_t0_prior_us).
466    pub position_l_scale_prior: Option<f64>,
467}
468
469impl Default for CalibrationConfig {
470    fn default() -> Self {
471        // Matches the validated Python calibrator (fatol=1e-3, not the
472        // NelderMeadConfig default 1e-4). The IC synthesis grid is DELIBERATELY
473        // lighter than the standalone IkedaCarpenter default (64×500 here vs the
474        // DEFAULT_N_ENERGIES×DEFAULT_N_TAU = 64×600 synthesis default): the outer
475        // loop re-synthesizes the kernel on every evaluation, and 500 τ-samples is
476        // ample for χ²/dof comparison.
477        Self {
478            flight_path_m: 25.0,
479            fit_background: false,
480            restarts: 1,
481            xatol: 1e-4,
482            fatol: 1e-3,
483            max_iter: 800,
484            ic_n_energies: 64,
485            ic_n_tau: 500,
486            psr_fwhm_ns: DEFAULT_PSR_FWHM_NS,
487            // Position is PINNED by default: pure shape/width calibration on the
488            // (already energy-calibrated) grid. Energy-scale fitting is an explicit
489            // opt-in via `with_position_prior`.
490            fit_t0: false,
491            fit_l_scale: false,
492            position_t0_center_us: 0.0,
493            position_l_scale_center: 1.0,
494            position_t0_prior_us: None,
495            position_l_scale_prior: None,
496        }
497    }
498}
499
500impl CalibrationConfig {
501    /// Enable a SHARED, metrology-priored energy-scale `(t0, L_scale)` fit: sets
502    /// [`fit_t0`](Self::fit_t0)/[`fit_l_scale`](Self::fit_l_scale), the prior means
503    /// (`*_center`), and the Gaussian prior σ. Use this for joint energy-scale or
504    /// cross-family identifiability work; the default config pins position (pure
505    /// shape/width calibration). Pass the prior σ from the instrument's independent
506    /// flight-path / timing metrology — a loose σ marginalizes position (weak,
507    /// honest shape-only discrimination), a tight σ pins it.
508    #[must_use]
509    pub fn with_position_prior(
510        mut self,
511        t0_center_us: f64,
512        l_scale_center: f64,
513        sigma_t0_us: f64,
514        sigma_l_scale: f64,
515    ) -> Self {
516        self.fit_t0 = true;
517        self.fit_l_scale = true;
518        self.position_t0_center_us = t0_center_us;
519        self.position_l_scale_center = l_scale_center;
520        self.position_t0_prior_us = Some(sigma_t0_us);
521        self.position_l_scale_prior = Some(sigma_l_scale);
522        self
523    }
524}
525
526/// Result of a resolution calibration.
527#[derive(Debug, Clone)]
528pub struct CalibrationResult {
529    /// Family label (`"gaussian"` | `"udr_corr"` | `"ic"`).
530    pub family: String,
531    /// Fitted parameter vector (raw optimizer space; see [`ResolutionFamily`]
532    /// and [`ResolutionFamily::param_names`]). For the IC family these are
533    /// ln/box-encoded — read decoded physical values off
534    /// [`resolution`](Self::resolution) instead of exponentiating by hand.
535    pub theta: Vec<f64>,
536    /// Reduced **data** χ²/dof of the best fit (after anorm/baseline). The
537    /// energy-scale prior penalty is *excluded* — it is reported separately as
538    /// [`prior_penalty`](Self::prior_penalty).
539    pub chi2_dof: f64,
540    /// The calibrated resolution, ready to pin into a sample fit.
541    pub resolution: ResolutionFunction,
542    /// Optimizer iterations of the winning restart.
543    pub iterations: usize,
544    /// Whether the winning restart self-converged.
545    pub converged: bool,
546    /// Fitted (or pinned) SAMMY energy-scale TOF zero `t0` (µs). Equals
547    /// `config.position_t0_center_us` when `fit_t0` is false (pinned). When fit, it
548    /// is a SHARED energy-scale parameter (not a per-family nuisance): the resonance
549    /// dip position is confounded with flight-path geometry (the asymmetric-kernel
550    /// lag is the same `1/√E` basis as `L_scale`), so `t0`/`L_scale` are constrained
551    /// by the metrology prior, not free.
552    pub position_t0_us: f64,
553    /// Fitted (or pinned) flight-path scale `L_scale`. Equals
554    /// `config.position_l_scale_center` when `fit_l_scale` is false.
555    pub position_l_scale: f64,
556    /// Gaussian-prior penalty `Σ((θ−center)/σ)²` on the fitted `(t0, L_scale)` at the
557    /// solution (0 when no position prior is active). `objective = χ²_data +
558    /// prior_penalty`; report it alongside `chi2_dof` so a large position move
559    /// (e.g. a wrong family needing ΔL/L ≫ the metrology σ) is visible, not hidden.
560    pub prior_penalty: f64,
561    /// Total outer-loop free parameters: resolution θ plus any FITTED position
562    /// coordinates (`t0`, `L_scale`). Makes cross-family χ² comparisons and
563    /// dof bookkeeping explicit now that families differ in size (IC is 4–5
564    /// parameters, Gaussian/UdrCorr are 2).
565    pub n_free_params: usize,
566    /// Coordinates that finished within `BOUND_HIT_REL_TOL·(hi−lo)` of a box
567    /// bound, as `"name:lower"` / `"name:upper"` (names from
568    /// [`ResolutionFamily::param_names`], plus `"t0_us"` / `"l_scale"` when
569    /// position is fitted). Empty = interior solution. A pinned bound makes a
570    /// degenerate calibration visible instead of silent: e.g. an eV-regime
571    /// calibrant with no storage tail drives `R → 0` (`"r:lower"`) — on that
572    /// β↔R ridge the storage term vanishes and β is unconstrained, so the
573    /// reported β must not be physically interpreted.
574    pub bounds_hit: Vec<String>,
575}
576
577fn build_resolution(
578    family: &ResolutionFamily,
579    theta: &[f64],
580    e_min: f64,
581    e_max: f64,
582    cfg: &CalibrationConfig,
583) -> Result<ResolutionFunction, FittingError> {
584    match family {
585        ResolutionFamily::Gaussian => {
586            let params =
587                ResolutionParams::new(cfg.flight_path_m, theta[0].abs(), theta[1].abs(), 0.0)
588                    .map_err(|e| FittingError::EvaluationFailed(format!("gaussian res: {e:?}")))?;
589            Ok(ResolutionFunction::Gaussian(params))
590        }
591        ResolutionFamily::UdrCorr { base } => {
592            let s0 = theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
593            let corrected = base
594                .width_corrected(s0, theta[1], UDR_E_REF)
595                .map_err(|e| FittingError::EvaluationFailed(format!("udr_corr width: {e}")))?;
596            Ok(ResolutionFunction::Tabulated(Arc::new(corrected)))
597        }
598        ResolutionFamily::IkedaCarpenter { fit_psr } => {
599            // θ = [ln a0, ln a1, ln β, R, (PSR FWHM µs iff fit_psr)] — see the
600            // IC_* constants for the bounds and their physics. Decoding the
601            // exp-encoded coordinates here (not in a new EnergyLaw variant)
602            // keeps the kernel physics in nereids-physics untouched: the
603            // optimizer space guarantees α(E) > 0 and β > 0 by construction.
604            let psr_us = if *fit_psr {
605                theta[4]
606            } else {
607                cfg.psr_fwhm_ns * NS_TO_US
608            };
609            let params = IkedaCarpenterParams {
610                alpha: EnergyLaw::SqrtE {
611                    a0: theta[0].exp(),
612                    a1: theta[1].exp(),
613                },
614                beta: theta[2].exp(),
615                // Scalar R: a free κ in ExpMilliEv is unidentifiable in the eV
616                // regime (R ≡ 0 across 1–200 eV for ANY plausible κ); a scalar
617                // lets the calibrant decide whether a storage tail is present.
618                r: EnergyLaw::Const(theta[3]),
619                burst_sigma_us: None,
620                // SNS PSR channel-triangle fold (0 disables). IC family only —
621                // tabulated/UDR kernels already carry the fold in the file.
622                channel_fwhm_us: (psr_us > 0.0).then_some(psr_us),
623            };
624            let grid = SynthesisGrid {
625                e_min_ev: (e_min * 0.5).max(1e-3),
626                e_max_ev: e_max * 2.0,
627                n_energies: cfg.ic_n_energies,
628                n_tau: cfg.ic_n_tau,
629            };
630            let ic = IkedaCarpenter::new(params, cfg.flight_path_m, &grid)
631                .map_err(|e| FittingError::EvaluationFailed(format!("ic res: {e:?}")))?;
632            Ok(ResolutionFunction::IkedaCarpenter(Arc::new(ic)))
633        }
634    }
635}
636
637/// Weighted residual sum of squares after analytically profiling out `anorm`
638/// (+ optional constant+linear baseline): `data ≈ a·model (+ b0 + b1·x)`, weighted
639/// by `1/unc²`. Returns `(ssr, k)` where `k` is the number of linear nuisance
640/// columns (1 = anorm only, 3 = anorm+const+linear). This is the **raw** χ² (not
641/// divided by dof) so an energy-scale **prior penalty** can be added to it in the
642/// same units before the optimizer minimizes — adding a penalty to a *reduced* χ²
643/// would silently rescale the prior by the dof. Returns `None` on a singular
644/// normal-equations system (a degenerate/constant model column), so the caller can
645/// treat the point as infeasible rather than as a spuriously zeroed fit.
646fn inner_ssr(data: &[f64], unc: &[f64], model: &[f64], fit_bg: bool) -> Option<(f64, usize)> {
647    let n = data.len();
648    let k = if fit_bg { 3 } else { 1 };
649    let mut ata = vec![0.0f64; k * k];
650    let mut atb = vec![0.0f64; k];
651    for i in 0..n {
652        let w2 = 1.0 / unc[i].max(1e-9).powi(2);
653        let x = if n > 1 {
654            -1.0 + 2.0 * (i as f64) / ((n - 1) as f64)
655        } else {
656            0.0
657        };
658        let col = [model[i], 1.0, x];
659        for a in 0..k {
660            atb[a] += w2 * col[a] * data[i];
661            for b in 0..k {
662                ata[a * k + b] += w2 * col[a] * col[b];
663            }
664        }
665    }
666    let coef = solve_small(&ata, &atb, k)?;
667    let mut ssr = 0.0;
668    for i in 0..n {
669        let w2 = 1.0 / unc[i].max(1e-9).powi(2);
670        let x = if n > 1 {
671            -1.0 + 2.0 * (i as f64) / ((n - 1) as f64)
672        } else {
673            0.0
674        };
675        let pred = if fit_bg {
676            coef[0] * model[i] + coef[1] + coef[2] * x
677        } else {
678            coef[0] * model[i]
679        };
680        ssr += (data[i] - pred).powi(2) * w2;
681    }
682    Some((ssr, k))
683}
684
685/// Reduced χ²/dof = [`inner_ssr`] `/ (n − k − n_res_params)`, ∞ on a singular
686/// system. `n_res_params` counts the outer-loop parameters (resolution + any free
687/// position) which are not in the linear system but still consume dof. Test-only:
688/// the calibrator minimizes raw `inner_ssr` (+ prior) and reduces at the solution.
689#[cfg(test)]
690fn inner_chi2(data: &[f64], unc: &[f64], model: &[f64], fit_bg: bool, n_res_params: usize) -> f64 {
691    match inner_ssr(data, unc, model, fit_bg) {
692        Some((ssr, k)) => {
693            let dof = data.len().saturating_sub(k + n_res_params).max(1) as f64;
694            ssr / dof
695        }
696        None => f64::INFINITY,
697    }
698}
699
700/// Gaussian-prior penalty `Σ((θ − center)/σ)²` on the fitted energy-scale
701/// `(t0, L_scale)`. Only active coordinates (fit + prior σ set) contribute; a flat
702/// (σ = `None`) or pinned coordinate contributes 0.
703fn position_prior_penalty(t0_us: f64, l_scale: f64, cfg: &CalibrationConfig) -> f64 {
704    let mut penalty = 0.0;
705    if cfg.fit_t0
706        && let Some(sigma) = cfg.position_t0_prior_us
707    {
708        penalty += ((t0_us - cfg.position_t0_center_us) / sigma).powi(2);
709    }
710    if cfg.fit_l_scale
711        && let Some(sigma) = cfg.position_l_scale_prior
712    {
713        penalty += ((l_scale - cfg.position_l_scale_center) / sigma).powi(2);
714    }
715    penalty
716}
717
718/// Solve a small `k×k` linear system `A x = b` (k ≤ 3) by Gaussian elimination
719/// with partial pivoting. Returns `None` on a singular system.
720fn solve_small(a: &[f64], b: &[f64], k: usize) -> Option<Vec<f64>> {
721    // Relative pivot threshold scaled by the matrix norm, so ill-conditioned
722    // systems (not just exactly-singular ones) are reported infeasible.
723    let scale = a
724        .iter()
725        .fold(0.0_f64, |m, &v| m.max(v.abs()))
726        .max(f64::MIN_POSITIVE);
727    let mut m = a.to_vec();
728    let mut y = b.to_vec();
729    for col in 0..k {
730        let mut piv = col;
731        for r in (col + 1)..k {
732            if m[r * k + col].abs() > m[piv * k + col].abs() {
733                piv = r;
734            }
735        }
736        if m[piv * k + col].abs() < 1e-12 * scale {
737            return None;
738        }
739        if piv != col {
740            for c in 0..k {
741                m.swap(piv * k + c, col * k + c);
742            }
743            y.swap(piv, col);
744        }
745        for r in (col + 1)..k {
746            let f = m[r * k + col] / m[col * k + col];
747            for c in col..k {
748                m[r * k + c] -= f * m[col * k + c];
749            }
750            y[r] -= f * y[col];
751        }
752    }
753    let mut x = vec![0.0; k];
754    for col in (0..k).rev() {
755        let mut s = y[col];
756        for c in (col + 1)..k {
757            s -= m[col * k + c] * x[c];
758        }
759        x[col] = s / m[col * k + col];
760    }
761    Some(x)
762}
763
764/// Calibrate the resolution parameters of `family` against a known-(ρ,T)
765/// calibrant.
766///
767/// `sample` carries the FIXED density and temperature (and isotopes/groups). By
768/// default **only the resolution shape/width is optimized**, at the pinned energy
769/// scale `(t0, L_scale) = (center, center)` — a pure broadening calibration on an
770/// already energy-calibrated grid (this is the SAMMY split: resolution is a
771/// broadening kernel; `t0`/`L` are a *separate* energy-scale calibration).
772///
773/// Set [`CalibrationConfig::fit_t0`]/[`fit_l_scale`](CalibrationConfig::fit_l_scale)
774/// (e.g. via [`CalibrationConfig::with_position_prior`]) to *also* fit the SHARED
775/// energy-scale `(t0, L_scale)` under a Gaussian metrology prior — for joint
776/// energy-scale work or a cross-family identifiability study. Do **not** fit
777/// position with a flat prior in production: the asymmetric-kernel mode→centroid
778/// lag is the same `1/√E` basis as `L_scale`, so a free `L_scale` absorbs the lag
779/// and corrupts the calibrated width.
780///
781/// The IC family fits the full bounded moderator shape (#642): `α(E) =
782/// e^{θ0}√E + e^{θ1}` — positive at every energy by construction — plus free
783/// bounded `β = e^{θ2}` and scalar storage fraction `R = θ3 ∈ [0, 1]`, folded
784/// with the SNS PSR channel triangle
785/// ([`CalibrationConfig::psr_fwhm_ns`], default 350 ns; `0` disables;
786/// optionally fitted via `IkedaCarpenter { fit_psr: true }` — a zero width
787/// combined with `fit_psr` contradicts "0 disables" and is rejected).
788///
789/// Returns the fitted shape parameters, the reduced **data** χ²/dof, the fitted (or
790/// pinned) `(t0, L_scale)`, the prior penalty, the calibrated
791/// [`ResolutionFunction`] (ready to pin), the free-parameter count
792/// ([`CalibrationResult::n_free_params`]), and the pinned-bound report
793/// ([`CalibrationResult::bounds_hit`]).
794///
795/// # Errors
796/// [`FittingError::EmptyData`] / [`FittingError::LengthMismatch`] for bad
797/// inputs; [`FittingError::InvalidConfig`] for a bad grid or position config;
798/// propagates optimizer errors.
799pub fn calibrate_resolution(
800    family: ResolutionFamily,
801    energies: &[f64],
802    data: &[f64],
803    unc: &[f64],
804    sample: &SampleParams,
805    config: &CalibrationConfig,
806) -> Result<CalibrationResult, FittingError> {
807    if data.is_empty() {
808        return Err(FittingError::EmptyData);
809    }
810    if energies.len() != data.len() || unc.len() != data.len() {
811        return Err(FittingError::LengthMismatch {
812            expected: data.len(),
813            actual: energies.len().min(unc.len()),
814            field: "energies/unc vs data",
815        });
816    }
817    // Reject non-finite inputs up front: a NaN datum would otherwise propagate
818    // to a NaN χ², and since `NaN < x` is false the optimizer could retain it as
819    // "best" and return a NaN-objective fit silently.
820    if !energies.iter().all(|v| v.is_finite())
821        || !data.iter().all(|v| v.is_finite())
822        || !unc.iter().all(|v| v.is_finite() && *v > 0.0)
823    {
824        return Err(FittingError::InvalidConfig(
825            "energies, data must be finite and uncertainty finite and > 0".into(),
826        ));
827    }
828    // Energy grid must be strictly positive and strictly ascending — mirror the
829    // Python entry point's `validate_energy_grid` so both public APIs reject the
830    // same inputs up front. Without this, a zero/negative energy panics deep in
831    // the Reich–Moore cross-section assert, a descending grid errors late as a
832    // generic "forward model failed", and duplicate energies are silently
833    // accepted (the recurring NEREIDS sibling-path validation gap).
834    if energies[0] <= 0.0 {
835        return Err(FittingError::InvalidConfig(
836            "energies must be strictly positive".into(),
837        ));
838    }
839    if !energies.windows(2).all(|w| w[1] > w[0]) {
840        return Err(FittingError::InvalidConfig(
841            "energies must be strictly ascending (no duplicates)".into(),
842        ));
843    }
844    // The calibrant must have at least one isotope with a finite, positive areal
845    // density. Otherwise `forward_model` skips every isotope (thickness ≤ 0) and
846    // returns a flat T≡1 that is independent of the resolution parameters, so the
847    // optimizer would converge to a finite but physically meaningless result —
848    // silently masking a whole-config error. Mirrors the Python wrapper's guard.
849    if !sample
850        .isotopes()
851        .iter()
852        .any(|(_, density)| density.is_finite() && *density > 0.0)
853    {
854        return Err(FittingError::InvalidConfig(
855            "calibrant must have at least one isotope with a finite, positive density".into(),
856        ));
857    }
858    // Reject under-determined calibrants: need strictly more data points than the
859    // total free parameters (resolution + any *fitted* position + anorm/baseline).
860    let n_res = family.n_params();
861    let n_pos = usize::from(config.fit_t0) + usize::from(config.fit_l_scale);
862    let baseline_cols = if config.fit_background { 3 } else { 1 };
863    if data.len() <= n_res + n_pos + baseline_cols {
864        return Err(FittingError::InvalidConfig(format!(
865            "calibrant has {} points but the model has {} resolution + {} position + {} baseline \
866             parameters; need strictly more data points than parameters",
867            data.len(),
868            n_res,
869            n_pos,
870            baseline_cols,
871        )));
872    }
873    // Flight path is a physical positive length. A non-positive / non-finite value
874    // would invert the t0 feasibility bound below (min_tof < 0 ⇒ t0_hi < t0_lo ⇒
875    // `clamp(lo, hi)` panic) as soon as `fit_t0` appends a bounded coordinate, and
876    // otherwise only surfaces as a generic "no finite-objective" error. Reject it
877    // precisely up front (covers every family and the fit/pin paths alike).
878    if !(config.flight_path_m.is_finite() && config.flight_path_m > 0.0) {
879        return Err(FittingError::InvalidConfig(
880            "flight_path_m must be finite and > 0".into(),
881        ));
882    }
883    // PSR triangle width: finite and >= 0 (0.0 disables the fold). A NaN or
884    // negative width would otherwise flow into IkedaCarpenter::new on every
885    // evaluation and only surface as the generic "no finite-objective
886    // resolution" error. Validated for every family (it is inert outside IC)
887    // so a mis-set config is caught regardless of the family under test.
888    if !config.psr_fwhm_ns.is_finite() || config.psr_fwhm_ns < 0.0 {
889        return Err(FittingError::InvalidConfig(format!(
890            "psr_fwhm_ns must be finite and >= 0 (0 disables the PSR fold), got {}",
891            config.psr_fwhm_ns
892        )));
893    }
894    // Sanity ceiling on the width itself (see PSR_FWHM_PIN_CEILING_US):
895    // psr_fwhm_ns is NANOSECONDS and synthesis cost is quadratic in the fold
896    // width, so a µs-as-ns unit slip pins a fictitious multi-hundred-µs fold
897    // that hangs the calibration for hours. Reject loudly, up front, for
898    // every family (inert outside IC, same rationale as the checks above).
899    if config.psr_fwhm_ns * NS_TO_US > PSR_FWHM_PIN_CEILING_US {
900        return Err(FittingError::InvalidConfig(format!(
901            "psr_fwhm_ns = {} ns (= {} µs) exceeds the {PSR_FWHM_PIN_CEILING_US} µs sanity \
902             ceiling (10x the {PSR_FWHM_US_MAX} µs fit bound). psr_fwhm_ns is in NANOSECONDS \
903             — the SNS/VENUS FTS convention is 350 ns — and kernel-synthesis cost grows \
904             quadratically with the fold width, so a µs-as-ns unit slip would hang the \
905             calibration behind a fictitious fold. Pass the width in ns, or 0 to disable \
906             the PSR fold",
907            config.psr_fwhm_ns,
908            config.psr_fwhm_ns * NS_TO_US
909        )));
910    }
911    // fit_psr fits the PSR FWHM from the psr_fwhm_ns starting value, but 0 is
912    // documented as "no fold": a zero start would be silently clamped into the
913    // [PSR_FWHM_US_MIN, PSR_FWHM_US_MAX] fit box, contradicting the "0
914    // disables" contract. Reject the contradiction loudly.
915    if matches!(family, ResolutionFamily::IkedaCarpenter { fit_psr: true })
916        && config.psr_fwhm_ns == 0.0
917    {
918        return Err(FittingError::InvalidConfig(
919            "fit_psr requires a positive psr_fwhm_ns starting value (psr_fwhm_ns = 0 disables \
920             the PSR fold; use fit_psr = false to calibrate without one)"
921                .into(),
922        ));
923    }
924    // IC synthesis-grid resolution: validate up front for the IC family (inert
925    // for the others) so an out-of-range value gives this precise error instead
926    // of every IkedaCarpenter::new evaluation failing into the generic late
927    // "no finite-objective resolution" error. Thresholds mirror both
928    // IkedaCarpenter::new (n_energies >= 2, n_tau >= 8) and the Python
929    // binding's sibling validation, so the two public entry points reject the
930    // same inputs.
931    if matches!(family, ResolutionFamily::IkedaCarpenter { .. }) {
932        if config.ic_n_energies < 2 {
933            return Err(FittingError::InvalidConfig(format!(
934                "ic_n_energies must be >= 2 for the IC family, got {}",
935                config.ic_n_energies
936            )));
937        }
938        if config.ic_n_tau < 8 {
939            return Err(FittingError::InvalidConfig(format!(
940                "ic_n_tau must be >= 8 for the IC family, got {}",
941                config.ic_n_tau
942            )));
943        }
944    }
945    // Validate the energy-scale (t0, L_scale) prior/center configuration up front.
946    if !config.position_t0_center_us.is_finite()
947        || !config.position_l_scale_center.is_finite()
948        || config.position_l_scale_center <= 0.0
949    {
950        return Err(FittingError::InvalidConfig(
951            "position centers must be finite and the L_scale center > 0".into(),
952        ));
953    }
954    if config.position_t0_center_us.abs() >= POSITION_T0_US_MAX {
955        return Err(FittingError::InvalidConfig(format!(
956            "position_t0_center_us must lie within ±{POSITION_T0_US_MAX} µs"
957        )));
958    }
959    if config.position_l_scale_center < POSITION_L_SCALE_MIN
960        || config.position_l_scale_center > POSITION_L_SCALE_MAX
961    {
962        return Err(FittingError::InvalidConfig(format!(
963            "position_l_scale_center must lie within [{POSITION_L_SCALE_MIN}, {POSITION_L_SCALE_MAX}]"
964        )));
965    }
966    for (sigma, name) in [
967        (config.position_t0_prior_us, "position_t0_prior_us"),
968        (config.position_l_scale_prior, "position_l_scale_prior"),
969    ] {
970        if let Some(s) = sigma
971            && !(s.is_finite() && s > 0.0)
972        {
973            return Err(FittingError::InvalidConfig(format!(
974                "{name} must be finite and > 0 when set"
975            )));
976        }
977    }
978
979    let e_min = energies.first().copied().unwrap_or(1.0);
980    let e_max = energies.last().copied().unwrap_or(1.0);
981    // Feasible t0 upper bound: the corrected TOF `tof − t0` must stay positive for
982    // every energy, i.e. `t0 < min(tof) = TOF_FACTOR·L/√E_max`. Far outside ±5 µs in
983    // the eV regime, but clamp defensively so a wide window can never make it bite.
984    let min_tof = TOF_FACTOR * config.flight_path_m / e_max.max(1e-12).sqrt();
985    // The (pinned or prior-mean) t0 center must itself be feasible: corrected_energy_grid
986    // needs `t0 < min(tof)` for every energy. In the eV regime min_tof ≫ 5 µs, but a
987    // short flight path or very high E_max can shrink it — reject up front with a precise
988    // message instead of a late, generic "corrected TOF ≤ 0" from the final recompute.
989    if config.position_t0_center_us >= min_tof {
990        return Err(FittingError::InvalidConfig(format!(
991            "position_t0_center_us ({:.3} µs) must be below the shortest flight time \
992             min_tof = TOF_FACTOR·L/√E_max = {min_tof:.3} µs",
993            config.position_t0_center_us
994        )));
995    }
996    let t0_lo = -POSITION_T0_US_MAX;
997    let t0_hi = POSITION_T0_US_MAX.min(min_tof - 1e-6);
998
999    // Optimizer coordinates: [resolution params (n_res)..., t0?, L_scale?]. A
1000    // position coordinate is appended only when fit; otherwise it is pinned at its
1001    // center. (Position is a SHARED energy-scale parameter, not a per-family
1002    // nuisance — fitting it is an explicit, prior-constrained opt-in.)
1003    let (mut x0, mut bounds) = family.x0_bounds(config);
1004    if config.fit_t0 {
1005        x0.push(config.position_t0_center_us.clamp(t0_lo, t0_hi));
1006        bounds.push((t0_lo, t0_hi));
1007    }
1008    if config.fit_l_scale {
1009        x0.push(
1010            config
1011                .position_l_scale_center
1012                .clamp(POSITION_L_SCALE_MIN, POSITION_L_SCALE_MAX),
1013        );
1014        bounds.push((POSITION_L_SCALE_MIN, POSITION_L_SCALE_MAX));
1015    }
1016    // PRE-FLIGHT the start (#645 round 3, F1): synthesize the resolution once
1017    // at x0 before any optimization. A start whose kernel cannot be
1018    // synthesized — e.g. any PSR triangle under ~58.6 ns: the default β/R
1019    // start (β = 0.1, R = 0.1) spans a 16/β = 160 µs storage tail, capping
1020    // the τ-step at 160/8191 ≈ 19.53 ns, above such a triangle's FWHM/3
1021    // resolution floor (note the PSR fit-box floor 0.05 µs = 50 ns is ITSELF
1022    // in this class, so a `>= PSR_FWHM_US_MIN` value check could not cover
1023    // it) — passes every value-level config check above yet makes EVERY
1024    // initial-simplex vertex infeasible (∞ objective): the Nelder–Mead
1025    // objective range is then ∞ − ∞ = NaN, so it can never self-converge,
1026    // burns max_iter, and used to die late with the generic "no
1027    // finite-objective" error blaming the forward model. Reject the START
1028    // precisely instead, surfacing the τ-geometry/synthesis diagnosis. A θ
1029    // that becomes infeasible only DURING the search remains an ∞ point the
1030    // simplex steps away from (see the objective below) — this pre-flight
1031    // rejects only an infeasible start.
1032    if let Err(synth_err) = build_resolution(&family, &x0, e_min, e_max, config) {
1033        let psr_note = if matches!(family, ResolutionFamily::IkedaCarpenter { .. }) {
1034            format!(
1035                " The starting PSR width comes from psr_fwhm_ns = {} ns — widen the \
1036                 triangle (the SNS/VENUS FTS convention is 350 ns), or pass 0 to \
1037                 disable the fold when not fitting it.",
1038                config.psr_fwhm_ns
1039            )
1040        } else {
1041            String::new()
1042        };
1043        return Err(FittingError::InvalidConfig(format!(
1044            "resolution kernel synthesis is infeasible at the starting parameter \
1045             vector, so every optimizer restart would begin from an all-infeasible \
1046             simplex: {synth_err}.{psr_note}"
1047        )));
1048    }
1049    let nm = NelderMeadConfig {
1050        xatol: config.xatol,
1051        fatol: config.fatol,
1052        max_iter: config.max_iter,
1053        ..Default::default()
1054    };
1055
1056    // Read the (possibly pinned) position coordinates out of an optimizer vector.
1057    let unpack_position = |theta: &[f64]| -> (f64, f64) {
1058        let mut idx = n_res;
1059        let t0 = if config.fit_t0 {
1060            let v = theta[idx];
1061            idx += 1;
1062            v
1063        } else {
1064            config.position_t0_center_us
1065        };
1066        let l_scale = if config.fit_l_scale {
1067            theta[idx]
1068        } else {
1069            config.position_l_scale_center
1070        };
1071        (t0, l_scale)
1072    };
1073
1074    let mut best: Option<NelderMeadResult> = None;
1075    for r in 0..config.restarts.max(1) {
1076        // Additive perturbation (a fraction of each parameter's bound range) so
1077        // restarts move even for zero-valued start components — a multiplicative
1078        // `x0·(1+0.1r)` left `udr_corr`'s `[0, 0]` start identical every restart.
1079        let start: Vec<f64> = x0
1080            .iter()
1081            .zip(&bounds)
1082            .map(|(&v, &(lo, hi))| (v + 0.1 * r as f64 * (hi - lo)).clamp(lo, hi))
1083            .collect();
1084        let mut obj = |theta: &[f64]| -> Result<f64, FittingError> {
1085            // theta = [resolution params (n_res)..., t0?, L_scale?]. The resolution
1086            // kernel uses only the first n_res; (t0, L_scale) set the energy scale.
1087            // An UNRESOLVABLE θ — nereids-physics rejects a kernel whose τ-grid
1088            // cannot resolve the requested fold / prompt core within the
1089            // MAX_TAU_SAMPLES cap (e.g. a fitted PSR at its 0.05 µs floor
1090            // against β at its own floor) — is an infeasible POINT of the
1091            // search, not a broken calibration: step away (mirrors the
1092            // corrected-TOF ≤ 0 guard below). Config-level failures cannot
1093            // reach here: they are rejected up front by calibrate_resolution.
1094            let Ok(res) = build_resolution(&family, theta, e_min, e_max, config) else {
1095                return Ok(f64::INFINITY);
1096            };
1097            let inst = InstrumentParams { resolution: res };
1098            let (t0, l_scale) = unpack_position(theta);
1099            // Infeasible energy scale (corrected TOF ≤ 0) → step away.
1100            let Ok(grid) = corrected_energy_grid(energies, t0, l_scale, config.flight_path_m)
1101            else {
1102                return Ok(f64::INFINITY);
1103            };
1104            let model = forward_model(&grid, sample, Some(&inst))
1105                .map_err(|e| FittingError::EvaluationFailed(format!("forward: {e:?}")))?;
1106            if !model.iter().all(|v| v.is_finite()) {
1107                return Err(FittingError::EvaluationFailed("non-finite model".into()));
1108            }
1109            // Minimize RAW χ²_data + metrology prior penalty (same units — adding
1110            // the penalty to a reduced χ² would rescale the prior by the dof).
1111            let Some((ssr, _k)) = inner_ssr(data, unc, &model, config.fit_background) else {
1112                return Ok(f64::INFINITY);
1113            };
1114            Ok(ssr + position_prior_penalty(t0, l_scale, config))
1115        };
1116        let mut res = nelder_mead_minimize(&mut obj, &start, Some(&bounds), &nm)?;
1117        // Simplex RE-INFLATION: Nelder–Mead's known failure mode is premature
1118        // simplex collapse — the spread criteria are met (`self_converged`)
1119        // at a point that is NOT the basin minimum. Observed on the
1120        // 4-parameter IC family: a 300 K synthetic calibrant stalled at
1121        // Δχ² ≈ +130 above the noise floor in the curved α↔β↔R valley, and
1122        // the ~1.5 % kernel-width error re-expressed as a ~23 K temperature
1123        // bias in the downstream pinned fit. Standard cure: restart a FRESH,
1124        // *larger* simplex at the incumbent (same 5 % edge re-collapses to
1125        // the same trap deterministically) and keep the improvement, until
1126        // it stops helping (bounded by MAX_SIMPLEX_REINFLATIONS). A
1127        // re-inflation from a true minimum re-contracts quickly, so the
1128        // extra cost there is small.
1129        let reinflate_nm = NelderMeadConfig {
1130            initial_step_frac: REINFLATE_STEP_FRAC,
1131            initial_step_abs: REINFLATE_STEP_ABS,
1132            ..nm.clone()
1133        };
1134        for _ in 0..MAX_SIMPLEX_REINFLATIONS {
1135            let again = nelder_mead_minimize(&mut obj, &res.x, Some(&bounds), &reinflate_nm)?;
1136            let improved = again.fun + nm.fatol < res.fun;
1137            res.iterations += again.iterations;
1138            res.n_evals += again.n_evals;
1139            if improved {
1140                res.x = again.x;
1141                res.fun = again.fun;
1142                res.self_converged = again.self_converged;
1143            } else {
1144                break;
1145            }
1146        }
1147        if best.as_ref().is_none_or(|b| res.fun < b.fun) {
1148            best = Some(res);
1149        }
1150    }
1151    let best = best.expect("at least one restart runs");
1152    if !best.fun.is_finite() {
1153        // Every ∞ source of the objective (#645 round 3 F1, round 4 F1):
1154        // `nelder_mead_minimize` maps every objective `Err` — forward-model
1155        // failures included — to an infeasible +∞ point rather than aborting
1156        // (see the `eval` closure in `nelder_mead.rs`), so no failure class
1157        // raises its own error during the search. Reaching here means every
1158        // vector tried hit one of them — kernel synthesis rejected (τ-grid
1159        // cap vs fold/prompt geometry), an invalid energy scale (corrected
1160        // TOF ≤ 0), a singular anorm/baseline system, or a forward-model
1161        // (transmission) failure. The start itself synthesized (pre-flighted
1162        // above), so the infeasibility arose during the search.
1163        return Err(FittingError::EvaluationFailed(
1164            "calibration found no finite-objective resolution: every parameter vector \
1165             tried was infeasible — kernel synthesis rejected it (τ-grid cap vs \
1166             fold/prompt geometry), the energy scale was invalid (corrected TOF ≤ 0), \
1167             the anorm/baseline system was singular, or the forward model failed"
1168                .into(),
1169        ));
1170    }
1171    let (position_t0_us, position_l_scale) = unpack_position(&best.x);
1172    let prior_penalty = position_prior_penalty(position_t0_us, position_l_scale, config);
1173    // Recompute the reduced DATA χ²/dof at the solution: the objective carries the
1174    // prior penalty, so `best.fun` is the penalized objective, not the data χ². dof
1175    // subtracts the linear anorm/baseline columns AND the outer-loop params
1176    // (resolution + any fitted position).
1177    let resolution = build_resolution(&family, &best.x, e_min, e_max, config)?;
1178    let grid = corrected_energy_grid(
1179        energies,
1180        position_t0_us,
1181        position_l_scale,
1182        config.flight_path_m,
1183    )?;
1184    let inst = InstrumentParams { resolution };
1185    let model = forward_model(&grid, sample, Some(&inst))
1186        .map_err(|e| FittingError::EvaluationFailed(format!("forward: {e:?}")))?;
1187    let (ssr, k) = inner_ssr(data, unc, &model, config.fit_background).ok_or_else(|| {
1188        FittingError::EvaluationFailed("singular anorm/baseline at the solution".into())
1189    })?;
1190    let dof = data.len().saturating_sub(k + n_res + n_pos).max(1) as f64;
1191    let chi2_dof = ssr / dof;
1192    let theta = best.x[..n_res].to_vec();
1193    // Bound-pinning report: label every optimizer coordinate that finished
1194    // within BOUND_HIT_REL_TOL·(hi−lo) of its box bound. This is the
1195    // degeneracy flag for the wider IC family (e.g. "r:lower" ⇒ the β↔R ridge:
1196    // no storage tail in the data, β unconstrained).
1197    let mut coord_names = family.param_names();
1198    if config.fit_t0 {
1199        coord_names.push("t0_us");
1200    }
1201    if config.fit_l_scale {
1202        coord_names.push("l_scale");
1203    }
1204    let bounds_hit: Vec<String> = best
1205        .x
1206        .iter()
1207        .zip(&bounds)
1208        .zip(&coord_names)
1209        .flat_map(|((&v, &(lo, hi)), name)| {
1210            let tol = BOUND_HIT_REL_TOL * (hi - lo);
1211            let mut hits = Vec::new();
1212            if v - lo <= tol {
1213                hits.push(format!("{name}:lower"));
1214            }
1215            if hi - v <= tol {
1216                hits.push(format!("{name}:upper"));
1217            }
1218            hits
1219        })
1220        .collect();
1221    Ok(CalibrationResult {
1222        family: family.label().to_string(),
1223        theta,
1224        chi2_dof,
1225        resolution: inst.resolution,
1226        iterations: best.iterations,
1227        converged: best.self_converged,
1228        position_t0_us,
1229        position_l_scale,
1230        prior_penalty,
1231        n_free_params: n_res + n_pos,
1232        bounds_hit,
1233    })
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238    use super::*;
1239    use nereids_endf::resonance::test_support::synthetic_isotope;
1240
1241    /// Decode the calibrated IC parameters `(a0, a1, β, R, psr_fwhm_us)` off
1242    /// the result's resolution — the single source of truth (raw `theta` is
1243    /// ln/box-encoded).
1244    fn decoded_ic(r: &CalibrationResult) -> (f64, f64, f64, f64, f64) {
1245        let ResolutionFunction::IkedaCarpenter(ic) = &r.resolution else {
1246            panic!("expected an IC resolution for family {}", r.family);
1247        };
1248        let p = ic.params();
1249        let EnergyLaw::SqrtE { a0, a1 } = p.alpha else {
1250            panic!("expected a SqrtE alpha law");
1251        };
1252        let EnergyLaw::Const(rr) = p.r else {
1253            panic!("expected a Const R law");
1254        };
1255        (a0, a1, p.beta, rr, p.channel_fwhm_us.unwrap_or(0.0))
1256    }
1257
1258    fn synthetic_base_udr() -> TabulatedResolution {
1259        // Asymmetric kernel (sharp rise, +TOF tail), at two reference energies.
1260        let offs = vec![-1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5];
1261        let wts = vec![0.05, 0.3, 1.0, 0.8, 0.5, 0.3, 0.15, 0.05];
1262        TabulatedResolution::from_kernels(
1263            vec![5.0, 50.0],
1264            vec![(offs.clone(), wts.clone()), (offs, wts)],
1265            25.0,
1266        )
1267        .unwrap()
1268    }
1269
1270    #[test]
1271    fn inner_chi2_zero_on_exact_anorm() {
1272        let model = vec![0.9, 0.7, 0.5, 0.8];
1273        let data: Vec<f64> = model.iter().map(|m| 1.0 * m).collect();
1274        let unc = vec![0.01; 4];
1275        assert!(inner_chi2(&data, &unc, &model, false, 0) < 1e-18);
1276    }
1277
1278    /// #645 round 4, F3: a `fit_psr` starting width outside the 0.05–1 µs fit
1279    /// box (legal as a PIN up to [`PSR_FWHM_PIN_CEILING_US`]) is clamped to
1280    /// the nearer box edge — documented behavior, not an error.
1281    #[test]
1282    fn fit_psr_out_of_box_start_clamps_to_box_edge() {
1283        let family = ResolutionFamily::IkedaCarpenter { fit_psr: true };
1284        // 5 000 ns = 5 µs: a valid pin width, above the 1 µs fit-box top.
1285        let above = CalibrationConfig {
1286            psr_fwhm_ns: 5_000.0,
1287            ..CalibrationConfig::default()
1288        };
1289        let (x0, bounds) = family.x0_bounds(&above);
1290        assert_eq!(x0.len(), 5);
1291        assert_eq!(x0[4], PSR_FWHM_US_MAX);
1292        assert_eq!(bounds[4], (PSR_FWHM_US_MIN, PSR_FWHM_US_MAX));
1293        // 10 ns: below the 50 ns identifiability floor — clamped UP.
1294        let below = CalibrationConfig {
1295            psr_fwhm_ns: 10.0,
1296            ..CalibrationConfig::default()
1297        };
1298        let (x0, _) = family.x0_bounds(&below);
1299        assert_eq!(x0[4], PSR_FWHM_US_MIN);
1300        // The in-box default (350 ns) passes through unclamped.
1301        let inside = CalibrationConfig::default();
1302        let (x0, _) = family.x0_bounds(&inside);
1303        assert_eq!(x0[4], DEFAULT_PSR_FWHM_NS * NS_TO_US);
1304    }
1305
1306    #[test]
1307    fn udr_corr_recovers_known_width_scale() {
1308        // Loop-closure / OPTIMIZER test: truth and fit both use width_corrected, so
1309        // this checks that the calibrator finds the s0=1.5 minimum — NOT that
1310        // width_corrected itself is physically correct. The width-scale physics
1311        // (centroid invariance + std scaling) is independently verified by
1312        // `width_corrected_preserves_centroid_scales_width_and_energy_dependence`
1313        // in nereids-physics.
1314        // Two well-separated resonances (15 + 45 eV) so the width is identifiable
1315        // (a single resonance leaves a width↔position ridge). Position is pinned by
1316        // default. Calibrant generated with a UDR truth scaled by s0=1.5; udr_corr
1317        // must recover s0≈1.5 at χ²≈0.
1318        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1319        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1320        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1321        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1322        let base = synthetic_base_udr();
1323        let truth = ResolutionFunction::Tabulated(Arc::new(
1324            base.width_corrected(1.5, 0.0, UDR_E_REF).unwrap(),
1325        ));
1326        let data = forward_model(
1327            &energies,
1328            &sample,
1329            Some(&InstrumentParams { resolution: truth }),
1330        )
1331        .unwrap();
1332        let unc = vec![0.004; energies.len()];
1333
1334        let cfg = CalibrationConfig {
1335            restarts: 2,
1336            ..Default::default()
1337        };
1338        let r = calibrate_resolution(
1339            ResolutionFamily::UdrCorr {
1340                base: Arc::new(base),
1341            },
1342            &energies,
1343            &data,
1344            &unc,
1345            &sample,
1346            &cfg,
1347        )
1348        .unwrap();
1349        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1350        assert!((s0 - 1.5).abs() < 0.05, "recovered s0={s0}, expected 1.5");
1351        assert!(r.chi2_dof < 1e-2, "matched χ²/dof={} too high", r.chi2_dof);
1352        assert!(matches!(r.resolution, ResolutionFunction::Tabulated(_)));
1353    }
1354
1355    #[test]
1356    fn udr_corr_recovers_known_width_scale_and_exponent() {
1357        // Two resonances at well-separated energies make the width EXPONENT p
1358        // identifiable — a single resonance constrains only s(E) at one energy (a
1359        // ridge in (s0, p)). Truth: s0=1.3, p=-0.5; the calibrator must recover
1360        // both (the s0-only test never exercised the p knob).
1361        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1362        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1363        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1364        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1365        let base = synthetic_base_udr();
1366        let (s0_true, p_true) = (1.3, -0.5);
1367        let truth = ResolutionFunction::Tabulated(Arc::new(
1368            base.width_corrected(s0_true, p_true, UDR_E_REF).unwrap(),
1369        ));
1370        let data = forward_model(
1371            &energies,
1372            &sample,
1373            Some(&InstrumentParams { resolution: truth }),
1374        )
1375        .unwrap();
1376        let unc = vec![0.004; energies.len()];
1377        let cfg = CalibrationConfig {
1378            restarts: 3,
1379            ..Default::default()
1380        };
1381        let r = calibrate_resolution(
1382            ResolutionFamily::UdrCorr {
1383                base: Arc::new(base),
1384            },
1385            &energies,
1386            &data,
1387            &unc,
1388            &sample,
1389            &cfg,
1390        )
1391        .unwrap();
1392        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1393        let p = r.theta[1];
1394        assert!(
1395            (s0 - s0_true).abs() < 0.1,
1396            "recovered s0={s0}, expected {s0_true}"
1397        );
1398        assert!(
1399            (p - p_true).abs() < 0.2,
1400            "recovered p={p}, expected {p_true}"
1401        );
1402        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1403    }
1404
1405    #[test]
1406    fn udr_corr_recovers_independent_raw_kernel() {
1407        // External-oracle coverage: the truth resolution is the RAW hand-built UDR
1408        // kernel broadened directly — it does NOT pass through `width_corrected`,
1409        // so truth-generation no longer shares the width-correction code with the
1410        // fit. Fitting udr_corr against that base must recover the identity width
1411        // (s0≈1) at χ²≈0. (The broadening OPERATOR itself is independently
1412        // validated by this crate's bit-exact `broaden_presorted_reference` tests.)
1413        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1414        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1415        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1416        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1417        let base = synthetic_base_udr();
1418        // Truth = the RAW base kernel (no width_corrected call).
1419        let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1420        let data = forward_model(
1421            &energies,
1422            &sample,
1423            Some(&InstrumentParams { resolution: truth }),
1424        )
1425        .unwrap();
1426        let unc = vec![0.004; energies.len()];
1427        let cfg = CalibrationConfig {
1428            restarts: 3,
1429            ..Default::default()
1430        };
1431        let r = calibrate_resolution(
1432            ResolutionFamily::UdrCorr {
1433                base: Arc::new(base),
1434            },
1435            &energies,
1436            &data,
1437            &unc,
1438            &sample,
1439            &cfg,
1440        )
1441        .unwrap();
1442        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1443        assert!((s0 - 1.0).abs() < 0.1, "recovered s0={s0}, expected ~1.0");
1444        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1445    }
1446
1447    #[test]
1448    fn gaussian_recovers_known_width() {
1449        // Gaussian loop-closure: a Gaussian truth must be recovered by the gaussian
1450        // family (the smoke test only checked finiteness+convergence). Two
1451        // resonances break the Δt/ΔL degeneracy (Δt is flat in TOF; ΔL scales with
1452        // TOF ∝ 1/√E).
1453        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1454        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1455        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1456        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1457        let (dt_true, dl_true) = (1.5, 1.0e-3);
1458        let truth = ResolutionFunction::Gaussian(
1459            ResolutionParams::new(25.0, dt_true, dl_true, 0.0).unwrap(),
1460        );
1461        let data = forward_model(
1462            &energies,
1463            &sample,
1464            Some(&InstrumentParams { resolution: truth }),
1465        )
1466        .unwrap();
1467        let unc = vec![0.004; energies.len()];
1468        let cfg = CalibrationConfig {
1469            restarts: 3,
1470            ..Default::default()
1471        };
1472        let r = calibrate_resolution(
1473            ResolutionFamily::Gaussian,
1474            &energies,
1475            &data,
1476            &unc,
1477            &sample,
1478            &cfg,
1479        )
1480        .unwrap();
1481        let (dt, dl) = (r.theta[0].abs(), r.theta[1].abs());
1482        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1483        assert!(
1484            (dt - dt_true).abs() < 0.2,
1485            "recovered Δt={dt}, expected {dt_true}"
1486        );
1487        assert!(
1488            (dl - dl_true).abs() < 1.0e-3,
1489            "recovered ΔL={dl}, expected {dl_true}"
1490        );
1491    }
1492
1493    #[test]
1494    fn fit_t0_recovers_injected_energy_scale_shift() {
1495        // With fit_t0 enabled, an injected TOF-zero offset in the calibrant is
1496        // recovered as the SHARED energy-scale t0 while the width is still
1497        // recovered — position is a fitted energy-scale parameter (−t0 convention),
1498        // not folded into the resolution. (Default config pins position; this test
1499        // opts in.) L_scale stays pinned at 1.
1500        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1501        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1502        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1503        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1504        let base = synthetic_base_udr();
1505        let (s0_true, t0_inject) = (1.4, 1.5_f64); // µs (energy-scale −t0 convention)
1506        let truth = ResolutionFunction::Tabulated(Arc::new(
1507            base.width_corrected(s0_true, 0.0, UDR_E_REF).unwrap(),
1508        ));
1509        // Calibrant generated on a grid displaced by the energy-scale t0.
1510        let shifted = corrected_energy_grid(&energies, t0_inject, 1.0, 25.0).unwrap();
1511        let data = forward_model(
1512            &shifted,
1513            &sample,
1514            Some(&InstrumentParams { resolution: truth }),
1515        )
1516        .unwrap();
1517        let unc = vec![0.004; energies.len()];
1518        // Opt into fitting t0 (flat prior); L_scale stays pinned at 1.
1519        let cfg = CalibrationConfig {
1520            restarts: 3,
1521            fit_t0: true,
1522            ..Default::default()
1523        };
1524        let r = calibrate_resolution(
1525            ResolutionFamily::UdrCorr {
1526                base: Arc::new(base),
1527            },
1528            &energies,
1529            &data,
1530            &unc,
1531            &sample,
1532            &cfg,
1533        )
1534        .unwrap();
1535        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1536        assert!(
1537            (s0 - s0_true).abs() < 0.1,
1538            "recovered s0={s0}, expected {s0_true}"
1539        );
1540        assert!(
1541            (r.position_t0_us - t0_inject).abs() < 0.3,
1542            "recovered t0={}, expected {t0_inject}",
1543            r.position_t0_us
1544        );
1545        assert!(
1546            (r.position_l_scale - 1.0).abs() < 1e-9,
1547            "L_scale should stay pinned at 1, got {}",
1548            r.position_l_scale
1549        );
1550        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1551    }
1552
1553    #[test]
1554    fn pinned_position_is_the_default_and_works_for_udr() {
1555        // The default config pins position (fit_t0/fit_l_scale = false) — a pure
1556        // shape/width fit. This is the no-position reference that the retired design
1557        // could NOT construct for the UDR family in Python (the width-correction was
1558        // Rust-internal). Self-fit must recover s0≈1 with position reported at its
1559        // pinned center and zero prior penalty.
1560        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1561        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1562        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1563        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1564        let base = synthetic_base_udr();
1565        let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1566        let data = forward_model(
1567            &energies,
1568            &sample,
1569            Some(&InstrumentParams { resolution: truth }),
1570        )
1571        .unwrap();
1572        let unc = vec![0.004; energies.len()];
1573        let cfg = CalibrationConfig::default();
1574        assert!(!cfg.fit_t0 && !cfg.fit_l_scale, "default must pin position");
1575        let r = calibrate_resolution(
1576            ResolutionFamily::UdrCorr {
1577                base: Arc::new(base),
1578            },
1579            &energies,
1580            &data,
1581            &unc,
1582            &sample,
1583            &cfg,
1584        )
1585        .unwrap();
1586        let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1587        assert!((s0 - 1.0).abs() < 0.1, "recovered s0={s0}, expected ~1.0");
1588        assert_eq!(r.position_t0_us, 0.0, "t0 pinned at center 0");
1589        assert_eq!(r.position_l_scale, 1.0, "L_scale pinned at center 1");
1590        assert_eq!(r.prior_penalty, 0.0, "no prior active when pinned");
1591        assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1592    }
1593
1594    #[test]
1595    fn free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination() {
1596        // The asymmetric IC mode→centroid lag is pure 1/√E — the SAME basis as an
1597        // L_scale error. So a Gaussian fitting an IC-broadened calibrant fits much
1598        // BETTER when L_scale is free than when position is pinned: a free physical
1599        // position lets the wrong (symmetric) family buy back the position evidence.
1600        // This is exactly why fitting position with a flat prior is unsafe for
1601        // family discrimination (and why the default pins it).
1602        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1603        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1604        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1605        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1606        let ic = IkedaCarpenter::new(
1607            IkedaCarpenterParams {
1608                alpha: EnergyLaw::SqrtE { a0: 0.30, a1: 0.0 },
1609                beta: 0.1,
1610                r: EnergyLaw::ExpMilliEv { kappa: 25.0 },
1611                burst_sigma_us: None,
1612                channel_fwhm_us: None,
1613            },
1614            25.0,
1615            &SynthesisGrid {
1616                e_min_ev: 4.0,
1617                e_max_ev: 100.0,
1618                n_energies: 64,
1619                n_tau: 500,
1620            },
1621        )
1622        .unwrap();
1623        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1624        let data = forward_model(
1625            &energies,
1626            &sample,
1627            Some(&InstrumentParams { resolution: truth }),
1628        )
1629        .unwrap();
1630        let unc = vec![0.004; energies.len()];
1631        let pinned = CalibrationConfig {
1632            restarts: 3,
1633            ..Default::default()
1634        };
1635        // Free physical position (flat priors): fit_t0 + fit_l_scale, sigmas None.
1636        let free_pos = CalibrationConfig {
1637            restarts: 3,
1638            fit_t0: true,
1639            fit_l_scale: true,
1640            ..Default::default()
1641        };
1642        let gau = |cfg: &CalibrationConfig| {
1643            calibrate_resolution(
1644                ResolutionFamily::Gaussian,
1645                &energies,
1646                &data,
1647                &unc,
1648                &sample,
1649                cfg,
1650            )
1651            .unwrap()
1652            .chi2_dof
1653        };
1654        let gau_pinned = gau(&pinned);
1655        let gau_free = gau(&free_pos);
1656        assert!(
1657            gau_free < 0.5 * gau_pinned,
1658            "free (t0,L_scale) should sharply erode the wrong-family penalty: \
1659             pinned χ²={gau_pinned}, free χ²={gau_free}"
1660        );
1661    }
1662
1663    #[test]
1664    fn position_prior_penalizes_displacement() {
1665        // A tight prior on t0 (center 0) penalizes a calibrant whose true t0 is
1666        // displaced: the fit cannot freely move to the displacement, so it pays a
1667        // prior penalty and leaves residual data χ². A loose prior recovers the
1668        // displacement with ~zero penalty. (Demonstrates the prior is the real
1669        // constraint on position, per the metrology-prior design.)
1670        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1671        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1672        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1673        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1674        let base = synthetic_base_udr();
1675        let t0_inject = 1.5_f64;
1676        let truth = ResolutionFunction::Tabulated(Arc::new(
1677            base.width_corrected(1.0, 0.0, UDR_E_REF).unwrap(),
1678        ));
1679        let shifted = corrected_energy_grid(&energies, t0_inject, 1.0, 25.0).unwrap();
1680        let data = forward_model(
1681            &shifted,
1682            &sample,
1683            Some(&InstrumentParams { resolution: truth }),
1684        )
1685        .unwrap();
1686        let unc = vec![0.004; energies.len()];
1687        let mk = |sigma_t0: f64| CalibrationConfig {
1688            restarts: 3,
1689            fit_t0: true,
1690            position_t0_prior_us: Some(sigma_t0),
1691            ..Default::default()
1692        };
1693        let mkbase = || ResolutionFamily::UdrCorr {
1694            base: Arc::new(base.clone()),
1695        };
1696        // Tight prior: σ must be small enough that the quadratic prior
1697        // curvature rivals the data-χ² curvature in t0, or the optimum
1698        // sits at the displacement and the pull is invisible. The
1699        // width-correct kernel interpolation sharpened the data term
1700        // (narrower between-reference kernels carry more positional
1701        // information than the over-wide chord blend used to), so the
1702        // binding regime needs a tighter σ than it once did.
1703        let tight =
1704            calibrate_resolution(mkbase(), &energies, &data, &unc, &sample, &mk(0.005)).unwrap();
1705        // Loose prior (σ=100 µs): recovers the displacement, ~no penalty.
1706        let loose =
1707            calibrate_resolution(mkbase(), &energies, &data, &unc, &sample, &mk(100.0)).unwrap();
1708        assert!(
1709            tight.prior_penalty > 1.0,
1710            "tight prior should incur a real penalty, got {}",
1711            tight.prior_penalty
1712        );
1713        assert!(
1714            tight.position_t0_us.abs() < t0_inject,
1715            "tight prior should pull t0 toward the center, got {}",
1716            tight.position_t0_us
1717        );
1718        assert!(
1719            (loose.position_t0_us - t0_inject).abs() < 0.3,
1720            "loose prior should recover the displacement, got {}",
1721            loose.position_t0_us
1722        );
1723        assert!(
1724            loose.prior_penalty < tight.prior_penalty,
1725            "loose penalty {} should be below tight penalty {}",
1726            loose.prior_penalty,
1727            tight.prior_penalty
1728        );
1729        assert!(
1730            loose.chi2_dof < tight.chi2_dof,
1731            "loose data χ² {} should beat tight data χ² {} (tight can't reach t0)",
1732            loose.chi2_dof,
1733            tight.chi2_dof
1734        );
1735    }
1736
1737    #[test]
1738    fn with_position_prior_builder_sets_fields() {
1739        let cfg = CalibrationConfig::default().with_position_prior(0.5, 1.001, 0.3, 0.002);
1740        assert!(cfg.fit_t0 && cfg.fit_l_scale);
1741        assert_eq!(cfg.position_t0_center_us, 0.5);
1742        assert_eq!(cfg.position_l_scale_center, 1.001);
1743        assert_eq!(cfg.position_t0_prior_us, Some(0.3));
1744        assert_eq!(cfg.position_l_scale_prior, Some(0.002));
1745    }
1746
1747    #[test]
1748    fn fit_l_scale_only_pins_t0() {
1749        // Per-coordinate control: fitting ONLY L_scale (fit_t0=false) must fit
1750        // position_l_scale while pinning t0 at its center — locks the
1751        // `unpack_position` indexing when only the SECOND position coordinate is
1752        // active (the single-coordinate path the round-2 review flagged).
1753        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1754        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1755        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1756        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1757        let base = synthetic_base_udr();
1758        let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1759        let data = forward_model(
1760            &energies,
1761            &sample,
1762            Some(&InstrumentParams { resolution: truth }),
1763        )
1764        .unwrap();
1765        let unc = vec![0.004; energies.len()];
1766        let cfg = CalibrationConfig {
1767            restarts: 2,
1768            fit_l_scale: true,
1769            ..Default::default()
1770        };
1771        let r = calibrate_resolution(
1772            ResolutionFamily::UdrCorr {
1773                base: Arc::new(base),
1774            },
1775            &energies,
1776            &data,
1777            &unc,
1778            &sample,
1779            &cfg,
1780        )
1781        .unwrap();
1782        assert_eq!(
1783            r.position_t0_us, 0.0,
1784            "t0 must stay pinned when fit_t0=false"
1785        );
1786        assert!(
1787            (r.position_l_scale - 1.0).abs() < 0.02,
1788            "L_scale fit within bound (~1 for a self-fit), got {}",
1789            r.position_l_scale
1790        );
1791        assert!(r.chi2_dof < 1e-1, "self-fit χ²/dof={} too high", r.chi2_dof);
1792    }
1793
1794    #[test]
1795    fn cross_family_chi2_selects_the_true_shape() {
1796        // Model-family discrimination at a KNOWN (pinned) energy scale: an
1797        // asymmetric IC-broadened calibrant generated at the nominal position
1798        // (t0=0, L_scale=1) must be best-fit by the IC family and clearly worse by
1799        // the symmetric Gaussian. With position pinned (the default), the Gaussian
1800        // is penalized for both shape AND the asymmetry-induced dip shift it cannot
1801        // reproduce — legitimate here because the truth's position is known exactly.
1802        // (When position is uncertain, that shift is confounded with flight-path L —
1803        // see `free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination`.)
1804        // Truth has NO width-correction/Gaussian generator, so the Gaussian arm is a
1805        // genuinely different shape (not loop-closure).
1806        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1807        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1808        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1809        let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1810        let ic = IkedaCarpenter::new(
1811            IkedaCarpenterParams {
1812                alpha: EnergyLaw::SqrtE { a0: 0.30, a1: 0.0 },
1813                beta: 0.1,
1814                r: EnergyLaw::ExpMilliEv { kappa: 25.0 },
1815                burst_sigma_us: None,
1816                channel_fwhm_us: None,
1817            },
1818            25.0,
1819            &SynthesisGrid {
1820                e_min_ev: 4.0,
1821                e_max_ev: 100.0,
1822                n_energies: 64,
1823                n_tau: 500,
1824            },
1825        )
1826        .unwrap();
1827        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1828        let data = forward_model(
1829            &energies,
1830            &sample,
1831            Some(&InstrumentParams { resolution: truth }),
1832        )
1833        .unwrap();
1834        let unc = vec![0.004; energies.len()];
1835        // The truth kernel carries NO PSR fold, so disable the calibrator's
1836        // default 350 ns fold — otherwise the IC family could not close.
1837        let cfg = CalibrationConfig {
1838            restarts: 3,
1839            psr_fwhm_ns: 0.0,
1840            ..Default::default()
1841        };
1842        let chi2 = |fam| {
1843            calibrate_resolution(fam, &energies, &data, &unc, &sample, &cfg)
1844                .unwrap()
1845                .chi2_dof
1846        };
1847        let ic_chi2 = chi2(ResolutionFamily::IkedaCarpenter { fit_psr: false });
1848        let gau_chi2 = chi2(ResolutionFamily::Gaussian);
1849        assert!(
1850            ic_chi2 < gau_chi2,
1851            "true (IC) shape χ²={ic_chi2} should beat the Gaussian χ²={gau_chi2}"
1852        );
1853        assert!(
1854            ic_chi2 < 1.0,
1855            "IC (true shape) should fit well: χ²={ic_chi2}"
1856        );
1857    }
1858
1859    #[test]
1860    fn gaussian_and_ic_families_run_and_converge() {
1861        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
1862        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
1863        let energies: Vec<f64> = (0..300).map(|i| 12.0 + i as f64 * 0.05).collect();
1864        let base = synthetic_base_udr();
1865        let truth = ResolutionFunction::Tabulated(Arc::new(
1866            base.width_corrected(1.2, 0.0, UDR_E_REF).unwrap(),
1867        ));
1868        let data = forward_model(
1869            &energies,
1870            &sample,
1871            Some(&InstrumentParams { resolution: truth }),
1872        )
1873        .unwrap();
1874        let unc = vec![0.004; energies.len()];
1875        let cfg = CalibrationConfig::default();
1876        for (fam, n_expected) in [
1877            (ResolutionFamily::Gaussian, 2),
1878            (ResolutionFamily::IkedaCarpenter { fit_psr: false }, 4),
1879        ] {
1880            let label = fam.label().to_string();
1881            let r = calibrate_resolution(fam, &energies, &data, &unc, &sample, &cfg).unwrap();
1882            assert!(r.chi2_dof.is_finite(), "{label} χ² not finite");
1883            assert_eq!(
1884                r.theta.len(),
1885                n_expected,
1886                "{label} should fit {n_expected} params"
1887            );
1888            assert_eq!(r.n_free_params, n_expected, "{label} n_free_params");
1889            // The objective is smooth and noise-free, so Nelder–Mead reaches its
1890            // tolerance well within max_iter — guard the "_and_converge" promise.
1891            assert!(r.converged, "{label} did not self-converge");
1892        }
1893    }
1894
1895    #[test]
1896    fn n_params_matches_family() {
1897        assert_eq!(ResolutionFamily::Gaussian.n_params(), 2);
1898        assert_eq!(
1899            ResolutionFamily::UdrCorr {
1900                base: Arc::new(synthetic_base_udr())
1901            }
1902            .n_params(),
1903            2
1904        );
1905        // IC fits θ = [ln a0, ln a1, ln β, R] (+ PSR FWHM iff fit_psr).
1906        assert_eq!(
1907            ResolutionFamily::IkedaCarpenter { fit_psr: false }.n_params(),
1908            4
1909        );
1910        assert_eq!(
1911            ResolutionFamily::IkedaCarpenter { fit_psr: true }.n_params(),
1912            5
1913        );
1914        // param_names track n_params, coordinate for coordinate.
1915        for fam in [
1916            ResolutionFamily::Gaussian,
1917            ResolutionFamily::IkedaCarpenter { fit_psr: false },
1918            ResolutionFamily::IkedaCarpenter { fit_psr: true },
1919        ] {
1920            assert_eq!(fam.param_names().len(), fam.n_params());
1921        }
1922        assert_eq!(
1923            ResolutionFamily::IkedaCarpenter { fit_psr: true }
1924                .param_names()
1925                .last()
1926                .copied(),
1927            Some("psr_fwhm_us")
1928        );
1929    }
1930
1931    #[test]
1932    fn rejects_empty_mismatched_and_non_finite_inputs() {
1933        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
1934        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
1935        let cfg = CalibrationConfig::default();
1936        assert!(matches!(
1937            calibrate_resolution(ResolutionFamily::Gaussian, &[], &[], &[], &sample, &cfg),
1938            Err(FittingError::EmptyData)
1939        ));
1940        let e = vec![1.0, 2.0, 3.0];
1941        let d = vec![0.5, 0.5];
1942        let u = vec![0.1, 0.1];
1943        assert!(matches!(
1944            calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg),
1945            Err(FittingError::LengthMismatch { .. })
1946        ));
1947        // Non-finite data and non-positive uncertainty are rejected up front.
1948        let e = vec![1.0, 2.0, 3.0];
1949        assert!(matches!(
1950            calibrate_resolution(
1951                ResolutionFamily::Gaussian,
1952                &e,
1953                &[0.5, f64::NAN, 0.7],
1954                &[0.1; 3],
1955                &sample,
1956                &cfg
1957            ),
1958            Err(FittingError::InvalidConfig(_))
1959        ));
1960        assert!(matches!(
1961            calibrate_resolution(
1962                ResolutionFamily::Gaussian,
1963                &e,
1964                &[0.5; 3],
1965                &[0.1, 0.0, 0.1],
1966                &sample,
1967                &cfg
1968            ),
1969            Err(FittingError::InvalidConfig(_))
1970        ));
1971    }
1972
1973    #[test]
1974    fn rejects_nonascending_and_nonpositive_energy_grid() {
1975        // Sibling-path parity with the Python `validate_energy_grid`: descending,
1976        // duplicate, zero, and negative energy grids must be rejected up front
1977        // rather than panicking deep in the cross-section assert or erroring late.
1978        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
1979        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
1980        let cfg = CalibrationConfig::default();
1981        for grid in [
1982            vec![4.0, 3.0, 2.0, 1.0],  // descending
1983            vec![1.0, 2.0, 2.0, 3.0],  // duplicate
1984            vec![0.0, 1.0, 2.0, 3.0],  // zero
1985            vec![-1.0, 1.0, 2.0, 3.0], // negative
1986        ] {
1987            let n = grid.len();
1988            assert!(
1989                matches!(
1990                    calibrate_resolution(
1991                        ResolutionFamily::Gaussian,
1992                        &grid,
1993                        &vec![0.5; n],
1994                        &vec![0.1; n],
1995                        &sample,
1996                        &cfg
1997                    ),
1998                    Err(FittingError::InvalidConfig(_))
1999                ),
2000                "expected InvalidConfig for grid {grid:?}"
2001            );
2002        }
2003    }
2004
2005    #[test]
2006    fn rejects_degenerate_calibrant_composition() {
2007        // A calibrant with no isotopes, or only zero/negative densities, yields a
2008        // flat (resolution-independent) forward model; the optimizer would return
2009        // a finite but meaningless result. Reject up front (Python-sibling parity).
2010        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2011        let energies: Vec<f64> = (0..64).map(|i| 5.0 + i as f64 * 0.4).collect();
2012        let data = vec![0.8; energies.len()];
2013        let unc = vec![0.01; energies.len()];
2014        let cfg = CalibrationConfig::default();
2015        for bad_sample in [
2016            SampleParams::new(300.0, vec![]).unwrap(),
2017            SampleParams::new(300.0, vec![(iso.clone(), 0.0)]).unwrap(),
2018            SampleParams::new(300.0, vec![(iso, -1.0e-3)]).unwrap(),
2019        ] {
2020            assert!(
2021                matches!(
2022                    calibrate_resolution(
2023                        ResolutionFamily::Gaussian,
2024                        &energies,
2025                        &data,
2026                        &unc,
2027                        &bad_sample,
2028                        &cfg
2029                    ),
2030                    Err(FittingError::InvalidConfig(_))
2031                ),
2032                "degenerate calibrant composition should be rejected"
2033            );
2034        }
2035    }
2036
2037    #[test]
2038    fn invalid_flight_path_propagates_build_error() {
2039        // flight_path <= 0 makes ResolutionParams::new fail on every eval, so the
2040        // calibration cannot build a resolution and returns an error.
2041        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2042        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2043        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2044        let d = vec![0.9; 60];
2045        let u = vec![0.01; 60];
2046        let cfg = CalibrationConfig {
2047            flight_path_m: -1.0,
2048            ..Default::default()
2049        };
2050        assert!(
2051            calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg).is_err()
2052        );
2053    }
2054
2055    #[test]
2056    fn inner_chi2_background_path_and_degenerate_model() {
2057        // 3-column baseline fit (anorm + const + linear) recovers an offset exactly.
2058        let model = vec![0.9, 0.7, 0.5, 0.8, 0.6];
2059        let data: Vec<f64> = model.iter().map(|m| 0.5 * m + 0.1).collect();
2060        let unc = vec![0.01; 5];
2061        assert!(inner_chi2(&data, &unc, &model, true, 0) < 1e-12);
2062        // all-zero model -> singular normal equations -> infeasible (χ²=∞), so the
2063        // optimizer steps away rather than seeing a spuriously inflated finite χ².
2064        let v = inner_chi2(&data, &unc, &[0.0; 5], false, 0);
2065        assert_eq!(v, f64::INFINITY);
2066    }
2067
2068    #[test]
2069    fn calibrate_with_background_runs() {
2070        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2071        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2072        let energies: Vec<f64> = (0..200).map(|i| 14.0 + i as f64 * 0.06).collect();
2073        let base = synthetic_base_udr();
2074        let truth = ResolutionFunction::Tabulated(Arc::new(
2075            base.width_corrected(1.3, 0.0, UDR_E_REF).unwrap(),
2076        ));
2077        let data = forward_model(
2078            &energies,
2079            &sample,
2080            Some(&InstrumentParams { resolution: truth }),
2081        )
2082        .unwrap();
2083        let unc = vec![0.004; energies.len()];
2084        let cfg = CalibrationConfig {
2085            fit_background: true,
2086            ..Default::default()
2087        };
2088        let r = calibrate_resolution(
2089            ResolutionFamily::UdrCorr {
2090                base: Arc::new(base),
2091            },
2092            &energies,
2093            &data,
2094            &unc,
2095            &sample,
2096            &cfg,
2097        )
2098        .unwrap();
2099        assert!(r.chi2_dof.is_finite());
2100    }
2101
2102    #[test]
2103    fn ic_recovers_known_alpha() {
2104        // Loop-closure / optimizer test (same caveat as udr_corr): truth and fit
2105        // both use the IC synthesis, so this checks the optimizer recovers the
2106        // full bounded 4-parameter family (#642) — the IC pulse physics is
2107        // independently covered by the ic_pulse tests in nereids-physics.
2108        // Truth, all interior to the new boxes: a0=0.35, a1=0.05, β=0.1, R=0.1,
2109        // PSR triangle 0.35 µs (= the calibrator's default 350 ns pin).
2110        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2111        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2112        let energies: Vec<f64> = (0..400).map(|i| 12.0 + i as f64 * 0.04).collect();
2113        let cfg = CalibrationConfig {
2114            restarts: 2,
2115            ..Default::default()
2116        };
2117        // Truth kernel on the SAME derived grid the calibrator synthesizes on,
2118        // so the loop closes exactly (a grid mismatch would leak into recovery).
2119        let ic_truth = IkedaCarpenter::new(
2120            IkedaCarpenterParams {
2121                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2122                beta: 0.1,
2123                r: EnergyLaw::Const(0.1),
2124                burst_sigma_us: None,
2125                channel_fwhm_us: Some(0.35),
2126            },
2127            cfg.flight_path_m,
2128            &SynthesisGrid {
2129                e_min_ev: (energies[0] * 0.5).max(1e-3),
2130                e_max_ev: energies.last().unwrap() * 2.0,
2131                n_energies: cfg.ic_n_energies,
2132                n_tau: cfg.ic_n_tau,
2133            },
2134        )
2135        .unwrap();
2136        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2137        let data = forward_model(
2138            &energies,
2139            &sample,
2140            Some(&InstrumentParams { resolution: truth }),
2141        )
2142        .unwrap();
2143        let unc = vec![0.004; energies.len()];
2144        let r = calibrate_resolution(
2145            ResolutionFamily::IkedaCarpenter { fit_psr: false },
2146            &energies,
2147            &data,
2148            &unc,
2149            &sample,
2150            &cfg,
2151        )
2152        .unwrap();
2153        let (a0, a1, beta, rr, psr) = decoded_ic(&r);
2154        assert!((a0 - 0.35).abs() < 0.05, "recovered a0={a0}, expected 0.35");
2155        assert!(a1 > 0.0, "a1 positive by construction, got {a1}");
2156        // β and R shape the kernel only jointly through the storage tail
2157        // (weight R, decay 1/β), so their windows are deliberately loose.
2158        assert!(
2159            (beta - 0.1).abs() < 0.08,
2160            "recovered β={beta}, expected 0.1"
2161        );
2162        assert!((rr - 0.1).abs() < 0.08, "recovered R={rr}, expected 0.1");
2163        assert!(
2164            (psr - 0.35).abs() < 1e-12,
2165            "PSR pin {psr} µs != 0.35 µs (config default)"
2166        );
2167        assert_eq!(r.n_free_params, 4);
2168        assert!(
2169            r.bounds_hit.is_empty(),
2170            "interior truth must not pin bounds, got {:?}",
2171            r.bounds_hit
2172        );
2173        assert!(r.chi2_dof < 1.0, "matched χ²/dof={} too high", r.chi2_dof);
2174    }
2175
2176    #[test]
2177    fn ic_recovers_known_psr_when_fit() {
2178        // Loop-closure / optimizer test for fit_psr (#645 F2, same caveat as
2179        // ic_recovers_known_alpha: truth and fit share the IC synthesis, so
2180        // this checks the 5-parameter optimizer, not the pulse physics).
2181        // Truth PSR FWHM = 0.6 µs — interior to the [0.05, 1] µs box and far
2182        // from the 0.35 µs default start — with the rest of the truth kernel
2183        // identical to ic_recovers_known_alpha. Two resonances (15 + 45 eV)
2184        // give the E-leverage that separates the E-independent triangle
2185        // width from the α(E) = a0·√E + a1 prompt law (a single resonance
2186        // probes the kernel at essentially one energy).
2187        // Full-density run (420 pts, 64×500 grid, restarts 2) recovers
2188        // psr = 0.5984 µs at χ²/dof ≈ 1e-6; this slimmed grid keeps the same
2189        // loop-closure semantics at a debug-friendly runtime.
2190        let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
2191        let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
2192        let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
2193        let energies: Vec<f64> = (0..280).map(|i| 8.0 + i as f64 * 0.15).collect();
2194        let cfg = CalibrationConfig {
2195            restarts: 1,
2196            ic_n_energies: 32,
2197            ic_n_tau: 320,
2198            ..Default::default()
2199        };
2200        let psr_true = 0.6;
2201        let ic_truth = IkedaCarpenter::new(
2202            IkedaCarpenterParams {
2203                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2204                beta: 0.1,
2205                r: EnergyLaw::Const(0.1),
2206                burst_sigma_us: None,
2207                channel_fwhm_us: Some(psr_true),
2208            },
2209            cfg.flight_path_m,
2210            &SynthesisGrid {
2211                e_min_ev: (energies[0] * 0.5).max(1e-3),
2212                e_max_ev: energies.last().unwrap() * 2.0,
2213                n_energies: cfg.ic_n_energies,
2214                n_tau: cfg.ic_n_tau,
2215            },
2216        )
2217        .unwrap();
2218        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2219        let data = forward_model(
2220            &energies,
2221            &sample,
2222            Some(&InstrumentParams { resolution: truth }),
2223        )
2224        .unwrap();
2225        let unc = vec![0.004; energies.len()];
2226        let r = calibrate_resolution(
2227            ResolutionFamily::IkedaCarpenter { fit_psr: true },
2228            &energies,
2229            &data,
2230            &unc,
2231            &sample,
2232            &cfg,
2233        )
2234        .unwrap();
2235        let (a0, _a1, _beta, _rr, psr) = decoded_ic(&r);
2236        assert_eq!(r.n_free_params, 5);
2237        assert!(
2238            (psr - psr_true).abs() < 0.1,
2239            "recovered PSR FWHM {psr} µs, expected {psr_true} µs"
2240        );
2241        assert!((a0 - 0.35).abs() < 0.05, "recovered a0={a0}, expected 0.35");
2242        assert!(r.chi2_dof < 1.0, "matched χ²/dof={} too high", r.chi2_dof);
2243    }
2244
2245    #[test]
2246    fn psr_disabled_at_zero_width() {
2247        // psr_fwhm_ns = 0.0 disables the triangle fold entirely: an UNFOLDED
2248        // truth is reproduced and the calibrated kernel carries no channel.
2249        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2250        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2251        let energies: Vec<f64> = (0..250).map(|i| 14.0 + i as f64 * 0.05).collect();
2252        let cfg = CalibrationConfig {
2253            psr_fwhm_ns: 0.0,
2254            ic_n_energies: 32,
2255            ic_n_tau: 300,
2256            ..Default::default()
2257        };
2258        let ic_truth = IkedaCarpenter::new(
2259            IkedaCarpenterParams {
2260                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2261                beta: 0.1,
2262                r: EnergyLaw::Const(0.1),
2263                burst_sigma_us: None,
2264                channel_fwhm_us: None, // unfolded truth
2265            },
2266            cfg.flight_path_m,
2267            &SynthesisGrid {
2268                e_min_ev: (energies[0] * 0.5).max(1e-3),
2269                e_max_ev: energies.last().unwrap() * 2.0,
2270                n_energies: cfg.ic_n_energies,
2271                n_tau: cfg.ic_n_tau,
2272            },
2273        )
2274        .unwrap();
2275        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2276        let data = forward_model(
2277            &energies,
2278            &sample,
2279            Some(&InstrumentParams { resolution: truth }),
2280        )
2281        .unwrap();
2282        let unc = vec![0.004; energies.len()];
2283        let r = calibrate_resolution(
2284            ResolutionFamily::IkedaCarpenter { fit_psr: false },
2285            &energies,
2286            &data,
2287            &unc,
2288            &sample,
2289            &cfg,
2290        )
2291        .unwrap();
2292        let ResolutionFunction::IkedaCarpenter(ic) = &r.resolution else {
2293            panic!("expected an IC resolution");
2294        };
2295        assert!(
2296            ic.params().channel_fwhm_us.is_none(),
2297            "psr_fwhm_ns = 0 must leave channel_fwhm_us = None, got {:?}",
2298            ic.params().channel_fwhm_us
2299        );
2300        assert!(
2301            r.chi2_dof < 1.0,
2302            "unfolded self-fit χ²/dof={} too high",
2303            r.chi2_dof
2304        );
2305    }
2306
2307    #[test]
2308    fn bounds_hit_reports_pinned_parameter() {
2309        // A truth WITHOUT a storage tail (R = 0) drives the fitted R onto its
2310        // lower box bound; the result must say so ("r:lower") — the β↔R-ridge
2311        // degeneracy flag (with no tail, β is unconstrained).
2312        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2313        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2314        let energies: Vec<f64> = (0..250).map(|i| 14.0 + i as f64 * 0.05).collect();
2315        let cfg = CalibrationConfig {
2316            ic_n_energies: 32,
2317            ic_n_tau: 300,
2318            restarts: 2,
2319            ..Default::default()
2320        };
2321        let ic_truth = IkedaCarpenter::new(
2322            IkedaCarpenterParams {
2323                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2324                beta: 0.1, // irrelevant at R = 0 (no storage term)
2325                r: EnergyLaw::Const(0.0),
2326                burst_sigma_us: None,
2327                channel_fwhm_us: Some(0.35),
2328            },
2329            cfg.flight_path_m,
2330            &SynthesisGrid {
2331                e_min_ev: (energies[0] * 0.5).max(1e-3),
2332                e_max_ev: energies.last().unwrap() * 2.0,
2333                n_energies: cfg.ic_n_energies,
2334                n_tau: cfg.ic_n_tau,
2335            },
2336        )
2337        .unwrap();
2338        let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2339        let data = forward_model(
2340            &energies,
2341            &sample,
2342            Some(&InstrumentParams { resolution: truth }),
2343        )
2344        .unwrap();
2345        let unc = vec![0.004; energies.len()];
2346        let r = calibrate_resolution(
2347            ResolutionFamily::IkedaCarpenter { fit_psr: false },
2348            &energies,
2349            &data,
2350            &unc,
2351            &sample,
2352            &cfg,
2353        )
2354        .unwrap();
2355        assert!(
2356            r.bounds_hit.iter().any(|s| s == "r:lower"),
2357            "R = 0 truth must pin the storage fraction: bounds_hit = {:?}, decoded = {:?}",
2358            r.bounds_hit,
2359            decoded_ic(&r)
2360        );
2361    }
2362
2363    #[test]
2364    fn rejects_invalid_psr_fwhm_ns() {
2365        // NaN / negative / infinite PSR widths are config errors caught up
2366        // front (NaN would silently disable the `> 0.0` fold gate; a negative
2367        // width would fail deep in IkedaCarpenter::new on every evaluation).
2368        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2369        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2370        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2371        let d = vec![0.9; 60];
2372        let u = vec![0.01; 60];
2373        for bad in [f64::NAN, -1.0, f64::INFINITY] {
2374            let cfg = CalibrationConfig {
2375                psr_fwhm_ns: bad,
2376                ..Default::default()
2377            };
2378            assert!(
2379                matches!(
2380                    calibrate_resolution(
2381                        ResolutionFamily::IkedaCarpenter { fit_psr: false },
2382                        &e,
2383                        &d,
2384                        &u,
2385                        &sample,
2386                        &cfg
2387                    ),
2388                    Err(FittingError::InvalidConfig(_))
2389                ),
2390                "psr_fwhm_ns={bad} should be rejected"
2391            );
2392        }
2393    }
2394
2395    #[test]
2396    fn rejects_absurd_pinned_psr_width() {
2397        // Review #645 round 2, F1: psr_fwhm_ns is NANOSECONDS (FTS convention
2398        // 350 ns) and synthesis cost is quadratic in the fold width — a
2399        // µs-as-ns unit slip (350 meaning µs → 350_000 ns) previously passed
2400        // the finite/sign check and pinned a fictitious 350 µs fold: a
2401        // multi-hour silent hang. Widths above PSR_FWHM_PIN_CEILING_US
2402        // (10 µs = 10_000 ns) must be a loud up-front config error.
2403        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2404        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2405        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2406        let d = vec![0.9; 60];
2407        let u = vec![0.01; 60];
2408        let cfg = CalibrationConfig {
2409            psr_fwhm_ns: 350_000.0, // "350 µs" unit slip
2410            ..Default::default()
2411        };
2412        let err = calibrate_resolution(
2413            ResolutionFamily::IkedaCarpenter { fit_psr: false },
2414            &e,
2415            &d,
2416            &u,
2417            &sample,
2418            &cfg,
2419        )
2420        .expect_err("a 350_000 ns (350 µs) pinned PSR width must be rejected");
2421        assert!(
2422            matches!(
2423                &err,
2424                FittingError::InvalidConfig(msg)
2425                    if msg.contains("NANOSECONDS") && msg.contains("350 ns")
2426            ),
2427            "ceiling error must name the ns unit and the 350-ns convention, got {err:?}"
2428        );
2429
2430        // Boundary + normal pins stay valid: exactly 10_000 ns sits ON the
2431        // ceiling (rejection is strict `>`; 10_000·1e-3 rounds to exactly
2432        // 10.0) and 350 ns is the FTS default. psr_fwhm_ns = 0 (disable) is
2433        // pinned valid by rejects_fit_psr_with_zero_psr_width. Tiny
2434        // grid/iteration budget: these arms assert config validity, not fit
2435        // quality.
2436        for ok_ns in [350.0, 10_000.0] {
2437            let cheap = CalibrationConfig {
2438                psr_fwhm_ns: ok_ns,
2439                ic_n_energies: 8,
2440                ic_n_tau: 32,
2441                max_iter: 10,
2442                ..Default::default()
2443            };
2444            assert!(
2445                calibrate_resolution(
2446                    ResolutionFamily::IkedaCarpenter { fit_psr: false },
2447                    &e,
2448                    &d,
2449                    &u,
2450                    &sample,
2451                    &cheap,
2452                )
2453                .is_ok(),
2454                "psr_fwhm_ns = {ok_ns} ns must remain a valid pinned width"
2455            );
2456        }
2457    }
2458
2459    #[test]
2460    fn rejects_infeasible_psr_start_width() {
2461        // Review #645 round 3, F1: a nonzero PSR width in (0, ~58.6 ns)
2462        // passes every value-level check (finite / sign / ceiling) yet cannot
2463        // be SYNTHESIZED at the optimizer start: the default β/R start
2464        // (β = 0.1, R = 0.1 > R_NEGLIGIBLE) spans a 16/β = 160 µs storage
2465        // tail, capping the τ-step at 160/8191 ≈ 19.53 ns, and tau_geometry
2466        // rejects any triangle whose FWHM/3 floor is below that (fwhm <
2467        // ~58.6 ns). Every initial-simplex vertex was then ∞ (objective range
2468        // ∞ − ∞ = NaN — no self-convergence), so the calibration burned
2469        // max_iter and died with the generic "no finite-objective" error
2470        // blaming the forward model. The pre-flight must reject the START
2471        // precisely, surfacing the τ-geometry diagnosis and naming
2472        // psr_fwhm_ns. The fit_psr arm starts AT the fit-box floor
2473        // PSR_FWHM_US_MIN = 0.05 µs (50 ns), which is itself infeasible at
2474        // the default start — proof that a `>= PSR_FWHM_US_MIN` value check
2475        // would not be sufficient.
2476        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2477        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2478        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2479        let d = vec![0.9; 60];
2480        let u = vec![0.01; 60];
2481        for (fit_psr, psr_ns) in [(false, 55.0), (true, 50.0)] {
2482            let cfg = CalibrationConfig {
2483                psr_fwhm_ns: psr_ns,
2484                ..Default::default()
2485            };
2486            let err = calibrate_resolution(
2487                ResolutionFamily::IkedaCarpenter { fit_psr },
2488                &e,
2489                &d,
2490                &u,
2491                &sample,
2492                &cfg,
2493            )
2494            .expect_err("a sub-59-ns PSR start must be rejected up front");
2495            assert!(
2496                matches!(
2497                    &err,
2498                    FittingError::InvalidConfig(msg)
2499                        if msg.contains("starting parameter vector")
2500                            && msg.contains("psr_fwhm_ns")
2501                            && msg.contains("cannot resolve")
2502                ),
2503                "pre-flight error must name the start, psr_fwhm_ns and the τ-cap cause \
2504                 (fit_psr = {fit_psr}, psr_ns = {psr_ns}), got {err:?}"
2505            );
2506        }
2507    }
2508
2509    #[test]
2510    fn rejects_fit_psr_with_zero_psr_width() {
2511        // psr_fwhm_ns = 0 means "no PSR fold"; fit_psr = true would silently
2512        // clamp that 0 start into the [0.05, 1] µs fit box, contradicting the
2513        // documented "0 disables". The contradiction is a config error.
2514        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2515        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2516        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2517        let d = vec![0.9; 60];
2518        let u = vec![0.01; 60];
2519        let cfg = CalibrationConfig {
2520            psr_fwhm_ns: 0.0,
2521            ..Default::default()
2522        };
2523        let err = calibrate_resolution(
2524            ResolutionFamily::IkedaCarpenter { fit_psr: true },
2525            &e,
2526            &d,
2527            &u,
2528            &sample,
2529            &cfg,
2530        )
2531        .expect_err("fit_psr with psr_fwhm_ns = 0 must be rejected");
2532        assert!(
2533            matches!(&err, FittingError::InvalidConfig(msg) if msg.contains("fit_psr")),
2534            "expected an InvalidConfig naming fit_psr, got {err:?}"
2535        );
2536        // The same zero width WITHOUT fit_psr stays valid ("0 disables").
2537        // Tiny grid/iteration budget: this arm only asserts the config
2538        // passes validation, not fit quality.
2539        let cheap = CalibrationConfig {
2540            psr_fwhm_ns: 0.0,
2541            ic_n_energies: 8,
2542            ic_n_tau: 32,
2543            max_iter: 10,
2544            ..Default::default()
2545        };
2546        assert!(
2547            calibrate_resolution(
2548                ResolutionFamily::IkedaCarpenter { fit_psr: false },
2549                &e,
2550                &d,
2551                &u,
2552                &sample,
2553                &cheap,
2554            )
2555            .is_ok(),
2556            "psr_fwhm_ns = 0 with fit_psr = false must remain a valid config"
2557        );
2558    }
2559
2560    #[test]
2561    fn rejects_undersized_ic_synthesis_grid() {
2562        // ic_n_energies < 2 / ic_n_tau < 8 previously surfaced only as the
2563        // late, generic "no finite-objective resolution" error (every
2564        // IkedaCarpenter::new evaluation failed). They must be precise
2565        // up-front InvalidConfig errors for the IC family — sibling parity
2566        // with the Python binding's validation.
2567        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2568        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2569        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2570        let d = vec![0.9; 60];
2571        let u = vec![0.01; 60];
2572        for (ne, nt, what) in [(1, 500, "ic_n_energies"), (64, 7, "ic_n_tau")] {
2573            // The loose iteration/tolerance budget only cheapens the Gaussian
2574            // is_ok arm below (validation-only assertion, not fit quality);
2575            // the InvalidConfig arm rejects before any optimization runs.
2576            let cfg = CalibrationConfig {
2577                ic_n_energies: ne,
2578                ic_n_tau: nt,
2579                max_iter: 5,
2580                xatol: 1.0,
2581                fatol: 1.0,
2582                ..Default::default()
2583            };
2584            let err = calibrate_resolution(
2585                ResolutionFamily::IkedaCarpenter { fit_psr: false },
2586                &e,
2587                &d,
2588                &u,
2589                &sample,
2590                &cfg,
2591            )
2592            .expect_err("undersized IC synthesis grid must be rejected");
2593            assert!(
2594                matches!(&err, FittingError::InvalidConfig(msg) if msg.contains(what)),
2595                "expected an InvalidConfig naming {what}, got {err:?}"
2596            );
2597            // The same values are inert for a non-IC family (mirrors the
2598            // Python binding: the knobs only size the IC synthesis grid).
2599            assert!(
2600                calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg).is_ok(),
2601                "ic grid knobs must stay inert for the Gaussian family"
2602            );
2603        }
2604    }
2605
2606    #[test]
2607    fn ic_box_worst_corner_synthesizes_within_tau_cap() {
2608        // #645 F1: the calibrator's box must not abort a calibration on an
2609        // unresolvable τ-grid. Worst corner of the box in the τ-cap sense:
2610        // β at its floor (slow reach 16/β = 800 µs — the longest admitted
2611        // storage tail, so the capped step is at its widest ≈ 0.098 µs),
2612        // R = 1 (storage fully active), a1 at its ceiling and a0 at the
2613        // documented physical ceiling (α ≈ 1–3 µs⁻¹ in the eV regime ⇒
2614        // a0 ≈ 0.2–0.5, see IC_A0_MIN), at the calibrator's default
2615        // n_tau = 500 on a representative eV-regime synthesis window. The
2616        // capped step resolves both the PSR triangle box (0.35 µs default
2617        // pin up to the 1 µs fitted ceiling: ≥ 3 samples per side) and any
2618        // prompt core with α ≤ 18/(7 · 0.098) ≈ 26 µs⁻¹ — far above eV-regime
2619        // moderator physics. (The remaining unresolvable pockets — a fitted
2620        // PSR near its 0.05 µs floor together with β near its floor, or
2621        // a0 driven ~50× past the physical ceiling — are handled as
2622        // infeasible points, see the companion test below.)
2623        let cfg = CalibrationConfig::default();
2624        for fwhm_us in [DEFAULT_PSR_FWHM_NS * NS_TO_US, PSR_FWHM_US_MAX] {
2625            let corner = IkedaCarpenterParams {
2626                alpha: EnergyLaw::SqrtE {
2627                    a0: 0.5,
2628                    a1: IC_A1_MAX,
2629                },
2630                beta: IC_BETA_MIN,
2631                r: EnergyLaw::Const(IC_R_MAX),
2632                burst_sigma_us: None,
2633                channel_fwhm_us: Some(fwhm_us),
2634            };
2635            let grid = SynthesisGrid {
2636                e_min_ev: 6.0,
2637                e_max_ev: 112.0,
2638                n_energies: cfg.ic_n_energies,
2639                n_tau: cfg.ic_n_tau,
2640            };
2641            assert!(
2642                IkedaCarpenter::new(corner, cfg.flight_path_m, &grid).is_ok(),
2643                "calibration-box worst corner must synthesize (fwhm = {fwhm_us} µs)"
2644            );
2645        }
2646    }
2647
2648    #[test]
2649    fn ic_unresolvable_theta_errs_in_build_resolution() {
2650        // A θ inside the box can still be unresolvable: a fitted PSR at its
2651        // 0.05 µs floor against β at its own floor needs a τ-step ≤ FWHM/3 ≈
2652        // 0.017 µs across an 800 µs storage tail — past the 8192-sample cap.
2653        // This test asserts the build_resolution half only: such θ must Err.
2654        // The calibration-level half — the objective maps that Err to an ∞
2655        // point the simplex steps away from, never aborting the calibration —
2656        // is asserted by ic_infeasible_pocket_inside_box_completes_calibration
2657        // below (#645 round 3, F4).
2658        let cfg = CalibrationConfig::default();
2659        let theta = [
2660            IC_A0_X0.ln(),
2661            IC_A1_X0.ln(),
2662            IC_BETA_MIN.ln(),
2663            0.5,
2664            PSR_FWHM_US_MIN,
2665        ];
2666        let fam = ResolutionFamily::IkedaCarpenter { fit_psr: true };
2667        assert!(
2668            build_resolution(&fam, &theta, 6.0, 112.0, &cfg).is_err(),
2669            "β at its floor + PSR at its floor must be unresolvable"
2670        );
2671    }
2672
2673    #[test]
2674    fn ic_infeasible_pocket_inside_box_completes_calibration() {
2675        // Review #645 round 3, F4 — the calibration-level half of the claim
2676        // above: with fit_psr the box CONTAINS the unresolvable pocket (PSR
2677        // near its 0.05 µs floor against β near its own floor), and the
2678        // simplex demonstrably brushes it — the 60 ns start sits just above
2679        // the ~58.6 ns feasibility edge at the default β/R start (the
2680        // pre-flight passes: 60/3 = 20 ns floor > 19.53 ns capped step), so
2681        // the FIRST simplex already carries an ∞ vertex: the β-decreased
2682        // vertex (ln β step is negative, β 0.1 → ~0.089) widens the storage
2683        // reach to ~180 µs and the capped step to ~21.9 ns, past the 20 ns
2684        // floor. The optimizer must treat such vertices as infeasible points
2685        // and finish: Ok, finite χ², decoded resolution inside the box. Tiny
2686        // grid/iteration budget — this asserts non-abortion, not fit quality.
2687        let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2688        let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2689        let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2690        let d = vec![0.9; 60];
2691        let u = vec![0.01; 60];
2692        let cfg = CalibrationConfig {
2693            psr_fwhm_ns: 60.0,
2694            ic_n_energies: 8,
2695            ic_n_tau: 32,
2696            max_iter: 60,
2697            ..Default::default()
2698        };
2699        let r = calibrate_resolution(
2700            ResolutionFamily::IkedaCarpenter { fit_psr: true },
2701            &e,
2702            &d,
2703            &u,
2704            &sample,
2705            &cfg,
2706        )
2707        .expect("an infeasible pocket inside the box must not abort the calibration");
2708        assert!(
2709            r.chi2_dof.is_finite(),
2710            "calibration through the infeasible pocket must return a finite χ²/dof, got {}",
2711            r.chi2_dof
2712        );
2713        let (_a0, _a1, beta, _r, psr_us) = decoded_ic(&r);
2714        assert!(
2715            (PSR_FWHM_US_MIN..=PSR_FWHM_US_MAX).contains(&psr_us)
2716                && (IC_BETA_MIN..=IC_BETA_MAX).contains(&beta),
2717            "decoded solution must be feasible and inside the box: β = {beta}, \
2718             psr = {psr_us} µs"
2719        );
2720    }
2721}