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