Skip to main content

nereids_physics/
resolution.rs

1//! Resolution broadening via convolution with instrument resolution function.
2//!
3//! Convolves theoretical cross-sections (or transmission) with the instrument
4//! resolution function to account for finite energy resolution. The resolution
5//! function is modeled as a Gaussian with energy-dependent width, optionally
6//! combined with an exponential tail, derived from time-of-flight instrument
7//! parameters.
8//!
9//! ## SAMMY Reference
10//! - `rsl/mrsl1.f90` — Main RSL resolution broadening routines (Resbrd)
11//! - `rsl/mrsl4.f90` — Resolution width calculation (Wdsint, Rolowg)
12//! - `rsl/mrsl5.f90` — Exponential tail peak shift (Shftge)
13//! - `fnc/exerfc.f90` — Scaled complementary error function
14//! - `convolution/DopplerAndResolutionBroadener.cpp` — Xcoef quadrature weights
15//! - Manual Section III.C (Resolution Broadening); quadrature Eq. IV B 3.8
16//!   (R3-revision numbering — see `compute_xcoef_weights` and the
17//!   Gaussian+exponential path in `resolution_broaden_presorted`)
18//!
19//! ## Width convention — read before supplying a number
20//!
21//! Every width here is a **W-parameter**, the width appearing in
22//! `exp(-x²/W²)`, not a standard deviation. The two differ by √2:
23//!
24//!   σ = W/√2        FWHM = 2·√(ln 2)·W = 1.6651·W
25//!
26//! This is SAMMY's convention, and it is the same one the Doppler width Δ_D
27//! uses, so the two broadening kernels compose without a conversion. Supplying
28//! a 1σ value where a W is expected yields a kernel √2 too narrow — 29 % — and
29//! a resolution that is too narrow is absorbed into a fitted temperature that
30//! is too high.
31//!
32//! [`ResolutionParams::from_sigma`] and [`ResolutionParams::from_fwhm`] convert
33//! for you. Use them rather than scaling by hand.
34//!
35//! SAMMY's own inputs are in yet other measures — `Deltag` is a FWHM and
36//! `Deltal` is the full width of a rectangular path spread — so reading a
37//! SAMMY `.inp` goes through `nereids_endf::sammy::sammy_to_nereids_resolution`
38//! (`Deltag/(2√ln2)`, `Deltal/√6`), never straight into these fields.
39//!
40//! ## Physics
41//!
42//! For a time-of-flight instrument, the energy resolution is:
43//!
44//!   (ΔE/E)² = (2·Δt/t)² + (2·ΔL/L)²
45//!
46//! where t = L/v is the neutron time-of-flight, Δt is the total timing width,
47//! and ΔL is the flight path width — both W-parameters, as above, so ΔE is one
48//! too. The factor 2 is the kinematic derivative dE/E = 2·dt/t; it is not a
49//! width-measure conversion. Since t ∝ 1/√E, the timing contribution gives
50//! ΔE ∝ E^(3/2) while the path contribution gives ΔE ∝ E.
51//!
52//! The broadened cross-section is:
53//!
54//!   σ_res(E) = ∫ R(E, E') · σ(E') dE'
55//!
56//! When Deltae = 0, R is a pure Gaussian (Iesopr=1):
57//!   R(E, E') = exp(-(E-E')²/Wg²) / (Wg·√π)
58//!
59//! When Deltae > 0, R is the convolution of a Gaussian with an exponential
60//! tail (Iesopr=3):
61//!   R(E, E') ∝ exp(2·C·A + C²) · erfc(C + A)
62//!
63//! where C = Wg/(2·We), A = (E - E')/Wg, Wg = Gaussian width, We = exponential
64//! width. This is the analytical result for convolving exp(-x²/Wg²) with
65//! exp(-x/We)·H(x).
66
67use nereids_core::constants::{DIVISION_FLOOR, NEAR_ZERO_FLOOR};
68use std::f64::consts::SQRT_2;
69use std::fmt;
70use std::sync::Arc;
71
72/// FWHM of `exp(-x²/W²)` divided by W: `2·√(ln 2)` = 1.6651.
73///
74/// The conversion between this module's W-parameters and a full width at half
75/// maximum. Paired with σ = W/√2 it fixes all three width measures against each
76/// other; see the module's width-convention section.
77const FWHM_PER_W: f64 = 1.665_109_222_315_395_4;
78
79/// TOF conversion factor: `t (μs) = TOF_FACTOR × L (m) / √(E in eV)`.
80///
81/// Derived from t = L / √(2E/m_n), converting to microseconds:
82///   TOF_FACTOR = 1e6 / √(2 × EV_TO_JOULES / NEUTRON_MASS_KG)
83///
84/// Uses CODATA 2018 values (both exact in the 2019 SI).
85///
86/// `pub` so the analytical [`crate::ikeda_carpenter`] model and the
87/// `nereids-fitting` resolution calibrator both use the *identical* TOF↔energy
88/// constant (the calibrator's position nuisance shifts the grid in TOF) — any
89/// drift here would make the IC-vs-tabulated cross-validation unfair.
90pub const TOF_FACTOR: f64 = 72.298_254_398_292_8;
91
92/// Errors from resolution broadening operations.
93#[derive(Debug, PartialEq)]
94pub enum ResolutionError {
95    /// The energy grid is not sorted in ascending order.
96    UnsortedEnergies,
97    /// The energy grid and data arrays have mismatched lengths.
98    LengthMismatch { energies: usize, data: usize },
99    /// A [`ResolutionPlan`] was passed together with an `energies`
100    /// slice that does not match the grid the plan was built for.
101    ///
102    /// Cheapest-available check hierarchy: length mismatch is caught
103    /// first via [`Self::LengthMismatch`] (`plan.len() ==
104    /// energies.len()` is necessary but not sufficient); a content
105    /// mismatch fires `PlanGridMismatch` with the index of the first
106    /// differing element so callers can diagnose silent-staleness
107    /// bugs at the cache layer.
108    PlanGridMismatch { first_diff_index: usize },
109    /// A [`ResolutionMatrix`] was passed together with an `energies`
110    /// slice that does not match the grid the matrix was compiled for.
111    /// Same semantics as [`Self::PlanGridMismatch`] but for the CSR
112    /// path (see [`apply_r`]).
113    MatrixGridMismatch { first_diff_index: usize },
114    /// [`TabulatedResolution::width_corrected`] was called with invalid
115    /// parameters: `s0` must be finite and `> 0`, `e_ref` finite and `> 0`,
116    /// and `p` finite. A non-positive `s0` would reverse/collapse the
117    /// (ascending) offset ordering the broadening loop assumes.
118    InvalidWidthCorrection { s0: f64, p: f64, e_ref: f64 },
119}
120
121impl fmt::Display for ResolutionError {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::UnsortedEnergies => write!(
125                f,
126                "energy grid must be sorted in non-descending order for binary search"
127            ),
128            Self::LengthMismatch { energies, data } => write!(
129                f,
130                "energy grid length ({}) must match data length ({})",
131                energies, data
132            ),
133            Self::PlanGridMismatch { first_diff_index } => write!(
134                f,
135                "resolution plan was built for a different energy grid than was \
136                 passed to apply_resolution_with_plan (first differing index: {})",
137                first_diff_index,
138            ),
139            Self::MatrixGridMismatch { first_diff_index } => write!(
140                f,
141                "resolution matrix was compiled for a different energy grid than was \
142                 passed to apply_resolution_with_matrix (first differing index: {})",
143                first_diff_index,
144            ),
145            Self::InvalidWidthCorrection { s0, p, e_ref } => write!(
146                f,
147                "width_corrected requires finite s0 > 0, finite e_ref > 0, and finite p; \
148                 got s0={s0}, p={p}, e_ref={e_ref}"
149            ),
150        }
151    }
152}
153
154impl std::error::Error for ResolutionError {}
155
156/// Errors from `ResolutionParams` construction.
157#[derive(Debug, PartialEq)]
158pub enum ResolutionParamsError {
159    /// Flight path must be positive and finite.
160    InvalidFlightPath(f64),
161    /// Timing uncertainty must be non-negative and finite.
162    InvalidDeltaT(f64),
163    /// Path length uncertainty must be non-negative and finite.
164    InvalidDeltaL(f64),
165    /// Exponential tail parameter must be non-negative and finite.
166    InvalidDeltaE(f64),
167}
168
169impl fmt::Display for ResolutionParamsError {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        match self {
172            Self::InvalidFlightPath(v) => {
173                write!(f, "flight_path_m must be positive and finite, got {v}")
174            }
175            Self::InvalidDeltaT(v) => {
176                write!(f, "delta_t_us must be non-negative and finite, got {v}")
177            }
178            Self::InvalidDeltaL(v) => {
179                write!(f, "delta_l_m must be non-negative and finite, got {v}")
180            }
181            Self::InvalidDeltaE(v) => {
182                write!(f, "delta_e_us must be non-negative and finite, got {v}")
183            }
184        }
185    }
186}
187
188impl std::error::Error for ResolutionParamsError {}
189
190/// Resolution function parameters for time-of-flight instruments.
191#[derive(Debug, Clone, Copy)]
192pub struct ResolutionParams {
193    /// Flight path length in meters (source to detector).
194    flight_path_m: f64,
195    /// Total timing width in microseconds, as a W-parameter (σ = W/√2).
196    /// Combines moderator pulse width, detector timing, and electronics.
197    ///
198    /// Not a standard deviation — see the module's width-convention section.
199    delta_t_us: f64,
200    /// Flight path width in meters, as a W-parameter (σ = W/√2).
201    delta_l_m: f64,
202    /// Exponential tail parameter (SAMMY Deltae, raw SAMMY units).
203    ///
204    /// When zero, pure Gaussian broadening is used (SAMMY Iesopr=1).
205    /// When positive, the kernel is the convolution of a Gaussian with an
206    /// exponential tail (SAMMY Iesopr=3).
207    ///
208    /// SAMMY Ref: `RslResolutionFunction_M.f90` getCo2, `rsl/mrsl4.f90` Wdsint.
209    delta_e_us: f64,
210}
211
212impl ResolutionParams {
213    /// Create validated resolution parameters.
214    ///
215    /// `delta_t_us` and `delta_l_m` are W-parameters (σ = W/√2), not standard
216    /// deviations. If your instrument numbers are 1σ or FWHM, use
217    /// [`from_sigma`](Self::from_sigma) or [`from_fwhm`](Self::from_fwhm)
218    /// instead of converting by hand.
219    ///
220    /// # Arguments
221    /// * `flight_path_m` — Flight path length in meters (must be > 0).
222    /// * `delta_t_us` — Timing width in microseconds, W-parameter (must be >= 0).
223    /// * `delta_l_m` — Flight path width in meters, W-parameter (must be >= 0).
224    /// * `delta_e_us` — Exponential tail parameter in SAMMY Deltae units
225    ///   (must be >= 0). When 0, pure Gaussian broadening is used.
226    ///
227    /// # Errors
228    /// Returns `ResolutionParamsError::InvalidFlightPath` if `flight_path_m <= 0.0`
229    /// or is not finite.
230    /// Returns `ResolutionParamsError::InvalidDeltaT` if `delta_t_us < 0.0` or is
231    /// not finite.
232    /// Returns `ResolutionParamsError::InvalidDeltaL` if `delta_l_m < 0.0` or is
233    /// not finite.
234    /// Returns `ResolutionParamsError::InvalidDeltaE` if `delta_e_us < 0.0` or is
235    /// not finite.
236    pub fn new(
237        flight_path_m: f64,
238        delta_t_us: f64,
239        delta_l_m: f64,
240        delta_e_us: f64,
241    ) -> Result<Self, ResolutionParamsError> {
242        if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
243            return Err(ResolutionParamsError::InvalidFlightPath(flight_path_m));
244        }
245        if !delta_t_us.is_finite() || delta_t_us < 0.0 {
246            return Err(ResolutionParamsError::InvalidDeltaT(delta_t_us));
247        }
248        if !delta_l_m.is_finite() || delta_l_m < 0.0 {
249            return Err(ResolutionParamsError::InvalidDeltaL(delta_l_m));
250        }
251        if !delta_e_us.is_finite() || delta_e_us < 0.0 {
252            return Err(ResolutionParamsError::InvalidDeltaE(delta_e_us));
253        }
254        Ok(Self {
255            flight_path_m,
256            delta_t_us,
257            delta_l_m,
258            delta_e_us,
259        })
260    }
261
262    /// Create resolution parameters from standard deviations.
263    ///
264    /// Instrument metrology is usually quoted as a 1σ jitter, while this type
265    /// stores W-parameters. This constructor applies W = σ·√2 to the timing and
266    /// flight-path terms, so a measured 1σ can be passed in directly.
267    ///
268    /// `delta_e_us` is not a Gaussian width — it is SAMMY's exponential tail
269    /// parameter Deltae — so it is passed through unchanged.
270    ///
271    /// # Errors
272    /// Same as [`new`](Self::new), on the converted values.
273    pub fn from_sigma(
274        flight_path_m: f64,
275        sigma_t_us: f64,
276        sigma_l_m: f64,
277        delta_e_us: f64,
278    ) -> Result<Self, ResolutionParamsError> {
279        Self::new(
280            flight_path_m,
281            sigma_t_us * SQRT_2,
282            sigma_l_m * SQRT_2,
283            delta_e_us,
284        )
285    }
286
287    /// Create resolution parameters from full widths at half maximum.
288    ///
289    /// Applies W = FWHM/(2·√(ln 2)) to the timing and flight-path terms. This
290    /// is the measure SAMMY's `Deltag` uses, so a value taken from a SAMMY
291    /// `.inp` timing field can be passed in directly.
292    ///
293    /// `delta_e_us` is SAMMY's exponential tail parameter, not a Gaussian
294    /// width, so it is passed through unchanged.
295    ///
296    /// # Errors
297    /// Same as [`new`](Self::new), on the converted values.
298    pub fn from_fwhm(
299        flight_path_m: f64,
300        fwhm_t_us: f64,
301        fwhm_l_m: f64,
302        delta_e_us: f64,
303    ) -> Result<Self, ResolutionParamsError> {
304        Self::new(
305            flight_path_m,
306            fwhm_t_us / FWHM_PER_W,
307            fwhm_l_m / FWHM_PER_W,
308            delta_e_us,
309        )
310    }
311
312    /// Returns the flight path length in meters.
313    #[must_use]
314    pub fn flight_path_m(&self) -> f64 {
315        self.flight_path_m
316    }
317
318    /// Total timing width in microseconds, as a W-parameter (σ = W/√2).
319    ///
320    /// The factor of 2 in [`gaussian_width()`](Self::gaussian_width) comes from
321    /// the energy-TOF derivative dE/E = 2·dt/t, not from a width-measure
322    /// conversion — the convention is unchanged between time and energy.
323    #[must_use]
324    pub fn delta_t_us(&self) -> f64 {
325        self.delta_t_us
326    }
327
328    /// Returns the flight path width in meters, as a W-parameter (σ = W/√2).
329    #[must_use]
330    pub fn delta_l_m(&self) -> f64 {
331        self.delta_l_m
332    }
333
334    /// Returns the exponential tail parameter (SAMMY Deltae units).
335    #[must_use]
336    pub fn delta_e_us(&self) -> f64 {
337        self.delta_e_us
338    }
339
340    /// Whether the exponential tail is active (Deltae > 0, SAMMY Iesopr=3).
341    #[must_use]
342    pub fn has_exponential_tail(&self) -> bool {
343        self.delta_e_us > NEAR_ZERO_FLOOR
344    }
345
346    /// Exponential tail width Widexp(E) in eV.
347    ///
348    /// SAMMY Ref: `rsl/mrsl4.f90` Wdsint lines 55-56 (Kedxfw=false path):
349    ///   `Widexp = E * Co2 * sqrt(E)` where `Co2 = 2·Deltae / (Sm2·Dist)`.
350    ///
351    /// Combined: `Widexp = 2·Deltae·E^(3/2) / (TOF_FACTOR·L)`.
352    #[must_use]
353    pub fn exp_width(&self, energy_ev: f64) -> f64 {
354        if energy_ev <= 0.0 || self.delta_e_us <= 0.0 {
355            return 0.0;
356        }
357        2.0 * self.delta_e_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m)
358    }
359
360    /// Gaussian resolution width W_g(E) in eV — the W of `exp(-x²/W_g²)`.
361    ///
362    /// Combines timing and flight-path contributions in quadrature:
363    ///   W_g² = (2·Δt/t × E)² + (2·ΔL/L × E)²
364    ///
365    /// where t = TOF_FACTOR × L / √E is the time-of-flight in μs. Δt and ΔL are
366    /// W-parameters too, so the convention carries through unchanged; the
367    /// standard deviation is `W_g/√2` and the FWHM is [`fwhm`](Self::fwhm).
368    #[must_use]
369    pub fn gaussian_width(&self, energy_ev: f64) -> f64 {
370        if energy_ev <= 0.0 || self.flight_path_m <= 0.0 {
371            return 0.0;
372        }
373
374        // Timing contribution: W_t = 2 × Δt × E^(3/2) / (TOF_FACTOR × L)
375        let timing =
376            2.0 * self.delta_t_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m);
377
378        // Path length contribution: W_L = 2 × ΔL × E / L
379        let path = 2.0 * self.delta_l_m * energy_ev / self.flight_path_m;
380
381        (timing * timing + path * path).sqrt()
382    }
383
384    /// FWHM of the resolution function at energy E, in eV.
385    ///
386    /// `FWHM = 2·√(ln 2)·W`, the conversion for `exp(-x²/W²)`. For the standard
387    /// deviation instead, use `gaussian_width(E)/√2`.
388    #[must_use]
389    pub fn fwhm(&self, energy_ev: f64) -> f64 {
390        FWHM_PER_W * self.gaussian_width(energy_ev)
391    }
392}
393
394/// Apply Gaussian resolution broadening to cross-section data.
395///
396/// Convolves the input cross-sections with a Gaussian kernel whose width
397/// varies with energy according to the instrument resolution function.
398///
399/// # Arguments
400/// * `energies` — Energy grid in eV (must be sorted ascending).
401/// * `cross_sections` — Cross-sections in barns at each energy point.
402/// * `params` — Resolution function parameters.
403///
404/// # Returns
405/// Resolution-broadened cross-sections on the same energy grid.
406///
407/// # Errors
408/// Returns [`ResolutionError::LengthMismatch`] if the arrays differ in length,
409/// or [`ResolutionError::UnsortedEnergies`] if the energy grid is not sorted
410/// in non-descending order.
411pub fn resolution_broaden(
412    energies: &[f64],
413    cross_sections: &[f64],
414    params: &ResolutionParams,
415) -> Result<Vec<f64>, ResolutionError> {
416    validate_inputs(energies, cross_sections)?;
417    Ok(resolution_broaden_presorted(
418        energies,
419        cross_sections,
420        params,
421    ))
422}
423
424/// Check that the energy grid is sorted and that its length matches the data.
425fn validate_inputs(energies: &[f64], data: &[f64]) -> Result<(), ResolutionError> {
426    if energies.len() != data.len() {
427        return Err(ResolutionError::LengthMismatch {
428            energies: energies.len(),
429            data: data.len(),
430        });
431    }
432    if !energies.windows(2).all(|w| w[0] <= w[1]) {
433        return Err(ResolutionError::UnsortedEnergies);
434    }
435    Ok(())
436}
437
438// ─── Xcoef quadrature weights ──────────────────────────────────────────────────
439
440/// Compute SAMMY's 4-point quadrature weights for a non-uniform energy grid.
441///
442/// Replaces the simple trapezoidal rule `de = (E[j+1] - E[j-1]) / 2` with
443/// SAMMY's higher-order scheme from Eq. IV B 3.8 (page 80 of SAMMY manual R3).
444///
445/// SAMMY Ref: `convolution/DopplerAndResolutionBroadener.cpp`, `setXcoefWeights()`.
446///
447/// The weights include a correction term x2(k) that accounts for non-uniform
448/// grid spacing, providing 4th-order accuracy on smooth grids.
449///
450/// Note: the returned weights are 12x the quantity in Eq. IV B 3.8. This
451/// constant factor cancels during normalization (sum/norm), so the broadened
452/// result is independent of the scaling.
453fn compute_xcoef_weights(energies: &[f64]) -> Vec<f64> {
454    let n = energies.len();
455    if n == 0 {
456        return vec![];
457    }
458    if n == 1 {
459        return vec![1.0];
460    }
461
462    // SAMMY's 4-point quadrature weights (Eq. IV B 3.8, SAMMY Manual R3 p80).
463    //
464    // Uses a sliding window of 5 consecutive energies E[0..4] to compute
465    // coefficients A[0..5] at each grid point k:
466    //
467    //   A[0] = v1  (k >= 2)
468    //   A[1] = 5·v2  (k >= 1)
469    //   A[2] = 5·v3  (k < n-1)
470    //   A[3] = v4  (k < n-2)
471    //   A[4] = (v3² - v1²)/v2   curvature correction  (k >= 2)
472    //   A[5] = -(v4² - v2²)/v3  curvature correction  (k >= 1)
473    //
474    // where v1..v4 are consecutive grid spacings around point k.
475    //
476    // The result is 12× Eq. IV B 3.8; this constant factor cancels during
477    // normalization (sum/norm) in the broadening loop.
478    //
479    // SAMMY Ref: `convolution/DopplerAndResolutionBroadener.cpp` lines 365-457
480    let mut weights = vec![0.0f64; n];
481
482    // Sliding window: e[j] holds energies relative to current k.
483    // At loop start for k: e[0]=E[k-2], e[1]=E[k-1], e[2]=E[k],
484    //                       e[3]=E[k+1], e[4]=E[k+2]
485    // Out-of-bounds positions are 0.0 (matching SAMMY's convention).
486    let mut e = [0.0f64; 5];
487    e[3] = energies[0];
488    if n > 1 {
489        e[4] = energies[1];
490    }
491
492    for k in 0..n {
493        // Shift window left.
494        e[0] = e[1];
495        e[1] = e[2];
496        e[2] = e[3];
497        e[3] = e[4];
498        e[4] = if k + 2 < n { energies[k + 2] } else { 0.0 };
499
500        let v1 = e[1] - e[0];
501        let v2 = e[2] - e[1];
502        let v3 = e[3] - e[2];
503        let v4 = e[4] - e[3];
504
505        let mut a = [0.0f64; 6];
506
507        if k >= 2 {
508            a[0] = v1;
509            // Curvature correction: x2(k-2) = (v3² - v1²) / v2
510            if v2.abs() > NEAR_ZERO_FLOOR {
511                a[4] = (v3 * v3 - v1 * v1) / v2;
512            }
513        }
514        if k >= 1 {
515            a[1] = 5.0 * v2;
516            // Curvature correction: -x2(k-1) = -(v4² - v2²) / v3
517            if v3.abs() > NEAR_ZERO_FLOOR {
518                a[5] = -(v4 * v4 - v2 * v2) / v3;
519            }
520        }
521        if k != n - 1 {
522            a[2] = 5.0 * v3;
523        }
524        if k < n.saturating_sub(2) {
525            a[3] = v4;
526        }
527
528        // Boundary overrides (SAMMY source lines 446-450).
529        if k == n.saturating_sub(2) {
530            a[5] = 0.0;
531        }
532        if k == n - 1 {
533            a[4] = 0.0;
534            a[5] = 0.0;
535        }
536
537        weights[k] = a.iter().sum::<f64>();
538    }
539
540    weights
541}
542
543/// Complementary error function erfc(x) = 1 − erf(x).
544///
545/// erfc(x) = exp(-x²) · exerfc(x) / √π for x ≥ 0, and
546/// erfc(x) = 2 − erfc(−x) for x < 0.
547pub(crate) fn erfc(x: f64) -> f64 {
548    const SQRT_PI: f64 = 1.772_453_850_905_516;
549    if x >= 0.0 {
550        (-x * x).exp() * exerfc(x) / SQRT_PI
551    } else {
552        let xp = -x;
553        2.0 - (-xp * xp).exp() * exerfc(xp) / SQRT_PI
554    }
555}
556
557// ─── Scaled complementary error function ───────────────────────────────────────
558
559/// Compute exp(x²)·erfc(x)·√π, numerically stable for all x.
560///
561/// SAMMY Ref: `fnc/exerfc.f90`.
562///
563/// Uses rational approximation for 0 < x ≤ 5.01 and asymptotic expansion
564/// (Abramowitz & Stegun 7.1.23) for x > 5.01. For x < 0 it uses
565/// erfc(x) = 2 − erfc(−x): exerfc(x) = 2√π·exp(x²) − exerfc(−x).
566pub(crate) fn exerfc(x: f64) -> f64 {
567    const SQRT_PI: f64 = 1.772_453_850_905_516;
568    const TWO_SQRT_PI: f64 = 3.544_907_701_811_032;
569    const XMAX: f64 = 5.01;
570    // Rational approximation coefficients (from SAMMY's exerfc.f90)
571    const A1: f64 = 8.584_076_57e-1;
572    const A2: f64 = 3.078_181_93e-1;
573    const A3: f64 = 6.383_238_91e-2;
574    const A4: f64 = 1.824_050_75e-4;
575    const A5: f64 = 6.509_742_65e-1;
576    const A6: f64 = 2.294_848_19e-1;
577    const A7: f64 = 3.403_018_23e-2;
578
579    if x < 0.0 {
580        TWO_SQRT_PI * (x * x).exp() - exerfc(-x)
581    } else if x > XMAX {
582        asympt(x)
583    } else if x > 0.0 {
584        let a = (A1 + x * (A2 + x * (A3 - x * A4))) / (1.0 + x * (A5 + x * (A6 + x * A7)));
585        let b = SQRT_PI + x * (2.0 - a);
586        b / (x * b + 1.0)
587    } else {
588        SQRT_PI
589    }
590}
591
592/// Asymptotic expansion of exp(x²)·erfc(x)·√π for large positive x.
593///
594/// SAMMY Ref: `fnc/exerfc.f90`, Asympt function.
595/// Uses Abramowitz & Stegun 7.1.23.
596fn asympt(x: f64) -> f64 {
597    if x == 0.0 {
598        return 0.0;
599    }
600    let e = 1.0 / x;
601    if e == 0.0 {
602        return 0.0;
603    }
604    let b = 1.0 / (x * x);
605    let mut a = 1.0;
606    let mut c = b * 0.5;
607    for n in 1..=40 {
608        a -= c;
609        c *= -(n as f64 + 0.5) * b;
610        if (a - c) == a || (c / a).abs() < 1e-8 {
611            break;
612        }
613    }
614    a * e
615}
616
617/// Compute the Gaussian+exponential combined kernel weight Z(A, B).
618///
619/// Returns √π · exp(-A² + B²) · erfc(B), computed via exerfc for stability.
620///
621/// SAMMY Ref: `rsl/mrsl1.f90` lines 467-484 (Resbrd, Iesopr=3 path).
622///
623/// When B >= 0: `Z = exp(-A²) · Exerfc(B)`
624/// When B < 0:  `Z = 2√π · exp(B² − A²) − exp(−A²) · Exerfc(−B)`, from
625///   erfc(B) = 2 − erfc(−B), with exp(B² − A²) taken as one exponential so
626///   that it does not underflow where exp(−A²) does.
627///
628/// SAMMY Ref: `fnc/xxerfc.f90`.
629fn gauss_exp_kernel(a: f64, b: f64) -> f64 {
630    const TWO_SQRT_PI: f64 = 3.544_907_701_811_032;
631    let exp_neg_a2 = (-a * a).exp();
632    if b >= 0.0 {
633        if exp_neg_a2 == 0.0 {
634            return 0.0;
635        }
636        exp_neg_a2 * exerfc(b)
637    } else {
638        TWO_SQRT_PI * (b * b - a * a).exp() - exp_neg_a2 * exerfc(-b)
639    }
640}
641
642/// Compute the energy shift for the Gaussian+exponential kernel peak.
643///
644/// Finds the peak of the combined kernel relative to E=0 via Newton-Raphson
645/// iteration. This centers the convolution window on the kernel maximum.
646///
647/// SAMMY Ref: `rsl/mrsl5.f90`, Shftge function.
648///
649/// # Arguments
650/// * `c` — Mixing parameter: Widgau / (2·Widexp)
651/// * `widgau` — Gaussian resolution width (eV)
652///
653/// # Returns
654/// The energy shift Est (eV) to apply to the measurement energy.
655fn shftge(c: f64, widgau: f64) -> f64 {
656    const ONE_OVER_SQRT_PI: f64 = 0.564_189_583_547_756_3;
657    const SMALL: f64 = 0.01;
658
659    let ax = c;
660    let bx = widgau;
661
662    // Initial guess
663    let mut x0 = if ax > ONE_OVER_SQRT_PI { ax } else { 0.0 };
664
665    let f0_initial = ax * exerfc(x0) - 1.0;
666    let mut f0 = f0_initial;
667    let fff = f0;
668
669    for _iter in 0..100 {
670        let f = ax * exerfc(x0) - 1.0;
671        let xma = x0 - ax;
672        let q = 1.0 - 2.0 * x0 * xma;
673        let delx = if q.abs() < NEAR_ZERO_FLOOR {
674            // q ≈ 0: division would overflow; accept current estimate.
675            break;
676        } else if xma * xma - q * f > 0.0 {
677            let disc = (xma * xma - q * f).sqrt();
678            if xma > 0.0 {
679                (-xma + disc) / q
680            } else {
681                (-xma - disc) / q
682            }
683        } else {
684            if xma.abs() < NEAR_ZERO_FLOOR {
685                break;
686            }
687            -f * 0.5 / xma
688        };
689        let x1 = x0 + delx;
690        let shftg = (ax - x1) * bx;
691        if (x1 - x0).abs() / x1.abs().max(1.0) < SMALL
692            && fff.abs() > NEAR_ZERO_FLOOR
693            && (f - f0).abs() / fff.abs() < SMALL
694        {
695            return shftg;
696        }
697        f0 = f;
698        x0 = x1;
699    }
700
701    (ax - x0) * bx
702}
703
704/// Threshold for the ratio C = W_g / (2·W_e) above which the exponential
705/// tail is negligible and the pure Gaussian PW-linear path is used instead.
706///
707/// At C = 2.5, erfc(2.5) ≈ 0.0005, so the exp tail contributes <0.05% of the
708/// kernel integral.  Using the pure Gaussian path at this threshold introduces
709/// negligible systematic error while enabling the more accurate PW-linear
710/// integration and adaptive intermediate point insertion.
711const EXP_TAIL_NEGLIGIBLE_C: f64 = 2.5;
712
713/// Resolution broadening assuming the energy grid is already validated
714/// (sorted ascending, same length as cross_sections).
715///
716/// For each broadening energy, selects the optimal integration method:
717/// - **PW-linear Gaussian** (exact, second-order): when `delta_e == 0` or
718///   the ratio C = W_g/(2·W_e) > [`EXP_TAIL_NEGLIGIBLE_C`] (exp tail negligible).
719/// - **Combined Gaussian+exp kernel** with SAMMY Xcoef quadrature: when the
720///   exponential tail is significant (C ≤ threshold).
721///
722/// SAMMY Ref: `rsl/mrsl1.f90` Resbrd, `convolution/DopplerAndResolutionBroadener.cpp`
723pub(crate) fn resolution_broaden_presorted(
724    energies: &[f64],
725    cross_sections: &[f64],
726    params: &ResolutionParams,
727) -> Vec<f64> {
728    let n = energies.len();
729    if n == 0 {
730        return vec![];
731    }
732
733    // Precompute Xcoef weights (used only by the combined kernel path).
734    // Even if some energies take the PW-linear path, we compute weights for
735    // the full grid — cheaper than branching per-energy.
736    let xcoef = if params.has_exponential_tail() {
737        compute_xcoef_weights(energies)
738    } else {
739        vec![]
740    };
741    let n_sigma = 5.0; // Integrate out to 5σ for Gaussian
742    let mut broadened = vec![0.0f64; n];
743
744    for i in 0..n {
745        let e = energies[i];
746        let widgau = params.gaussian_width(e);
747
748        if widgau < NEAR_ZERO_FLOOR {
749            broadened[i] = cross_sections[i];
750            continue;
751        }
752
753        // Per-energy decision: use combined kernel only when the exp tail
754        // is significant at THIS energy.
755        let widexp = params.exp_width(e);
756        let use_combined =
757            widexp > NEAR_ZERO_FLOOR && widgau / (2.0 * widexp) <= EXP_TAIL_NEGLIGIBLE_C;
758
759        // Compute integration limits.
760        let (e_low, e_high) = if use_combined {
761            // SAMMY Ref: mrsl4.f90 lines 57-65
762            let wlow = n_sigma * widgau;
763            let rwid = widgau / widexp;
764            let wup = if rwid <= 1.0 {
765                6.25 * widexp
766            } else if rwid <= 2.0 {
767                n_sigma * (3.0 - rwid) * widgau
768            } else {
769                n_sigma * widgau
770            };
771            (e - wlow, e + wup)
772        } else {
773            (e - n_sigma * widgau, e + n_sigma * widgau)
774        };
775
776        let j_lo = energies.partition_point(|&ej| ej < e_low);
777        let j_hi = energies.partition_point(|&ej| ej <= e_high);
778
779        if j_hi.saturating_sub(j_lo) <= 1 {
780            broadened[i] = cross_sections[i];
781            continue;
782        }
783
784        let mut sum = 0.0;
785        let mut norm = 0.0;
786
787        if use_combined {
788            // Combined Gaussian + exponential kernel (SAMMY Iesopr=3)
789            // with 4-point Xcoef quadrature weights.
790            // SAMMY Ref: mrsl1.f90 lines 455-484
791            let c = widgau * 0.5 / widexp;
792            let est = shftge(c, widgau);
793            let y = c * widgau + e - est;
794
795            for j in j_lo..j_hi {
796                let ee = energies[j];
797                let a = (e - est - ee) / widgau;
798                let b = (y - ee) / widgau;
799                let z = gauss_exp_kernel(a, b);
800                let wt = xcoef[j] * z;
801                sum += wt * cross_sections[j];
802                norm += wt;
803            }
804        } else {
805            // Pure Gaussian kernel with piecewise-linear exact integration.
806            //
807            // For each interval [E_j, E_{j+1}], integrate G(E_i - E') × σ_linear(E')
808            // exactly, where G(x) = exp(-x²/W²) / (W√π).
809            //
810            // Substituting u = (E' - E_i)/W, dE' = W du:
811            //   ∫ G × [σ_j + slope×(E'-E_j)] dE'
812            //   = (1/√π) ∫ exp(-u²) [σ_j + slope×W×(u - a_j)] du
813            //
814            // With I₀ = erf(a_{j+1}) - erf(a_j) and
815            //      I₁ = (exp(-a_j²) - exp(-a_{j+1}²)) / 2:
816            //
817            // The normalization integral is I₀/2, so after sum/norm (2 cancels):
818            //   sum += σ_j × I₀ + slope × W × (2/√π × I₁ - a_j × I₀)
819            //   norm += I₀
820            //
821            // The factor 2/√π on I₁ comes from the u·exp(-u²) integral
822            // needing to match the normalization convention erf(x) = 2/√π ∫ exp(-t²) dt.
823            const TWO_OVER_SQRT_PI: f64 = std::f64::consts::FRAC_2_SQRT_PI;
824            let inv_w = 1.0 / widgau;
825            for j in j_lo..j_hi.saturating_sub(1) {
826                let e_j = energies[j];
827                let e_j1 = energies[j + 1];
828                let h = e_j1 - e_j;
829                if h < NEAR_ZERO_FLOOR {
830                    continue;
831                }
832
833                let a_j = (e_j - e) * inv_w;
834                let a_j1 = (e_j1 - e) * inv_w;
835
836                // I₀ = erf(a_{j+1}) - erf(a_j) = erfc(a_j) - erfc(a_{j+1})
837                let erfc_aj = erfc(a_j);
838                let erfc_aj1 = erfc(a_j1);
839                let i0 = erfc_aj - erfc_aj1;
840
841                if i0 < NEAR_ZERO_FLOOR {
842                    continue;
843                }
844
845                // I₁ = (exp(-a_j²) - exp(-a_{j+1}²)) / 2
846                let i1 = ((-a_j * a_j).exp() - (-a_j1 * a_j1).exp()) * 0.5;
847
848                let slope = (cross_sections[j + 1] - cross_sections[j]) / h;
849
850                // σ_j × I₀ + slope × W × (2/√π × I₁ - a_j × I₀)
851                sum += cross_sections[j] * i0 + slope * widgau * (TWO_OVER_SQRT_PI * i1 - a_j * i0);
852                norm += i0;
853            }
854        }
855
856        if norm > DIVISION_FLOOR {
857            broadened[i] = sum / norm;
858        } else {
859            broadened[i] = cross_sections[i];
860        }
861    }
862
863    broadened
864}
865
866/// A tabulated resolution function from Monte Carlo instrument simulation.
867///
868/// Contains reference kernels R(Δt; E_ref) at discrete energies, stored in
869/// TOF-offset space (μs). Kernels are interpolated between reference energies
870/// and converted from TOF to energy space when applied.
871///
872/// ## Offset orientation
873///
874/// Positive `Δt` = delayed emission (the moderator storage tail); the
875/// kernel mode sits at `Δt = 0`. At apply time the broadener gathers
876/// theory at `t − Δt` (convolution — see [`Self::broaden`]), so the
877/// positive-`Δt` tail reads theory from earlier TOF = higher energy and
878/// broadened dips acquire their tail toward lower apparent energy.
879///
880/// ## File Format (VENUS/FTS)
881///
882/// ```text
883/// FTS BL10 case i00dd folded triang FWHM 350 ns PSR   ← header
884/// -----                                                 ← separator
885///    5.00000e-004   0.00000e+000                        ← energy block start
886/// -53.458917835671329 2.051764258257523e-04             ← (tof_offset_μs, weight)
887/// ...
888///                                                       ← blank line separates blocks
889///    1.00000e-003   0.00000e+000                        ← next energy block
890/// ...
891/// ```
892#[derive(Debug, Clone)]
893pub struct TabulatedResolution {
894    /// Reference energies (eV), sorted ascending.
895    ///
896    /// Shared: a fit with a free `L_scale` rebinds the flight path on every
897    /// forward evaluation, which must cost a reference count, not a copy.
898    ref_energies: Arc<Vec<f64>>,
899    /// For each reference energy: (tof_offsets_μs, weights) pairs.
900    /// Weights are peak-normalized (max=1.0).
901    ///
902    /// Shared for the same reason as `ref_energies`.
903    kernels: Arc<Vec<(Vec<f64>, Vec<f64>)>>,
904    /// Flight path length in meters (needed for TOF↔energy conversion).
905    flight_path_m: f64,
906}
907
908/// Trapezoidal-weighted centroid and RMS width of one kernel block.
909///
910/// The `dt` weights match the quadrature `broaden_presorted` integrates
911/// with (single point → 1.0; edges → one-sided span; interior → half
912/// the neighbour span), so these are the moments the broadener
913/// effectively applies: zero-weight entries contribute nothing
914/// (`tw = 0`, matching the broadener's `w <= 0` skip) and negative
915/// weights are rejected at construction, so the integration domains
916/// coincide exactly. The centroid pass is byte-identical to the
917/// was factored out. Returns `(centroid, sigma)`; `sigma` is `0.0` for
918/// a single-point or zero-mass block — callers treat a non-positive or
919/// non-finite `sigma` as degenerate.
920fn trapezoidal_moments(offsets: &[f64], weights: &[f64]) -> (f64, f64) {
921    let n_k = offsets.len();
922    let dt_width = |k: usize| -> f64 {
923        if n_k <= 1 {
924            1.0
925        } else if k == 0 {
926            offsets[1] - offsets[0]
927        } else if k == n_k - 1 {
928            offsets[k] - offsets[k - 1]
929        } else {
930            (offsets[k + 1] - offsets[k - 1]) * 0.5
931        }
932    };
933    let (mut cnum, mut cden) = (0.0, 0.0);
934    for (k, (&o, &w)) in offsets.iter().zip(weights).enumerate() {
935        let tw = w * dt_width(k).abs();
936        cnum += o * tw;
937        cden += tw;
938    }
939    let centroid = if cden > 0.0 { cnum / cden } else { 0.0 };
940    let mut m2 = 0.0;
941    for (k, (&o, &w)) in offsets.iter().zip(weights).enumerate() {
942        let tw = w * dt_width(k).abs();
943        m2 += (o - centroid).powi(2) * tw;
944    }
945    let sigma = if cden > 0.0 { (m2 / cden).sqrt() } else { 0.0 };
946    (centroid, sigma)
947}
948
949/// Integrate a sampled probability distribution into adjacent requested
950/// edges. A one-point kernel is treated as a delta mass at that point; longer
951/// kernels are piecewise-linear densities.
952///
953/// The density is normalized over its complete supplied support. The result
954/// is deliberately not renormalized to `edges`: a requested detector window
955/// that covers only part of the supplied pulse therefore sums to less than
956/// one.
957pub(crate) fn piecewise_linear_bin_probabilities(
958    times: &[f64],
959    weights: &[f64],
960    edges: &[f64],
961) -> Option<Vec<f64>> {
962    if times.is_empty() || times.len() != weights.len() {
963        return None;
964    }
965    // A duplicated (or decreasing) time gives a zero-width segment whose
966    // slope is ±inf/NaN inside the CDF interpolation when an edge lands
967    // there — and a NaN time passes a pure monotonicity check (every NaN
968    // comparison is false) only to underflow `hi - 1` in the CDF closure
969    // (`partition_point` returns 0 when the first predicate is false).
970    // Validate finiteness and strict ordering of BOTH coordinate arrays up
971    // front; NaN weights are caught downstream by the total-mass check.
972    if times.iter().any(|t| !t.is_finite()) || times.windows(2).any(|w| w[1] <= w[0]) {
973        return None;
974    }
975    if edges.iter().any(|e| !e.is_finite()) || edges.windows(2).any(|w| w[1] <= w[0]) {
976        return None;
977    }
978
979    if times.len() == 1 {
980        if !times[0].is_finite() || !weights[0].is_finite() || weights[0] <= 0.0 {
981            return None;
982        }
983        let mut probabilities = vec![0.0; edges.len().saturating_sub(1)];
984        for (index, edge) in edges.windows(2).enumerate() {
985            let in_bin = edge[0] <= times[0]
986                && (times[0] < edge[1]
987                    || (index + 1 == probabilities.len() && times[0] == edge[1]));
988            if in_bin {
989                probabilities[index] = 1.0;
990                break;
991            }
992        }
993        return Some(probabilities);
994    }
995
996    let mut cumulative = Vec::with_capacity(times.len());
997    cumulative.push(0.0);
998    for i in 0..times.len() - 1 {
999        let width = times[i + 1] - times[i];
1000        let area = 0.5 * (weights[i] + weights[i + 1]) * width;
1001        cumulative.push(cumulative[i] + area.max(0.0));
1002    }
1003    let total = *cumulative.last()?;
1004    if !total.is_finite() || total <= 0.0 {
1005        return None;
1006    }
1007    let cdf = |x: f64| -> f64 {
1008        if x <= times[0] {
1009            return 0.0;
1010        }
1011        if x >= times[times.len() - 1] {
1012            return 1.0;
1013        }
1014        let hi = times.partition_point(|&time| time <= x);
1015        let lo = hi - 1;
1016        let width = times[hi] - times[lo];
1017        let dx = x - times[lo];
1018        let slope = (weights[hi] - weights[lo]) / width;
1019        let partial = weights[lo] * dx + 0.5 * slope * dx * dx;
1020        ((cumulative[lo] + partial) / total).clamp(0.0, 1.0)
1021    };
1022
1023    Some(
1024        edges
1025            .windows(2)
1026            .map(|edge| (cdf(edge[1]) - cdf(edge[0])).max(0.0))
1027            .collect(),
1028    )
1029}
1030
1031impl TabulatedResolution {
1032    /// Reference energies (eV), sorted ascending.
1033    pub fn ref_energies(&self) -> &[f64] {
1034        &self.ref_energies
1035    }
1036
1037    /// For each reference energy: (tof_offsets_μs, weights) pairs.
1038    /// Weights are peak-normalized (max=1.0).
1039    pub fn kernels(&self) -> &[(Vec<f64>, Vec<f64>)] {
1040        &self.kernels
1041    }
1042
1043    /// Flight path length in meters (needed for TOF↔energy conversion).
1044    pub fn flight_path_m(&self) -> f64 {
1045        self.flight_path_m
1046    }
1047
1048    /// Probability that a neutron of known true energy is recorded in each
1049    /// supplied detector-time bin.
1050    ///
1051    /// The tabulated pulse is selected and interpolated at `true_energy_ev`;
1052    /// an energy outside the tabulated reference range uses the nearest
1053    /// reference kernel unchanged (the same clamping the broadening path
1054    /// applies).  Its offsets are relative to the reference pulse mode, so
1055    /// the nominal arrival is
1056    /// `timing_offset_us + TOF_FACTOR * flight_path_m / sqrt(E)`.
1057    /// `timing_offset_us` is the effective clock/energy-axis offset calibrated
1058    /// for the measurement; this method does not invent an absolute moderator
1059    /// emission time that is absent from a mode-centred UDR file.
1060    ///
1061    /// The returned vector has one entry per adjacent edge pair and is not
1062    /// renormalized to the supplied window.  Probability outside the measured
1063    /// window remains outside it; the quantified acquisition-window loss at
1064    /// this energy is one minus the sum of the returned vector.
1065    ///
1066    /// # Errors
1067    /// Returns [`ResolutionParseError::InvalidFormat`] unless the true energy
1068    /// is positive and finite, the timing offset is finite, and at least two
1069    /// finite bin edges are supplied in strictly increasing order.  It also
1070    /// fails if the interpolated tabulated pulse has zero area.
1071    pub fn detector_bin_probabilities(
1072        &self,
1073        true_energy_ev: f64,
1074        detector_time_edges_us: &[f64],
1075        timing_offset_us: f64,
1076    ) -> Result<Vec<f64>, ResolutionParseError> {
1077        if !true_energy_ev.is_finite() || true_energy_ev <= 0.0 {
1078            return Err(ResolutionParseError::InvalidFormat(format!(
1079                "true energy must be positive and finite, got {true_energy_ev}"
1080            )));
1081        }
1082        if !timing_offset_us.is_finite() {
1083            return Err(ResolutionParseError::InvalidFormat(format!(
1084                "timing_offset_us must be finite, got {timing_offset_us}"
1085            )));
1086        }
1087        if detector_time_edges_us.len() < 2
1088            || detector_time_edges_us.iter().any(|edge| !edge.is_finite())
1089            || detector_time_edges_us
1090                .windows(2)
1091                .any(|edge| edge[0] >= edge[1])
1092        {
1093            return Err(ResolutionParseError::InvalidFormat(
1094                "detector time edges must contain at least two finite, strictly increasing values"
1095                    .to_string(),
1096            ));
1097        }
1098
1099        let nominal_arrival =
1100            timing_offset_us + TOF_FACTOR * self.flight_path_m / true_energy_ev.sqrt();
1101        let relative_edges: Vec<f64> = detector_time_edges_us
1102            .iter()
1103            .map(|edge| edge - nominal_arrival)
1104            .collect();
1105        let (times, weights) = self.interpolated_kernel(true_energy_ev);
1106        piecewise_linear_bin_probabilities(&times, &weights, &relative_edges).ok_or_else(|| {
1107            ResolutionParseError::InvalidFormat(format!(
1108                "tabulated resolution at E = {true_energy_ev} eV has zero sampled \
1109                 area or a degenerate interpolated kernel"
1110            ))
1111        })
1112    }
1113
1114    /// Width-corrected copy of this tabulated kernel.
1115    ///
1116    /// Shape-preserving instrument-resolution calibration knob: each
1117    /// reference-energy block's TOF offsets are scaled by
1118    /// `s(E) = s0 · (E / e_ref)^p` **about the block's intensity centroid**, so
1119    /// the kernel widens/narrows without moving its centroid — width and position
1120    /// stay orthogonal (`t0`/`L` handle absolute position). Weights are unchanged;
1121    /// the apply-time trapezoidal renormalization preserves unit area.
1122    ///
1123    /// Exactness note: the orthogonality is exact **at reference
1124    /// energies**. Between references, `interpolated_kernel`'s
1125    /// width-normalized blend re-scales each block about the mode
1126    /// (offset 0), so the applied centroid picks up a second-order
1127    /// dependence on the width exponent `p` (measured ~1 % of σ for
1128    /// |p| ≤ 0.1 on widely spaced references) — absorbed by the
1129    /// jointly fitted `t0` in calibration.
1130    ///
1131    /// The pivot is the **trapezoidal-weighted** centroid `Σ o·w·dt / Σ w·dt`,
1132    /// using the *same* `dt` quadrature weights as the broadening integral (see
1133    /// [`Self::broaden`]). Because `dt` is itself affine in the offsets, the width
1134    /// scale multiplies every `dt` by `s`, so the integrated centroid is preserved
1135    /// exactly on **any** offset grid (uniform or not) — not just on uniform grids
1136    /// where the trapezoidal and plain centroids happen to coincide.
1137    ///
1138    /// `s0 = 1, p = 0` returns a width-identical copy. This is the fittable model
1139    /// behind the `udr_corr` resolution-calibration family: it trusts the
1140    /// Monte-Carlo *shape* and calibrates only its width / energy-dependence.
1141    ///
1142    /// # Errors
1143    /// Returns [`ResolutionError::InvalidWidthCorrection`] unless `s0` is finite
1144    /// and `> 0`, `e_ref` is finite and `> 0`, and `p` is finite. A non-positive
1145    /// `s0` would reverse/collapse the (ascending) offset ordering the broadening
1146    /// loop assumes, so it is rejected up front rather than silently clamped.
1147    pub fn width_corrected(
1148        &self,
1149        s0: f64,
1150        p: f64,
1151        e_ref: f64,
1152    ) -> Result<TabulatedResolution, ResolutionError> {
1153        if !(s0.is_finite() && s0 > 0.0 && e_ref.is_finite() && e_ref > 0.0 && p.is_finite()) {
1154            return Err(ResolutionError::InvalidWidthCorrection { s0, p, e_ref });
1155        }
1156        let kernels = self
1157            .ref_energies
1158            .iter()
1159            .zip(self.kernels.iter())
1160            .map(|(&e, (offsets, weights))| {
1161                // The power law can overflow `s` to ±∞ for finite-but-extreme `p`;
1162                // reject up front rather than build a non-finite kernel directly
1163                // (which would bypass `from_kernels`' finiteness check).
1164                let s = s0 * (e / e_ref).powf(p);
1165                if !(s.is_finite() && s > 0.0) {
1166                    return Err(ResolutionError::InvalidWidthCorrection { s0, p, e_ref });
1167                }
1168                // Pivot about the trapezoidal-weighted centroid (matching the
1169                // `dt`-weighting in `broaden_presorted`), so the *integrated*
1170                // centroid is preserved on non-uniform offset grids — not only on
1171                // uniform grids where this reduces to the plain centroid.
1172                let (centroid, _) = trapezoidal_moments(offsets, weights);
1173                let scaled = offsets
1174                    .iter()
1175                    .map(|&o| centroid + s * (o - centroid))
1176                    .collect();
1177                Ok((scaled, weights.clone()))
1178            })
1179            .collect::<Result<Vec<_>, ResolutionError>>()?;
1180        Ok(TabulatedResolution {
1181            ref_energies: self.ref_energies.clone(),
1182            kernels: Arc::new(kernels),
1183            flight_path_m: self.flight_path_m,
1184        })
1185    }
1186
1187    /// The same kernel table read against a different flight path.
1188    ///
1189    /// The stored offsets are times relative to this table's own anchor, and
1190    /// the flight path enters only the TOF↔energy map they are applied
1191    /// through. So rebinding it is exact and needs no resynthesis — the kernel
1192    /// itself is a property of the moderator, not of how far the neutron then
1193    /// flew.
1194    ///
1195    /// This is what a fitted `L_scale` requires: the data's energy grid is
1196    /// built with `L·L_scale`, and a kernel still reading `L` applies a width
1197    /// wrong by that same factor.
1198    ///
1199    /// # Errors
1200    /// Returns [`ResolutionParseError::InvalidFormat`] if `flight_path_m` is
1201    /// not positive and finite.
1202    pub fn with_flight_path(&self, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1203        if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
1204            return Err(ResolutionParseError::InvalidFormat(format!(
1205                "Flight path must be a positive finite number, got {flight_path_m}"
1206            )));
1207        }
1208        Ok(Self {
1209            ref_energies: Arc::clone(&self.ref_energies),
1210            kernels: Arc::clone(&self.kernels),
1211            flight_path_m,
1212        })
1213    }
1214
1215    /// Kernel support at energy `e_ev`, in eV: the larger of the two distances
1216    /// in [`Self::gather_bounds_ev`].  Returns `0.0` for non-positive or
1217    /// non-finite `e_ev`, an empty kernel set, or a non-positive flight path.
1218    #[must_use]
1219    pub fn kernel_support_ev(&self, e_ev: f64) -> f64 {
1220        let (lo, hi) = self.gather_bounds_ev(e_ev);
1221        (e_ev - lo).max(hi - e_ev).max(0.0)
1222    }
1223
1224    /// The lowest and highest energies the kernel at `e_ev` gathers theory
1225    /// from, as `(low, high)` in eV with `low ≤ e_ev ≤ high`, over the points
1226    /// [`Self::broaden`] keeps and in its arithmetic.  Returns `(e_ev, e_ev)`
1227    /// for non-positive or non-finite `e_ev`, an empty kernel set, or a
1228    /// non-positive flight path.
1229    #[must_use]
1230    pub fn gather_bounds_ev(&self, e_ev: f64) -> (f64, f64) {
1231        if e_ev <= 0.0 || !e_ev.is_finite() || self.kernels.is_empty() || self.flight_path_m <= 0.0
1232        {
1233            return (e_ev, e_ev);
1234        }
1235        let tof_center = TOF_FACTOR * self.flight_path_m / e_ev.sqrt();
1236        let (offsets, weights) = self.interpolated_kernel(e_ev);
1237        let (mut low, mut high) = (e_ev, e_ev);
1238        for (&dt, &w) in offsets.iter().zip(weights.iter()) {
1239            if w <= 0.0 {
1240                continue;
1241            }
1242            let tof_prime = tof_center - dt;
1243            if tof_prime <= 0.0 {
1244                continue;
1245            }
1246            let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
1247            low = low.min(e_prime);
1248            high = high.max(e_prime);
1249        }
1250        (low, high)
1251    }
1252}
1253
1254/// Resolution function: analytical Gaussian, tabulated from Monte Carlo, or
1255/// analytical Ikeda–Carpenter moderator model.
1256///
1257/// The `Tabulated` and `IkedaCarpenter` variants wrap an `Arc` so that cloning
1258/// (e.g., per-pixel in spatial mapping) is a cheap reference-count bump rather
1259/// than a deep copy.
1260///
1261/// `IkedaCarpenter` synthesizes a [`TabulatedResolution`] at construction and
1262/// is applied through the *same* per-call convolution path as `Tabulated`
1263/// (`broaden` / `broaden_presorted` / `plan`) — only the kernel *source* differs
1264/// (analytic IC pulse vs Monte-Carlo file). This keeps the three-way resolution
1265/// cross-validation (Gaussian | tabulated-UDR | Ikeda–Carpenter) fair on the
1266/// reference broadening path. Note: `IkedaCarpenter` does **not** opt into the
1267/// spatial-map surrogate fast-paths (the scalar/cubature plans gate on
1268/// `Tabulated`); it falls back to the general path, which is correct but
1269/// unoptimized — see the resolution-calibration notes for the W6 follow-up.
1270#[derive(Debug, Clone)]
1271pub enum ResolutionFunction {
1272    /// Analytical Gaussian resolution from instrument parameters.
1273    Gaussian(ResolutionParams),
1274    /// Tabulated resolution from Monte Carlo instrument simulation.
1275    Tabulated(Arc<TabulatedResolution>),
1276    /// Analytical Ikeda–Carpenter moderator resolution model.
1277    IkedaCarpenter(Arc<crate::ikeda_carpenter::IkedaCarpenter>),
1278}
1279
1280/// Widths of Gaussian resolution the broadening limits reach on each side.
1281///
1282/// SAMMY Ref: `rsl/mrsl4.f90` `Wdsint`, `Wlow = Wup = Brdlim*Widgau`;
1283/// `inp/minp06.f` line 212, `Brdlim = 5`.
1284const BRDLIM: f64 = 5.0;
1285
1286/// Lowest energy the Gaussian working grid extends to, in eV: the PW-linear
1287/// quadrature maps points through `1/√E`, which has no value at zero.
1288const GAUSSIAN_LOW_ENERGY_FLOOR_EV: f64 = 0.001;
1289
1290impl ResolutionFunction {
1291    /// The energies a working grid for the data window `energies` has to span,
1292    /// as `(low, high)` in eV with `low ≤ e_min` and `high ≥ e_max`: SAMMY's
1293    /// Wdsint limits at the two ends for a Gaussian, the extremes of
1294    /// [`TabulatedResolution::gather_bounds_ev`] over the grid for a sampled
1295    /// kernel.  Returns `(0.0, 0.0)` for an empty grid.
1296    #[must_use]
1297    pub fn grid_bounds_ev(&self, energies: &[f64]) -> (f64, f64) {
1298        let (Some(&e_min), Some(&e_max)) = (energies.first(), energies.last()) else {
1299            return (0.0, 0.0);
1300        };
1301        let sampled = |table: &TabulatedResolution| {
1302            energies.iter().fold((e_min, e_max), |(low, high), &e| {
1303                let (l, h) = table.gather_bounds_ev(e);
1304                (low.min(l), high.max(h))
1305            })
1306        };
1307        match self {
1308            Self::Gaussian(params) => {
1309                let wg_high = params.gaussian_width(e_max);
1310                let we_high = params.exp_width(e_max);
1311                // SAMMY grades the high side by the ratio of the Gaussian core to
1312                // the one-sided exponential tail (Wdsint, `Rwid`).
1313                let above = if we_high > 1e-30 {
1314                    let rwid = wg_high / we_high;
1315                    if rwid <= 1.0 {
1316                        6.25 * we_high
1317                    } else if rwid <= 2.0 {
1318                        BRDLIM * (3.0 - rwid) * wg_high
1319                    } else {
1320                        BRDLIM * wg_high
1321                    }
1322                } else {
1323                    BRDLIM * wg_high
1324                };
1325                let below = (BRDLIM * params.gaussian_width(e_min))
1326                    .min(e_min - GAUSSIAN_LOW_ENERGY_FLOOR_EV)
1327                    .max(0.0);
1328                (e_min - below, e_max + above)
1329            }
1330            Self::Tabulated(tabulated) => sampled(tabulated),
1331            Self::IkedaCarpenter(ic) => sampled(ic.tabulated()),
1332        }
1333    }
1334
1335    /// Flight path used to map true neutron energy to detector time.
1336    pub fn flight_path_m(&self) -> f64 {
1337        match self {
1338            Self::Gaussian(params) => params.flight_path_m(),
1339            Self::Tabulated(tabulated) => tabulated.flight_path_m(),
1340            Self::IkedaCarpenter(ic) => ic.flight_path_m(),
1341        }
1342    }
1343
1344    /// The same resolution function read against a different flight path.
1345    ///
1346    /// A fit that frees `L_scale` evaluates the theory on an energy grid built
1347    /// with `L·L_scale`. The kernel has to be read against the same flight
1348    /// path or its width is wrong by that factor — on every family, since all
1349    /// three convert between energy and detector time through `L`. No variant
1350    /// resynthesizes: the flight path is not part of what the kernel IS, only
1351    /// of the map it is applied through.
1352    ///
1353    /// # Errors
1354    /// Returns [`ResolutionParseError::InvalidFormat`] if `flight_path_m` is
1355    /// not positive and finite.
1356    pub fn with_flight_path(&self, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1357        match self {
1358            Self::Gaussian(params) => Ok(Self::Gaussian(
1359                ResolutionParams::new(
1360                    flight_path_m,
1361                    params.delta_t_us(),
1362                    params.delta_l_m(),
1363                    params.delta_e_us(),
1364                )
1365                .map_err(|e| ResolutionParseError::InvalidFormat(e.to_string()))?,
1366            )),
1367            Self::Tabulated(tabulated) => Ok(Self::Tabulated(Arc::new(
1368                tabulated.with_flight_path(flight_path_m)?,
1369            ))),
1370            Self::IkedaCarpenter(ic) => Ok(Self::IkedaCarpenter(Arc::new(
1371                ic.with_flight_path(flight_path_m)?,
1372            ))),
1373        }
1374    }
1375
1376    /// Probability that one neutron of known true energy is recorded in each
1377    /// supplied detector-time bin.
1378    ///
1379    /// The tabulated and Ikeda–Carpenter variants are evaluated directly in
1380    /// detector time. In particular, the analytical IC variant does not pass
1381    /// through its legacy synthesized [`TabulatedResolution`] broadening
1382    /// table, and it refuses a Gaussian burst. The older Gaussian
1383    /// energy-broadening model has no physical detector-time probability law
1384    /// and is therefore rejected rather than silently treated as one.
1385    ///
1386    /// `timing_offset_us` is convention-dependent and NOT transferable
1387    /// between variants: a mode-centred tabulated (UDR) kernel places its
1388    /// pulse mode at the nominal arrival, so the offset must absorb the
1389    /// calibrated moderator mean delay, while the causal Ikeda–Carpenter
1390    /// pulse rises from the nominal arrival onward and its offset is a pure
1391    /// clock/detector shift.  Swapping response models under one calibrated
1392    /// offset shifts every bin systematically.
1393    pub fn detector_bin_probabilities(
1394        &self,
1395        true_energy_ev: f64,
1396        detector_time_edges_us: &[f64],
1397        timing_offset_us: f64,
1398    ) -> Result<Vec<f64>, ResolutionParseError> {
1399        match self {
1400            Self::Tabulated(tabulated) => tabulated.detector_bin_probabilities(
1401                true_energy_ev,
1402                detector_time_edges_us,
1403                timing_offset_us,
1404            ),
1405            Self::IkedaCarpenter(ic) => ic.detector_bin_probabilities(
1406                true_energy_ev,
1407                detector_time_edges_us,
1408                timing_offset_us,
1409            ),
1410            Self::Gaussian(_) => Err(ResolutionParseError::InvalidFormat(
1411                "Gaussian energy broadening cannot produce detector-time bin probabilities; use a validated tabulated or Ikeda–Carpenter time response"
1412                    .to_string(),
1413            )),
1414        }
1415    }
1416}
1417
1418/// Pre-built resolution-broadening plan for a specific target energy grid.
1419///
1420/// Encodes every quantity that depends only on the target grid, the
1421/// reference kernel, and the flight path — so applying the plan to a
1422/// spectrum reduces to a gather + multiply-add loop with no
1423/// transcendentals, no allocations, and no binary / pointer search.
1424///
1425/// Build via [`TabulatedResolution::plan`] — returns a `Result` and
1426/// validates the sorted-grid precondition that `broaden` enforces.
1427/// Apply via [`ResolutionPlan::apply`].  One plan is tied to one
1428/// `(target_energies, ref_energies, flight_path_m)` triple; the plan
1429/// owns a copy of the target-energy grid so callers cannot apply it to
1430/// a spectrum that was measured on a *different* grid even when the
1431/// grid length matches — use [`Self::target_energies`] to verify the
1432/// grid identity before applying.
1433///
1434/// The layout is a flat Struct-of-Arrays (SoA): per-target `(lo_idx,
1435/// frac, weight)` tuples packed into three parallel `Vec`s, with
1436/// `starts[i]..starts[i+1]` naming the range for target `i`.  SoA keeps
1437/// the inner loop memory-access pattern sequential and cache-friendly.
1438#[derive(Debug, Clone)]
1439pub struct ResolutionPlan {
1440    /// Target energy grid the plan was built for (owned copy).
1441    ///
1442    /// Stored so `apply()` can verify `spectrum.len() == self.len()`
1443    /// and expose a cheap grid identity for caller-side caching.
1444    /// ~28 KB for the VENUS 3471-point grid — negligible compared to
1445    /// the ~8 MB `lo_idx`/`frac`/`weight` footprint of a full plan.
1446    target_energies: Vec<f64>,
1447    /// `starts[i]..starts[i+1]` indexes into `lo_idx`/`frac`/`weight`
1448    /// for target `i`.  `starts` has length `target_energies.len() + 1`.
1449    starts: Vec<u32>,
1450    /// For each valid (target, kernel-point) entry: the lower bracket
1451    /// index into the target grid (spectrum[lo] + frac * (spectrum[lo+1]
1452    /// - spectrum[lo])).
1453    lo_idx: Vec<u32>,
1454    /// Spectrum-interp fraction in [0, 1].  Set to 0 for degenerate
1455    /// brackets; the apply-time loop short-circuits `frac == 0.0` so
1456    /// degenerate entries never touch `spectrum[lo+1]`.  This matches
1457    /// `broaden_presorted` even when `spectrum[lo+1]` is NaN/±∞.
1458    frac: Vec<f64>,
1459    /// Pre-computed per-entry weight (`w * dt_width.abs()`).  Summing
1460    /// these yields the per-target normalisation.
1461    weight: Vec<f64>,
1462    /// Pre-summed `Σ weight` per target (in the same accumulation order
1463    /// as `broaden_presorted` visits the valid entries).  When `norm <=
1464    /// DIVISION_FLOOR` the apply path returns `spectrum[i]` directly
1465    /// — the exact `broaden_presorted` passthrough behaviour.
1466    norm: Vec<f64>,
1467}
1468
1469impl ResolutionPlan {
1470    /// Number of target energies this plan covers.
1471    pub fn len(&self) -> usize {
1472        self.target_energies.len()
1473    }
1474
1475    /// True when the plan covers no target energies.
1476    pub fn is_empty(&self) -> bool {
1477        self.target_energies.is_empty()
1478    }
1479
1480    /// Target energy grid the plan was built for.
1481    ///
1482    /// Callers implementing plan caches can compare this against their
1483    /// current grid to decide whether the plan is still valid.  Using
1484    /// pointer identity of the returned slice gives an O(1) check when
1485    /// the grid hasn't moved; slice equality is `O(n)` but catches
1486    /// cases where the underlying buffer was reallocated.
1487    pub fn target_energies(&self) -> &[f64] {
1488        &self.target_energies
1489    }
1490
1491    /// Apply the plan to a spectrum on the same target grid the plan
1492    /// was built for.
1493    ///
1494    /// The spectrum length must equal [`Self::len`].  Passing a
1495    /// spectrum on a different grid that happens to have the same
1496    /// length is caller error — verify via [`Self::target_energies`]
1497    /// when in doubt.
1498    ///
1499    /// Bit-exact with `broaden_presorted(target_energies, spectrum)`
1500    /// for finite spectrum values; degenerate-bracket entries
1501    /// short-circuit the interpolation so the equivalence also holds
1502    /// when `spectrum[lo+1]` is NaN or ±∞ (the reference path returns
1503    /// `spectrum[lo]` directly in that case without touching the upper
1504    /// bracket).
1505    pub fn apply(&self, spectrum: &[f64]) -> Vec<f64> {
1506        let n = self.target_energies.len();
1507        assert_eq!(
1508            spectrum.len(),
1509            n,
1510            "spectrum length ({}) must match plan target-grid length ({})",
1511            spectrum.len(),
1512            n,
1513        );
1514        if n == 0 {
1515            return Vec::new();
1516        }
1517
1518        let mut result = vec![0.0f64; n];
1519
1520        // Pre-bind plan slices once per call and pre-slice each
1521        // target's entry range before the hot loop.  This is a
1522        // bounds-check-elimination (BCE) refactor — every per-entry
1523        // index is proven in-bounds by the invariants established in
1524        // `plan_presorted`, so the inner loop uses `get_unchecked`
1525        // with SAFETY comments citing those invariants.  The compiler
1526        // then auto-vectorizes the inner compute where profitable.
1527        //
1528        // We deliberately do NOT use explicit 2-wide SIMD here — an
1529        // experiment via the `wide` crate (commit abandoned;
1530        // `perf-lessons.md`) showed that 2-wide f64x2 with gather
1531        // emulation is net-negative on AArch64 Neon vs the compiler's
1532        // scalar auto-vectorization of the BCE'd inner loop.  On
1533        // wider targets (x86 AVX2 / AVX-512) a SIMD rewrite could
1534        // still pay off but is out of scope here.
1535        //
1536        // Control flow, accumulation order, and the `frac == 0.0`
1537        // NaN-safety short-circuit are all preserved exactly so the
1538        // bit-exact contract with `broaden_presorted` holds for
1539        // finite AND pathological (NaN, ±∞) spectra.
1540        let lo_idx = self.lo_idx.as_slice();
1541        let frac_all = self.frac.as_slice();
1542        let weight_all = self.weight.as_slice();
1543        let starts = self.starts.as_slice();
1544        let norm = self.norm.as_slice();
1545        let spec = spectrum;
1546
1547        // Defence-in-depth: debug-only invariant checks right after
1548        // slice binding, so a future change to `plan_presorted` that
1549        // silently violates the `unsafe { get_unchecked }` SAFETY
1550        // claims below fails loudly in debug builds.  Zero release-
1551        // build cost.
1552        debug_assert_eq!(starts.len(), n + 1);
1553        debug_assert_eq!(
1554            starts.last().copied(),
1555            Some(lo_idx.len() as u32),
1556            "plan_presorted invariant: starts.last() must equal lo_idx.len()",
1557        );
1558        debug_assert_eq!(lo_idx.len(), frac_all.len());
1559        debug_assert_eq!(lo_idx.len(), weight_all.len());
1560        debug_assert_eq!(norm.len(), n);
1561        debug_assert_eq!(spec.len(), n);
1562
1563        for i in 0..n {
1564            let norm_i = norm[i];
1565            if norm_i <= DIVISION_FLOOR {
1566                // Passthrough — matches `broaden_presorted`'s
1567                // `spectrum[i]` fallback for e ≤ 0, empty kernel, or
1568                // degenerate norm accumulation.
1569                result[i] = spec[i];
1570                continue;
1571            }
1572            let start = starts[i] as usize;
1573            let end = starts[i + 1] as usize;
1574            // Zip-compatible pre-bound slices of exactly `end - start`
1575            // elements each — the per-j bounds check is elided by the
1576            // compiler because the slice length bounds the loop.
1577            let los = &lo_idx[start..end];
1578            let fracs = &frac_all[start..end];
1579            let ws = &weight_all[start..end];
1580
1581            let mut sum = 0.0f64;
1582            for k in 0..los.len() {
1583                // SAFETY: `k < los.len()` is guaranteed by the range;
1584                // `los`, `fracs`, and `ws` all have length `end - start`
1585                // (same subslice bounds), so each `get_unchecked(k)`
1586                // read is in-bounds.
1587                let lo = unsafe { *los.get_unchecked(k) } as usize;
1588                let frac = unsafe { *fracs.get_unchecked(k) };
1589                let w = unsafe { *ws.get_unchecked(k) };
1590
1591                // Degenerate-bracket short-circuit: when the plan
1592                // built `frac = -0.0` (span < NEAR_ZERO_FLOOR) we skip
1593                // `spectrum[lo+1]` entirely.  Without this branch,
1594                // `0.0 * NaN = NaN` would propagate and diverge from
1595                // the reference `broaden_presorted`, which returns
1596                // `spectrum[lo]` directly for that case.  Branch is
1597                // well-predicted (degenerate brackets are rare on
1598                // real grids) and preserves bit-exactness under
1599                // pathological spectra.
1600                //
1601                // The check MUST use `to_bits()` because the non-
1602                // degenerate path can legitimately produce
1603                // `frac == +0.0` when `e_prime == energies[lo]`
1604                // exactly.  In that case `broaden_presorted` still
1605                // reads `spectrum[lo+1]` (and propagates NaN if
1606                // present there), so the short-circuit MUST NOT
1607                // trigger.  `+0.0 == -0.0` returns `true` but
1608                // `(+0.0).to_bits() != (-0.0).to_bits()`, so the
1609                // bit-pattern check disambiguates exactly which
1610                // semantic `plan_presorted` meant.
1611                let s = if frac.to_bits() == (-0.0_f64).to_bits() {
1612                    // SAFETY: `lo < n` by plan invariant.
1613                    // `plan_presorted` only pushes `lo = bracket_hi - 1`
1614                    // with `bracket_hi ∈ [1, n - 1]`, so `lo ∈
1615                    // [0, n - 2]`.  `spec.len() == n` by the
1616                    // precondition assert at the top of `apply`.
1617                    unsafe { *spec.get_unchecked(lo) }
1618                } else {
1619                    // SAFETY: same `lo ∈ [0, n - 2]` invariant, so
1620                    // `lo + 1 ∈ [1, n - 1]` is also in-bounds.
1621                    let s_lo = unsafe { *spec.get_unchecked(lo) };
1622                    let s_hi = unsafe { *spec.get_unchecked(lo + 1) };
1623                    s_lo + frac * (s_hi - s_lo)
1624                };
1625                // Serial accumulation preserved — no multi-accumulator
1626                // reassociation, no SIMD lane-wise tree reduce.
1627                // IEEE-754 addition is not associative; changing the
1628                // order would break bit-exactness with
1629                // `broaden_presorted_reference` (and all
1630                // `*_bit_exact_*` unit tests + the maintainers'
1631                // real-VENUS bit-exact baseline harness).
1632                sum += w * s;
1633            }
1634            result[i] = sum / norm_i;
1635        }
1636
1637        result
1638    }
1639
1640    /// Compile this plan into a row-stochastic CSR
1641    /// [`ResolutionMatrix`].
1642    ///
1643    /// The compiled matrix is an explicit sparse representation of
1644    /// the resolution operator `R` on the plan's target grid.  Each
1645    /// row sums to 1.0 to machine precision (passthrough rows store
1646    /// a single `(i, i, 1.0)` entry to match [`ResolutionPlan::apply`]
1647    /// 's `norm ≤ DIVISION_FLOOR` fallback).
1648    ///
1649    /// Degenerate-bracket handling uses the `-0.0` sentinel
1650    /// convention from `plan_presorted`: if `plan.frac[e]` has the
1651    /// bit pattern of `-0.0`, the entry contributes `weight / norm`
1652    /// at column `lo` only (no `lo+1` bracket).  A regular `+0.0`
1653    /// frac contributes `weight * 1.0 / norm` at `lo` and
1654    /// `weight * 0.0 / norm = 0.0` at `lo+1` — those zero columns
1655    /// are retained in CSR with `value = 0.0` to preserve
1656    /// downstream NaN-safety if the consumer re-multiplies by a
1657    /// spectrum containing NaN at `lo+1`.
1658    ///
1659    /// # Equivalence contract (finite spectra only)
1660    ///
1661    /// For a spectrum with **all finite values**, [`apply_r`] on the
1662    /// compiled matrix produces per-element output within `1e-12`
1663    /// relative tolerance of [`Self::apply`] on the same spectrum —
1664    /// not bit-exact, because the CSR matvec sums contributions in
1665    /// column order while `apply` sums in entry order and IEEE-754
1666    /// addition is non-associative.  The `1e-12` bound accounts for
1667    /// accumulation error across the ~82 entries per row on the
1668    /// 3471-bin VENUS production grid (500 × 2.22e-16 ≈ 1.1e-13 per
1669    /// row; `1e-12` leaves comfortable headroom).
1670    ///
1671    /// # Non-finite and near-overflow spectra
1672    ///
1673    /// The equivalence bound does **NOT** extend to spectra with
1674    /// `NaN` / `±∞` values, **nor to near-f64::MAX overflow
1675    /// inputs**.  Both divergences trace back to the same
1676    /// algebraic rewrite:
1677    ///
1678    /// * [`Self::apply`] computes each entry as `spec[lo] + frac *
1679    ///   (spec[lo+1] - spec[lo])`, which can overflow the
1680    ///   subtraction even for finite inputs (opposite-sign
1681    ///   f64::MAX → `-∞`).
1682    /// * The compiled CSR form splits the interp into `(1 - frac) *
1683    ///   spec[lo] + frac * spec[lo + 1]`, which scales before
1684    ///   summing and stays finite in the same case.
1685    ///
1686    /// For bounded finite Beer-Lambert transmissions (`T ∈ [0, 1]`)
1687    /// neither divergence can arise; callers who deliberately pass
1688    /// non-finite or near-overflow spectra (e.g., as debug sentinels
1689    /// or out-of-range diagnostics) must not rely on cross-API
1690    /// equivalence.  See `resolution_matrix_nonfinite_contract` and
1691    /// `resolution_matrix_large_finite_contract` for executable
1692    /// demonstrations.
1693    pub fn compile_to_matrix(&self) -> ResolutionMatrix {
1694        let n = self.target_energies.len();
1695        let mut row_starts: Vec<u32> = Vec::with_capacity(n + 1);
1696        row_starts.push(0);
1697        let mut col_indices: Vec<u32> = Vec::new();
1698        let mut values: Vec<f64> = Vec::new();
1699
1700        // Reusable per-row accumulator.  Columns accumulate into a
1701        // BTreeMap keyed by spectrum index so the final CSR row is
1702        // emitted in ascending column order — the required CSR
1703        // invariant and the condition the `apply_r` equivalence
1704        // bound depends on.
1705        let mut acc: std::collections::BTreeMap<u32, f64> = std::collections::BTreeMap::new();
1706
1707        for i in 0..n {
1708            acc.clear();
1709            let norm_i = self.norm[i];
1710            if norm_i <= DIVISION_FLOOR {
1711                // Passthrough row — matches `apply`'s early return.
1712                col_indices.push(i as u32);
1713                values.push(1.0);
1714                // See u32-overflow `debug_assert!` below — the same
1715                // bound applies after every `push`.
1716                debug_assert!(
1717                    col_indices.len() <= u32::MAX as usize,
1718                    "CSR row_starts/col_indices u32 overflow: nnz = {}",
1719                    col_indices.len(),
1720                );
1721                row_starts.push(col_indices.len() as u32);
1722                continue;
1723            }
1724            let start = self.starts[i] as usize;
1725            let end = self.starts[i + 1] as usize;
1726            for e in start..end {
1727                let lo = self.lo_idx[e];
1728                let frac = self.frac[e];
1729                let w = self.weight[e];
1730                if frac.to_bits() == (-0.0_f64).to_bits() {
1731                    // Degenerate bracket — `apply` reads `spec[lo]`
1732                    // only, so the CSR row contributes only at `lo`.
1733                    *acc.entry(lo).or_insert(0.0) += w / norm_i;
1734                } else {
1735                    // Regular linear-interp entry: `w * ((1 - frac)
1736                    // * spec[lo] + frac * spec[lo + 1]) / norm_i`.
1737                    *acc.entry(lo).or_insert(0.0) += w * (1.0 - frac) / norm_i;
1738                    *acc.entry(lo + 1).or_insert(0.0) += w * frac / norm_i;
1739                }
1740            }
1741            for (&col, &val) in acc.iter() {
1742                col_indices.push(col);
1743                values.push(val);
1744            }
1745            // Defence-in-depth: a future large-grid caller that
1746            // accumulates more than u32::MAX entries would silently
1747            // truncate the `as u32` cast below.  The `plan_presorted`
1748            // helper already has matching `debug_assert!` guards on
1749            // its u32 offsets (resolution.rs, `plan_presorted`).
1750            debug_assert!(
1751                col_indices.len() <= u32::MAX as usize,
1752                "CSR row_starts/col_indices u32 overflow: nnz = {}",
1753                col_indices.len(),
1754            );
1755            row_starts.push(col_indices.len() as u32);
1756        }
1757
1758        ResolutionMatrix {
1759            target_energies: self.target_energies.clone(),
1760            row_starts,
1761            col_indices,
1762            values,
1763        }
1764    }
1765}
1766
1767/// Row-stochastic CSR representation of the resolution operator `R`
1768/// on a fixed target energy grid.
1769///
1770/// Built from a [`ResolutionPlan`] via
1771/// [`ResolutionPlan::compile_to_matrix`].  Exposed so downstream
1772/// surrogates (see ) can access the row-local entries
1773/// `R_{i, j}` directly for LP / quadrature construction.
1774///
1775/// Owns a copy of the target energy grid for the same reason
1776/// [`ResolutionPlan`] does: caller-side grid-identity checks and
1777/// explicit grid-mismatch errors via
1778/// [`ResolutionError::MatrixGridMismatch`].
1779#[derive(Debug, Clone)]
1780pub struct ResolutionMatrix {
1781    /// Target energy grid the matrix was compiled for (owned copy).
1782    target_energies: Vec<f64>,
1783    /// `row_starts[i]..row_starts[i+1]` indexes into
1784    /// `col_indices`/`values` for row `i`.  Length `n + 1`.
1785    row_starts: Vec<u32>,
1786    /// Column indices in ascending order within each row.
1787    col_indices: Vec<u32>,
1788    /// CSR values.  Row `i` sums to 1.0 within machine precision
1789    /// (passthrough rows store exactly `1.0` at column `i`).
1790    values: Vec<f64>,
1791}
1792
1793impl ResolutionMatrix {
1794    /// Number of rows (target-grid size) covered by this matrix.
1795    pub fn len(&self) -> usize {
1796        self.target_energies.len()
1797    }
1798
1799    /// True when the matrix covers no target energies.
1800    pub fn is_empty(&self) -> bool {
1801        self.target_energies.is_empty()
1802    }
1803
1804    /// Total number of stored entries (structural nnz).
1805    ///
1806    /// Regular-bracket entries with `frac == +0.0` retain a
1807    /// zero-valued contribution at the `lo + 1` column to preserve
1808    /// NaN-safety under re-application to spectra with NaN at that
1809    /// column; those stored zeros are counted in this total.
1810    pub fn nnz(&self) -> usize {
1811        self.values.len()
1812    }
1813
1814    /// Target energy grid the matrix was compiled for.
1815    pub fn target_energies(&self) -> &[f64] {
1816        &self.target_energies
1817    }
1818
1819    /// CSR row-start offsets.  `row_starts()[i]..row_starts()[i+1]`
1820    /// names the entry range for row `i`.  Length `len() + 1`.
1821    pub fn row_starts(&self) -> &[u32] {
1822        &self.row_starts
1823    }
1824
1825    /// CSR column indices.  Sorted ascending within each row.
1826    pub fn col_indices(&self) -> &[u32] {
1827        &self.col_indices
1828    }
1829
1830    /// CSR values.  Each row sums to 1.0 to machine precision.
1831    pub fn values(&self) -> &[f64] {
1832        &self.values
1833    }
1834}
1835
1836/// Apply a compiled [`ResolutionMatrix`] to a spectrum on the same
1837/// target grid the matrix was compiled for.
1838///
1839/// For finite spectra, the output is numerically equivalent to
1840/// [`ResolutionPlan::apply`] on the same spectrum within `1e-12`
1841/// relative tolerance per element; not bit-exact, because CSR matvec
1842/// sums in column order while `ResolutionPlan::apply` sums in entry
1843/// order.
1844///
1845/// # Non-finite and near-overflow inputs
1846///
1847/// See [`ResolutionPlan::compile_to_matrix`] for the full contract
1848/// on `NaN` / `±∞` spectra **and on near-f64::MAX finite spectra** —
1849/// the equivalence bound does not extend to either.  Production
1850/// forward models feed Beer-Lambert transmissions (`T ∈ [0, 1]`) so
1851/// the distinction never arises in practice.
1852///
1853/// # Panics
1854///
1855/// Panics if `spectrum.len() != matrix.len()`.  Use
1856/// [`apply_resolution_with_matrix`] for a checked entrypoint that
1857/// returns [`ResolutionError::LengthMismatch`] instead.
1858pub fn apply_r(matrix: &ResolutionMatrix, spectrum: &[f64]) -> Vec<f64> {
1859    let n = matrix.len();
1860    assert_eq!(
1861        spectrum.len(),
1862        n,
1863        "spectrum length ({}) must match matrix grid length ({})",
1864        spectrum.len(),
1865        n,
1866    );
1867    let mut out = vec![0.0f64; n];
1868    for (i, out_i) in out.iter_mut().enumerate() {
1869        let start = matrix.row_starts[i] as usize;
1870        let end = matrix.row_starts[i + 1] as usize;
1871        let mut sum = 0.0f64;
1872        for e in start..end {
1873            let col = matrix.col_indices[e] as usize;
1874            sum += matrix.values[e] * spectrum[col];
1875        }
1876        *out_i = sum;
1877    }
1878    out
1879}
1880
1881/// Checked variant of [`apply_r`] that validates the matrix was
1882/// compiled for `energies` before applying.
1883///
1884/// Returns [`ResolutionError::LengthMismatch`] when either
1885/// `energies` or `spectrum` has a length that disagrees with the
1886/// matrix grid size.  For the `spectrum` check, the `energies` field
1887/// of the returned error holds the matrix grid length (the required
1888/// length) so callers can read it as "expected vs got".  Returns
1889/// [`ResolutionError::MatrixGridMismatch`] when the lengths match
1890/// but the grid contents differ (per-element `to_bits()` compare).
1891///
1892/// Unlike [`apply_resolution_with_plan`], this entrypoint does not
1893/// enforce an ascending `energies` grid through the crate's internal
1894/// `validate_inputs` helper.  That check is redundant here: the plan
1895/// that produced the matrix was itself built on a sorted grid (via
1896/// [`TabulatedResolution::plan`], which validates sortedness), and the
1897/// stored `target_energies` copy is used in the `to_bits()`
1898/// grid-identity check above.  Any `energies` slice that is not
1899/// bit-identical to the matrix's stored copy — including an unsorted
1900/// permutation of the same values — fails with
1901/// [`ResolutionError::MatrixGridMismatch`].
1902pub fn apply_resolution_with_matrix(
1903    energies: &[f64],
1904    matrix: &ResolutionMatrix,
1905    spectrum: &[f64],
1906) -> Result<Vec<f64>, ResolutionError> {
1907    if energies.len() != matrix.len() {
1908        return Err(ResolutionError::LengthMismatch {
1909            energies: energies.len(),
1910            data: matrix.len(),
1911        });
1912    }
1913    if spectrum.len() != matrix.len() {
1914        // Reuse the `LengthMismatch` variant for the spectrum branch:
1915        // `energies` = expected length (matrix grid size), `data` =
1916        // actual spectrum length.  See docstring above.
1917        return Err(ResolutionError::LengthMismatch {
1918            energies: matrix.len(),
1919            data: spectrum.len(),
1920        });
1921    }
1922    for (i, (e_cur, e_ref)) in energies.iter().zip(matrix.target_energies()).enumerate() {
1923        // `to_bits()` equality catches `-0.0 vs +0.0` and NaN-bit
1924        // differences that float `==` silently accepts or rejects.
1925        if e_cur.to_bits() != e_ref.to_bits() {
1926            return Err(ResolutionError::MatrixGridMismatch {
1927                first_diff_index: i,
1928            });
1929        }
1930    }
1931    Ok(apply_r(matrix, spectrum))
1932}
1933
1934impl TabulatedResolution {
1935    /// Parse a VENUS/FTS resolution file.
1936    ///
1937    /// # Arguments
1938    /// * `text` — File contents as a string.
1939    /// * `flight_path_m` — Flight path length in meters.
1940    pub fn from_text(text: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1941        if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
1942            return Err(ResolutionParseError::InvalidFormat(format!(
1943                "Flight path must be positive and finite, got {flight_path_m}"
1944            )));
1945        }
1946        let mut lines = text.lines();
1947
1948        // Skip header and separator
1949        let _header = lines
1950            .next()
1951            .ok_or(ResolutionParseError::InvalidFormat("Empty file".into()))?;
1952        let _sep = lines.next().ok_or(ResolutionParseError::InvalidFormat(
1953            "Missing separator".into(),
1954        ))?;
1955
1956        let mut ref_energies = Vec::new();
1957        let mut kernels = Vec::new();
1958        let mut current_energy: Option<f64> = None;
1959        let mut current_offsets: Vec<f64> = Vec::new();
1960        let mut current_weights: Vec<f64> = Vec::new();
1961
1962        for line in lines {
1963            let trimmed = line.trim();
1964            if trimmed.is_empty() {
1965                // End of current block
1966                if let Some(e) = current_energy.take() {
1967                    ref_energies.push(e);
1968                    kernels.push((
1969                        std::mem::take(&mut current_offsets),
1970                        std::mem::take(&mut current_weights),
1971                    ));
1972                }
1973                continue;
1974            }
1975
1976            let parts: Vec<&str> = trimmed.split_whitespace().collect();
1977            if parts.len() != 2 {
1978                if current_energy.is_some() {
1979                    return Err(ResolutionParseError::InvalidFormat(format!(
1980                        "Expected 2 columns inside energy block, got {}: '{}'",
1981                        parts.len(),
1982                        trimmed
1983                    )));
1984                }
1985                // Outside a data block (e.g. extra header lines) — skip
1986                continue;
1987            }
1988
1989            let x: f64 = parts[0].parse().map_err(|_| {
1990                ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[0]))
1991            })?;
1992            let y: f64 = parts[1].parse().map_err(|_| {
1993                ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[1]))
1994            })?;
1995
1996            if current_energy.is_none() {
1997                // First line of block: energy + 0.0 marker
1998                current_energy = Some(x);
1999            } else {
2000                current_offsets.push(x);
2001                current_weights.push(y);
2002            }
2003        }
2004
2005        // Flush last block
2006        if let Some(e) = current_energy.take() {
2007            ref_energies.push(e);
2008            kernels.push((current_offsets, current_weights));
2009        }
2010
2011        if ref_energies.is_empty() {
2012            return Err(ResolutionParseError::InvalidFormat(
2013                "No energy blocks found".into(),
2014            ));
2015        }
2016
2017        // Validate finite, POSITIVE, strictly ascending reference
2018        // energies.  The finiteness check must come first: NaN compares
2019        // false against everything, so a NaN energy would slip through
2020        // the ascending check below and then poison the bracketing
2021        // binary search.  Positivity is load-bearing twice over: the
2022        // TOF map t = TOF_FACTOR·L/√E needs E > 0, and the
2023        // between-reference width interpolation takes ln(E_ref) — a
2024        // non-positive reference would turn every blended weight into
2025        // NaN, which bypasses the broadener's norm guard and silently
2026        // disables broadening (NaN comparisons are false).
2027        if let Some(bad) = ref_energies.iter().find(|e| !(e.is_finite() && **e > 0.0)) {
2028            return Err(ResolutionParseError::InvalidFormat(format!(
2029                "Reference energies must be finite and positive, got {bad}"
2030            )));
2031        }
2032        for i in 1..ref_energies.len() {
2033            if ref_energies[i] <= ref_energies[i - 1] {
2034                return Err(ResolutionParseError::InvalidFormat(format!(
2035                    "Reference energies must be strictly ascending, but E[{}]={} <= E[{}]={}",
2036                    i,
2037                    ref_energies[i],
2038                    i - 1,
2039                    ref_energies[i - 1],
2040                )));
2041            }
2042        }
2043
2044        // Per-block kernel invariants — the same set `from_kernels`
2045        // enforces, because both constructors feed the same broadener:
2046        //
2047        // * non-empty: an empty block would make `broaden_presorted`
2048        //   accumulate `norm == 0` and silently pass the spectrum
2049        //   through;
2050        // * all-finite offsets and weights (checked BEFORE sortedness:
2051        //   a NaN offset fails the ascending comparison too, and the
2052        //   "must be strictly ascending" message would mislead): a NaN
2053        //   weight poisons the kernel norm and bypasses the division
2054        //   guard;
2055        // * strictly ascending offsets: the monotonic two-pointer
2056        //   bracket walk and the trapezoidal `dt` widths
2057        //   (`offsets[k+1] − offsets[k−1]`) both assume sorted offsets
2058        //   — an unsorted block would broaden silently-wrong.
2059        for (i, (offsets, weights)) in kernels.iter().enumerate() {
2060            if offsets.is_empty() {
2061                return Err(ResolutionParseError::InvalidFormat(format!(
2062                    "Kernel {i} (E = {} eV) has no (offset, weight) points",
2063                    ref_energies[i],
2064                )));
2065            }
2066            if offsets.iter().any(|v| !v.is_finite()) || weights.iter().any(|v| !v.is_finite()) {
2067                return Err(ResolutionParseError::InvalidFormat(format!(
2068                    "Kernel {i} (E = {} eV) contains non-finite offsets or weights",
2069                    ref_energies[i],
2070                )));
2071            }
2072            // Negative weights have no physical meaning (a resolution
2073            // kernel is an emission-time density); the broadener skips
2074            // w <= 0 entries, and the width machinery's trapezoidal
2075            // moments must integrate over the same domain — reject at
2076            // the door rather than let the two disagree.
2077            if weights.iter().any(|&v| v < 0.0) {
2078                return Err(ResolutionParseError::InvalidFormat(format!(
2079                    "Kernel {i} (E = {} eV) contains negative weights",
2080                    ref_energies[i],
2081                )));
2082            }
2083            if !offsets.windows(2).all(|w| w[0] < w[1]) {
2084                return Err(ResolutionParseError::InvalidFormat(format!(
2085                    "Kernel {i} (E = {} eV) TOF offsets must be strictly ascending",
2086                    ref_energies[i],
2087                )));
2088            }
2089        }
2090
2091        Ok(TabulatedResolution {
2092            ref_energies: Arc::new(ref_energies),
2093            kernels: Arc::new(kernels),
2094            flight_path_m,
2095        })
2096    }
2097
2098    /// Build a tabulated resolution directly from synthesized kernels.
2099    ///
2100    /// Used by the analytical [`crate::ikeda_carpenter::IkedaCarpenter`] model,
2101    /// which generates `(tof_offset_µs, weight)` kernels at a set of reference
2102    /// energies and then rides the exact same broadening machinery as a
2103    /// Monte-Carlo file. Validates the same invariants `from_text` enforces:
2104    /// non-empty + strictly ascending reference energies, one kernel per energy,
2105    /// each kernel non-empty with matching offset/weight lengths, and all-finite
2106    /// offsets/weights.
2107    ///
2108    /// # Errors
2109    /// Returns [`ResolutionParseError::InvalidFormat`] if the flight path is
2110    /// not positive and finite, if the reference energies are empty / not
2111    /// strictly ascending, if the energy and kernel counts differ, if any
2112    /// kernel is empty, if a kernel's offset and weight vectors differ in
2113    /// length, or if any offset/weight is non-finite.
2114    pub fn from_kernels(
2115        ref_energies: Vec<f64>,
2116        kernels: Vec<(Vec<f64>, Vec<f64>)>,
2117        flight_path_m: f64,
2118    ) -> Result<Self, ResolutionParseError> {
2119        if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
2120            return Err(ResolutionParseError::InvalidFormat(format!(
2121                "Flight path must be positive and finite, got {flight_path_m}"
2122            )));
2123        }
2124        if ref_energies.is_empty() {
2125            return Err(ResolutionParseError::InvalidFormat(
2126                "No reference energies provided".into(),
2127            ));
2128        }
2129        if ref_energies.len() != kernels.len() {
2130            return Err(ResolutionParseError::InvalidFormat(format!(
2131                "Reference-energy count {} != kernel count {}",
2132                ref_energies.len(),
2133                kernels.len(),
2134            )));
2135        }
2136        // Finiteness first: NaN compares false against everything, so a
2137        // NaN energy would slip through the ascending check below and
2138        // then poison the bracketing binary search.  Positivity is
2139        // load-bearing twice over: the TOF map needs E > 0, and the
2140        // between-reference width interpolation takes ln(E_ref) — a
2141        // non-positive reference would turn every blended weight into
2142        // NaN and silently disable broadening.
2143        if let Some(bad) = ref_energies.iter().find(|e| !(e.is_finite() && **e > 0.0)) {
2144            return Err(ResolutionParseError::InvalidFormat(format!(
2145                "Reference energies must be finite and positive, got {bad}"
2146            )));
2147        }
2148        for i in 1..ref_energies.len() {
2149            if ref_energies[i] <= ref_energies[i - 1] {
2150                return Err(ResolutionParseError::InvalidFormat(format!(
2151                    "Reference energies must be strictly ascending, but E[{}]={} <= E[{}]={}",
2152                    i,
2153                    ref_energies[i],
2154                    i - 1,
2155                    ref_energies[i - 1],
2156                )));
2157            }
2158        }
2159        for (i, (offsets, weights)) in kernels.iter().enumerate() {
2160            // Reject empty kernels: an empty `(offsets, weights)` passes the
2161            // length-match check (0 == 0) but later makes `broaden_presorted`
2162            // accumulate `norm == 0` and silently fall back to pass-through.
2163            if offsets.is_empty() {
2164                return Err(ResolutionParseError::InvalidFormat(format!(
2165                    "Kernel {i} is empty; each kernel needs at least one (offset, weight) point"
2166                )));
2167            }
2168            if offsets.len() != weights.len() {
2169                return Err(ResolutionParseError::InvalidFormat(format!(
2170                    "Kernel {i} offset length {} != weight length {}",
2171                    offsets.len(),
2172                    weights.len(),
2173                )));
2174            }
2175            // Reject non-finite synthesized kernels (e.g. a poisoned analytic
2176            // pulse) so a NaN table fails loudly here rather than silently
2177            // degrading the convolution to pass-through (a NaN norm bypasses the
2178            // division guard in `broaden_presorted`).
2179            if offsets.iter().chain(weights.iter()).any(|v| !v.is_finite()) {
2180                return Err(ResolutionParseError::InvalidFormat(format!(
2181                    "Kernel {i} contains non-finite offset/weight values"
2182                )));
2183            }
2184            // Negative weights have no physical meaning; the broadener
2185            // skips w <= 0 entries and the trapezoidal width moments
2186            // must integrate over the same domain (see `from_text`).
2187            if weights.iter().any(|&v| v < 0.0) {
2188                return Err(ResolutionParseError::InvalidFormat(format!(
2189                    "Kernel {i} contains negative weights"
2190                )));
2191            }
2192            // Offsets must be strictly ascending: `broaden_presorted`/`plan` walk a
2193            // monotonic two-pointer bracket and derive trapezoidal `dt` widths from
2194            // `offsets[k+1] − offsets[k−1]`, both of which assume sorted offsets.
2195            // An unsorted kernel would otherwise broaden silently-wrong, not error.
2196            if !offsets.windows(2).all(|w| w[0] < w[1]) {
2197                return Err(ResolutionParseError::InvalidFormat(format!(
2198                    "Kernel {i} TOF offsets must be strictly ascending"
2199                )));
2200            }
2201        }
2202        Ok(TabulatedResolution {
2203            ref_energies: Arc::new(ref_energies),
2204            kernels: Arc::new(kernels),
2205            flight_path_m,
2206        })
2207    }
2208
2209    /// Parse a VENUS/FTS resolution file from disk.
2210    pub fn from_file(path: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
2211        let text = std::fs::read_to_string(path)
2212            .map_err(|e| ResolutionParseError::IoError(format!("Cannot read '{}': {}", path, e)))?;
2213        Self::from_text(&text, flight_path_m)
2214    }
2215
2216    /// Apply tabulated resolution broadening to a spectrum.
2217    ///
2218    /// For each energy point:
2219    /// 1. Find bracketing reference energies and interpolate kernel (log-space)
2220    /// 2. Convert TOF offsets to energy offsets using exact TOF↔energy relation
2221    /// 3. Convolve spectrum with interpolated kernel (trapezoidal integration)
2222    ///
2223    /// Kernel points whose delayed-emission offset reaches the nominal
2224    /// flight time at the target energy (`dt ≥ TOF(E)`) gather from
2225    /// past infinite energy; they are dropped and the kernel
2226    /// renormalized over the surviving points, mirroring the grid-edge
2227    /// handling — see the tail-truncation note on `broaden_presorted`.
2228    ///
2229    /// # Errors
2230    /// Returns [`ResolutionError::LengthMismatch`] if the arrays differ in
2231    /// length, or [`ResolutionError::UnsortedEnergies`] if the energy grid is
2232    /// not sorted in non-descending order.
2233    pub fn broaden(&self, energies: &[f64], spectrum: &[f64]) -> Result<Vec<f64>, ResolutionError> {
2234        validate_inputs(energies, spectrum)?;
2235        Ok(self.broaden_presorted(energies, spectrum))
2236    }
2237
2238    /// Tabulated resolution broadening assuming the energy grid is already
2239    /// validated (sorted ascending, same length as spectrum).
2240    ///
2241    /// ## Convolution orientation
2242    ///
2243    /// The broadened value at measured TOF `t` gathers theory at
2244    /// `t − dt`: a neutron *measured* at `t` whose emission was delayed
2245    /// by `dt` really flew for `t − dt`, i.e. it is faster than nominal,
2246    /// so the kernel's positive-offset (delayed-emission) tail pulls
2247    /// theory from earlier TOF = **higher** energy, and a resonance dip
2248    /// acquires its tail toward lower apparent energy. This is the
2249    /// convolution ∫R(τ)·S(t−τ)dτ, matching SAMMY's user-defined
2250    /// resolution: `sammy/src/udr/mudr4.f90` `Ud_Convolute` (line 288)
2251    /// computes `Cc(Tc) = ∫Bb(τ)·Aa(Tc−τ)dτ` (the theory-segment search
2252    /// binds `Tc−Ta` to the kernel grid), and `Ud_Mesh_Time` (line 7)
2253    /// sizes the needed theory window as `[T0−UdT_last, T0−UdT_first]`.
2254    ///
2255    /// ## Delayed-tail truncation at short nominal flight times
2256    ///
2257    /// A kernel point with `dt ≥ tof_center` would gather at
2258    /// `tof_prime = tof_center − dt ≤ 0`, i.e. from past infinite
2259    /// energy where no theory value exists.  Such points are silently
2260    /// dropped and the trapezoidal normalisation runs over the
2261    /// surviving points — the same truncate-and-renormalize treatment
2262    /// applied when `e_prime` falls outside the target grid
2263    /// (`[e_min, e_max]`).  This engages when the nominal TOF at the
2264    /// target energy is shorter than the kernel's delayed-emission
2265    /// reach — very high energy and/or a short flight path — and is
2266    /// reachable at *any* target energy because `interpolated_kernel`
2267    /// clamps to the nearest reference kernel outside the tabulated
2268    /// range.
2269    ///
2270    /// ## Inner-loop optimization
2271    ///
2272    /// The per-kernel-point spectrum interpolation uses a **two-pointer
2273    /// walk** instead of a binary search: `e_prime` is monotonically
2274    /// increasing in `k` (since `dt = offsets[k]` is non-decreasing,
2275    /// `TOF' = tof_center − dt` is non-increasing, and `E' = (L/TOF')²`
2276    /// is non-decreasing).  We maintain `bracket_hi` as the smallest
2277    /// index into `energies[]` whose value is `>= e_prime`; the walk is
2278    /// a bidirectional fixed-point search (first kernel point descends
2279    /// from `n−1`, subsequent points walk upward).  Amortized O(1) per
2280    /// kernel point within a target.
2281    ///
2282    /// Math is identical to the reference implementation pinned by
2283    /// `broaden_presorted_reference` in the test module.
2284    ///
2285    /// For callers that broaden many spectra on the same target grid —
2286    /// LM iterations with fixed TZERO, spatial maps with a pre-calibrated
2287    /// energy axis — [`TabulatedResolution::plan`] +
2288    /// [`ResolutionPlan::apply`] produce bit-exact output while
2289    /// hoisting the per-target invariants (TOF conversion, kernel
2290    /// interpolation, bracket lookup, trapezoidal widths) out of the
2291    /// broadening hot loop.  This `broaden_presorted` entry is the
2292    /// single-broadening path and keeps the original inline
2293    /// implementation to avoid plan-construction overhead on one-shot
2294    /// callers.
2295    pub(crate) fn broaden_presorted(&self, energies: &[f64], spectrum: &[f64]) -> Vec<f64> {
2296        let n = energies.len();
2297        if n == 0 {
2298            return vec![];
2299        }
2300        if n == 1 {
2301            return spectrum.to_vec();
2302        }
2303
2304        let e_min = energies[0];
2305        let e_max = energies[n - 1];
2306
2307        let mut result = vec![0.0f64; n];
2308
2309        for i in 0..n {
2310            let e = energies[i];
2311            if e <= 0.0 {
2312                result[i] = spectrum[i];
2313                continue;
2314            }
2315
2316            let tof_center = TOF_FACTOR * self.flight_path_m / e.sqrt();
2317            let (offsets, weights) = self.interpolated_kernel(e);
2318            let n_k = offsets.len();
2319            let mut bracket_hi: usize = n - 1;
2320
2321            let mut sum = 0.0;
2322            let mut norm = 0.0;
2323
2324            for k in 0..n_k {
2325                let dt = offsets[k];
2326                let w = weights[k];
2327                if w <= 0.0 {
2328                    continue;
2329                }
2330
2331                // Convolution gather: theory at t − dt (see docstring;
2332                // SAMMY mudr4.f90 Ud_Convolute).
2333                let tof_prime = tof_center - dt;
2334                if tof_prime <= 0.0 {
2335                    continue;
2336                }
2337
2338                let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
2339
2340                if e_prime < e_min || e_prime > e_max {
2341                    continue;
2342                }
2343
2344                while bracket_hi > 1 && energies[bracket_hi - 1] > e_prime {
2345                    bracket_hi -= 1;
2346                }
2347                while bracket_hi < n - 1 && energies[bracket_hi] <= e_prime {
2348                    bracket_hi += 1;
2349                }
2350
2351                let lo = bracket_hi - 1;
2352                let hi = bracket_hi;
2353                let span = energies[hi] - energies[lo];
2354                let s = if span.abs() < NEAR_ZERO_FLOOR {
2355                    spectrum[lo]
2356                } else {
2357                    let frac = (e_prime - energies[lo]) / span;
2358                    spectrum[lo] + frac * (spectrum[hi] - spectrum[lo])
2359                };
2360
2361                let dt_width = if k > 0 && k < n_k - 1 {
2362                    (offsets[k + 1] - offsets[k - 1]) * 0.5
2363                } else if k == 0 && n_k > 1 {
2364                    offsets[1] - offsets[0]
2365                } else if k == n_k - 1 && n_k > 1 {
2366                    offsets[k] - offsets[k - 1]
2367                } else {
2368                    1.0
2369                };
2370
2371                let weight = w * dt_width.abs();
2372                sum += weight * s;
2373                norm += weight;
2374            }
2375
2376            result[i] = if norm > DIVISION_FLOOR {
2377                sum / norm
2378            } else {
2379                spectrum[i]
2380            };
2381        }
2382
2383        result
2384    }
2385
2386    /// Build a reusable broadening plan for a specific target energy grid.
2387    ///
2388    /// Validates that `energies` is non-descending — the same sorted-grid
2389    /// precondition enforced by [`TabulatedResolution::broaden`] via
2390    /// `validate_inputs`.  An
2391    /// unsorted grid would produce a silently-wrong plan (misbracketed
2392    /// `e_prime` lookups against `e_min` / `e_max`), so it must be
2393    /// caught at build time rather than returning garbage from
2394    /// [`ResolutionPlan::apply`].
2395    ///
2396    /// The plan hoists every quantity that depends only on
2397    /// `(target_energies, self.ref_energies, self.flight_path_m)` —
2398    /// namely the TOF conversion, the log-space kernel interpolation,
2399    /// the per-kernel-point `e_prime` and spectrum-bracket lookup, and
2400    /// the trapezoidal integration widths.  Applying the plan to a
2401    /// spectrum becomes a pure gather + multiply-add loop.
2402    ///
2403    /// Build cost: same as one call to the private `broaden_presorted`
2404    /// helper (O(N_target × N_kernel) TOF / bracket / interp work, plus
2405    /// ~2 × N_kernel log-interp ops per target energy for
2406    /// `interpolated_kernel`).  Apply cost per target: 1 branch +
2407    /// ~3 loads + 3 flops per retained entry, plus the final divide —
2408    /// typically < 10 % of the build cost.  The payoff comes from
2409    /// reusing one plan across many spectra.
2410    ///
2411    /// Bit-exact with `broaden_presorted`: pre-computes the same
2412    /// floating-point sequences (TOF, `e_prime`, `dt_width`, `frac`,
2413    /// `weight`, `norm`) in the same order.
2414    ///
2415    /// # Errors
2416    /// Returns [`ResolutionError::UnsortedEnergies`] if `energies` is
2417    /// not non-descending.
2418    pub fn plan(&self, energies: &[f64]) -> Result<ResolutionPlan, ResolutionError> {
2419        if !energies.windows(2).all(|w| w[0] <= w[1]) {
2420            return Err(ResolutionError::UnsortedEnergies);
2421        }
2422        Ok(self.plan_presorted(energies))
2423    }
2424
2425    /// Build a plan assuming `energies` is already validated as
2426    /// non-descending.  Used internally by `broaden_presorted` (whose
2427    /// caller already validated the grid) and by `plan()` after its
2428    /// validation succeeded.
2429    fn plan_presorted(&self, energies: &[f64]) -> ResolutionPlan {
2430        let n = energies.len();
2431        if n == 0 {
2432            return ResolutionPlan {
2433                target_energies: Vec::new(),
2434                starts: vec![0],
2435                lo_idx: Vec::new(),
2436                frac: Vec::new(),
2437                weight: Vec::new(),
2438                norm: Vec::new(),
2439            };
2440        }
2441        if n == 1 {
2442            // No bracket available; passthrough. Represent as n=1 with
2443            // zero entries and norm=0, which triggers the passthrough
2444            // branch in `ResolutionPlan::apply`.
2445            return ResolutionPlan {
2446                target_energies: energies.to_vec(),
2447                starts: vec![0, 0],
2448                lo_idx: Vec::new(),
2449                frac: Vec::new(),
2450                weight: Vec::new(),
2451                norm: vec![0.0],
2452            };
2453        }
2454
2455        let e_min = energies[0];
2456        let e_max = energies[n - 1];
2457
2458        // Preallocate the entry Vecs to ~n × 2·kernel_len: the
2459        // width-normalized shape blend merges the two bracketing
2460        // blocks, so between-reference targets emit up to
2461        // n_lo + n_hi points (~2× a single block; real VENUS grids
2462        // push ~n × 998 entries). Over-allocating for at-reference
2463        // targets is cheap vs. repeated grow-and-memcpy during the
2464        // plan build.
2465        let estimated_kernel_len = self.kernels.first().map_or(0, |(off, _)| off.len());
2466        let estimated_entries = n.saturating_mul(estimated_kernel_len.saturating_mul(2));
2467
2468        let mut starts: Vec<u32> = Vec::with_capacity(n + 1);
2469        let mut lo_idx: Vec<u32> = Vec::with_capacity(estimated_entries);
2470        let mut frac: Vec<f64> = Vec::with_capacity(estimated_entries);
2471        let mut weight: Vec<f64> = Vec::with_capacity(estimated_entries);
2472        let mut norm: Vec<f64> = Vec::with_capacity(n);
2473
2474        starts.push(0);
2475
2476        for i in 0..n {
2477            let e = energies[i];
2478            if e <= 0.0 {
2479                // Passthrough: no entries contribute, norm=0.
2480                norm.push(0.0);
2481                // Guard the u32 invariant for diagnostic callers; the
2482                // headroom is enormous for any realistic grid (VENUS
2483                // 3471 × 499 ≈ 1.7M entries, u32::MAX ≈ 4.29B), but
2484                // the debug-only assert documents the contract.
2485                debug_assert!(
2486                    lo_idx.len() <= u32::MAX as usize,
2487                    "plan entry count overflows u32"
2488                );
2489                starts.push(lo_idx.len() as u32);
2490                continue;
2491            }
2492
2493            // TOF at this energy: t = TOF_FACTOR * L / sqrt(E).
2494            // Computed here in the plan build and NOT at apply time — this
2495            // is the main invariant we hoist.
2496            let tof_center = TOF_FACTOR * self.flight_path_m / e.sqrt();
2497
2498            // Interpolated kernel at this target energy.  Allocates two
2499            // ~N_kernel Vecs; those allocations happen once per plan
2500            // build instead of once per broadening call.
2501            let (offsets, weights) = self.interpolated_kernel(e);
2502            let n_k = offsets.len();
2503
2504            // Two-pointer walk state (same invariant as broaden_presorted).
2505            let mut bracket_hi: usize = n - 1;
2506
2507            let mut target_norm = 0.0;
2508
2509            for k in 0..n_k {
2510                let dt = offsets[k];
2511                let w = weights[k];
2512                if w <= 0.0 {
2513                    continue;
2514                }
2515
2516                // Convolution gather: theory at t − dt (see
2517                // broaden_presorted; SAMMY mudr4.f90 Ud_Convolute).
2518                // Points with dt ≥ tof_center gather from past infinite
2519                // energy and are dropped, renormalizing over the
2520                // survivors (see the tail-truncation note there).
2521                let tof_prime = tof_center - dt;
2522                if tof_prime <= 0.0 {
2523                    continue;
2524                }
2525
2526                let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
2527
2528                if e_prime < e_min || e_prime > e_max {
2529                    continue;
2530                }
2531
2532                // Two-pointer walk — same logic + invariants as
2533                // broaden_presorted, in the same order, so bracket_hi
2534                // reaches the identical position for each kept (i, k).
2535                while bracket_hi > 1 && energies[bracket_hi - 1] > e_prime {
2536                    bracket_hi -= 1;
2537                }
2538                while bracket_hi < n - 1 && energies[bracket_hi] <= e_prime {
2539                    bracket_hi += 1;
2540                }
2541
2542                let lo = bracket_hi - 1;
2543                let hi = bracket_hi;
2544                let span = energies[hi] - energies[lo];
2545                // Degenerate-bracket guard: if span < NEAR_ZERO_FLOOR,
2546                // broaden_presorted returns `spectrum[lo]` directly
2547                // without the interp arithmetic.  Store `frac = -0.0`
2548                // — the apply path short-circuits on the exact bit
2549                // pattern of `-0.0` and returns `spectrum[lo]` without
2550                // touching `spectrum[lo+1]`, so bit-exactness holds
2551                // even if `spectrum[lo+1]` is NaN or ±∞.
2552                //
2553                // `-0.0` (negative-signed zero) is used as the sentinel
2554                // because the non-degenerate path can legitimately
2555                // produce `frac == +0.0` when `e_prime == energies[lo]`
2556                // exactly — in that case `broaden_presorted` still
2557                // reads `spectrum[lo+1]` (and propagates NaN if present
2558                // there), so the apply path MUST do the same.  `+0.0`
2559                // and `-0.0` compare equal under `==` but differ in
2560                // `to_bits()`, which is what apply uses to disambiguate.
2561                let entry_frac = if span.abs() < NEAR_ZERO_FLOOR {
2562                    -0.0_f64
2563                } else {
2564                    (e_prime - energies[lo]) / span
2565                };
2566
2567                let dt_width = if k > 0 && k < n_k - 1 {
2568                    (offsets[k + 1] - offsets[k - 1]) * 0.5
2569                } else if k == 0 && n_k > 1 {
2570                    offsets[1] - offsets[0]
2571                } else if k == n_k - 1 && n_k > 1 {
2572                    offsets[k] - offsets[k - 1]
2573                } else {
2574                    1.0
2575                };
2576
2577                let entry_weight = w * dt_width.abs();
2578
2579                debug_assert!(
2580                    lo_idx.len() < u32::MAX as usize,
2581                    "plan entry count overflows u32"
2582                );
2583                lo_idx.push(lo as u32);
2584                frac.push(entry_frac);
2585                weight.push(entry_weight);
2586                target_norm += entry_weight;
2587            }
2588
2589            norm.push(target_norm);
2590            starts.push(lo_idx.len() as u32);
2591        }
2592
2593        ResolutionPlan {
2594            target_energies: energies.to_vec(),
2595            starts,
2596            lo_idx,
2597            frac,
2598            weight,
2599            norm,
2600        }
2601    }
2602
2603    /// Interpolate the kernel at an arbitrary energy as a
2604    /// **width-normalized shape blend** between the two bracketing
2605    /// reference kernels:
2606    ///
2607    /// 1. Exact hits (a reference energy, or outside the reference
2608    ///    range) return that reference kernel unchanged.
2609    /// 2. Each bracketing block's trapezoidal RMS width `σ_b` is
2610    ///    computed ([`trapezoidal_moments`]); the target width is the
2611    ///    **geometric** interpolation `σ_t = σ_lo·(σ_hi/σ_lo)^frac`
2612    ///    with `frac` linear in log E — exact for the physical
2613    ///    power-law width `σ_t ∝ E^p` (log σ linear in log E).
2614    /// 3. Both blocks' offsets are scaled about the mode (offset 0 —
2615    ///    the anchoring convention; see the discussion in
2616    ///    `ikeda_carpenter`) by `σ_t/σ_b`, merged into one
2617    ///    strictly-ascending grid, and the weights blended pointwise:
2618    ///    `w = w_lo(x) + frac·(w_hi(x) − w_lo(x))`, each block's weight
2619    ///    linearly interpolated (zero outside its support). Identical
2620    ///    blocks reduce to a **bitwise identity** (the scale ratios are
2621    ///    exactly 1.0 and the blend form is exact when `w_lo == w_hi`).
2622    ///
2623    /// Degenerate blocks (single-point, zero mass → `σ_b ≤ 0`) fall
2624    /// back to the nearer reference clone.
2625    ///
2626    /// ## INTENTIONAL DEPARTURE from SAMMY
2627    ///
2628    /// SAMMY's user-defined resolution blends both the amplitude and
2629    /// the time-point arrays element-wise, **linear in E**: in the
2630    /// active `Gen_Udr_Par` (sammy/src/udr/mudr3.f90, subroutine at
2631    /// line 164; blend block at lines 241–255: `UdR_E(J,Nud) =
2632    /// UdR(J,I−1,Nud)·a + UdR(J,I,Nud)·b` and likewise `UdT_E`), i.e.
2633    /// the arithmetic width chord. (The file's first routine
2634    /// `Gen_Udr_Par_x` holds the same blend at lines 92–112 but is
2635    /// marked "never called" at line 10 — cite the live twin.)
2636    /// Because the physical width law
2637    /// `σ_t ∝ ~E^{−1/2}` is convex, that chord systematically
2638    /// over-widens every between-reference energy: +7.8 % at the
2639    /// midpoint of synthetic 10/50 eV Gaussian blocks, +4.1…+7.2 %
2640    /// across the production VENUS 5→50 eV reference gap — a direct
2641    /// resolution-width systematic that biases fitted temperatures
2642    /// low. The geometric-width shape blend above removes it (and the
2643    /// nearest-reference width sawtooth that unequal point counts used
2644    /// to produce). SAMMY additionally re-aligns the blended kernel so
2645    /// its trapezoidal centroid `Ct` sits at T = 0 ("Realign so that
2646    /// centroid is at T=0", mudr3.f90 lines 266–292);
2647    /// NEREIDS does not re-align: the blend scales offsets about 0, so each
2648    /// table keeps its own origin — a loaded UDR file its peak, a synthesized
2649    /// Ikeda–Carpenter table the emission instant.
2650    ///
2651    /// Exactness caveat: the blend reproduces `σ_t` exactly when the
2652    /// two blocks' width-normalized shapes agree (self-similar
2653    /// families, e.g. any pure power-law file). Genuinely different
2654    /// bracketing shapes add a second-order mixture-spread term —
2655    /// inherent to shape blending and far below the removed chord
2656    /// error for real moderator files.
2657    ///
2658    /// Allocates the two output Vecs per call (≈ n_lo + n_hi points
2659    /// between references); scratch reuse is tracked separately as a
2660    /// performance follow-up.
2661    ///
2662    /// `ref_energies` is validated as strictly ascending by `from_text()` /
2663    /// `from_file()` at construction time, so no per-call sort check is needed.
2664    fn interpolated_kernel(&self, energy: f64) -> (Vec<f64>, Vec<f64>) {
2665        debug_assert!(
2666            self.ref_energies.windows(2).all(|w| w[0] < w[1]),
2667            "ref_energies must be strictly ascending (invariant broken)"
2668        );
2669        let n_ref = self.ref_energies.len();
2670
2671        // NaN target energies never reach here through the validated
2672        // public paths (a NaN in a multi-point grid fails the sorted
2673        // check), but every comparison below is false for NaN, and the
2674        // width-scaled merge would then emit NaN offsets — violating
2675        // the strictly-ascending, all-finite invariants the broadener
2676        // assumes. Clamp to the lowest reference so the output kernel
2677        // is well-formed unconditionally (the old element-wise blend
2678        // degraded to its nearest-reference fallback here by accident
2679        // of its monotonicity guard; this keeps that graceful
2680        // behaviour explicit). +∞ needs no guard: the high clamp
2681        // below already catches it.
2682        if energy.is_nan() {
2683            return self.kernels[0].clone();
2684        }
2685
2686        // Clamp to nearest reference if outside range
2687        if energy <= self.ref_energies[0] || n_ref == 1 {
2688            return self.kernels[0].clone();
2689        }
2690        if energy >= self.ref_energies[n_ref - 1] {
2691            return self.kernels[n_ref - 1].clone();
2692        }
2693
2694        // Find bracketing indices
2695        let pos = self.ref_energies.partition_point(|&e| e < energy);
2696        // Interior exact hit: return that reference unchanged rather than
2697        // blending it with itself, which reproduces it only up to ULPs.
2698        if self.ref_energies[pos] == energy {
2699            return self.kernels[pos].clone();
2700        }
2701        let idx = if pos == 0 {
2702            0
2703        } else {
2704            (pos - 1).min(n_ref - 2)
2705        };
2706
2707        let e_lo = self.ref_energies[idx];
2708        let e_hi = self.ref_energies[idx + 1];
2709
2710        // Log-space interpolation fraction
2711        let frac = (energy.ln() - e_lo.ln()) / (e_hi.ln() - e_lo.ln());
2712
2713        let (off_lo, w_lo) = &self.kernels[idx];
2714        let (off_hi, w_hi) = &self.kernels[idx + 1];
2715
2716        let nearest = || -> (Vec<f64>, Vec<f64>) {
2717            let k = if frac < 0.5 {
2718                &self.kernels[idx]
2719            } else {
2720                &self.kernels[idx + 1]
2721            };
2722            (k.0.clone(), k.1.clone())
2723        };
2724
2725        let (_, s_lo) = trapezoidal_moments(off_lo, w_lo);
2726        let (_, s_hi) = trapezoidal_moments(off_hi, w_hi);
2727        // Degenerate blocks (σ ≤ 0) cannot be width-scaled, and a
2728        // non-finite fraction (constructors enforce positive reference
2729        // energies, so defense-in-depth only) would poison every
2730        // blended weight with NaN — both take the nearest-clone
2731        // fallback so the output is well-formed unconditionally.
2732        if !(s_lo.is_finite() && s_lo > 0.0 && s_hi.is_finite() && s_hi > 0.0 && frac.is_finite()) {
2733            return nearest();
2734        }
2735
2736        // Geometric width interpolation. This float form (ratio +
2737        // powf) is a bitwise no-op when σ_lo == σ_hi: the ratio is
2738        // exactly 1.0, powf(1.0, f) == 1.0, so both scale factors are
2739        // exactly 1.0 and scaled offsets are the originals.
2740        let s_t = s_lo * (s_hi / s_lo).powf(frac);
2741        let r_lo = s_t / s_lo;
2742        let r_hi = s_t / s_hi;
2743
2744        // Single-pass sorted merge of the two scaled grids. Each
2745        // block's weight at a merged point is its own tabulated value
2746        // when the point came from that block, else the linear
2747        // interpolation of its shape (zero outside its support).
2748        let n_lo = off_lo.len();
2749        let n_hi = off_hi.len();
2750        let mut out_off: Vec<f64> = Vec::with_capacity(n_lo + n_hi);
2751        let mut out_w: Vec<f64> = Vec::with_capacity(n_lo + n_hi);
2752
2753        // Piecewise-linear sample of one block's shape at `x`, with a
2754        // monotone cursor (merged points arrive in ascending order).
2755        let sample = |offs: &[f64], ws: &[f64], r: f64, cursor: &mut usize, x: f64| -> f64 {
2756            let n = offs.len();
2757            if x < offs[0] * r || x > offs[n - 1] * r {
2758                return 0.0;
2759            }
2760            while *cursor + 1 < n && offs[*cursor + 1] * r <= x {
2761                *cursor += 1;
2762            }
2763            if *cursor + 1 >= n {
2764                return ws[n - 1];
2765            }
2766            let x0 = offs[*cursor] * r;
2767            let x1 = offs[*cursor + 1] * r;
2768            let span = x1 - x0;
2769            if x <= x0 || span <= 0.0 {
2770                return ws[*cursor];
2771            }
2772            ws[*cursor] + (x - x0) / span * (ws[*cursor + 1] - ws[*cursor])
2773        };
2774
2775        let (mut i, mut j) = (0usize, 0usize);
2776        let (mut ci, mut cj) = (0usize, 0usize);
2777        while i < n_lo || j < n_hi {
2778            let xa = if i < n_lo {
2779                off_lo[i] * r_lo
2780            } else {
2781                f64::INFINITY
2782            };
2783            let xb = if j < n_hi {
2784                off_hi[j] * r_hi
2785            } else {
2786                f64::INFINITY
2787            };
2788            // Near-duplicate merge (covers the exact 0 == 0 mode point):
2789            // emit one point carrying both blocks' exact tabulated
2790            // weights, so identical blocks blend to their exact values.
2791            let near_dup = i < n_lo
2792                && j < n_hi
2793                && (xa - xb).abs() <= 4.0 * f64::EPSILON * xa.abs().max(xb.abs());
2794            let (x, wl, wh) = if near_dup {
2795                let v = (xa, w_lo[i], w_hi[j]);
2796                i += 1;
2797                j += 1;
2798                v
2799            } else if xa < xb {
2800                let v = (xa, w_lo[i], sample(off_hi, w_hi, r_hi, &mut cj, xa));
2801                i += 1;
2802                v
2803            } else {
2804                let v = (xb, sample(off_lo, w_lo, r_lo, &mut ci, xb), w_hi[j]);
2805                j += 1;
2806                v
2807            };
2808            // Defensive strict-ascension guard: the broadener's
2809            // trapezoidal quadrature and two-pointer walk require it
2810            // unconditionally, regardless of the dedup epsilon.
2811            if let Some(&last) = out_off.last()
2812                && x <= last
2813            {
2814                continue;
2815            }
2816            out_off.push(x);
2817            // Exact when `wl == wh` (identical blocks), and exactly the
2818            // endpoint values at frac → 0/1.
2819            out_w.push(wl + frac * (wh - wl));
2820        }
2821
2822        // Blended weights inherit the blocks' scale (peak-normalized by
2823        // convention); the broadener renormalizes at apply time, so no
2824        // re-normalization is done here — preserving the bitwise
2825        // identity for identical blocks unconditionally.
2826        (out_off, out_w)
2827    }
2828}
2829
2830/// Apply resolution broadening using either Gaussian or tabulated kernel.
2831///
2832/// # Errors
2833/// Returns [`ResolutionError`] if the energy grid is unsorted or array
2834/// lengths do not match.
2835pub fn apply_resolution(
2836    energies: &[f64],
2837    spectrum: &[f64],
2838    resolution: &ResolutionFunction,
2839) -> Result<Vec<f64>, ResolutionError> {
2840    match resolution {
2841        ResolutionFunction::Gaussian(params) => resolution_broaden(energies, spectrum, params),
2842        ResolutionFunction::Tabulated(tab) => tab.broaden(energies, spectrum),
2843        ResolutionFunction::IkedaCarpenter(ic) => ic.tabulated().broaden(energies, spectrum),
2844    }
2845}
2846
2847/// Apply resolution broadening assuming the energy grid is already validated
2848/// (sorted ascending, same length as spectrum).
2849///
2850/// Used by `transmission.rs` to avoid redundant O(N) sort checks when
2851/// broadening multiple isotopes on the same pre-validated energy grid.
2852pub(crate) fn apply_resolution_presorted(
2853    energies: &[f64],
2854    spectrum: &[f64],
2855    resolution: &ResolutionFunction,
2856) -> Vec<f64> {
2857    match resolution {
2858        ResolutionFunction::Gaussian(params) => {
2859            resolution_broaden_presorted(energies, spectrum, params)
2860        }
2861        ResolutionFunction::Tabulated(tab) => tab.broaden_presorted(energies, spectrum),
2862        ResolutionFunction::IkedaCarpenter(ic) => {
2863            ic.tabulated().broaden_presorted(energies, spectrum)
2864        }
2865    }
2866}
2867
2868/// Build a broadening plan for `(energies, resolution)`.
2869///
2870/// Returns `Some(plan)` for [`ResolutionFunction::Tabulated`] and
2871/// [`ResolutionFunction::IkedaCarpenter`] (which rides its synthesized tabulated
2872/// kernel) — the plan hoists the per-target TOF / kernel-interpolation / bracket
2873/// / trap-weight work that would otherwise run on every call to
2874/// [`apply_resolution`].  Returns `None` for
2875/// [`ResolutionFunction::Gaussian`] — the Gaussian path has no
2876/// meaningful pixel-invariant kernel structure to cache at this
2877/// level, so callers fall back to the per-call broadening path with
2878/// no loss.
2879///
2880/// Callers that want a single-branch API can unconditionally call
2881/// [`apply_resolution_with_plan`] passing `plan.as_ref()`; when the
2882/// plan is `None` it transparently forwards to the non-plan path and
2883/// returns byte-identical output.
2884///
2885/// # Errors
2886/// Returns [`ResolutionError::UnsortedEnergies`] if `energies` is not
2887/// non-descending — the same precondition that [`apply_resolution`]
2888/// enforces per-call.
2889pub fn build_resolution_plan(
2890    energies: &[f64],
2891    resolution: &ResolutionFunction,
2892) -> Result<Option<ResolutionPlan>, ResolutionError> {
2893    match resolution {
2894        ResolutionFunction::Gaussian(_) => {
2895            if !energies.windows(2).all(|w| w[0] <= w[1]) {
2896                return Err(ResolutionError::UnsortedEnergies);
2897            }
2898            Ok(None)
2899        }
2900        ResolutionFunction::Tabulated(tab) => tab.plan(energies).map(Some),
2901        ResolutionFunction::IkedaCarpenter(ic) => ic.tabulated().plan(energies).map(Some),
2902    }
2903}
2904
2905/// Apply resolution broadening, optionally via a pre-built
2906/// [`ResolutionPlan`].
2907///
2908/// When `plan` is `Some(p)` and `resolution` is a tabulated kernel,
2909/// `p.apply(spectrum)` runs the cached per-target broadening inner
2910/// loop — the expensive TOF / kernel-interpolation / bracket work
2911/// was already captured at plan build time.
2912///
2913/// When `plan` is `None`, or when `resolution` is Gaussian, the call
2914/// forwards to [`apply_resolution`] and is byte-identical to the
2915/// un-planned path.
2916///
2917/// # Errors
2918/// * Returns the same errors as [`apply_resolution`] on the non-plan
2919///   path.
2920/// * Returns [`ResolutionError::LengthMismatch`] if the plan was built
2921///   for a different-length grid than `energies`, or if
2922///   `energies.len() != spectrum.len()`.
2923/// * Returns [`ResolutionError::PlanGridMismatch`] if the plan was
2924///   built for a different grid of the same length — the cached
2925///   `(lo_idx, frac, weight)` entries encode brackets into the old
2926///   grid and would silently produce a wrong broadened spectrum if
2927///   applied.
2928pub fn apply_resolution_with_plan(
2929    plan: Option<&ResolutionPlan>,
2930    energies: &[f64],
2931    spectrum: &[f64],
2932    resolution: &ResolutionFunction,
2933) -> Result<Vec<f64>, ResolutionError> {
2934    if let Some(p) = plan
2935        && matches!(
2936            resolution,
2937            ResolutionFunction::Tabulated(_) | ResolutionFunction::IkedaCarpenter(_)
2938        )
2939    {
2940        validate_inputs(energies, spectrum)?;
2941        if p.len() != energies.len() {
2942            return Err(ResolutionError::LengthMismatch {
2943                energies: energies.len(),
2944                data: p.len(),
2945            });
2946        }
2947        // Grid-identity check.  A plan built for a different grid of
2948        // the same length would still pass the length check and then
2949        // gather spectrum values at brackets that belong to the old
2950        // grid — silently corrupt output.  Pointer identity is not
2951        // enough here because callers legitimately hold the plan and
2952        // the target grid in separate `Arc`s whose storage may or
2953        // may not alias; bit-exact content equality is the only
2954        // robust invariant.  The cost is one full grid scan per
2955        // broadening call (O(n), ~27 KB of f64 values for the VENUS
2956        // 3471-point grid) — orders of magnitude cheaper than the
2957        // broadening itself and cheap vs the silent-staleness
2958        // failure mode.
2959        let plan_grid = p.target_energies();
2960        for i in 0..plan_grid.len() {
2961            if plan_grid[i].to_bits() != energies[i].to_bits() {
2962                return Err(ResolutionError::PlanGridMismatch {
2963                    first_diff_index: i,
2964                });
2965            }
2966        }
2967        return Ok(p.apply(spectrum));
2968    }
2969    apply_resolution(energies, spectrum, resolution)
2970}
2971
2972/// Errors from resolution file parsing.
2973#[derive(Debug)]
2974pub enum ResolutionParseError {
2975    InvalidFormat(String),
2976    IoError(String),
2977}
2978
2979impl fmt::Display for ResolutionParseError {
2980    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2981        match self {
2982            Self::InvalidFormat(msg) => write!(f, "Invalid resolution file format: {}", msg),
2983            Self::IoError(msg) => write!(f, "I/O error: {}", msg),
2984        }
2985    }
2986}
2987
2988impl std::error::Error for ResolutionParseError {}
2989
2990/// Test-only helpers for building synthetic [`ResolutionPlan`] /
2991/// [`TabulatedResolution`] instances without going through the full
2992/// parse-from-text path.  Gated behind `#[cfg(test)]` for in-crate
2993/// tests and the `test-support` feature flag for downstream-crate
2994/// tests.  Never ships in a release build with `test-support =
2995/// false` (the default), so the raw constructors remain out of the
2996/// production API surface.
2997#[cfg(any(test, feature = "test-support"))]
2998pub mod test_support {
2999    use super::{DIVISION_FLOOR, NEAR_ZERO_FLOOR, ResolutionPlan, TabulatedResolution};
3000    use std::sync::Arc;
3001
3002    /// Build a [`ResolutionPlan`] directly from its SoA fields.
3003    ///
3004    /// The caller is responsible for maintaining the invariants that
3005    /// `plan_presorted` normally enforces (`starts.last() ==
3006    /// lo_idx.len()`, lo_idx in [0, n-2] for regular entries, etc.).
3007    /// Used by surrogate-module tests + downstream crate tests to
3008    /// construct hand-designed plans that exercise specific CSR
3009    /// patterns.
3010    pub fn plan_from_raw_parts(
3011        target_energies: Vec<f64>,
3012        starts: Vec<u32>,
3013        lo_idx: Vec<u32>,
3014        frac: Vec<f64>,
3015        weight: Vec<f64>,
3016        norm: Vec<f64>,
3017    ) -> ResolutionPlan {
3018        ResolutionPlan {
3019            target_energies,
3020            starts,
3021            lo_idx,
3022            frac,
3023            weight,
3024            norm,
3025        }
3026    }
3027
3028    /// Build a minimal [`TabulatedResolution`] with a single
3029    /// reference energy and a trivial delta-like kernel — just
3030    /// enough for tests that need an `InstrumentParams` with a
3031    /// tabulated resolution (e.g., to exercise cubature dispatch
3032    /// guards that refuse Gaussian resolution).  The broadening
3033    /// would be effectively identity if anyone ever called it, but
3034    /// typical consumers (cubature dispatch tests) never invoke the
3035    /// kernel.
3036    pub fn trivial_tabulated_resolution(flight_path_m: f64) -> TabulatedResolution {
3037        TabulatedResolution {
3038            ref_energies: Arc::new(vec![100.0]),
3039            kernels: Arc::new(vec![(vec![-1e-6, 0.0, 1e-6], vec![0.0, 1.0, 0.0])]),
3040            flight_path_m,
3041        }
3042    }
3043
3044    /// Thin shim exposing the crate-internal
3045    /// [`TabulatedResolution::broaden_presorted`] to integration
3046    /// tests living under `crates/nereids-physics/tests/`.  The
3047    /// internal method stays `pub(crate)` so the broader public
3048    /// API surface (the operator-style `apply_resolution`,
3049    /// `plan` / `apply` / `compile_to_matrix`) remains the
3050    /// recommended entry point; this shim exists solely so the
3051    /// fixture-gated bit-exact regression and microbenchmark
3052    /// tests can call the optimized two-pointer walk directly.
3053    pub fn broaden_presorted(
3054        tab: &TabulatedResolution,
3055        energies: &[f64],
3056        spectrum: &[f64],
3057    ) -> Vec<f64> {
3058        tab.broaden_presorted(energies, spectrum)
3059    }
3060
3061    /// Thin shim exposing the crate-internal
3062    /// `TabulatedResolution::interpolated_kernel` to integration
3063    /// tests.  Needed by the bit-exact equivalence oracle that
3064    /// the fixture-gated regression test runs against the
3065    /// optimized `broaden_presorted` path.
3066    pub fn interpolated_kernel(tab: &TabulatedResolution, energy: f64) -> (Vec<f64>, Vec<f64>) {
3067        tab.interpolated_kernel(energy)
3068    }
3069
3070    /// The TOF↔energy conversion factor used by
3071    /// `broaden_presorted` and its oracle.  Exposed so the
3072    /// integration-test oracle uses the exact same constant as
3073    /// the SUT, preserving bit-exact equivalence.
3074    pub const TOF_FACTOR: f64 = super::TOF_FACTOR;
3075
3076    /// Bit-exact regression oracle: binary-search piecewise-linear
3077    /// interpolation of `spectrum` onto target energy `e`.  Mirror of
3078    /// the pre-optimization in-src reference; called transitively by
3079    /// [`broaden_presorted_reference`] inside its inner convolution
3080    /// loop.
3081    ///
3082    /// **Do not "clean up" this function.** Consumers are bit-exact
3083    /// equivalence tests that pin the optimized two-pointer path in
3084    /// `TabulatedResolution::broaden_presorted` against this byte-
3085    /// identical reference.  A rewrite that shifts edge cases by even
3086    /// one bit (e.g. swapping the upper-bound binary search for
3087    /// `partition_point`, or changing the `<=` to `<` in the midpoint
3088    /// comparison) would flip the comparison and invalidate the
3089    /// regression suite.
3090    pub fn interp_spectrum(energies: &[f64], spectrum: &[f64], e: f64) -> Option<f64> {
3091        let n = energies.len();
3092        if n == 0 {
3093            return None;
3094        }
3095        if e < energies[0] || e > energies[n - 1] {
3096            return None;
3097        }
3098        let mut lo = 0;
3099        let mut hi = n - 1;
3100        while hi - lo > 1 {
3101            let mid = (lo + hi) / 2;
3102            if energies[mid] <= e {
3103                lo = mid;
3104            } else {
3105                hi = mid;
3106            }
3107        }
3108        let span = energies[hi] - energies[lo];
3109        if span.abs() < NEAR_ZERO_FLOOR {
3110            return Some(spectrum[lo]);
3111        }
3112        let frac = (e - energies[lo]) / span;
3113        Some(spectrum[lo] + frac * (spectrum[hi] - spectrum[lo]))
3114    }
3115
3116    /// Bit-exact regression oracle: pre-optimization reference
3117    /// implementation of [`TabulatedResolution::broaden_presorted`].
3118    /// Used by the in-src + integration + microbench bit-exact test
3119    /// suites that pin the optimized two-pointer path against this
3120    /// reference.
3121    ///
3122    /// Same "do not refactor" caveat as [`interp_spectrum`].  Reads
3123    /// `tab.flight_path_m()` via the public getter so the oracle stays
3124    /// callable from integration tests (the underlying field is
3125    /// private; the getter is a no-op wrapper, so byte-equivalence
3126    /// against the original in-src field access is preserved).
3127    pub fn broaden_presorted_reference(
3128        tab: &TabulatedResolution,
3129        energies: &[f64],
3130        spectrum: &[f64],
3131    ) -> Vec<f64> {
3132        let n = energies.len();
3133        if n == 0 {
3134            return vec![];
3135        }
3136
3137        let mut result = vec![0.0f64; n];
3138
3139        for i in 0..n {
3140            let e = energies[i];
3141            if e <= 0.0 {
3142                result[i] = spectrum[i];
3143                continue;
3144            }
3145
3146            let tof_center = TOF_FACTOR * tab.flight_path_m() / e.sqrt();
3147            let (offsets, weights) = tab.interpolated_kernel(e);
3148
3149            let mut sum = 0.0;
3150            let mut norm = 0.0;
3151
3152            for k in 0..offsets.len() {
3153                let dt = offsets[k];
3154                let w = weights[k];
3155                if w <= 0.0 {
3156                    continue;
3157                }
3158
3159                // Convolution gather: theory at t − dt (see
3160                // broaden_presorted; SAMMY mudr4.f90 Ud_Convolute).
3161                // Points with dt ≥ tof_center gather from past infinite
3162                // energy and are dropped, renormalizing over the
3163                // survivors (see the tail-truncation note there).
3164                let tof_prime = tof_center - dt;
3165                if tof_prime <= 0.0 {
3166                    continue;
3167                }
3168
3169                let e_prime = (TOF_FACTOR * tab.flight_path_m() / tof_prime).powi(2);
3170
3171                let s = match interp_spectrum(energies, spectrum, e_prime) {
3172                    Some(v) => v,
3173                    None => continue,
3174                };
3175
3176                let dt_width = if k > 0 && k < offsets.len() - 1 {
3177                    (offsets[k + 1] - offsets[k - 1]) * 0.5
3178                } else if k == 0 && offsets.len() > 1 {
3179                    offsets[1] - offsets[0]
3180                } else if k == offsets.len() - 1 && offsets.len() > 1 {
3181                    offsets[k] - offsets[k - 1]
3182                } else {
3183                    1.0
3184                };
3185
3186                let weight = w * dt_width.abs();
3187                sum += weight * s;
3188                norm += weight;
3189            }
3190
3191            result[i] = if norm > DIVISION_FLOOR {
3192                sum / norm
3193            } else {
3194                spectrum[i]
3195            };
3196        }
3197
3198        result
3199    }
3200}
3201
3202#[cfg(test)]
3203mod tests {
3204    use super::*;
3205    use nereids_core::constants;
3206
3207    fn kernel_centroid_std(offs: &[f64], wts: &[f64]) -> (f64, f64) {
3208        let wsum: f64 = wts.iter().sum();
3209        let c = offs.iter().zip(wts).map(|(o, w)| o * w).sum::<f64>() / wsum;
3210        let var = offs
3211            .iter()
3212            .zip(wts)
3213            .map(|(o, w)| w * (o - c).powi(2))
3214            .sum::<f64>()
3215            / wsum;
3216        (c, var.sqrt())
3217    }
3218
3219    const SQRT_PI: f64 = 1.772_453_850_905_516;
3220
3221    /// Largest relative difference from the independent erfc of `libm`.
3222    fn worst(points: impl Iterator<Item = (f64, f64, f64)>) -> (f64, f64) {
3223        points
3224            .map(|(x, ours, want)| (x, (ours - want).abs() / want.abs()))
3225            .fold((f64::NAN, 0.0), |w, p| if p.1 > w.1 { p } else { w })
3226    }
3227
3228    #[test]
3229    fn exerfc_and_erfc_match_an_independent_erfc_on_both_sides_of_zero() {
3230        let xs = (-2600..=2600).map(|i| f64::from(i) * 0.01);
3231        let (x, relative) = worst(xs.map(|x| {
3232            let want = SQRT_PI * (x * x).exp() * libm::erfc(x);
3233            (x, exerfc(x), want)
3234        }));
3235        assert!(relative < 2e-7, "exerfc({x}) is {relative:.2e} off");
3236
3237        let xs = (-2600..=2600).map(|i| f64::from(i) * 0.01);
3238        let (x, relative) = worst(xs.map(|x| (x, erfc(x), libm::erfc(x))));
3239        assert!(relative < 2e-7, "erfc({x}) is {relative:.2e} off");
3240    }
3241
3242    /// Z(A, B) = √π·exp(B² − A²)·erfc(B) with B = A + C, so
3243    /// B² − A² = 2BC − C². Below B = −5.01 the kernel is the exponential
3244    /// tail 2√π·exp(2BC − C²).
3245    #[test]
3246    fn the_combined_kernel_matches_an_independent_erfc_down_its_tail() {
3247        for c in [0.05, 0.1, 0.25, 1.0] {
3248            let bs = (-6000..=2600).map(|i| f64::from(i) * 0.01);
3249            let (b, relative) = worst(bs.map(|b| {
3250                let want = SQRT_PI * (2.0 * b * c - c * c).exp() * libm::erfc(b);
3251                (b, gauss_exp_kernel(b - c, b), want)
3252            }));
3253            assert!(
3254                relative < 2e-7,
3255                "C = {c}: Z at B = {b} is {relative:.2e} off"
3256            );
3257        }
3258    }
3259
3260    /// ∫ Z dA = √π/C: integrate by parts with u = A + C,
3261    /// ∫ e^{2Cu} erfc(u) du = e^{C²}/C.
3262    #[test]
3263    fn the_combined_kernel_keeps_its_whole_area() {
3264        for c in [0.05, 0.1, 0.25] {
3265            let step = 0.01;
3266            let low = -50.0 / c;
3267            let count = ((10.0 - low) / step) as usize;
3268            let area: f64 = (0..=count)
3269                .map(|i| {
3270                    let a = low + step * i as f64;
3271                    let end = if i == 0 || i == count { 0.5 } else { 1.0 };
3272                    end * gauss_exp_kernel(a, a + c)
3273                })
3274                .sum::<f64>()
3275                * step;
3276            let want = SQRT_PI / c;
3277            let relative = (area - want).abs() / want;
3278            assert!(
3279                relative < 1e-9,
3280                "C = {c}: area {area:.9} against √π/C = {want:.9}, {relative:.2e} off"
3281            );
3282        }
3283    }
3284
3285    #[test]
3286    fn width_corrected_preserves_centroid_scales_width_and_energy_dependence() {
3287        // asymmetric kernel straddling 0 (peak off-centre), two ref energies.
3288        let offs = vec![-2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0];
3289        let wts = vec![0.1, 0.3, 1.0, 0.8, 0.5, 0.3, 0.1];
3290        let tab = TabulatedResolution::from_kernels(
3291            vec![5.0, 50.0],
3292            vec![(offs.clone(), wts.clone()), (offs.clone(), wts.clone())],
3293            25.0,
3294        )
3295        .unwrap();
3296
3297        // Uniform 2.5x width scale (p=0): centroid fixed, std scales by s0, weights unchanged.
3298        let s0 = 2.5;
3299        let wc = tab.width_corrected(s0, 0.0, 10.0).unwrap();
3300        for (orig, scaled) in tab.kernels().iter().zip(wc.kernels()) {
3301            let (c0, std0) = kernel_centroid_std(&orig.0, &orig.1);
3302            let (c1, std1) = kernel_centroid_std(&scaled.0, &scaled.1);
3303            assert!((c0 - c1).abs() < 1e-12, "centroid moved {c0} -> {c1}");
3304            assert!(
3305                (std1 / std0 - s0).abs() < 1e-12,
3306                "width ratio {} != {s0}",
3307                std1 / std0
3308            );
3309        }
3310        assert_eq!(
3311            tab.kernels()[0].1,
3312            wc.kernels()[0].1,
3313            "weights must be unchanged"
3314        );
3315
3316        // s0=1,p=0 is an exact width-identical copy.
3317        let id = tab.width_corrected(1.0, 0.0, 10.0).unwrap();
3318        assert_eq!(id.kernels()[0].0, tab.kernels()[0].0);
3319
3320        // p<0 -> higher energy is narrower (energy-dependent width).
3321        let wc2 = tab.width_corrected(1.0, -0.5, 10.0).unwrap();
3322        let (_, std_lo) = kernel_centroid_std(&wc2.kernels()[0].0, &wc2.kernels()[0].1); // 5 eV
3323        let (_, std_hi) = kernel_centroid_std(&wc2.kernels()[1].0, &wc2.kernels()[1].1); // 50 eV
3324        assert!(
3325            std_hi < std_lo,
3326            "p<0 should narrow higher E: {std_hi} !< {std_lo}"
3327        );
3328    }
3329
3330    #[test]
3331    fn width_corrected_preserves_trapezoidal_centroid_on_nonuniform_grid() {
3332        // On a NON-uniform offset grid the trapezoidal-weighted centroid (what the
3333        // broadening integral actually integrates against) differs from the plain
3334        // centroid. The width scale must pivot about the former, else the fitted
3335        // width leaks into position. The uniform-grid test above cannot see this —
3336        // there the two centroids coincide.
3337        let offs = vec![-2.0, -1.5, 0.0, 0.5, 3.0]; // deliberately non-uniform
3338        let wts = vec![0.2, 0.6, 1.0, 0.7, 0.2];
3339        let tab =
3340            TabulatedResolution::from_kernels(vec![10.0], vec![(offs.clone(), wts.clone())], 25.0)
3341                .unwrap();
3342
3343        // Trapezoidal-weighted centroid, mirroring broaden_presorted's dt weights.
3344        let trap_centroid = |o: &[f64], w: &[f64]| -> f64 {
3345            let n = o.len();
3346            let dt = |k: usize| -> f64 {
3347                if n <= 1 {
3348                    1.0
3349                } else if k == 0 {
3350                    o[1] - o[0]
3351                } else if k == n - 1 {
3352                    o[k] - o[k - 1]
3353                } else {
3354                    (o[k + 1] - o[k - 1]) * 0.5
3355                }
3356            };
3357            let (mut num, mut den) = (0.0, 0.0);
3358            for (k, (&oi, &wi)) in o.iter().zip(w).enumerate() {
3359                let tw = wi * dt(k).abs();
3360                num += oi * tw;
3361                den += tw;
3362            }
3363            num / den
3364        };
3365        let plain_centroid = |o: &[f64], w: &[f64]| -> f64 {
3366            o.iter().zip(w).map(|(a, b)| a * b).sum::<f64>() / w.iter().sum::<f64>()
3367        };
3368
3369        let c_trap_before = trap_centroid(&offs, &wts);
3370        // Sanity: on this grid the trapezoidal and plain centroids genuinely differ,
3371        // so the test would fail under the old plain-centroid pivot.
3372        assert!(
3373            (c_trap_before - plain_centroid(&offs, &wts)).abs() > 1e-3,
3374            "test grid not non-uniform enough"
3375        );
3376
3377        let wc = tab.width_corrected(2.0, 0.0, 10.0).unwrap();
3378        let c_trap_after = trap_centroid(&wc.kernels()[0].0, &wc.kernels()[0].1);
3379        assert!(
3380            (c_trap_after - c_trap_before).abs() < 1e-12,
3381            "integrated centroid leaked under width scale: {c_trap_before} -> {c_trap_after}"
3382        );
3383    }
3384
3385    #[test]
3386    fn width_corrected_zero_weight_block_falls_back_to_zero_pivot() {
3387        // A degenerate all-zero-weight kernel has no centroid; the scale pivots
3388        // about 0 rather than dividing by a zero weight sum.
3389        let tab = TabulatedResolution::from_kernels(
3390            vec![10.0],
3391            vec![(vec![-1.0, 0.0, 2.0], vec![0.0, 0.0, 0.0])],
3392            25.0,
3393        )
3394        .unwrap();
3395        let wc = tab.width_corrected(2.0, 0.0, 10.0).unwrap();
3396        assert_eq!(wc.kernels()[0].0, vec![-2.0, 0.0, 4.0]); // scaled about 0
3397    }
3398
3399    #[test]
3400    fn width_corrected_rejects_invalid_params() {
3401        // Public API: invalid whole-configuration inputs must hard-error up front
3402        // (not silently clamp), since a non-positive s0 reverses the offset order.
3403        let tab = TabulatedResolution::from_kernels(
3404            vec![10.0],
3405            vec![(vec![-1.0, 0.0, 2.0], vec![0.1, 1.0, 0.1])],
3406            25.0,
3407        )
3408        .unwrap();
3409        for (s0, p, e_ref) in [
3410            (0.0, 0.0, 10.0),          // s0 == 0
3411            (-1.0, 0.0, 10.0),         // s0 < 0
3412            (f64::NAN, 0.0, 10.0),     // s0 non-finite
3413            (1.0, f64::NAN, 10.0),     // p non-finite
3414            (1.0, 0.0, 0.0),           // e_ref == 0
3415            (1.0, 0.0, -5.0),          // e_ref < 0
3416            (1.0, 0.0, f64::INFINITY), // e_ref non-finite
3417        ] {
3418            assert!(
3419                matches!(
3420                    tab.width_corrected(s0, p, e_ref),
3421                    Err(ResolutionError::InvalidWidthCorrection { .. })
3422                ),
3423                "expected InvalidWidthCorrection for s0={s0}, p={p}, e_ref={e_ref}"
3424            );
3425        }
3426    }
3427
3428    #[test]
3429    fn from_kernels_rejects_empty_kernel() {
3430        // An empty kernel passes the length-match check (0 == 0) but makes
3431        // broadening silently fall back to pass-through; reject it up front.
3432        let err = TabulatedResolution::from_kernels(vec![10.0], vec![(vec![], vec![])], 25.0);
3433        assert!(
3434            matches!(err, Err(ResolutionParseError::InvalidFormat(_))),
3435            "empty kernel should be rejected, got {err:?}"
3436        );
3437        // A non-empty kernel still constructs fine.
3438        assert!(
3439            TabulatedResolution::from_kernels(vec![10.0], vec![(vec![0.0], vec![1.0])], 25.0)
3440                .is_ok()
3441        );
3442        // Unsorted offsets are rejected — the broadener walks a monotonic two-
3443        // pointer bracket and derives trapezoidal widths assuming sorted offsets.
3444        let unsorted = TabulatedResolution::from_kernels(
3445            vec![10.0],
3446            vec![(vec![0.0, -1.0, 2.0], vec![0.2, 1.0, 0.2])],
3447            25.0,
3448        );
3449        assert!(
3450            matches!(unsorted, Err(ResolutionParseError::InvalidFormat(_))),
3451            "unsorted offsets should be rejected, got {unsorted:?}"
3452        );
3453        // Duplicate offsets (non-strict) are also rejected.
3454        let dup = TabulatedResolution::from_kernels(
3455            vec![10.0],
3456            vec![(vec![-1.0, 0.0, 0.0, 2.0], vec![0.1, 1.0, 1.0, 0.1])],
3457            25.0,
3458        );
3459        assert!(matches!(dup, Err(ResolutionParseError::InvalidFormat(_))));
3460    }
3461
3462    #[test]
3463    fn from_text_rejects_unsorted_offsets_within_block() {
3464        // The second energy block has an out-of-order offset pair
3465        // (1.0 followed by 0.0): the trapezoidal `dt_width` quadrature
3466        // and the two-pointer bracket walk both assume sorted offsets,
3467        // so the parser must error rather than construct a
3468        // silently-corrupt kernel (same invariant `from_kernels`
3469        // enforces).
3470        let text = "\
3471Resolution file
3472---------------
34735.0 0.0
3474-1.0 0.1
34750.0 1.0
34761.0 0.5
3477
347810.0 0.0
3479-1.0 0.1
34801.0 0.5
34810.0 1.0
3482";
3483        let err = TabulatedResolution::from_text(text, 25.0);
3484        let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3485            panic!("unsorted offsets must be rejected, got {err:?}");
3486        };
3487        assert!(
3488            msg.contains("Kernel 1") && msg.contains("E = 10 eV"),
3489            "error must name the offending block and reference energy: {msg}"
3490        );
3491        // The same file with sorted offsets parses fine.
3492        let sorted = "\
3493Resolution file
3494---------------
34955.0 0.0
3496-1.0 0.1
34970.0 1.0
34981.0 0.5
3499
350010.0 0.0
3501-1.0 0.1
35020.0 1.0
35031.0 0.5
3504";
3505        assert!(TabulatedResolution::from_text(sorted, 25.0).is_ok());
3506    }
3507
3508    /// The remaining `from_kernels` invariants hold for parsed files too:
3509    /// empty energy blocks (silent pass-through at broaden time), non-finite
3510    /// kernel values (NaN norm bypasses the division guard), and non-finite
3511    /// reference energies (NaN compares false, slipping through the
3512    /// ascending check into the bracketing binary search) must all error.
3513    #[test]
3514    fn from_text_rejects_empty_block_and_non_finite_values() {
3515        // Empty block: header line for E=10 with no data lines.
3516        let empty_block = "\
3517Resolution file
3518---------------
35195.0 0.0
3520-1.0 0.1
35210.0 1.0
3522
352310.0 0.0
3524
3525";
3526        let err = TabulatedResolution::from_text(empty_block, 25.0);
3527        let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3528            panic!("empty energy block must be rejected, got {err:?}");
3529        };
3530        assert!(
3531            msg.contains("E = 10 eV") && msg.contains("no (offset, weight) points"),
3532            "error must name the empty block: {msg}"
3533        );
3534
3535        // Non-finite kernel weight.
3536        let nan_weight = "\
3537Resolution file
3538---------------
35395.0 0.0
3540-1.0 0.1
35410.0 NaN
35421.0 0.5
3543";
3544        let err = TabulatedResolution::from_text(nan_weight, 25.0);
3545        let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3546            panic!("non-finite weight must be rejected, got {err:?}");
3547        };
3548        assert!(
3549            msg.contains("non-finite"),
3550            "error must name the non-finite value class: {msg}"
3551        );
3552
3553        // Non-finite kernel OFFSET: must report the finiteness problem,
3554        // not the misleading "must be strictly ascending" (a NaN offset
3555        // fails the ascending comparison too — finiteness is checked
3556        // first so the message stays accurate).
3557        let nan_offset = "\
3558Resolution file
3559---------------
35605.0 0.0
3561-1.0 0.1
3562NaN 1.0
35631.0 0.5
3564";
3565        let err = TabulatedResolution::from_text(nan_offset, 25.0);
3566        let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3567            panic!("non-finite offset must be rejected, got {err:?}");
3568        };
3569        assert!(
3570            msg.contains("non-finite") && !msg.contains("ascending"),
3571            "NaN offset must report finiteness, not sortedness: {msg}"
3572        );
3573
3574        // Non-finite reference energy.
3575        let nan_energy = "\
3576Resolution file
3577---------------
3578NaN 0.0
3579-1.0 0.1
35800.0 1.0
3581";
3582        let err = TabulatedResolution::from_text(nan_energy, 25.0);
3583        let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3584            panic!("non-finite reference energy must be rejected, got {err:?}");
3585        };
3586        assert!(
3587            msg.contains("finite"),
3588            "error must name the finiteness requirement: {msg}"
3589        );
3590    }
3591
3592    /// Non-positive reference energies must be rejected by BOTH
3593    /// constructors: the TOF map needs E > 0, and the width
3594    /// interpolation takes ln(E_ref) — a zero/negative reference would
3595    /// turn every blended weight into NaN, which bypasses the
3596    /// broadener's norm guard and silently disables broadening (a
3597    /// stray `0.0 0.0` line after a blank line in a VENUS/FTS file
3598    /// parses as an energy-block header).
3599    #[test]
3600    fn constructors_reject_non_positive_reference_energies() {
3601        let zero_energy = "\
3602Resolution file
3603---------------
36040.0 0.0
3605-1.0 0.1
36060.0 1.0
36071.0 0.5
3608
360910.0 0.0
3610-1.0 0.1
36110.0 1.0
36121.0 0.5
3613";
3614        let err = TabulatedResolution::from_text(zero_energy, 25.0);
3615        let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3616            panic!("zero reference energy must be rejected, got {err:?}");
3617        };
3618        assert!(
3619            msg.contains("positive"),
3620            "error must name the positivity requirement: {msg}"
3621        );
3622
3623        let err = TabulatedResolution::from_kernels(
3624            vec![-10.0, 10.0],
3625            vec![
3626                (vec![-1.0, 0.0, 1.0], vec![0.1, 1.0, 0.1]),
3627                (vec![-1.0, 0.0, 1.0], vec![0.1, 1.0, 0.1]),
3628            ],
3629            25.0,
3630        );
3631        let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3632            panic!("negative reference energy must be rejected, got {err:?}");
3633        };
3634        assert!(
3635            msg.contains("positive"),
3636            "error must name the positivity requirement: {msg}"
3637        );
3638    }
3639
3640    /// Negative kernel weights (no physical meaning; the broadener
3641    /// skips them while the width moments would otherwise fold them
3642    /// in) must be rejected by BOTH constructors.
3643    #[test]
3644    fn constructors_reject_negative_weights() {
3645        let neg_weight = "\
3646Resolution file
3647---------------
36485.0 0.0
3649-1.0 0.1
36500.0 1.0
36511.0 -0.2
3652";
3653        let err = TabulatedResolution::from_text(neg_weight, 25.0);
3654        let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3655            panic!("negative weight must be rejected, got {err:?}");
3656        };
3657        assert!(
3658            msg.contains("negative weights"),
3659            "error must name the negative-weight problem: {msg}"
3660        );
3661
3662        let err = TabulatedResolution::from_kernels(
3663            vec![10.0],
3664            vec![(vec![-1.0, 0.0, 1.0], vec![-1.0, 0.2, -1.0])],
3665            25.0,
3666        );
3667        let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3668            panic!("negative weights must be rejected, got {err:?}");
3669        };
3670        assert!(
3671            msg.contains("negative weights"),
3672            "error must name the negative-weight problem: {msg}"
3673        );
3674    }
3675
3676    #[test]
3677    fn interpolated_kernel_blend_stays_ascending_and_mode_anchored() {
3678        // Two equal-length kernels with their peak at offset 0 — a loaded
3679        // UDR file's anchoring — through the width-normalized shape blend
3680        // that every between-reference energy takes. The blend must stay
3681        // strictly ascending (the sorted invariant the broadener relies on)
3682        // and leave whatever sits at 0 there.
3683        let off_lo = vec![-1.0, -0.4, 0.0, 0.6, 1.5, 3.0];
3684        let off_hi = vec![-0.5, -0.2, 0.0, 0.3, 0.8, 1.6]; // narrower, same length
3685        let wts = vec![0.1, 0.5, 1.0, 0.6, 0.3, 0.1]; // mode at index 2 (offset 0)
3686        let tab = TabulatedResolution::from_kernels(
3687            vec![5.0, 50.0],
3688            vec![(off_lo, wts.clone()), (off_hi, wts.clone())],
3689            25.0,
3690        )
3691        .unwrap();
3692        let (blended, weights) = tab.interpolated_kernel(15.0); // between 5 and 50 eV
3693        assert!(
3694            blended.windows(2).all(|w| w[0] < w[1]),
3695            "blended offsets not strictly ascending: {blended:?}"
3696        );
3697        let kmax = (0..weights.len())
3698            .max_by(|&a, &b| weights[a].total_cmp(&weights[b]))
3699            .unwrap();
3700        assert!(
3701            blended[kmax].abs() < 1e-9,
3702            "mode not anchored at offset 0: {}",
3703            blended[kmax]
3704        );
3705    }
3706
3707    /// For a self-similar family (each block a width-scaled copy of one
3708    /// asymmetric template), the width-normalized shapes agree, so the
3709    /// blended kernel's trapezoidal width must equal the geometric
3710    /// interpolation `σ_lo·(σ_hi/σ_lo)^frac` — near-exactly (the scaled
3711    /// grids coincide point-for-point in z, so the merge degenerates to
3712    /// the template shape at the target width). An interior reference
3713    /// energy must return that block bitwise (exact-hit arm).
3714    ///
3715    /// SCOPE NOTE: this measures the blend with the implementation's
3716    /// own `trapezoidal_moments` and target formula, so it pins the
3717    /// merge machinery against its coded target (plus the chord
3718    /// separation below), not the physical width law independently —
3719    /// that independent, end-to-end anchor lives in
3720    /// `tests/kernel_width_interpolation.rs` and the pytest port,
3721    /// which measure the APPLIED broadening of parsed kernels against
3722    /// the analytic power law.
3723    #[test]
3724    fn interpolated_kernel_width_follows_power_law_for_self_similar_blocks() {
3725        let template_off = [-1.0, -0.4, 0.0, 0.8, 2.0, 4.0];
3726        let template_w = vec![0.05, 0.5, 1.0, 0.6, 0.2, 0.02];
3727        // σ ∝ E^{-1/2} scaling across refs 10 / 100 / 1000 eV.
3728        let scale = |e: f64| (e / 10.0f64).powf(-0.5);
3729        let block = |e: f64| -> (Vec<f64>, Vec<f64>) {
3730            (
3731                template_off.iter().map(|&o| o * scale(e)).collect(),
3732                template_w.clone(),
3733            )
3734        };
3735        let tab = TabulatedResolution::from_kernels(
3736            vec![10.0, 100.0, 1000.0],
3737            vec![block(10.0), block(100.0), block(1000.0)],
3738            25.0,
3739        )
3740        .unwrap();
3741
3742        // Interior exact hit: the middle reference comes back bitwise.
3743        let (off_ref, w_ref) = test_support::interpolated_kernel(&tab, 100.0);
3744        let (exp_off, exp_w) = block(100.0);
3745        assert_eq!(off_ref.len(), exp_off.len());
3746        for (a, b) in off_ref.iter().zip(&exp_off) {
3747            assert_eq!(
3748                a.to_bits(),
3749                b.to_bits(),
3750                "exact-hit offsets must be bitwise"
3751            );
3752        }
3753        for (a, b) in w_ref.iter().zip(&exp_w) {
3754            assert_eq!(
3755                a.to_bits(),
3756                b.to_bits(),
3757                "exact-hit weights must be bitwise"
3758            );
3759        }
3760
3761        // Between references: measured width equals the geometric law.
3762        let (off_lo, w_lo) = block(10.0);
3763        let (off_hi, w_hi) = block(100.0);
3764        let (_, s_lo) = trapezoidal_moments(&off_lo, &w_lo);
3765        let (_, s_hi) = trapezoidal_moments(&off_hi, &w_hi);
3766        for e in [16.0f64, 25.0, 40.0, 70.0] {
3767            let frac = (e.ln() - 10.0f64.ln()) / (100.0f64.ln() - 10.0f64.ln());
3768            let expected = s_lo * (s_hi / s_lo).powf(frac);
3769            let (offs, ws) = test_support::interpolated_kernel(&tab, e);
3770            let (_, got) = trapezoidal_moments(&offs, &ws);
3771            assert!(
3772                (got - expected).abs() / expected < 1e-9,
3773                "blended width at {e} eV: got {got}, expected {expected} \
3774                 (the pre-fix arithmetic chord gave {})",
3775                s_lo + frac * (s_hi - s_lo)
3776            );
3777            // Non-vacuity: the removed chord error is resolvable at
3778            // this tolerance (σ_hi/σ_lo = 10^{-1/2} → several % apart).
3779            assert!(
3780                (s_lo + frac * (s_hi - s_lo) - expected).abs() / expected > 1e-2,
3781                "fixture must separate chord from geometric law at {e} eV"
3782            );
3783        }
3784    }
3785
3786    /// Identical bracketing blocks must come back bitwise-identical
3787    /// between the references (scale ratios exactly 1.0; the blend form
3788    /// `a + frac·(b − a)` is exact when a == b).
3789    #[test]
3790    fn interpolated_kernel_is_bitwise_identity_for_identical_blocks() {
3791        let off = vec![-1.0, -0.4, 0.0, 0.8, 2.0, 4.0];
3792        let w = vec![0.05, 0.5, 1.0, 0.6, 0.2, 0.02];
3793        let tab = TabulatedResolution::from_kernels(
3794            vec![5.0, 500.0],
3795            vec![(off.clone(), w.clone()), (off.clone(), w.clone())],
3796            25.0,
3797        )
3798        .unwrap();
3799        let (offs, ws) = test_support::interpolated_kernel(&tab, 42.0);
3800        assert_eq!(offs.len(), off.len());
3801        for (a, b) in offs.iter().zip(&off) {
3802            assert_eq!(a.to_bits(), b.to_bits(), "identity offsets must be bitwise");
3803        }
3804        for (a, b) in ws.iter().zip(&w) {
3805            assert_eq!(a.to_bits(), b.to_bits(), "identity weights must be bitwise");
3806        }
3807    }
3808
3809    /// A NaN target energy (unreachable through validated public paths,
3810    /// but defended anyway) must yield a well-formed reference clone —
3811    /// never NaN offsets that break the broadener's invariants.
3812    #[test]
3813    fn interpolated_kernel_nan_energy_clamps_to_lowest_reference() {
3814        let off = vec![-1.0, 0.0, 2.0];
3815        let w = vec![0.3, 1.0, 0.2];
3816        let tab = TabulatedResolution::from_kernels(
3817            vec![10.0, 1000.0],
3818            vec![(off.clone(), w.clone()), (off.clone(), w.clone())],
3819            25.0,
3820        )
3821        .unwrap();
3822        let (offs, ws) = test_support::interpolated_kernel(&tab, f64::NAN);
3823        assert_eq!(offs, off);
3824        assert_eq!(ws, w);
3825    }
3826
3827    /// Degenerate blocks (single-point → σ = 0) cannot be width-scaled;
3828    /// the nearer reference is cloned instead.
3829    #[test]
3830    fn interpolated_kernel_degenerate_blocks_fall_back_to_nearest() {
3831        let tab = TabulatedResolution::from_kernels(
3832            vec![10.0, 1000.0],
3833            vec![(vec![0.0], vec![1.0]), (vec![0.5], vec![1.0])],
3834            25.0,
3835        )
3836        .unwrap();
3837        // frac < 0.5 → lower block; frac > 0.5 → upper block.
3838        let (lo_off, _) = test_support::interpolated_kernel(&tab, 15.0);
3839        assert_eq!(lo_off, vec![0.0]);
3840        let (hi_off, _) = test_support::interpolated_kernel(&tab, 700.0);
3841        assert_eq!(hi_off, vec![0.5]);
3842    }
3843
3844    // ── Smoke tests for the test_support oracles (`interp_spectrum` +
3845    //    `broaden_presorted_reference`).  The 7+ bit-exact tests below
3846    //    exercise the math thoroughly; these smoke tests just pin the
3847    //    boundary-condition return-shape behavior of the oracles so a
3848    //    future refactor that breaks empty-input or out-of-range
3849    //    handling fails loudly rather than only via bit-exact diffs.
3850
3851    #[test]
3852    fn test_support_interp_spectrum_empty_returns_none() {
3853        assert_eq!(test_support::interp_spectrum(&[], &[], 1.0), None);
3854    }
3855
3856    #[test]
3857    fn test_support_interp_spectrum_out_of_range_returns_none() {
3858        let energies = [1.0, 2.0, 3.0];
3859        let spectrum = [10.0, 20.0, 30.0];
3860        assert_eq!(
3861            test_support::interp_spectrum(&energies, &spectrum, 0.5),
3862            None
3863        );
3864        assert_eq!(
3865            test_support::interp_spectrum(&energies, &spectrum, 3.5),
3866            None
3867        );
3868    }
3869
3870    #[test]
3871    fn test_support_broaden_presorted_reference_empty_returns_empty() {
3872        let tab = test_support::trivial_tabulated_resolution(25.0);
3873        let out = test_support::broaden_presorted_reference(&tab, &[], &[]);
3874        assert!(out.is_empty());
3875    }
3876
3877    #[test]
3878    fn test_tof_factor_consistency() {
3879        // Verify our TOF_FACTOR matches the constants module.
3880        let e = 10.0; // eV
3881        let l = 25.0; // meters
3882        let tof_constants = constants::energy_to_tof(e, l);
3883        let tof_ours = TOF_FACTOR * l / e.sqrt();
3884        let rel_diff = (tof_constants - tof_ours).abs() / tof_constants;
3885        assert!(
3886            rel_diff < 1e-10,
3887            "TOF mismatch: constants={}, ours={}, diff={:.4}%",
3888            tof_constants,
3889            tof_ours,
3890            rel_diff * 100.0
3891        );
3892    }
3893
3894    #[test]
3895    fn test_resolution_width_scaling() {
3896        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
3897
3898        // Resolution width should increase with energy.
3899        let w1 = params.gaussian_width(1.0);
3900        let w10 = params.gaussian_width(10.0);
3901        let w100 = params.gaussian_width(100.0);
3902
3903        assert!(w10 > w1, "Width should increase with energy");
3904        assert!(w100 > w10, "Width should increase with energy");
3905
3906        // At low energies, timing dominates: ΔE ∝ E^(3/2)
3907        // At high energies, path dominates: ΔE ∝ E
3908        // The ratio ΔE(10)/ΔE(1) should be between 10 and 31.6 (= 10^1.5)
3909        let ratio = w10 / w1;
3910        assert!(
3911            ratio > 5.0 && ratio < 40.0,
3912            "Width ratio = {}, expected between 10 and 31.6",
3913            ratio
3914        );
3915    }
3916
3917    #[test]
3918    fn test_zero_width_passthrough() {
3919        // If resolution parameters are zero, output should equal input.
3920        let energies = vec![1.0, 2.0, 3.0, 4.0, 5.0];
3921        let xs = vec![10.0, 20.0, 30.0, 20.0, 10.0];
3922        let params = ResolutionParams::new(25.0, 0.0, 0.0, 0.0).unwrap();
3923        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
3924        assert_eq!(broadened, xs);
3925    }
3926
3927    #[test]
3928    fn test_broadening_reduces_peak() {
3929        // Resolution broadening should reduce peak heights and fill valleys.
3930        let n = 1001;
3931        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.01).collect();
3932        let center = 10.0;
3933        let gamma: f64 = 0.1; // Resonance width
3934        let xs: Vec<f64> = energies
3935            .iter()
3936            .map(|&e| {
3937                let de = e - center;
3938                1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
3939            })
3940            .collect();
3941
3942        let params = ResolutionParams::new(25.0, 5.0, 0.01, 0.0).unwrap();
3943        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
3944
3945        let orig_peak = xs.iter().cloned().fold(0.0_f64, f64::max);
3946        let broad_peak = broadened.iter().cloned().fold(0.0_f64, f64::max);
3947
3948        assert!(
3949            broad_peak < orig_peak,
3950            "Broadened peak ({}) should be < original ({})",
3951            broad_peak,
3952            orig_peak
3953        );
3954        assert!(
3955            broad_peak > 1.0,
3956            "Broadened peak ({}) should still be substantial",
3957            broad_peak
3958        );
3959    }
3960
3961    #[test]
3962    fn test_broadening_conserves_area() {
3963        // Resolution broadening should approximately conserve the area
3964        // under the cross-section curve.
3965        let n = 2001;
3966        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
3967        let center = 10.0;
3968        let gamma: f64 = 0.5;
3969        let xs: Vec<f64> = energies
3970            .iter()
3971            .map(|&e| {
3972                let de = e - center;
3973                1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
3974            })
3975            .collect();
3976
3977        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
3978        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
3979
3980        // Trapezoidal area
3981        let area_orig: f64 = (0..n - 1)
3982            .map(|i| 0.5 * (xs[i] + xs[i + 1]) * (energies[i + 1] - energies[i]))
3983            .sum();
3984        let area_broad: f64 = (0..n - 1)
3985            .map(|i| 0.5 * (broadened[i] + broadened[i + 1]) * (energies[i + 1] - energies[i]))
3986            .sum();
3987
3988        let rel_diff = (area_orig - area_broad).abs() / area_orig;
3989        assert!(
3990            rel_diff < 0.02,
3991            "Area not conserved: orig={:.2}, broad={:.2}, rel_diff={:.4}",
3992            area_orig,
3993            area_broad,
3994            rel_diff
3995        );
3996    }
3997
3998    #[test]
3999    fn test_gaussian_broadening_analytical() {
4000        // Broadening a Gaussian with a Gaussian should give a wider Gaussian.
4001        //
4002        // Input:  exp(-x²/(2σ₁²)) with σ₁ = 0.5 eV (standard Gaussian form)
4003        // Kernel: exp(-x²/W²) with W = 0.3 eV → std dev σ₂ = W/√2 = 0.2121 eV
4004        // Output: Gaussian with σ_out = √(σ₁² + σ₂²) = √(0.25 + 0.045) = 0.543 eV
4005        //
4006        // Note: kernel width varies slightly with energy (σ_E ∝ E for the
4007        // path-length contribution), so we allow ~5% tolerance.
4008        let n = 2001;
4009        let center = 10.0;
4010        let sigma_input = 0.5; // eV (standard deviation)
4011        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
4012        let xs: Vec<f64> = energies
4013            .iter()
4014            .map(|&e| {
4015                let de = e - center;
4016                1000.0 * (-de * de / (2.0 * sigma_input * sigma_input)).exp()
4017            })
4018            .collect();
4019
4020        // Set delta_l such that W = gaussian_width(E=10) ≈ 0.3 eV.
4021        // W = 2·ΔL·E/L, so ΔL = W·L/(2E) = 0.3×25/(20) = 0.375 m
4022        let w_kernel = 0.3; // Kernel parameter W (exp(-x²/W²))
4023        let params =
4024            ResolutionParams::new(25.0, 0.0, w_kernel * 25.0 / (2.0 * center), 0.0).unwrap();
4025
4026        // Verify kernel W at center energy
4027        let w_at_center = params.gaussian_width(center);
4028        assert!(
4029            (w_at_center - w_kernel).abs() / w_kernel < 0.01,
4030            "Kernel W at center: {}, expected {}",
4031            w_at_center,
4032            w_kernel
4033        );
4034
4035        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
4036
4037        // Kernel std dev = W/√2
4038        let sigma_kernel = w_kernel / 2.0_f64.sqrt();
4039        let sigma_expected = (sigma_input * sigma_input + sigma_kernel * sigma_kernel).sqrt();
4040        let fwhm_expected = 2.0 * (2.0_f64.ln() * 2.0).sqrt() * sigma_expected;
4041
4042        // Measure FWHM from the broadened output
4043        let peak_idx = broadened
4044            .iter()
4045            .enumerate()
4046            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
4047            .unwrap()
4048            .0;
4049        let peak_val = broadened[peak_idx];
4050        let half_max = peak_val / 2.0;
4051
4052        let mut left_hm = energies[0];
4053        for i in (0..peak_idx).rev() {
4054            if broadened[i] < half_max {
4055                let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
4056                left_hm = energies[i] + t * (energies[i + 1] - energies[i]);
4057                break;
4058            }
4059        }
4060        let mut right_hm = energies[n - 1];
4061        for i in peak_idx..n - 1 {
4062            if broadened[i + 1] < half_max {
4063                let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
4064                right_hm = energies[i] + t * (energies[i + 1] - energies[i]);
4065                break;
4066            }
4067        }
4068
4069        let fwhm_measured = right_hm - left_hm;
4070        let rel_err = (fwhm_measured - fwhm_expected).abs() / fwhm_expected;
4071
4072        assert!(
4073            rel_err < 0.05,
4074            "FWHM: measured={:.4}, expected={:.4}, rel_err={:.2}%",
4075            fwhm_measured,
4076            fwhm_expected,
4077            rel_err * 100.0
4078        );
4079    }
4080
4081    #[test]
4082    fn test_venus_typical_resolution() {
4083        // Verify resolution width for typical VENUS parameters.
4084        // VENUS: L ≈ 25 m, Δt ≈ 10 μs (pulsed source), ΔL ≈ 0.01 m
4085        let params = ResolutionParams::new(25.0, 10.0, 0.01, 0.0).unwrap();
4086
4087        // At 1 eV: ΔE/E should be small (good resolution)
4088        let de_1 = params.gaussian_width(1.0);
4089        let de_over_e_1 = de_1 / 1.0;
4090        assert!(
4091            de_over_e_1 < 0.05,
4092            "ΔE/E at 1 eV = {:.4}, should be < 5%",
4093            de_over_e_1
4094        );
4095
4096        // At 100 eV: resolution degrades
4097        let de_100 = params.gaussian_width(100.0);
4098        let de_over_e_100 = de_100 / 100.0;
4099        assert!(
4100            de_over_e_100 > de_over_e_1,
4101            "Resolution should degrade at higher energies"
4102        );
4103    }
4104
4105    #[test]
4106    fn test_unsorted_energies_returns_error() {
4107        let energies = vec![1.0, 3.0, 2.0, 4.0]; // not sorted
4108        let xs = vec![10.0, 30.0, 20.0, 40.0];
4109        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
4110        let result = resolution_broaden(&energies, &xs, &params);
4111        assert!(result.is_err());
4112        assert!(matches!(
4113            result.unwrap_err(),
4114            ResolutionError::UnsortedEnergies
4115        ));
4116    }
4117
4118    #[test]
4119    fn test_length_mismatch_returns_error() {
4120        let energies = vec![1.0, 2.0, 3.0];
4121        let xs = vec![10.0, 20.0]; // wrong length
4122        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
4123        let result = resolution_broaden(&energies, &xs, &params);
4124        assert!(result.is_err());
4125        assert!(matches!(
4126            result.unwrap_err(),
4127            ResolutionError::LengthMismatch {
4128                energies: 3,
4129                data: 2
4130            }
4131        ));
4132    }
4133
4134    // --- ResolutionParams validation tests ---
4135
4136    #[test]
4137    fn test_resolution_params_valid() {
4138        let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
4139        assert!((p.flight_path_m() - 25.0).abs() < 1e-15);
4140        assert!((p.delta_t_us() - 1.0).abs() < 1e-15);
4141        assert!((p.delta_l_m() - 0.01).abs() < 1e-15);
4142    }
4143
4144    #[test]
4145    fn test_resolution_params_rejects_zero_flight_path() {
4146        let err = ResolutionParams::new(0.0, 1.0, 0.01, 0.0).unwrap_err();
4147        assert_eq!(err, ResolutionParamsError::InvalidFlightPath(0.0));
4148    }
4149
4150    #[test]
4151    fn test_resolution_params_rejects_negative_flight_path() {
4152        let err = ResolutionParams::new(-1.0, 1.0, 0.01, 0.0).unwrap_err();
4153        assert_eq!(err, ResolutionParamsError::InvalidFlightPath(-1.0));
4154    }
4155
4156    #[test]
4157    fn test_resolution_params_rejects_nan_flight_path() {
4158        let err = ResolutionParams::new(f64::NAN, 1.0, 0.01, 0.0).unwrap_err();
4159        assert!(matches!(err, ResolutionParamsError::InvalidFlightPath(_)));
4160    }
4161
4162    #[test]
4163    fn test_resolution_params_rejects_negative_delta_t() {
4164        let err = ResolutionParams::new(25.0, -1.0, 0.01, 0.0).unwrap_err();
4165        assert_eq!(err, ResolutionParamsError::InvalidDeltaT(-1.0));
4166    }
4167
4168    #[test]
4169    fn test_resolution_params_rejects_nan_delta_t() {
4170        let err = ResolutionParams::new(25.0, f64::NAN, 0.01, 0.0).unwrap_err();
4171        assert!(matches!(err, ResolutionParamsError::InvalidDeltaT(_)));
4172    }
4173
4174    #[test]
4175    fn test_resolution_params_rejects_negative_delta_l() {
4176        let err = ResolutionParams::new(25.0, 1.0, -0.01, 0.0).unwrap_err();
4177        assert_eq!(err, ResolutionParamsError::InvalidDeltaL(-0.01));
4178    }
4179
4180    #[test]
4181    fn test_resolution_params_rejects_inf_delta_l() {
4182        let err = ResolutionParams::new(25.0, 1.0, f64::INFINITY, 0.0).unwrap_err();
4183        assert!(matches!(err, ResolutionParamsError::InvalidDeltaL(_)));
4184    }
4185
4186    #[test]
4187    fn test_resolution_params_rejects_negative_delta_e() {
4188        let err = ResolutionParams::new(25.0, 1.0, 0.01, -0.05).unwrap_err();
4189        assert_eq!(err, ResolutionParamsError::InvalidDeltaE(-0.05));
4190    }
4191
4192    #[test]
4193    fn test_resolution_params_rejects_nan_delta_e() {
4194        let err = ResolutionParams::new(25.0, 1.0, 0.01, f64::NAN).unwrap_err();
4195        assert!(matches!(err, ResolutionParamsError::InvalidDeltaE(_)));
4196    }
4197
4198    #[test]
4199    fn test_resolution_params_accepts_zero_delta_e() {
4200        let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
4201        assert!((p.delta_e_us() - 0.0).abs() < 1e-15);
4202        assert!(!p.has_exponential_tail());
4203    }
4204
4205    // ─── broaden_presorted bit-exact equivalence harness ─────────────────────
4206    //
4207    // The optimized broaden_presorted uses a two-pointer walk instead of
4208    // binary search inside the inner convolution loop.  These tests pin
4209    // the math: the same formula, in the same order, must yield bit-exact
4210    // output against a canonical reference implementation that preserves
4211    // the pre-optimization code path.
4212
4213    // The `interp_spectrum` + `broaden_presorted_reference` oracles
4214    // were promoted to `test_support` so the integration tests
4215    // (`tests/venus_usr_resolution{,_microbench}.rs`) share the same
4216    // byte-identical reference.  Imported below.
4217    use super::test_support::broaden_presorted_reference;
4218
4219    /// Synthetic TabulatedResolution with 3 reference energies and a
4220    /// triangular kernel of varying widths.  Deterministic, no I/O.
4221    fn synthetic_tab_resolution() -> TabulatedResolution {
4222        fn triangle(width_us: f64, n: usize) -> (Vec<f64>, Vec<f64>) {
4223            let half = width_us;
4224            let dt_step = 2.0 * half / (n - 1) as f64;
4225            let offsets: Vec<f64> = (0..n).map(|i| -half + i as f64 * dt_step).collect();
4226            let weights: Vec<f64> = offsets
4227                .iter()
4228                .map(|&dt| (1.0 - dt.abs() / half).max(0.0))
4229                .collect();
4230            (offsets, weights)
4231        }
4232        TabulatedResolution {
4233            ref_energies: Arc::new(vec![5.0, 50.0, 500.0]),
4234            kernels: Arc::new(vec![
4235                triangle(0.5, 31),
4236                triangle(1.0, 41),
4237                triangle(2.0, 51),
4238            ]),
4239            flight_path_m: 25.0,
4240        }
4241    }
4242
4243    fn assert_bit_exact(reference: &[f64], actual: &[f64], label: &str) {
4244        assert_eq!(reference.len(), actual.len(), "{label}: length mismatch");
4245        for (i, (&a, &b)) in reference.iter().zip(actual.iter()).enumerate() {
4246            assert_eq!(
4247                a.to_bits(),
4248                b.to_bits(),
4249                "{label}: element {i} mismatch: reference={a:.17e} actual={b:.17e}"
4250            );
4251        }
4252    }
4253
4254    #[test]
4255    fn test_broaden_presorted_bit_exact_synthetic_uniform() {
4256        let tab = synthetic_tab_resolution();
4257        // Uniform log-spaced grid typical of VENUS analysis.
4258        let energies: Vec<f64> = (0..401).map(|i| 7.0 + i as f64 * 0.4825).collect();
4259        // Triangular dip + smooth background (resonance-like spectrum).
4260        let spectrum: Vec<f64> = energies
4261            .iter()
4262            .enumerate()
4263            .map(|(i, &e)| 0.9 - 0.7 * (-((e - 50.0).powi(2) / 4.0)).exp() + 0.001 * (i as f64))
4264            .collect();
4265
4266        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4267        let actual = tab.broaden_presorted(&energies, &spectrum);
4268        assert_bit_exact(&reference, &actual, "synthetic_uniform");
4269    }
4270
4271    #[test]
4272    fn test_broaden_presorted_bit_exact_synthetic_nonuniform() {
4273        let tab = synthetic_tab_resolution();
4274        // Non-uniform: denser near 6.674 eV (resonance-like), sparser far away.
4275        let energies: Vec<f64> = {
4276            let mut e = Vec::new();
4277            for i in 0..200 {
4278                e.push(5.0 + (i as f64) * 0.05);
4279            }
4280            for i in 0..100 {
4281                e.push(15.0 + (i as f64) * 0.5);
4282            }
4283            for i in 0..50 {
4284                e.push(65.0 + (i as f64) * 2.0);
4285            }
4286            e
4287        };
4288        let spectrum: Vec<f64> = energies
4289            .iter()
4290            .map(|&e| 1.0 - 0.5 * (-((e - 6.674).powi(2) / 0.1)).exp())
4291            .collect();
4292
4293        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4294        let actual = tab.broaden_presorted(&energies, &spectrum);
4295        assert_bit_exact(&reference, &actual, "synthetic_nonuniform");
4296    }
4297
4298    #[test]
4299    fn test_broaden_presorted_bit_exact_constant_spectrum() {
4300        // Constant spectrum must pass through unchanged (within trapezoid
4301        // normalization) — preserves integral exactly.
4302        let tab = synthetic_tab_resolution();
4303        let energies: Vec<f64> = (0..501).map(|i| 1.0 + i as f64 * 0.5).collect();
4304        let spectrum = vec![0.42f64; energies.len()];
4305
4306        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4307        let actual = tab.broaden_presorted(&energies, &spectrum);
4308        assert_bit_exact(&reference, &actual, "constant_spectrum");
4309    }
4310
4311    #[test]
4312    fn test_broaden_presorted_bit_exact_short_grid() {
4313        // Edge case: 2-point grid.  Exercises the smallest grid that has
4314        // a valid (lo, hi) bracket — tests bracket_hi bounds handling.
4315        let tab = synthetic_tab_resolution();
4316        let energies = vec![10.0, 12.0];
4317        let spectrum = vec![0.5, 0.8];
4318
4319        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4320        let actual = tab.broaden_presorted(&energies, &spectrum);
4321        assert_bit_exact(&reference, &actual, "short_grid");
4322    }
4323
4324    #[test]
4325    fn test_broaden_presorted_bit_exact_single_point_grid() {
4326        // Edge case: 1-point grid.  Exercises the n == 1 early-return
4327        // pass-through guard that the optimized path adds (no bracket
4328        // available for interpolation).
4329        let tab = synthetic_tab_resolution();
4330        let energies = vec![10.0];
4331        let spectrum = vec![0.5];
4332
4333        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4334        let actual = tab.broaden_presorted(&energies, &spectrum);
4335        assert_bit_exact(&reference, &actual, "single_point_grid");
4336    }
4337
4338    #[test]
4339    fn test_broaden_presorted_bit_exact_exact_equality_target() {
4340        // Regression: exercise the tie-break case where `e_prime` lands
4341        // exactly on a grid point.  The kernel has a point at dt=0, so
4342        // `e_prime == energies[i]` exactly at the center kernel offset
4343        // for every target `i`.  The optimized path must match the
4344        // reference's upper-bound binary-search semantics bit-exactly.
4345        let tab = synthetic_tab_resolution();
4346        // Irregular-spacing grid so the spectrum interp at the equality
4347        // point isn't trivially reducible to the input value.
4348        let mut energies: Vec<f64> = Vec::new();
4349        let mut e = 3.0f64;
4350        for k in 0..800 {
4351            energies.push(e);
4352            e += 0.05 + 0.01 * (k as f64).sin();
4353        }
4354        // Spectrum with large local gradient so `a + (b - a)` vs `b`
4355        // would diverge at 1 ULP if the tie-break were wrong.
4356        let spectrum: Vec<f64> = energies
4357            .iter()
4358            .map(|&e| 1.0e10 * (-(((e - 6.0) / 0.2).powi(2))).exp() + 1.0e-10 * e)
4359            .collect();
4360
4361        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4362        let actual = tab.broaden_presorted(&energies, &spectrum);
4363        assert_bit_exact(&reference, &actual, "exact_equality_target");
4364    }
4365
4366    #[test]
4367    fn test_broaden_presorted_bit_exact_random_spectrum() {
4368        // Random spectrum with varied magnitudes exercises the interpolation
4369        // arithmetic across sign changes and scales.
4370        let tab = synthetic_tab_resolution();
4371        let energies: Vec<f64> = (0..1001).map(|i| 2.0 + i as f64 * 0.2).collect();
4372        // Deterministic pseudo-random via a simple LCG (no external dep).
4373        let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
4374        let spectrum: Vec<f64> = energies
4375            .iter()
4376            .map(|_| {
4377                state = state
4378                    .wrapping_mul(6364136223846793005)
4379                    .wrapping_add(1442695040888963407);
4380                let f = ((state >> 33) as f64) / (u32::MAX as f64);
4381                f * 2.0 - 1.0
4382            })
4383            .collect();
4384
4385        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4386        let actual = tab.broaden_presorted(&energies, &spectrum);
4387        assert_bit_exact(&reference, &actual, "random_spectrum");
4388    }
4389
4390    // ---------------------------------------------------------------
4391    // `crates/nereids-physics/tests/venus_usr_resolution.rs`
4392    // (`test_broaden_presorted_bit_exact_on_venus_usr`) — see issue
4393    //. The integration test parses a synthetic SAMMY USR-format
4394    // kernel via `common::synthetic_venus_usr_tab()` (the real VENUS
4395    // BL10 fixture is not approved for public release; ).
4396    // ---------------------------------------------------------------
4397
4398    #[test]
4399    fn test_plan_reuse_bit_exact_across_multiple_spectra() {
4400        // Core promise of ResolutionPlan: building the plan once and
4401        // applying it to K different spectra must yield the same output
4402        // as K independent `broaden_presorted` calls.
4403        let tab = synthetic_tab_resolution();
4404        let energies: Vec<f64> = (0..401).map(|i| 7.0 + i as f64 * 0.4825).collect();
4405
4406        // Build plan ONCE.
4407        let plan = tab.plan(&energies).expect("sorted grid must validate");
4408        assert_eq!(plan.len(), energies.len());
4409        assert_eq!(plan.target_energies(), &energies[..]);
4410
4411        // Apply across 5 varied spectra.
4412        let mut state: u64 = 0xCAFE_BABE_DEAD_BEEF;
4413        for spec_idx in 0..5 {
4414            let spectrum: Vec<f64> = energies
4415                .iter()
4416                .enumerate()
4417                .map(|(i, &e)| {
4418                    state = state
4419                        .wrapping_mul(6364136223846793005)
4420                        .wrapping_add(1442695040888963407);
4421                    let noise = ((state >> 33) as f64) / (u32::MAX as f64);
4422                    // Varied magnitudes and shapes per spectrum to catch
4423                    // spectrum-dependent arithmetic drift.
4424                    (10.0f64).powi(spec_idx - 2) * (1.0 - 0.5 * noise)
4425                        + 0.3 * (-((e - 50.0).powi(2) / 4.0)).exp()
4426                        + (spec_idx as f64) * 1e-8 * (i as f64)
4427                })
4428                .collect();
4429
4430            let via_plan = plan.apply(&spectrum);
4431            let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4432            assert_bit_exact(
4433                &via_reference,
4434                &via_plan,
4435                &format!("plan_reuse[spec_idx={spec_idx}]"),
4436            );
4437        }
4438    }
4439
4440    #[test]
4441    fn test_plan_passthrough_cases() {
4442        // n == 0, n == 1, and e <= 0.0 must all produce the same
4443        // passthrough behaviour via plan as via broaden_presorted.
4444        let tab = synthetic_tab_resolution();
4445
4446        // n == 0: empty plan, empty result.
4447        let plan = tab.plan(&[]).unwrap();
4448        assert_eq!(plan.len(), 0);
4449        assert!(plan.is_empty());
4450        let out: Vec<f64> = plan.apply(&[]);
4451        assert!(out.is_empty());
4452
4453        // n == 1: passthrough for any spectrum value.
4454        let plan1 = tab.plan(&[5.0]).unwrap();
4455        assert_eq!(plan1.len(), 1);
4456        let out1 = plan1.apply(&[0.42]);
4457        assert_eq!(out1, vec![0.42]);
4458
4459        // e <= 0.0 in the middle of a grid: passthrough at that index.
4460        // Mixed positive / non-positive energies are pathological but
4461        // the current implementation handles them, and the plan must
4462        // match.  Grid is still non-descending (0.0 ≤ 10.0 etc.) so
4463        // plan() accepts it.
4464        let energies = vec![1.0, 1.0, 10.0, 100.0];
4465        let spectrum = vec![0.1, 0.5, 0.9, 0.3];
4466        let via_plan = {
4467            let plan = tab.plan(&energies).unwrap();
4468            plan.apply(&spectrum)
4469        };
4470        let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4471        assert_bit_exact(
4472            &via_reference,
4473            &via_plan,
4474            "mixed_positive_and_zero_energies",
4475        );
4476    }
4477
4478    #[test]
4479    fn test_plan_rejects_unsorted_energies() {
4480        // `broaden()` rejects unsorted grids via validate_inputs; `plan()`
4481        // must do the same so a caller doesn't silently build a plan with
4482        // misbracketed e_prime lookups and then produce wrong σ output
4483        // from `ResolutionPlan::apply`.
4484        let tab = synthetic_tab_resolution();
4485        let result = tab.plan(&[10.0, 1.0, 100.0]);
4486        assert!(matches!(result, Err(ResolutionError::UnsortedEnergies)));
4487    }
4488
4489    #[test]
4490    fn test_plan_apply_is_nan_safe_at_degenerate_bracket() {
4491        // When two adjacent target energies are equal (span = 0), the
4492        // plan encodes `frac = 0.0` and the apply path must short-
4493        // circuit to `spectrum[lo]` without reading `spectrum[lo+1]`.
4494        // A NaN at the upper bracket would propagate through
4495        // `0.0 * NaN = NaN` and corrupt the result otherwise.
4496        let tab = synthetic_tab_resolution();
4497        // Grid has a degenerate duplicate at indices 1 and 2.
4498        let energies = vec![8.0, 10.0, 10.0, 12.0, 50.0, 100.0];
4499        // Spectrum with NaN exactly at the upper-bracket index (2) that
4500        // the degenerate pair maps to; any retained (target, kernel-
4501        // point) entry whose `e_prime` lands inside that duplicate
4502        // bracket MUST NOT pull the NaN into the output.
4503        let spectrum = vec![0.1, 0.5, f64::NAN, 0.9, 0.2, 0.05];
4504        let plan = tab.plan(&energies).unwrap();
4505        let via_plan = plan.apply(&spectrum);
4506        let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4507        // Both paths must agree on the non-pathological targets.  The
4508        // reference path returns `spectrum[lo]` directly in the
4509        // degenerate case (no touch of `spectrum[lo+1]`) and the plan
4510        // path's `frac == 0.0` short-circuit matches bit-exactly.
4511        // Targets whose kernel legitimately interpolates across index 2
4512        // will pull the NaN in BOTH paths equally — that's physics, not
4513        // a bug — so we compare bit-pattern with a NaN-aware helper.
4514        assert_eq!(via_plan.len(), via_reference.len());
4515        for (i, (&a, &b)) in via_reference.iter().zip(via_plan.iter()).enumerate() {
4516            // Both NaN or both finite and bit-exact.
4517            if a.is_nan() {
4518                assert!(b.is_nan(), "plan[{i}]={b} but reference is NaN");
4519            } else {
4520                assert_eq!(
4521                    a.to_bits(),
4522                    b.to_bits(),
4523                    "nan_safe mismatch at {i}: reference={a} plan={b}"
4524                );
4525            }
4526        }
4527    }
4528
4529    #[test]
4530    fn test_plan_apply_exact_match_frac_plus_zero_propagates_nan() {
4531        // Regression gate for a subtle sign-of-zero short-circuit bug.
4532        //
4533        // When `e_prime` aligns EXACTLY with a grid point `energies[lo]`,
4534        // `plan_presorted`'s interp fraction computes to `+0.0`, yet the
4535        // bracket is NOT degenerate (span is a normal positive float).
4536        // In that case `broaden_presorted` still evaluates
4537        //   s = spectrum[lo] + (+0.0) * (spectrum[lo+1] - spectrum[lo])
4538        // which, for `spectrum[lo+1] = NaN`, reads `0.0 * NaN = NaN` and
4539        // propagates `NaN` into `s`.  The earlier `frac == 0.0` branch
4540        // in `ResolutionPlan::apply` incorrectly treated this case as
4541        // degenerate (since `+0.0 == -0.0` under `==`) and short-circuited
4542        // to `spectrum[lo]`, producing a finite output where the scalar
4543        // reference produced NaN.
4544        //
4545        // Fix: `plan_presorted` now stores `-0.0` (negative-signed zero)
4546        // for the degenerate sentinel, and `apply` disambiguates via
4547        // `to_bits()`, so the non-degenerate `+0.0` path correctly reads
4548        // `spectrum[lo+1]` and propagates NaN.
4549        let tab = synthetic_tab_resolution();
4550
4551        // Grid has a point at energy = 10.0.  We engineer a target grid
4552        // where the broadened kernel at one of the targets produces an
4553        // `e_prime` that aligns exactly with `energies[lo]` of one of its
4554        // retained entries.  Achieved by building a coarse target grid
4555        // and letting the two-pointer walk land on an exact match on at
4556        // least one (target, kernel-point) pair.
4557        let energies: Vec<f64> = (0..32).map(|i| 1.0 + i as f64).collect();
4558        // Spectrum with NaN scattered at multiple lo+1 indices.  At
4559        // least one retained plan entry in this synthetic configuration
4560        // will have `frac == +0.0` from an exact-match case, which must
4561        // propagate NaN in apply.
4562        let mut spectrum = vec![0.5_f64; energies.len()];
4563        for v in &mut spectrum[3..] {
4564            *v = f64::NAN;
4565        }
4566        let plan = tab.plan(&energies).unwrap();
4567        let via_plan = plan.apply(&spectrum);
4568        let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4569
4570        // Bit-exact equivalence on all targets, including NaN-propagated
4571        // ones.  This test would FAIL pre-fix (plan returns finite where
4572        // reference returns NaN for any exact-match plan entry with a
4573        // NaN at `lo+1`).
4574        assert_eq!(via_plan.len(), via_reference.len());
4575        for (i, (&a, &b)) in via_reference.iter().zip(via_plan.iter()).enumerate() {
4576            if a.is_nan() {
4577                assert!(
4578                    b.is_nan(),
4579                    "target {i}: reference produced NaN (NaN propagated through \
4580                     exact-match `frac = +0.0` path) but plan returned finite {b}",
4581                );
4582            } else {
4583                assert_eq!(
4584                    a.to_bits(),
4585                    b.to_bits(),
4586                    "target {i}: reference={a} plan={b}"
4587                );
4588            }
4589        }
4590    }
4591
4592    #[test]
4593    #[should_panic(expected = "must match plan target-grid length")]
4594    fn test_plan_apply_spectrum_length_mismatch_panics() {
4595        let tab = synthetic_tab_resolution();
4596        let plan = tab.plan(&[1.0, 2.0, 3.0]).unwrap();
4597        // Wrong spectrum length — caller error should panic with a
4598        // clear message rather than silently producing garbage.
4599        let _ = plan.apply(&[0.1, 0.2]);
4600    }
4601
4602    // ─── apply_resolution_with_plan / _presorted_with_plan dispatch harness ───
4603    //
4604    // These gates cover the public/crate-visible wrappers added for
4605    // production plan-caching.  Every production caller (fit-model
4606    // layer, spatial dispatch) goes through one of these two entries;
4607    // their dispatch choices must be byte-identical to the non-plan
4608    // paths they replace.
4609
4610    #[test]
4611    fn test_apply_resolution_with_plan_tabulated_matches_non_plan_path() {
4612        let tab = synthetic_tab_resolution();
4613        let resolution = ResolutionFunction::Tabulated(Arc::new(tab.clone()));
4614        let energies: Vec<f64> = (0..128).map(|i| 1.0 + i as f64 * (200.0 / 128.0)).collect();
4615        let spectrum: Vec<f64> = energies
4616            .iter()
4617            .map(|&e| 1.0 - 0.3 * (-((e - 20.0).powi(2) / 4.0)).exp())
4618            .collect();
4619
4620        let baseline = apply_resolution(&energies, &spectrum, &resolution).unwrap();
4621
4622        let plan = build_resolution_plan(&energies, &resolution).unwrap();
4623        assert!(
4624            plan.is_some(),
4625            "tabulated resolution must produce Some(plan)"
4626        );
4627        let planned =
4628            apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &resolution).unwrap();
4629        assert_eq!(planned.len(), baseline.len());
4630        for (i, (&a, &b)) in baseline.iter().zip(planned.iter()).enumerate() {
4631            assert_eq!(
4632                a.to_bits(),
4633                b.to_bits(),
4634                "apply_resolution_with_plan mismatch at {i}: baseline={a} planned={b}"
4635            );
4636        }
4637    }
4638
4639    #[test]
4640    fn test_apply_resolution_with_plan_gaussian_returns_none_plan_and_matches() {
4641        let resolution =
4642            ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 1.0e-3, 0.02, 0.01).unwrap());
4643        let energies: Vec<f64> = (0..64).map(|i| 1.0 + i as f64 * 3.0).collect();
4644        let spectrum: Vec<f64> = energies.iter().map(|&e| 1.0 / e).collect();
4645
4646        let plan = build_resolution_plan(&energies, &resolution).unwrap();
4647        assert!(
4648            plan.is_none(),
4649            "Gaussian resolution must not produce a plan"
4650        );
4651
4652        let baseline = apply_resolution(&energies, &spectrum, &resolution).unwrap();
4653        let planned =
4654            apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &resolution).unwrap();
4655        assert_eq!(planned.len(), baseline.len());
4656        for (i, (&a, &b)) in baseline.iter().zip(planned.iter()).enumerate() {
4657            assert_eq!(
4658                a.to_bits(),
4659                b.to_bits(),
4660                "gaussian fallback mismatch at {i}: baseline={a} planned={b}"
4661            );
4662        }
4663    }
4664
4665    #[test]
4666    fn test_apply_resolution_with_plan_rejects_same_length_different_grid() {
4667        // `p.len() == energies.len()` is necessary
4668        // but not sufficient.  A plan built for one grid and applied
4669        // to a different same-length grid would silently gather
4670        // spectrum values at brackets belonging to the original grid
4671        // — wrong σ output without any error surfaced.  The grid-
4672        // identity check in `apply_resolution_with_plan` guards this
4673        // failure mode and reports the first differing index.
4674        let tab = synthetic_tab_resolution();
4675        let resolution = ResolutionFunction::Tabulated(Arc::new(tab.clone()));
4676        let energies_plan: Vec<f64> = (0..32).map(|i| 1.0 + i as f64).collect();
4677        let mut energies_apply = energies_plan.clone();
4678        // Perturb a single interior point so lengths still match.
4679        energies_apply[5] += 0.25;
4680        let spectrum = vec![0.7; energies_apply.len()];
4681
4682        let plan = tab.plan(&energies_plan).unwrap();
4683        let result =
4684            apply_resolution_with_plan(Some(&plan), &energies_apply, &spectrum, &resolution);
4685        match result {
4686            Err(ResolutionError::PlanGridMismatch { first_diff_index }) => {
4687                assert_eq!(first_diff_index, 5);
4688            }
4689            other => panic!("expected PlanGridMismatch, got {:?}", other),
4690        }
4691    }
4692
4693    #[test]
4694    fn test_apply_resolution_with_plan_rejects_length_mismatch() {
4695        let tab = synthetic_tab_resolution();
4696        let resolution = ResolutionFunction::Tabulated(Arc::new(tab.clone()));
4697        let energies_plan: Vec<f64> = (0..32).map(|i| 1.0 + i as f64).collect();
4698        let energies_apply: Vec<f64> = (0..48).map(|i| 1.0 + i as f64).collect();
4699        let spectrum = vec![0.5; energies_apply.len()];
4700
4701        let plan = tab.plan(&energies_plan).unwrap();
4702        let result =
4703            apply_resolution_with_plan(Some(&plan), &energies_apply, &spectrum, &resolution);
4704        match result {
4705            Err(ResolutionError::LengthMismatch { energies, data }) => {
4706                assert_eq!(energies, 48);
4707                assert_eq!(data, 32);
4708            }
4709            other => panic!("expected LengthMismatch, got {:?}", other),
4710        }
4711    }
4712
4713    #[test]
4714    fn test_build_resolution_plan_rejects_unsorted_energies_for_gaussian() {
4715        // Gaussian returns None on success, but must still reject an
4716        // unsorted grid — callers use `build_resolution_plan` to
4717        // centralise the sort-check so the downstream `apply` path can
4718        // skip it.
4719        let resolution =
4720            ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 1.0e-3, 0.02, 0.01).unwrap());
4721        let result = build_resolution_plan(&[3.0, 1.0, 2.0], &resolution);
4722        assert!(matches!(result, Err(ResolutionError::UnsortedEnergies)));
4723    }
4724
4725    // ---------------------------------------------------------------
4726    // `crates/nereids-physics/tests/venus_usr_resolution_microbench.rs`
4727    // (`test_broaden_presorted_bench`, `test_plan_reuse_bench`,
4728    // `resolution_matrix_apply_microbench`) — see. They
4729    // parse a synthetic SAMMY USR-format kernel via
4730    // `common::synthetic_venus_usr_tab()` (the real VENUS BL10
4731    // fixture is not approved for public release; ).
4732    // ---------------------------------------------------------------
4733
4734    // ---------- ResolutionMatrix (CSR compile) tests ----------
4735    //
4736    // CI-hermetic synthetic tests — use hand-constructed
4737    // `ResolutionPlan`s via `make_synthetic_plan`; no fixture
4738    // dependency, run on every `cargo test` invocation.  Cover
4739    // passthrough rows, `-0.0` sentinel rows, regular linear-interp
4740    // rows, CSR invariants, and the non-finite contract exclusion.
4741    //
4742    // End-to-end equivalence tests against the VENUS-like USR
4743    // operator (synthetic SAMMY-format kernel) at realistic grid
4744    // sizes (512, 3471) live in
4745    // `crates/nereids-physics/tests/venus_usr_resolution.rs` — see
4746    // issues and.
4747
4748    /// Hybrid abs+rel tolerance used across equivalence tests.  Guards
4749    /// against the `a ≈ 0` trap where `a.abs().max(1e-300)` produces
4750    /// meaningless relative errors for genuinely-zero reference values.
4751    fn max_hybrid_err(a: &[f64], b: &[f64]) -> f64 {
4752        a.iter()
4753            .zip(b)
4754            .map(|(x, y)| {
4755                let denom = x.abs().max(y.abs()).max(1e-12);
4756                (x - y).abs() / denom
4757            })
4758            .fold(0.0_f64, f64::max)
4759    }
4760
4761    /// Build a synthetic multi-row plan with realistic overlap
4762    /// patterns — used as a CI-hermetic stand-in for the VENUS
4763    /// kernel.  Each target row `i` draws weights from a triangular
4764    /// kernel around column `i`, normalized so the row is
4765    /// row-stochastic.  `half_kernel` controls the spread.
4766    fn make_synthetic_overlap_plan(n_grid: usize, half_kernel: usize) -> ResolutionPlan {
4767        assert!(n_grid > 2 * half_kernel, "grid too small for kernel");
4768        let energies: Vec<f64> = (0..n_grid).map(|i| 10.0 + i as f64).collect();
4769        let mut rows: Vec<SyntheticRow> = Vec::with_capacity(n_grid);
4770        for i in 0..n_grid {
4771            let lo_min = i.saturating_sub(half_kernel);
4772            // Clamp so `lo ∈ [0, n_grid - 2]` — the linear-interp
4773            // branch reads `spec[lo + 1]`, and the `-0.0` sentinel is
4774            // the only way to safely go up to `lo = n_grid - 1`.  We
4775            // keep all synthetic entries on the regular branch here.
4776            let lo_max = (i + half_kernel).min(n_grid - 2);
4777            let entries: Vec<SyntheticEntry> = (lo_min..=lo_max)
4778                .map(|lo| {
4779                    let d = (lo as i64 - i as i64).abs() as f64;
4780                    let w = 1.0 - d / (half_kernel as f64 + 1.0);
4781                    // A uniform `frac = 0.5` distributes each entry's
4782                    // weight evenly across `lo` and `lo + 1`, which
4783                    // exercises the regular linear-interp branch of
4784                    // `compile_to_matrix`.
4785                    SyntheticEntry {
4786                        lo: lo as u32,
4787                        frac: 0.5,
4788                        weight: w,
4789                    }
4790                })
4791                .collect();
4792            let norm: f64 = entries.iter().map(|e| e.weight).sum();
4793            rows.push(SyntheticRow { entries, norm });
4794        }
4795        make_synthetic_plan(energies, rows)
4796    }
4797
4798    /// CI-hermetic: row-stochasticity on a synthetic multi-row plan.
4799    #[test]
4800    fn resolution_matrix_is_row_stochastic_synthetic() {
4801        let plan = make_synthetic_overlap_plan(40, 5);
4802        let matrix = plan.compile_to_matrix();
4803        for i in 0..matrix.len() {
4804            let start = matrix.row_starts()[i] as usize;
4805            let end = matrix.row_starts()[i + 1] as usize;
4806            let row_sum: f64 = matrix.values()[start..end].iter().sum();
4807            assert!(
4808                (row_sum - 1.0).abs() < 1e-13,
4809                "row {} sum = {} (expected 1.0 within 1e-13)",
4810                i,
4811                row_sum,
4812            );
4813        }
4814    }
4815
4816    /// CI-hermetic: equivalence of `apply_r` and `plan.apply` on a
4817    /// synthetic multi-row plan, 40-point grid, half-kernel 5.
4818    #[test]
4819    fn resolution_matrix_apply_equivalent_to_plan_apply_synthetic() {
4820        let plan = make_synthetic_overlap_plan(40, 5);
4821        let matrix = plan.compile_to_matrix();
4822        // Beer-Lambert-shaped synthetic spectrum, bounded [0, 1].
4823        let spec: Vec<f64> = (0..matrix.len())
4824            .map(|i| {
4825                let x = i as f64 / 39.0;
4826                1.0 - 0.7 * (-((x - 0.5).powi(2)) / 0.01).exp()
4827            })
4828            .collect();
4829        let plan_out = plan.apply(&spec);
4830        let matrix_out = apply_r(&matrix, &spec);
4831        let max_err = max_hybrid_err(&plan_out, &matrix_out);
4832        assert!(
4833            max_err < 1e-12,
4834            "synthetic apply_r vs plan.apply max hybrid err = {:.3e} (expected < 1e-12)",
4835            max_err,
4836        );
4837    }
4838
4839    /// CI-hermetic: CSR column indices strictly ascending per row on
4840    /// a synthetic multi-row plan.
4841    #[test]
4842    fn resolution_matrix_csr_column_indices_sorted_per_row_synthetic() {
4843        let plan = make_synthetic_overlap_plan(30, 4);
4844        let matrix = plan.compile_to_matrix();
4845        for i in 0..matrix.len() {
4846            let start = matrix.row_starts()[i] as usize;
4847            let end = matrix.row_starts()[i + 1] as usize;
4848            let row_cols = &matrix.col_indices()[start..end];
4849            for w in row_cols.windows(2) {
4850                assert!(
4851                    w[0] < w[1],
4852                    "row {} col_indices not strictly ascending: {:?}",
4853                    i,
4854                    row_cols,
4855                );
4856            }
4857        }
4858    }
4859
4860    /// CI-hermetic: grid-mismatch / length-mismatch detection via
4861    /// `apply_resolution_with_matrix` on a synthetic plan.
4862    #[test]
4863    fn resolution_matrix_grid_and_length_mismatch_synthetic() {
4864        let plan = make_synthetic_overlap_plan(16, 3);
4865        let matrix = plan.compile_to_matrix();
4866        let n = matrix.len();
4867        let energies: Vec<f64> = (0..n).map(|i| 10.0 + i as f64).collect();
4868        let spec = vec![1.0_f64; n];
4869
4870        // Same grid + length → passes.
4871        assert!(apply_resolution_with_matrix(&energies, &matrix, &spec).is_ok());
4872
4873        // Perturb one energy → MatrixGridMismatch with offending
4874        // index.
4875        let mut mutated = energies.clone();
4876        mutated[7] += 1e-12;
4877        let err = apply_resolution_with_matrix(&mutated, &matrix, &spec)
4878            .expect_err("grid mismatch must error");
4879        assert_eq!(
4880            err,
4881            ResolutionError::MatrixGridMismatch {
4882                first_diff_index: 7,
4883            }
4884        );
4885
4886        // Short spectrum → LengthMismatch.
4887        let short = vec![1.0_f64; n - 1];
4888        let err = apply_resolution_with_matrix(&energies, &matrix, &short)
4889            .expect_err("length mismatch must error");
4890        assert!(matches!(err, ResolutionError::LengthMismatch { .. }));
4891    }
4892
4893    // ---------------------------------------------------------------
4894    // `crates/nereids-physics/tests/venus_usr_resolution.rs`
4895    // (`resolution_matrix_is_row_stochastic_on_venus_kernel`,
4896    //  `resolution_matrix_apply_equivalent_to_plan_apply_on_venus_kernel`,
4897    //  `resolution_matrix_apply_equivalent_at_production_grid`,
4898    //  `resolution_matrix_apply_equivalent_across_densities`,
4899    //  `resolution_matrix_csr_column_indices_sorted_per_row`,
4900    //  `resolution_matrix_grid_mismatch_detected`,
4901    //  `resolution_matrix_length_mismatch_detected`) — see issues
4902    // and. They parse a synthetic SAMMY USR-format kernel
4903    // via `common::synthetic_venus_usr_tab()`.
4904    // ---------------------------------------------------------------
4905
4906    #[test]
4907    fn resolution_matrix_empty_plan() {
4908        // Compile must not panic and must produce a valid empty
4909        // matrix when the plan itself is empty.  Build the empty
4910        // plan synthetically (no fixture needed) — an empty
4911        // `target_energies` plus empty `norm` / `starts = [0]`
4912        // yields the same zero-row plan that
4913        // `TabulatedResolution::plan(&[])` would produce.
4914        let plan = make_synthetic_plan(Vec::new(), Vec::new());
4915        let matrix = plan.compile_to_matrix();
4916        assert_eq!(matrix.len(), 0);
4917        assert!(matrix.is_empty());
4918        assert_eq!(matrix.nnz(), 0);
4919    }
4920
4921    /// Hand-construct a `ResolutionPlan` that deliberately exercises
4922    /// both the passthrough branch (`norm ≤ DIVISION_FLOOR`) and the
4923    /// `-0.0` degenerate-bracket sentinel — neither of which is
4924    /// reached on the VENUS fixture at the tested grid sizes, which
4925    /// made the earlier fixture-based passthrough test vacuous.  This
4926    /// replacement verifies the two unreached branches with direct
4927    /// assertions on the resulting CSR.
4928    fn make_synthetic_plan(target_energies: Vec<f64>, rows: Vec<SyntheticRow>) -> ResolutionPlan {
4929        let n = target_energies.len();
4930        assert_eq!(rows.len(), n);
4931        let mut starts: Vec<u32> = Vec::with_capacity(n + 1);
4932        starts.push(0);
4933        let mut lo_idx: Vec<u32> = Vec::new();
4934        let mut frac: Vec<f64> = Vec::new();
4935        let mut weight: Vec<f64> = Vec::new();
4936        let mut norm: Vec<f64> = Vec::with_capacity(n);
4937        for row in &rows {
4938            norm.push(row.norm);
4939            for entry in &row.entries {
4940                lo_idx.push(entry.lo);
4941                frac.push(entry.frac);
4942                weight.push(entry.weight);
4943            }
4944            starts.push(lo_idx.len() as u32);
4945        }
4946        ResolutionPlan {
4947            target_energies,
4948            starts,
4949            lo_idx,
4950            frac,
4951            weight,
4952            norm,
4953        }
4954    }
4955
4956    struct SyntheticRow {
4957        entries: Vec<SyntheticEntry>,
4958        norm: f64,
4959    }
4960
4961    struct SyntheticEntry {
4962        lo: u32,
4963        frac: f64,
4964        weight: f64,
4965    }
4966
4967    #[test]
4968    fn resolution_matrix_passthrough_row_compiles_to_identity_entry() {
4969        // Row 0: passthrough via norm ≤ DIVISION_FLOOR.
4970        // Row 1: regular linear-interp entry (lo=1 → reads cols 1, 2).
4971        // Row 2: degenerate `-0.0` sentinel entry (lo=2 → reads col 2 only).
4972        //
4973        // Grid has 4 cells so `lo ∈ [0, n-2] = [0, 2]` holds for all
4974        // entries — this preserves the `ResolutionPlan::apply` SAFETY
4975        // invariant that `lo + 1 < n` even if a future refactor
4976        // weakens the `-0.0` sentinel short-circuit.
4977        let plan = make_synthetic_plan(
4978            vec![10.0, 20.0, 30.0, 40.0],
4979            vec![
4980                SyntheticRow {
4981                    entries: vec![],
4982                    // 0.0 is <= DIVISION_FLOOR, so row 0 goes through
4983                    // the passthrough branch.
4984                    norm: 0.0,
4985                },
4986                SyntheticRow {
4987                    entries: vec![SyntheticEntry {
4988                        lo: 1,
4989                        frac: 0.25,
4990                        weight: 1.0,
4991                    }],
4992                    norm: 1.0,
4993                },
4994                SyntheticRow {
4995                    entries: vec![SyntheticEntry {
4996                        lo: 2,
4997                        frac: -0.0,
4998                        weight: 1.0,
4999                    }],
5000                    norm: 1.0,
5001                },
5002                // Row 3: passthrough too, to round out the 4-cell grid.
5003                SyntheticRow {
5004                    entries: vec![],
5005                    norm: 0.0,
5006                },
5007            ],
5008        );
5009        let matrix = plan.compile_to_matrix();
5010
5011        // Row 0 — single (0, 0, 1.0).
5012        let r0_start = matrix.row_starts()[0] as usize;
5013        let r0_end = matrix.row_starts()[1] as usize;
5014        assert_eq!(r0_end - r0_start, 1, "passthrough row must have 1 entry");
5015        assert_eq!(matrix.col_indices()[r0_start], 0);
5016        assert_eq!(matrix.values()[r0_start].to_bits(), 1.0_f64.to_bits());
5017
5018        // Row 1 — linear-interp: contributes at col 1 and col 2.
5019        let r1_start = matrix.row_starts()[1] as usize;
5020        let r1_end = matrix.row_starts()[2] as usize;
5021        assert_eq!(
5022            r1_end - r1_start,
5023            2,
5024            "linear-interp row must have 2 entries"
5025        );
5026        assert_eq!(matrix.col_indices()[r1_start], 1);
5027        assert_eq!(matrix.col_indices()[r1_start + 1], 2);
5028        assert!((matrix.values()[r1_start] - 0.75).abs() < 1e-14);
5029        assert!((matrix.values()[r1_start + 1] - 0.25).abs() < 1e-14);
5030
5031        // Row 2 — `-0.0` sentinel: single entry at col 2 (no col 3).
5032        let r2_start = matrix.row_starts()[2] as usize;
5033        let r2_end = matrix.row_starts()[3] as usize;
5034        assert_eq!(
5035            r2_end - r2_start,
5036            1,
5037            "-0.0 sentinel row must have exactly 1 entry (not 2)",
5038        );
5039        assert_eq!(matrix.col_indices()[r2_start], 2);
5040        assert_eq!(matrix.values()[r2_start].to_bits(), 1.0_f64.to_bits());
5041
5042        // Cross-check with apply semantics: spec[3] is chosen so the
5043        // sentinel row, if buggy, would contaminate the output.
5044        // Both `plan.apply` and `apply_r` must ignore spec[3] at
5045        // row 2.
5046        let spec = vec![7.0, 11.0, 13.0, 999.0];
5047        let plan_out = plan.apply(&spec);
5048        let matrix_out = apply_r(&matrix, &spec);
5049        // Row 0 passthrough: out[0] = spec[0] = 7.
5050        assert!((matrix_out[0] - 7.0).abs() < 1e-14);
5051        assert!((plan_out[0] - 7.0).abs() < 1e-14);
5052        // Row 1: 0.75 * spec[1] + 0.25 * spec[2] = 0.75*11 + 0.25*13 = 11.5.
5053        assert!((matrix_out[1] - 11.5).abs() < 1e-14);
5054        assert!((plan_out[1] - 11.5).abs() < 1e-14);
5055        // Row 2 sentinel: 1.0 * spec[2] = 13 — NOT 999 (would indicate
5056        // spec[lo+1] was read).
5057        assert!((matrix_out[2] - 13.0).abs() < 1e-14);
5058        assert!((plan_out[2] - 13.0).abs() < 1e-14);
5059        // Row 3 passthrough: out[3] = spec[3] = 999.
5060        assert!((matrix_out[3] - 999.0).abs() < 1e-14);
5061        assert!((plan_out[3] - 999.0).abs() < 1e-14);
5062    }
5063
5064    /// Documents (and guards) the explicit contract exclusion on
5065    /// non-finite spectra between `ResolutionPlan::apply` and
5066    /// `apply_r`.  See [`ResolutionPlan::compile_to_matrix`] docstring
5067    /// for the full reasoning; this test simply pins the divergence
5068    /// so a future unification attempt fails loudly.
5069    #[test]
5070    fn resolution_matrix_nonfinite_contract() {
5071        // 3-cell grid so `lo = 0` for the regular row reads cols 0, 1
5072        // and the sentinel row at `lo = 1` reads col 1 only — `lo ∈
5073        // [0, n-2] = [0, 1]` satisfied.
5074        let plan = make_synthetic_plan(
5075            vec![10.0, 20.0, 30.0],
5076            vec![
5077                SyntheticRow {
5078                    entries: vec![SyntheticEntry {
5079                        lo: 0,
5080                        frac: 0.5,
5081                        weight: 1.0,
5082                    }],
5083                    norm: 1.0,
5084                },
5085                SyntheticRow {
5086                    entries: vec![SyntheticEntry {
5087                        lo: 1,
5088                        frac: -0.0, // sentinel: short-circuit to spec[lo]
5089                        weight: 1.0,
5090                    }],
5091                    norm: 1.0,
5092                },
5093                SyntheticRow {
5094                    entries: vec![],
5095                    norm: 0.0, // passthrough
5096                },
5097            ],
5098        );
5099        let matrix = plan.compile_to_matrix();
5100
5101        // Spectrum with same-sign infinities in both bins of row 0's
5102        // non-degenerate bracket.
5103        let inf_spec = vec![f64::INFINITY, f64::INFINITY, 0.0];
5104        let plan_out = plan.apply(&inf_spec);
5105        let matrix_out = apply_r(&matrix, &inf_spec);
5106
5107        // Row 0: plan.apply evaluates `s_lo + frac * (s_hi - s_lo)`
5108        // = `+∞ + 0.5 * (+∞ - +∞)` = `+∞ + 0.5 * NaN` = NaN.
5109        // apply_r evaluates `0.5 * +∞ + 0.5 * +∞` = `+∞`.
5110        assert!(plan_out[0].is_nan(), "plan.apply must produce NaN on ∞+∞");
5111        assert!(matrix_out[0].is_infinite(), "apply_r collapses ∞+∞ to ∞");
5112
5113        // Row 1 (sentinel): both paths short-circuit to spec[lo] = ∞,
5114        // so there is no divergence here.
5115        assert!(plan_out[1].is_infinite());
5116        assert!(matrix_out[1].is_infinite());
5117    }
5118
5119    /// Documents (and guards) the analogous
5120    /// divergence on **finite spectra near f64 overflow**.  With
5121    /// opposite-sign neighboring bins at f64::MAX, `plan.apply`'s
5122    /// `s_lo + frac * (s_hi - s_lo)` overflows in the subtraction
5123    /// and returns `±∞`, while `apply_r`'s `(1 - frac) * s_lo +
5124    /// frac * s_hi` stays finite because the overflow is avoided by
5125    /// scaling before summation.  This is why the equivalence
5126    /// contract on [`ResolutionPlan::compile_to_matrix`] is scoped
5127    /// to bounded finite spectra (Beer-Lambert `T ∈ [0, 1]`) — no
5128    /// production forward model can hit this case.
5129    #[test]
5130    fn resolution_matrix_large_finite_contract() {
5131        let plan = make_synthetic_plan(
5132            vec![10.0, 20.0, 30.0],
5133            vec![
5134                SyntheticRow {
5135                    entries: vec![SyntheticEntry {
5136                        lo: 0,
5137                        frac: 0.5,
5138                        weight: 1.0,
5139                    }],
5140                    norm: 1.0,
5141                },
5142                SyntheticRow {
5143                    entries: vec![],
5144                    norm: 0.0, // passthrough
5145                },
5146                SyntheticRow {
5147                    entries: vec![],
5148                    norm: 0.0,
5149                },
5150            ],
5151        );
5152        let matrix = plan.compile_to_matrix();
5153
5154        // Opposite-sign large finite bins at row 0's non-degenerate
5155        // bracket.  `s_hi - s_lo = -f64::MAX - f64::MAX = -∞`.
5156        let big_spec = vec![f64::MAX, -f64::MAX, 0.0];
5157        let plan_out = plan.apply(&big_spec);
5158        let matrix_out = apply_r(&matrix, &big_spec);
5159
5160        // plan.apply: s_lo + frac * (s_hi - s_lo) = MAX + 0.5 * (-∞)
5161        // = MAX + -∞ = -∞.
5162        assert!(
5163            plan_out[0].is_infinite() && plan_out[0] < 0.0,
5164            "plan.apply must overflow to -∞ on opposite-sign MAX bins; got {}",
5165            plan_out[0],
5166        );
5167        // apply_r: 0.5 * MAX + 0.5 * -MAX = 0.
5168        assert!(
5169            matrix_out[0].is_finite(),
5170            "apply_r must stay finite (scaled before summation); got {}",
5171            matrix_out[0],
5172        );
5173        assert!(matrix_out[0].abs() < 1e-280);
5174    }
5175
5176    // ------------------------------------------------------------------
5177    // TabulatedResolution::kernel_support_ev — used by SAMMY EMIN/EMAX
5178    // -equivalent fit-energy-range margin computation.
5179    // ------------------------------------------------------------------
5180
5181    /// `synthetic_tab_resolution` uses triangular kernels whose
5182    /// outermost entries (`weight = 1 - |dt|/half`) are exactly zero
5183    /// at `dt = ±half`; the actual non-zero support is the next-
5184    /// outermost entry at `±half · (1 - 1/(n-1))`.
5185    fn triangle_dt_max(half: f64, n: usize) -> f64 {
5186        // dt_step = 2*half / (n-1); next-outermost = half - dt_step
5187        half - 2.0 * half / (n - 1) as f64
5188    }
5189
5190    /// Exact-map expected support: the larger of the up-side excursion
5191    /// `E·((t/(t−dt⁺))²−1)` and the down-side `E·(1−(t/(t+dt⁻))²)`
5192    /// with `t = TOF_FACTOR·L/√E` — the same map the broadener applies
5193    /// per kernel point, so this oracle is exact by construction.
5194    fn exact_support(e: f64, dt_pos: f64, dt_neg: f64, l: f64) -> f64 {
5195        let t = TOF_FACTOR * l / e.sqrt();
5196        let up = e * ((t / (t - dt_pos)).powi(2) - 1.0);
5197        let down = e * (1.0 - (t / (t + dt_neg)).powi(2));
5198        up.max(down)
5199    }
5200
5201    /// At a reference energy with a known kernel half-width in TOF, the
5202    /// support must equal the exact TOF→E excursion of the outermost
5203    /// non-zero offsets.  At E = 50 eV the triangle kernel has
5204    /// half = 1.0 μs, n = 41, so the largest non-zero offset is
5205    /// `1.0 · (1 − 1/40) = 0.975` on both sides.
5206    #[test]
5207    fn test_tabulated_kernel_support_at_ref_energy_matches_exact_map() {
5208        let r = synthetic_tab_resolution();
5209        let e: f64 = 50.0;
5210        let dt_max = triangle_dt_max(1.0, 41);
5211        let expected = exact_support(e, dt_max, dt_max, 25.0);
5212        let got = r.kernel_support_ev(e);
5213        assert!(
5214            (got - expected).abs() / expected < 1e-12,
5215            "support at ref energy: got {got}, expected {expected}"
5216        );
5217    }
5218
5219    /// Between two reference energies the support tracks the ACTUAL
5220    /// width-interpolated kernel: it must cover that kernel's non-zero
5221    /// offsets (non-circular — the blend comes from
5222    /// `interpolated_kernel` itself), while sitting strictly BELOW the
5223    /// old take-the-wider-bracket bound (proving the interior arm
5224    /// engaged rather than falling back to per-kernel extremes).
5225    #[test]
5226    fn test_tabulated_kernel_support_covers_actual_blend_between_refs() {
5227        let r = synthetic_tab_resolution();
5228        let e: f64 = 100.0; // between the 50 eV and 500 eV references
5229        let got = r.kernel_support_ev(e);
5230
5231        // Cover: exact excursion of the blended kernel's w>0 extremes.
5232        let (offs, ws) = test_support::interpolated_kernel(&r, e);
5233        let dt_pos = offs
5234            .iter()
5235            .zip(&ws)
5236            .filter(|&(_, &w)| w > 0.0)
5237            .map(|(&o, _)| o)
5238            .fold(0.0f64, f64::max);
5239        let dt_neg = offs
5240            .iter()
5241            .zip(&ws)
5242            .filter(|&(_, &w)| w > 0.0)
5243            .map(|(&o, _)| -o)
5244            .fold(0.0f64, f64::max);
5245        let actual_excursion = exact_support(e, dt_pos, dt_neg, 25.0);
5246        assert!(
5247            got >= actual_excursion * (1.0 - 1e-12),
5248            "support must cover the actual blended kernel: got {got}, \
5249             actual excursion {actual_excursion}"
5250        );
5251
5252        // Tightness + non-vacuity: strictly below the pre-blend bound
5253        // built from the wider 500 eV bracket's extreme (1.96 µs) —
5254        // the between-ref kernel is genuinely narrower.
5255        let old_bound = exact_support(e, triangle_dt_max(2.0, 51), triangle_dt_max(2.0, 51), 25.0);
5256        assert!(
5257            got < old_bound,
5258            "interior support must track the narrower interpolated \
5259             kernel: got {got}, old wider-bracket bound {old_bound}"
5260        );
5261    }
5262
5263    /// Below the lowest ref energy: use the lowest ref kernel.
5264    /// Above the highest ref energy: use the highest ref kernel.
5265    #[test]
5266    fn test_tabulated_kernel_support_uses_nearest_outside_grid() {
5267        let r = synthetic_tab_resolution();
5268        // Below grid (ref_min = 5 eV; triangle(half=0.5, n=31)).
5269        let e_low: f64 = 1.0;
5270        let dt_low = triangle_dt_max(0.5, 31);
5271        let exp_low = exact_support(e_low, dt_low, dt_low, 25.0);
5272        assert!((r.kernel_support_ev(e_low) - exp_low).abs() / exp_low < 1e-12);
5273        // Above grid (ref_max = 500 eV; triangle(half=2.0, n=51)).
5274        let e_hi: f64 = 1000.0;
5275        let dt_hi = triangle_dt_max(2.0, 51);
5276        let exp_hi = exact_support(e_hi, dt_hi, dt_hi, 25.0);
5277        assert!((r.kernel_support_ev(e_hi) - exp_hi).abs() / exp_hi < 1e-12);
5278    }
5279
5280    /// The exact up-side excursion strictly exceeds the linear
5281    /// chain-rule estimate for a wide positive (delayed-emission) tail
5282    /// at high energy — the case where the old linear margin
5283    /// under-covered exactly the side the convolution gather loads.
5284    #[test]
5285    fn test_tabulated_kernel_support_exceeds_linear_estimate_for_wide_tail() {
5286        let offsets = vec![-1.0, 0.0, 15.0];
5287        let weights = vec![0.3, 1.0, 0.2];
5288        let r = TabulatedResolution {
5289            ref_energies: Arc::new(vec![100.0]),
5290            kernels: Arc::new(vec![(offsets, weights)]),
5291            flight_path_m: 25.0,
5292        };
5293        let e: f64 = 100.0;
5294        let linear = 2.0 * e.powf(1.5) / (TOF_FACTOR * 25.0) * 15.0;
5295        let got = r.kernel_support_ev(e);
5296        assert!(
5297            got > linear,
5298            "exact support must exceed the linear estimate on the \
5299             high-E side: got {got}, linear {linear}"
5300        );
5301        let expected = exact_support(e, 15.0, 1.0, 25.0);
5302        assert!(
5303            (got - expected).abs() / expected < 1e-12,
5304            "exact support: got {got}, expected {expected}"
5305        );
5306    }
5307
5308    /// An offset at or past the nominal flight time contributes no reach: the
5309    /// support is that of the surviving points.
5310    #[test]
5311    fn test_tabulated_kernel_support_counts_only_points_the_broadening_keeps() {
5312        let offsets = vec![0.0, 2.0, 10.0];
5313        let weights = vec![1.0, 0.5, 0.5];
5314        let r = TabulatedResolution {
5315            ref_energies: Arc::new(vec![100.0]),
5316            kernels: Arc::new(vec![(offsets, weights)]),
5317            flight_path_m: 25.0,
5318        };
5319        // Choose E high enough that 2 μs < t = K·L/√E ≤ 10 μs, so the
5320        // 10 μs point is dropped and the 2 μs point is the last kept.
5321        let t_at = |e: f64| TOF_FACTOR * 25.0 / e.sqrt();
5322        let mut e: f64 = 100.0;
5323        while t_at(e) > 10.0 {
5324            e *= 10.0;
5325        }
5326        let t = t_at(e);
5327        assert!(t > 2.0, "fixture must keep the 2 μs point (t = {t})");
5328        let expected_above = e * ((t / (t - 2.0)).powi(2) - 1.0);
5329        let (_, high) = r.gather_bounds_ev(e);
5330        let above = high - e;
5331        assert!(above.is_finite(), "reach must be finite, got {above}");
5332        assert!(
5333            (above - expected_above).abs() <= 1e-9 * expected_above,
5334            "reach {above} should be that of the last surviving offset, {expected_above}"
5335        );
5336    }
5337
5338    /// Between two references an offset past the flight time drops only
5339    /// itself: the reach is that of the last surviving offset of the blended
5340    /// kernel.
5341    #[test]
5342    fn test_tabulated_kernel_support_between_refs_keeps_surviving_offsets() {
5343        let offsets = vec![0.0, 10.0, 20.0, 80.0];
5344        let weights = vec![1.0; 4];
5345        let r = TabulatedResolution::from_kernels(
5346            vec![1.0, 4.0],
5347            vec![(offsets.clone(), weights.clone()), (offsets, weights)],
5348            1.0,
5349        )
5350        .expect("valid two-block table");
5351        let e: f64 = 2.0;
5352        let t = TOF_FACTOR / e.sqrt();
5353        assert!(
5354            t > 20.0 && t < 80.0,
5355            "fixture must drop the 80 μs point and keep the 20 μs point (t = {t})"
5356        );
5357        let expected_above = e * ((t / (t - 20.0)).powi(2) - 1.0);
5358        let (_, high) = r.gather_bounds_ev(e);
5359        let above = high - e;
5360        assert!(
5361            (above - expected_above).abs() <= 1e-9 * expected_above,
5362            "reach {above} should be that of the last surviving offset, {expected_above}"
5363        );
5364    }
5365
5366    /// Non-positive / non-finite energy → 0.0 (no broadening footprint).
5367    #[test]
5368    fn test_tabulated_kernel_support_returns_zero_for_invalid_energy() {
5369        let r = synthetic_tab_resolution();
5370        assert_eq!(r.kernel_support_ev(0.0), 0.0);
5371        assert_eq!(r.kernel_support_ev(-1.0), 0.0);
5372        assert_eq!(r.kernel_support_ev(f64::NAN), 0.0);
5373        assert_eq!(r.kernel_support_ev(f64::INFINITY), 0.0);
5374    }
5375
5376    /// Zero-weight tail entries must not inflate the support; only
5377    /// `weights[i] > 0` entries count.
5378    #[test]
5379    fn test_tabulated_kernel_support_ignores_zero_weight_entries() {
5380        // Build a kernel where the outermost entries have weight 0.
5381        let offsets = vec![-10.0, -1.0, 0.0, 1.0, 10.0];
5382        let weights = vec![0.0, 0.5, 1.0, 0.5, 0.0];
5383        let r = TabulatedResolution {
5384            ref_energies: Arc::new(vec![100.0]),
5385            kernels: Arc::new(vec![(offsets, weights)]),
5386            flight_path_m: 25.0,
5387        };
5388        let e: f64 = 100.0;
5389        // Expected support uses dt = ±1.0 (the outermost zero-weight
5390        // entries at ±10 are ignored), not ±10.0.
5391        let expected = exact_support(e, 1.0, 1.0, 25.0);
5392        let got = r.kernel_support_ev(e);
5393        assert!(
5394            (got - expected).abs() / expected < 1e-12,
5395            "support should ignore zero-weight entries: got {got}, expected {expected}"
5396        );
5397    }
5398
5399    /// Between reference kernels, the blended shape is positive on the
5400    /// FRINGE between a block's outermost `w > 0` entry and its
5401    /// adjacent `w == 0` entry (linear interpolation), and a merged
5402    /// point from the other block can land there — so the support's
5403    /// closure extremes (outermost positive weight extended to the
5404    /// adjacent zero-weight entry) must cover the actual blended
5405    /// kernel, which reaches beyond both blocks' bare `w > 0` maxima.
5406    #[test]
5407    fn test_tabulated_kernel_support_covers_blend_activated_offsets() {
5408        // Kernel A's positive support ends at z ≈ 3.5 (offset 5,
5409        // σ_A ≈ 1.44) with a zero-weight fringe out to z ≈ 7.0
5410        // (offset 10). Kernel B is compact (σ_B ≈ 0.97) with a
5411        // low-weight point at z ≈ 5.2 (offset 5) — inside A's fringe
5412        // after width normalization — so the blended kernel is
5413        // positive beyond A's bare w>0 extreme.
5414        let r = TabulatedResolution {
5415            ref_energies: Arc::new(vec![10.0, 1000.0]),
5416            kernels: Arc::new(vec![
5417                (vec![0.0, 5.0, 10.0, 20.0], vec![1.0, 0.1, 0.0, 0.0]),
5418                (vec![0.0, 1.0, 5.0, 6.0], vec![1.0, 0.8, 0.05, 0.0]),
5419            ]),
5420            flight_path_m: 25.0,
5421        };
5422        let e: f64 = 100.0; // strictly between the reference energies
5423        let got = r.kernel_support_ev(e);
5424
5425        // Non-circular cover: the actual blended kernel's w>0 extremes.
5426        let (offs, ws) = test_support::interpolated_kernel(&r, e);
5427        let dt_pos = offs
5428            .iter()
5429            .zip(&ws)
5430            .filter(|&(_, &w)| w > 0.0)
5431            .map(|(&o, _)| o)
5432            .fold(0.0f64, f64::max);
5433        let dt_neg = offs
5434            .iter()
5435            .zip(&ws)
5436            .filter(|&(_, &w)| w > 0.0)
5437            .map(|(&o, _)| -o)
5438            .fold(0.0f64, f64::max);
5439        let actual_excursion = exact_support(e, dt_pos, dt_neg, 25.0);
5440        assert!(
5441            got >= actual_excursion * (1.0 - 1e-12),
5442            "support must cover the actual blended kernel (incl. the \
5443             zero-weight fringe): got {got}, actual {actual_excursion}"
5444        );
5445
5446        // The fringe matters: the actual blend reaches beyond block
5447        // A's bare positive maximum (5 µs) scaled to the target width
5448        // — assert the blend truly is wider than a no-fringe reading
5449        // of block A would suggest, keeping this case load-bearing.
5450        let (_, s_lo) = trapezoidal_moments(&r.kernels[0].0, &r.kernels[0].1);
5451        let (_, s_hi) = trapezoidal_moments(&r.kernels[1].0, &r.kernels[1].1);
5452        let frac = (e.ln() - 10.0f64.ln()) / (1000.0f64.ln() - 10.0f64.ln());
5453        let s_t = s_lo * (s_hi / s_lo).powf(frac);
5454        let bare_positive_bound = 5.0 / s_lo * s_t;
5455        assert!(
5456            dt_pos > bare_positive_bound,
5457            "blend must extend into the zero-weight fringe: dt_pos {dt_pos}, \
5458             bare-positive bound {bare_positive_bound}"
5459        );
5460    }
5461
5462    /// The support must contain the footprint of the ACTUAL broadener:
5463    /// broadening a flat baseline with a single narrow dip must leave
5464    /// every target untouched whose window
5465    /// `[e − support(e), e + support(e)]` excludes the dip.
5466    /// Non-circular by construction — the oracle here is `broaden`
5467    /// itself, not the `exact_support` formula mirror.
5468    #[test]
5469    fn test_tabulated_kernel_support_contains_broadener_footprint() {
5470        // Asymmetric kernel with a dominant delayed (positive) tail.
5471        let r = TabulatedResolution {
5472            ref_energies: Arc::new(vec![100.0]),
5473            kernels: Arc::new(vec![(vec![-1.0, 0.0, 6.0], vec![0.2, 1.0, 0.7])]),
5474            flight_path_m: 25.0,
5475        };
5476        // Dense uniform grid; flat baseline with a single-point dip.
5477        let de = 0.05;
5478        let energies: Vec<f64> = (0..2001).map(|j| 50.0 + de * j as f64).collect();
5479        let f = 1000; // dip mid-grid, at ~100 eV
5480        let e_f = energies[f];
5481        let mut spectrum = vec![1.0; energies.len()];
5482        spectrum[f] = 0.0;
5483        let out = r.broaden(&energies, &spectrum).unwrap();
5484
5485        // Piecewise-linear reads touch spectrum[f] only for
5486        // e′ ∈ (energies[f−1], energies[f+1]); require one extra grid
5487        // step of margin so bracketing/interpolation edge effects
5488        // cannot straddle the window boundary.
5489        let margin = 2.0 * de;
5490        let (mut excluded_below, mut excluded_above) = (0usize, 0usize);
5491        let mut included_differs = false;
5492        let mut upside_differs = false;
5493        for (&e, &o) in energies.iter().zip(out.iter()) {
5494            let s = r.kernel_support_ev(e);
5495            if e + s + margin < e_f || e - s - margin > e_f {
5496                assert!(
5497                    (o - 1.0).abs() < 1e-12,
5498                    "target {e} eV (support {s}) must be untouched by a \
5499                     dip at {e_f} eV outside its window; got {o}"
5500                );
5501                if e < e_f {
5502                    excluded_below += 1;
5503                } else {
5504                    excluded_above += 1;
5505                }
5506            } else if (o - 1.0).abs() > 1e-3 {
5507                included_differs = true;
5508                if e < e_f - margin {
5509                    upside_differs = true;
5510                }
5511            }
5512        }
5513        // Non-vacuity: both exclusion regions were exercised, the dip
5514        // measurably alters at least one in-window target, and the
5515        // delayed tail reaches the dip from BELOW — the direction the
5516        // convolution gather loads.
5517        assert!(
5518            excluded_below > 0 && excluded_above > 0,
5519            "grid must exercise both exclusion regions \
5520             (below: {excluded_below}, above: {excluded_above})"
5521        );
5522        assert!(
5523            included_differs,
5524            "the dip must measurably alter at least one in-window target"
5525        );
5526        assert!(
5527            upside_differs,
5528            "the delayed tail must reach the dip from a target below it"
5529        );
5530    }
5531
5532    /// Same containment property, exercised through the
5533    /// BETWEEN-REFERENCES support arm: two width-scaled reference
5534    /// blocks bracket the grid, so every target energy uses the
5535    /// width-interpolated kernel and the closure-extent support bound.
5536    /// The oracle is `broaden` itself — fully independent of both the
5537    /// support formula and the interpolation implementation.
5538    #[test]
5539    fn test_tabulated_kernel_support_contains_broadener_footprint_between_refs() {
5540        let r = TabulatedResolution {
5541            ref_energies: Arc::new(vec![10.0, 1000.0]),
5542            kernels: Arc::new(vec![
5543                (vec![-2.0, 0.0, 12.0], vec![0.2, 1.0, 0.7]),
5544                (vec![-0.5, 0.0, 3.0], vec![0.2, 1.0, 0.7]),
5545            ]),
5546            flight_path_m: 25.0,
5547        };
5548        let de = 0.05;
5549        let energies: Vec<f64> = (0..2001).map(|j| 50.0 + de * j as f64).collect();
5550        let f = 1000; // dip mid-grid, at ~100 eV — between the refs
5551        let e_f = energies[f];
5552        let mut spectrum = vec![1.0; energies.len()];
5553        spectrum[f] = 0.0;
5554        let out = r.broaden(&energies, &spectrum).unwrap();
5555
5556        let margin = 2.0 * de;
5557        let (mut excluded, mut included_differs) = (0usize, false);
5558        for (&e, &o) in energies.iter().zip(out.iter()) {
5559            let s = r.kernel_support_ev(e);
5560            if e + s + margin < e_f || e - s - margin > e_f {
5561                assert!(
5562                    (o - 1.0).abs() < 1e-12,
5563                    "between-refs target {e} eV (support {s}) must be \
5564                     untouched by a dip at {e_f} eV outside its window; got {o}"
5565                );
5566                excluded += 1;
5567            } else if (o - 1.0).abs() > 1e-3 {
5568                included_differs = true;
5569            }
5570        }
5571        assert!(
5572            excluded > 0,
5573            "grid must exercise the exclusion region between references"
5574        );
5575        assert!(
5576            included_differs,
5577            "the dip must measurably alter at least one in-window target"
5578        );
5579    }
5580
5581    #[test]
5582    fn piecewise_linear_bin_probabilities_pins_delta_and_partial_window() {
5583        let delta = |t: f64, edges: &[f64]| {
5584            piecewise_linear_bin_probabilities(&[t], &[3.5], edges)
5585                .expect("one-point kernel is a unit delta under normalization")
5586        };
5587        assert_eq!(delta(2.0, &[0.0, 1.0, 3.0, 5.0]), vec![0.0, 1.0, 0.0]);
5588        // The last bin is right-closed: a delta exactly on the final edge
5589        // belongs to it.
5590        assert_eq!(delta(5.0, &[0.0, 1.0, 3.0, 5.0]), vec![0.0, 0.0, 1.0]);
5591        // A delta outside every bin contributes nothing.
5592        assert_eq!(delta(9.0, &[0.0, 1.0, 3.0, 5.0]), vec![0.0, 0.0, 0.0]);
5593        let w3 = [0.0, 1.0, 0.0];
5594        assert!(
5595            piecewise_linear_bin_probabilities(
5596                &[0.0, 1.0, 1.0, 2.0],
5597                &[0.0, 1.0, 1.0, 0.0],
5598                &[0.5, 1.5]
5599            )
5600            .is_none()
5601        );
5602        for bad_times in [[0.0, f64::NAN, 2.0], [0.0, 1.0, f64::INFINITY]] {
5603            assert!(piecewise_linear_bin_probabilities(&bad_times, &w3, &[0.5, 1.5]).is_none());
5604        }
5605        for bad_edges in [[0.5, f64::NAN], [1.5, 0.5]] {
5606            assert!(
5607                piecewise_linear_bin_probabilities(&[0.0, 1.0, 2.0], &w3, &bad_edges).is_none()
5608            );
5609        }
5610
5611        // Support normalization: a triangle on [0, 2] integrates to one over
5612        // its full support, and a partial window reports the true fraction.
5613        let times = [0.0, 1.0, 2.0];
5614        let weights = [0.0, 4.0, 0.0]; // arbitrary scale — normalization removes it
5615        let full = piecewise_linear_bin_probabilities(&times, &weights, &[0.0, 2.0])
5616            .expect("triangle integrates");
5617        assert!((full[0] - 1.0).abs() < 1e-15);
5618        let halves = piecewise_linear_bin_probabilities(&times, &weights, &[0.0, 0.5, 1.0])
5619            .expect("triangle integrates");
5620        assert!((halves[0] - 0.125).abs() < 1e-15);
5621        assert!((halves[1] - 0.375).abs() < 1e-15);
5622    }
5623}
5624
5625#[cfg(test)]
5626mod width_convention_tests {
5627    use super::*;
5628
5629    /// Grid half-span, in units of the nominal width. Beyond 6 the remaining
5630    /// Gaussian mass is below 1e-16, so 12 is already generous.
5631    const SPAN_IN_WIDTHS: usize = 12;
5632
5633    /// Grid points per nominal width.
5634    ///
5635    /// Two errors scale as `(step/W)²` here: the trapezoidal second-moment
5636    /// quadrature, and the variance the one-bin-wide impulse carries in its own
5637    /// right (`step²/12`). At 50 samples per width both are ~1e-4 of the
5638    /// measured σ, two orders below the tolerances these tests assert, while
5639    /// the convolution cost scales as the square of the point count.
5640    const SAMPLES_PER_WIDTH: usize = 50;
5641
5642    /// The kernel's second moment, measured numerically rather than taken from
5643    /// the width parameter the kernel was built from.
5644    ///
5645    /// Broadens a unit impulse and reads the standard deviation back off the
5646    /// result. `nominal_width_ev` only sizes the grid — the measurement itself
5647    /// never uses it, so the result cannot agree with `gaussian_width` by
5648    /// construction. That independence is the point of the test.
5649    fn measured_sigma_ev(params: &ResolutionParams, center_ev: f64, nominal_width_ev: f64) -> f64 {
5650        let n: usize = 2 * SPAN_IN_WIDTHS * SAMPLES_PER_WIDTH + 1;
5651        let half_span_ev = SPAN_IN_WIDTHS as f64 * nominal_width_ev;
5652        let step = 2.0 * half_span_ev / (n - 1) as f64;
5653        let energies: Vec<f64> = (0..n)
5654            .map(|i| center_ev - half_span_ev + i as f64 * step)
5655            .collect();
5656        // A unit impulse at the centre bin: broadening it returns the kernel.
5657        let mut impulse = vec![0.0; n];
5658        impulse[(n - 1) / 2] = 1.0 / step;
5659
5660        let kernel = resolution_broaden(&energies, &impulse, params).expect("broadening runs");
5661
5662        let mass: f64 = kernel.iter().sum::<f64>() * step;
5663        assert!(
5664            (mass - 1.0).abs() < 1.0e-3,
5665            "kernel is not normalised on this span: mass {mass}"
5666        );
5667        let mean: f64 = kernel
5668            .iter()
5669            .zip(&energies)
5670            .map(|(k, e)| k * e * step)
5671            .sum::<f64>()
5672            / mass;
5673        let variance: f64 = kernel
5674            .iter()
5675            .zip(&energies)
5676            .map(|(k, e)| k * (e - mean) * (e - mean) * step)
5677            .sum::<f64>()
5678            / mass;
5679        variance.sqrt()
5680    }
5681
5682    /// `gaussian_width` returns W, and W/√2 is the standard deviation.
5683    ///
5684    /// This is the assertion that fixes the convention. If the width were a
5685    /// standard deviation instead, the measured σ would come back a factor √2
5686    /// larger than W/√2 and this fails by 41 %.
5687    #[test]
5688    fn gaussian_width_is_a_w_parameter_not_a_standard_deviation() {
5689        let center = 10.0;
5690        let params = ResolutionParams::new(25.0, 1.0, 0.0, 0.0).expect("valid params");
5691        let w = params.gaussian_width(center);
5692        assert!(w > 0.0, "test is vacuous without a width");
5693
5694        let measured = measured_sigma_ev(&params, center, w);
5695        let expected = w / SQRT_2;
5696        assert!(
5697            (measured - expected).abs() / expected < 2.0e-3,
5698            "measured σ {measured:.9} but W/√2 is {expected:.9} (W = {w:.9})"
5699        );
5700        // And it is NOT the standard deviation itself, by a clear margin.
5701        assert!(
5702            (measured - w).abs() / w > 0.25,
5703            "measured σ {measured:.9} is indistinguishable from W {w:.9}"
5704        );
5705    }
5706
5707    /// `fwhm()` is the full width at half maximum of the kernel it describes.
5708    #[test]
5709    fn fwhm_matches_the_kernel_measured_half_maximum() {
5710        let center = 10.0;
5711        let params = ResolutionParams::new(25.0, 1.0, 0.0, 0.0).expect("valid params");
5712        let w = params.gaussian_width(center);
5713        let measured_sigma = measured_sigma_ev(&params, center, w);
5714        // FWHM of a Gaussian in terms of its own standard deviation.
5715        let fwhm_from_measured = 2.0 * (2.0 * 2.0_f64.ln()).sqrt() * measured_sigma;
5716        let reported = params.fwhm(center);
5717        assert!(
5718            (reported - fwhm_from_measured).abs() / fwhm_from_measured < 2.0e-3,
5719            "fwhm() reports {reported:.9} but the kernel measures {fwhm_from_measured:.9}"
5720        );
5721    }
5722
5723    /// `FWHM_PER_W` is exactly what the previous open-coded expression produced.
5724    #[test]
5725    fn fwhm_per_w_constant_is_bit_exact() {
5726        assert_eq!(
5727            FWHM_PER_W.to_bits(),
5728            (2.0 * (2.0_f64.ln()).sqrt()).to_bits(),
5729            "the named constant changed the value it replaced"
5730        );
5731    }
5732
5733    /// `from_sigma` accepts a standard deviation and produces that σ.
5734    #[test]
5735    fn from_sigma_takes_a_standard_deviation() {
5736        let sigma_t_us = 1.0;
5737        let from_sigma = ResolutionParams::from_sigma(25.0, sigma_t_us, 0.0, 0.0).expect("valid");
5738        let direct = ResolutionParams::new(25.0, sigma_t_us, 0.0, 0.0).expect("valid");
5739
5740        // The converted parameters are √2 wider than the raw ones.
5741        assert!(
5742            (from_sigma.delta_t_us() - sigma_t_us * SQRT_2).abs() < 1.0e-15,
5743            "from_sigma did not apply W = σ·√2"
5744        );
5745
5746        // And the kernel it builds has the σ the caller asked for, in energy:
5747        // σ_E = 2·σ_t·E^{3/2}/(TOF_FACTOR·L), the ordinary propagation.
5748        let center = 10.0_f64;
5749        let expected_sigma_e =
5750            2.0 * sigma_t_us * center.powf(1.5) / (TOF_FACTOR * from_sigma.flight_path_m());
5751        let measured = measured_sigma_ev(&from_sigma, center, from_sigma.gaussian_width(center));
5752        assert!(
5753            (measured - expected_sigma_e).abs() / expected_sigma_e < 2.0e-3,
5754            "from_sigma kernel measures σ {measured:.9}, asked for {expected_sigma_e:.9}"
5755        );
5756        // The direct constructor, given the same number, is √2 narrower —
5757        // which is exactly the error the documentation used to invite.
5758        let measured_direct = measured_sigma_ev(&direct, center, direct.gaussian_width(center));
5759        assert!(
5760            (measured_direct * SQRT_2 - measured).abs() / measured < 5.0e-3,
5761            "the two constructors do not differ by √2"
5762        );
5763    }
5764
5765    /// `from_fwhm` accepts a full width at half maximum.
5766    #[test]
5767    fn from_fwhm_takes_a_full_width_at_half_maximum() {
5768        let fwhm_t_us = 1.0;
5769        let params = ResolutionParams::from_fwhm(25.0, fwhm_t_us, 0.0, 0.0).expect("valid");
5770        assert!(
5771            (params.delta_t_us() - fwhm_t_us / FWHM_PER_W).abs() < 1.0e-15,
5772            "from_fwhm did not apply W = FWHM/(2√ln2)"
5773        );
5774    }
5775
5776    /// `from_fwhm` is the conversion SAMMY's `Deltag` needs.
5777    ///
5778    /// `nereids_endf::sammy::sammy_to_nereids_resolution` divides Deltag by
5779    /// 2√(ln 2) to reach this module's convention; the two must agree, because
5780    /// the samtry baselines depend on that mapping being the right one.
5781    #[test]
5782    fn from_fwhm_agrees_with_the_sammy_deltag_conversion() {
5783        let delta_g = 0.022_f64; // tr007's BROADENING card value.
5784        let via_constructor = ResolutionParams::from_fwhm(25.0, delta_g, 0.0, 0.0).expect("valid");
5785        let sammy_mapping = delta_g / (2.0 * 2.0_f64.ln().sqrt());
5786        assert!(
5787            (via_constructor.delta_t_us() - sammy_mapping).abs() < 1.0e-15,
5788            "from_fwhm disagrees with the SAMMY Deltag conversion"
5789        );
5790    }
5791}
5792
5793#[cfg(test)]
5794mod flight_path_rebinding_tests {
5795    use super::*;
5796
5797    /// Rebinding the flight path scales the Gaussian energy width by 1/s.
5798    ///
5799    /// The oracle is the analytic law, not the code: a timing width Δt maps to
5800    /// an energy width `W_E = 2·Δt·E^{3/2}/(F·L)`, so the same Δt read against
5801    /// `L·s` gives `W_E/s`. A fit that frees `L_scale` builds its grid with
5802    /// `L·L_scale`; if the kernel keeps `L`, this is exactly the factor it is
5803    /// wrong by.
5804    #[test]
5805    fn rebinding_scales_the_gaussian_width_inversely() {
5806        let l_nom = 25.0;
5807        let delta_t = 1.0;
5808        let base = ResolutionParams::new(l_nom, delta_t, 0.0, 0.0).expect("valid");
5809        for s in [0.97_f64, 1.0, 1.03, 1.25] {
5810            let rebound = ResolutionFunction::Gaussian(base)
5811                .with_flight_path(l_nom * s)
5812                .expect("positive flight path");
5813            let ResolutionFunction::Gaussian(rebound) = rebound else {
5814                panic!("rebinding changed the family");
5815            };
5816            for e in [5.0_f64, 20.0, 100.0] {
5817                let analytic = 2.0 * delta_t * e.powf(1.5) / (TOF_FACTOR * l_nom * s);
5818                let got = rebound.gaussian_width(e);
5819                assert!(
5820                    (got - analytic).abs() / analytic < 1.0e-14,
5821                    "s={s}, E={e}: width {got:e} but the law gives {analytic:e}"
5822                );
5823                // And it is the base width divided by s, which is the
5824                // statement the fit depends on.
5825                let base_w = base.gaussian_width(e);
5826                assert!(
5827                    (got - base_w / s).abs() / (base_w / s) < 1.0e-14,
5828                    "s={s}, E={e}: rebound width is not base/s"
5829                );
5830            }
5831        }
5832    }
5833
5834    /// Rebinding does not touch the tabulated kernel itself.
5835    ///
5836    /// The stored offsets are emission times. The flight path belongs to the
5837    /// map they are applied through, so a rebind must leave every offset and
5838    /// weight byte-identical — if it resynthesized or rescaled them it would
5839    /// be changing the moderator, not the geometry.
5840    #[test]
5841    fn rebinding_leaves_the_tabulated_kernel_byte_identical() {
5842        let offsets = vec![-2.0, -1.0, 0.0, 1.0, 3.0];
5843        let weights = vec![0.1, 0.6, 1.0, 0.5, 0.05];
5844        let base = TabulatedResolution::from_kernels(
5845            vec![5.0, 50.0],
5846            vec![
5847                (offsets.clone(), weights.clone()),
5848                (offsets.clone(), weights.clone()),
5849            ],
5850            25.0,
5851        )
5852        .expect("valid kernel table");
5853
5854        let rebound = base.with_flight_path(25.0 * 1.03).expect("positive");
5855        assert_eq!(rebound.flight_path_m(), 25.0 * 1.03);
5856        assert_eq!(base.ref_energies, rebound.ref_energies);
5857        for (i, ((b_off, b_w), (r_off, r_w))) in
5858            base.kernels.iter().zip(rebound.kernels.iter()).enumerate()
5859        {
5860            assert_eq!(
5861                b_off.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
5862                r_off.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
5863                "rebinding moved the emission-time offsets of block {i}"
5864            );
5865            assert_eq!(
5866                b_w.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
5867                r_w.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
5868                "rebinding changed the weights of block {i}"
5869            );
5870        }
5871    }
5872
5873    /// A non-positive flight path is refused rather than silently accepted.
5874    #[test]
5875    fn rebinding_refuses_a_non_physical_flight_path() {
5876        let params = ResolutionParams::new(25.0, 1.0, 0.0, 0.0).expect("valid");
5877        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
5878            assert!(
5879                ResolutionFunction::Gaussian(params)
5880                    .with_flight_path(bad)
5881                    .is_err(),
5882                "flight path {bad} was accepted"
5883            );
5884        }
5885    }
5886}