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