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[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).
55const TOF_FACTOR: f64 = 72.298_254_398_292_8;
56
57/// Errors from resolution broadening operations.
58#[derive(Debug, PartialEq)]
59pub enum ResolutionError {
60    /// The energy grid is not sorted in ascending order.
61    UnsortedEnergies,
62    /// The energy grid and data arrays have mismatched lengths.
63    LengthMismatch { energies: usize, data: usize },
64    /// A [`ResolutionPlan`] was passed together with an `energies`
65    /// slice that does not match the grid the plan was built for.
66    ///
67    /// Cheapest-available check hierarchy: length mismatch is caught
68    /// first via [`Self::LengthMismatch`] (`plan.len() ==
69    /// energies.len()` is necessary but not sufficient); a content
70    /// mismatch fires `PlanGridMismatch` with the index of the first
71    /// differing element so callers can diagnose silent-staleness
72    /// bugs at the cache layer.
73    PlanGridMismatch { first_diff_index: usize },
74    /// A [`ResolutionMatrix`] was passed together with an `energies`
75    /// slice that does not match the grid the matrix was compiled for.
76    /// Same semantics as [`Self::PlanGridMismatch`] but for the CSR
77    /// path (see [`apply_r`]).
78    MatrixGridMismatch { first_diff_index: usize },
79}
80
81impl fmt::Display for ResolutionError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::UnsortedEnergies => write!(
85                f,
86                "energy grid must be sorted in non-descending order for binary search"
87            ),
88            Self::LengthMismatch { energies, data } => write!(
89                f,
90                "energy grid length ({}) must match data length ({})",
91                energies, data
92            ),
93            Self::PlanGridMismatch { first_diff_index } => write!(
94                f,
95                "resolution plan was built for a different energy grid than was \
96                 passed to apply_resolution_with_plan (first differing index: {})",
97                first_diff_index,
98            ),
99            Self::MatrixGridMismatch { first_diff_index } => write!(
100                f,
101                "resolution matrix was compiled for a different energy grid than was \
102                 passed to apply_resolution_with_matrix (first differing index: {})",
103                first_diff_index,
104            ),
105        }
106    }
107}
108
109impl std::error::Error for ResolutionError {}
110
111/// Errors from `ResolutionParams` construction.
112#[derive(Debug, PartialEq)]
113pub enum ResolutionParamsError {
114    /// Flight path must be positive and finite.
115    InvalidFlightPath(f64),
116    /// Timing uncertainty must be non-negative and finite.
117    InvalidDeltaT(f64),
118    /// Path length uncertainty must be non-negative and finite.
119    InvalidDeltaL(f64),
120    /// Exponential tail parameter must be non-negative and finite.
121    InvalidDeltaE(f64),
122}
123
124impl fmt::Display for ResolutionParamsError {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        match self {
127            Self::InvalidFlightPath(v) => {
128                write!(f, "flight_path_m must be positive and finite, got {v}")
129            }
130            Self::InvalidDeltaT(v) => {
131                write!(f, "delta_t_us must be non-negative and finite, got {v}")
132            }
133            Self::InvalidDeltaL(v) => {
134                write!(f, "delta_l_m must be non-negative and finite, got {v}")
135            }
136            Self::InvalidDeltaE(v) => {
137                write!(f, "delta_e_us must be non-negative and finite, got {v}")
138            }
139        }
140    }
141}
142
143impl std::error::Error for ResolutionParamsError {}
144
145/// Resolution function parameters for time-of-flight instruments.
146#[derive(Debug, Clone, Copy)]
147pub struct ResolutionParams {
148    /// Flight path length in meters (source to detector).
149    flight_path_m: f64,
150    /// Total timing uncertainty (1σ Gaussian) in microseconds.
151    /// Combines moderator pulse width, detector timing, and electronics.
152    delta_t_us: f64,
153    /// Flight path uncertainty (1σ Gaussian) in meters.
154    delta_l_m: f64,
155    /// Exponential tail parameter (SAMMY Deltae, raw SAMMY units).
156    ///
157    /// When zero, pure Gaussian broadening is used (SAMMY Iesopr=1).
158    /// When positive, the kernel is the convolution of a Gaussian with an
159    /// exponential tail (SAMMY Iesopr=3).
160    ///
161    /// SAMMY Ref: `RslResolutionFunction_M.f90` getCo2, `rsl/mrsl4.f90` Wdsint.
162    delta_e_us: f64,
163}
164
165impl ResolutionParams {
166    /// Create validated resolution parameters.
167    ///
168    /// # Arguments
169    /// * `flight_path_m` — Flight path length in meters (must be > 0).
170    /// * `delta_t_us` — Timing uncertainty in microseconds (must be >= 0).
171    /// * `delta_l_m` — Flight path uncertainty in meters (must be >= 0).
172    /// * `delta_e_us` — Exponential tail parameter in SAMMY Deltae units
173    ///   (must be >= 0). When 0, pure Gaussian broadening is used.
174    ///
175    /// # Errors
176    /// Returns `ResolutionParamsError::InvalidFlightPath` if `flight_path_m <= 0.0`
177    /// or is not finite.
178    /// Returns `ResolutionParamsError::InvalidDeltaT` if `delta_t_us < 0.0` or is
179    /// not finite.
180    /// Returns `ResolutionParamsError::InvalidDeltaL` if `delta_l_m < 0.0` or is
181    /// not finite.
182    /// Returns `ResolutionParamsError::InvalidDeltaE` if `delta_e_us < 0.0` or is
183    /// not finite.
184    pub fn new(
185        flight_path_m: f64,
186        delta_t_us: f64,
187        delta_l_m: f64,
188        delta_e_us: f64,
189    ) -> Result<Self, ResolutionParamsError> {
190        if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
191            return Err(ResolutionParamsError::InvalidFlightPath(flight_path_m));
192        }
193        if !delta_t_us.is_finite() || delta_t_us < 0.0 {
194            return Err(ResolutionParamsError::InvalidDeltaT(delta_t_us));
195        }
196        if !delta_l_m.is_finite() || delta_l_m < 0.0 {
197            return Err(ResolutionParamsError::InvalidDeltaL(delta_l_m));
198        }
199        if !delta_e_us.is_finite() || delta_e_us < 0.0 {
200            return Err(ResolutionParamsError::InvalidDeltaE(delta_e_us));
201        }
202        Ok(Self {
203            flight_path_m,
204            delta_t_us,
205            delta_l_m,
206            delta_e_us,
207        })
208    }
209
210    /// Returns the flight path length in meters.
211    #[must_use]
212    pub fn flight_path_m(&self) -> f64 {
213        self.flight_path_m
214    }
215
216    /// Total timing uncertainty (1σ Gaussian) in microseconds.
217    ///
218    /// The factor of 2 in [`gaussian_width()`](Self::gaussian_width) comes from
219    /// the energy-TOF derivative dE/E = 2·dt/t, not from a σ-to-FWHM conversion.
220    #[must_use]
221    pub fn delta_t_us(&self) -> f64 {
222        self.delta_t_us
223    }
224
225    /// Returns the flight path uncertainty (1σ Gaussian) in meters.
226    #[must_use]
227    pub fn delta_l_m(&self) -> f64 {
228        self.delta_l_m
229    }
230
231    /// Returns the exponential tail parameter (SAMMY Deltae units).
232    #[must_use]
233    pub fn delta_e_us(&self) -> f64 {
234        self.delta_e_us
235    }
236
237    /// Whether the exponential tail is active (Deltae > 0, SAMMY Iesopr=3).
238    #[must_use]
239    pub fn has_exponential_tail(&self) -> bool {
240        self.delta_e_us > NEAR_ZERO_FLOOR
241    }
242
243    /// Exponential tail width Widexp(E) in eV.
244    ///
245    /// SAMMY Ref: `rsl/mrsl4.f90` Wdsint lines 55-56 (Kedxfw=false path):
246    ///   `Widexp = E * Co2 * sqrt(E)` where `Co2 = 2·Deltae / (Sm2·Dist)`.
247    ///
248    /// Combined: `Widexp = 2·Deltae·E^(3/2) / (TOF_FACTOR·L)`.
249    #[must_use]
250    pub fn exp_width(&self, energy_ev: f64) -> f64 {
251        if energy_ev <= 0.0 || self.delta_e_us <= 0.0 {
252            return 0.0;
253        }
254        2.0 * self.delta_e_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m)
255    }
256
257    /// Gaussian resolution width σ_E(E) in eV.
258    ///
259    /// Combines timing and flight-path contributions in quadrature:
260    ///   σ_E² = (2·Δt/t × E)² + (2·ΔL/L × E)²
261    ///
262    /// where t = TOF_FACTOR × L / √E is the time-of-flight in μs.
263    #[must_use]
264    pub fn gaussian_width(&self, energy_ev: f64) -> f64 {
265        if energy_ev <= 0.0 || self.flight_path_m <= 0.0 {
266            return 0.0;
267        }
268
269        // Timing contribution: σ_t = 2 × Δt × E^(3/2) / (TOF_FACTOR × L)
270        let timing =
271            2.0 * self.delta_t_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m);
272
273        // Path length contribution: σ_L = 2 × ΔL × E / L
274        let path = 2.0 * self.delta_l_m * energy_ev / self.flight_path_m;
275
276        (timing * timing + path * path).sqrt()
277    }
278
279    /// FWHM of the resolution function at energy E, in eV.
280    #[must_use]
281    pub fn fwhm(&self, energy_ev: f64) -> f64 {
282        2.0 * (2.0_f64.ln()).sqrt() * self.gaussian_width(energy_ev)
283    }
284}
285
286/// Apply Gaussian resolution broadening to cross-section data.
287///
288/// Convolves the input cross-sections with a Gaussian kernel whose width
289/// varies with energy according to the instrument resolution function.
290///
291/// # Arguments
292/// * `energies` — Energy grid in eV (must be sorted ascending).
293/// * `cross_sections` — Cross-sections in barns at each energy point.
294/// * `params` — Resolution function parameters.
295///
296/// # Returns
297/// Resolution-broadened cross-sections on the same energy grid.
298///
299/// # Errors
300/// Returns [`ResolutionError::LengthMismatch`] if the arrays differ in length,
301/// or [`ResolutionError::UnsortedEnergies`] if the energy grid is not sorted
302/// in non-descending order.
303pub fn resolution_broaden(
304    energies: &[f64],
305    cross_sections: &[f64],
306    params: &ResolutionParams,
307) -> Result<Vec<f64>, ResolutionError> {
308    validate_inputs(energies, cross_sections)?;
309    Ok(resolution_broaden_presorted(
310        energies,
311        cross_sections,
312        params,
313    ))
314}
315
316/// Check that the energy grid is sorted and that its length matches the data.
317fn validate_inputs(energies: &[f64], data: &[f64]) -> Result<(), ResolutionError> {
318    if energies.len() != data.len() {
319        return Err(ResolutionError::LengthMismatch {
320            energies: energies.len(),
321            data: data.len(),
322        });
323    }
324    if !energies.windows(2).all(|w| w[0] <= w[1]) {
325        return Err(ResolutionError::UnsortedEnergies);
326    }
327    Ok(())
328}
329
330// ─── Xcoef quadrature weights ──────────────────────────────────────────────────
331
332/// Compute SAMMY's 4-point quadrature weights for a non-uniform energy grid.
333///
334/// Replaces the simple trapezoidal rule `de = (E[j+1] - E[j-1]) / 2` with
335/// SAMMY's higher-order scheme from Eq. IV B 3.8 (page 80 of SAMMY manual R3).
336///
337/// SAMMY Ref: `convolution/DopplerAndResolutionBroadener.cpp`, `setXcoefWeights()`.
338///
339/// The weights include a correction term x2(k) that accounts for non-uniform
340/// grid spacing, providing 4th-order accuracy on smooth grids.
341///
342/// Note: the returned weights are 12x the quantity in Eq. IV B 3.8. This
343/// constant factor cancels during normalization (sum/norm), so the broadened
344/// result is independent of the scaling.
345fn compute_xcoef_weights(energies: &[f64]) -> Vec<f64> {
346    let n = energies.len();
347    if n == 0 {
348        return vec![];
349    }
350    if n == 1 {
351        return vec![1.0];
352    }
353
354    // SAMMY's 4-point quadrature weights (Eq. IV B 3.8, SAMMY Manual R3 p80).
355    //
356    // Uses a sliding window of 5 consecutive energies E[0..4] to compute
357    // coefficients A[0..5] at each grid point k:
358    //
359    //   A[0] = v1  (k >= 2)
360    //   A[1] = 5·v2  (k >= 1)
361    //   A[2] = 5·v3  (k < n-1)
362    //   A[3] = v4  (k < n-2)
363    //   A[4] = (v3² - v1²)/v2   curvature correction  (k >= 2)
364    //   A[5] = -(v4² - v2²)/v3  curvature correction  (k >= 1)
365    //
366    // where v1..v4 are consecutive grid spacings around point k.
367    //
368    // The result is 12× Eq. IV B 3.8; this constant factor cancels during
369    // normalization (sum/norm) in the broadening loop.
370    //
371    // SAMMY Ref: `convolution/DopplerAndResolutionBroadener.cpp` lines 365-457
372    let mut weights = vec![0.0f64; n];
373
374    // Sliding window: e[j] holds energies relative to current k.
375    // At loop start for k: e[0]=E[k-2], e[1]=E[k-1], e[2]=E[k],
376    //                       e[3]=E[k+1], e[4]=E[k+2]
377    // Out-of-bounds positions are 0.0 (matching SAMMY's convention).
378    let mut e = [0.0f64; 5];
379    e[3] = energies[0];
380    if n > 1 {
381        e[4] = energies[1];
382    }
383
384    for k in 0..n {
385        // Shift window left.
386        e[0] = e[1];
387        e[1] = e[2];
388        e[2] = e[3];
389        e[3] = e[4];
390        e[4] = if k + 2 < n { energies[k + 2] } else { 0.0 };
391
392        let v1 = e[1] - e[0];
393        let v2 = e[2] - e[1];
394        let v3 = e[3] - e[2];
395        let v4 = e[4] - e[3];
396
397        let mut a = [0.0f64; 6];
398
399        if k >= 2 {
400            a[0] = v1;
401            // Curvature correction: x2(k-2) = (v3² - v1²) / v2
402            if v2.abs() > NEAR_ZERO_FLOOR {
403                a[4] = (v3 * v3 - v1 * v1) / v2;
404            }
405        }
406        if k >= 1 {
407            a[1] = 5.0 * v2;
408            // Curvature correction: -x2(k-1) = -(v4² - v2²) / v3
409            if v3.abs() > NEAR_ZERO_FLOOR {
410                a[5] = -(v4 * v4 - v2 * v2) / v3;
411            }
412        }
413        if k != n - 1 {
414            a[2] = 5.0 * v3;
415        }
416        if k < n.saturating_sub(2) {
417            a[3] = v4;
418        }
419
420        // Boundary overrides (SAMMY source lines 446-450).
421        if k == n.saturating_sub(2) {
422            a[5] = 0.0;
423        }
424        if k == n - 1 {
425            a[4] = 0.0;
426            a[5] = 0.0;
427        }
428
429        weights[k] = a.iter().sum::<f64>();
430    }
431
432    weights
433}
434
435/// Compute erfc(x) using the existing `exerfc` function.
436///
437/// erfc(x) = exp(-x²) · exerfc(x) / √π
438///
439/// For x < 0: erfc(-|x|) = 2 - erfc(|x|)
440fn erfc_from_exerfc(x: f64) -> f64 {
441    const SQRT_PI: f64 = 1.772_453_850_905_516;
442    if x >= 0.0 {
443        (-x * x).exp() * exerfc(x) / SQRT_PI
444    } else {
445        let xp = -x;
446        2.0 - (-xp * xp).exp() * exerfc(xp) / SQRT_PI
447    }
448}
449
450// ─── Scaled complementary error function ───────────────────────────────────────
451
452/// Compute exp(x²)·erfc(x)·√π, numerically stable for all x.
453///
454/// SAMMY Ref: `fnc/exerfc.f90`.
455///
456/// Uses rational approximation for |x| < 5.01 and asymptotic expansion
457/// (Abramowitz & Stegun 7.1.23) for |x| >= 5.01.
458pub(crate) fn exerfc(x: f64) -> f64 {
459    const SQRT_PI: f64 = 1.772_453_850_905_516;
460    const TWO_SQRT_PI: f64 = 3.544_907_701_811_032;
461    const XMAX: f64 = 5.01;
462    // Rational approximation coefficients (from SAMMY's exerfc.f90)
463    const A1: f64 = 8.584_076_57e-1;
464    const A2: f64 = 3.078_181_93e-1;
465    const A3: f64 = 6.383_238_91e-2;
466    const A4: f64 = 1.824_050_75e-4;
467    const A5: f64 = 6.509_742_65e-1;
468    const A6: f64 = 2.294_848_19e-1;
469    const A7: f64 = 3.403_018_23e-2;
470
471    if x < 0.0 {
472        let xp = -x;
473        if xp > XMAX {
474            TWO_SQRT_PI - asympt(xp)
475        } else {
476            let a =
477                (A1 + xp * (A2 + xp * (A3 - xp * A4))) / (1.0 + xp * (A5 + xp * (A6 + xp * A7)));
478            let b = SQRT_PI + xp * (2.0 - a);
479            let a_rat = b / (xp * b + 1.0);
480            TWO_SQRT_PI * (x * x).exp() - a_rat
481        }
482    } else if x > XMAX {
483        asympt(x)
484    } else if x > 0.0 {
485        let a = (A1 + x * (A2 + x * (A3 - x * A4))) / (1.0 + x * (A5 + x * (A6 + x * A7)));
486        let b = SQRT_PI + x * (2.0 - a);
487        b / (x * b + 1.0)
488    } else {
489        SQRT_PI
490    }
491}
492
493/// Asymptotic expansion of exp(x²)·erfc(x)·√π for large positive x.
494///
495/// SAMMY Ref: `fnc/exerfc.f90`, Asympt function.
496/// Uses Abramowitz & Stegun 7.1.23.
497fn asympt(x: f64) -> f64 {
498    if x == 0.0 {
499        return 0.0;
500    }
501    let e = 1.0 / x;
502    if e == 0.0 {
503        return 0.0;
504    }
505    let b = 1.0 / (x * x);
506    let mut a = 1.0;
507    let mut c = b * 0.5;
508    for n in 1..=40 {
509        a -= c;
510        c *= -(n as f64 + 0.5) * b;
511        if (a - c) == a || (c / a).abs() < 1e-8 {
512            break;
513        }
514    }
515    a * e
516}
517
518/// Compute the Gaussian+exponential combined kernel weight Z(A, B).
519///
520/// Returns √π · exp(-A² + B²) · erfc(B), computed via exerfc for stability.
521///
522/// SAMMY Ref: `rsl/mrsl1.f90` lines 467-484 (Resbrd, Iesopr=3 path).
523///
524/// When B >= 0: `Z = exp(-A²) · Exerfc(B)`
525/// When B < 0:  `Z = Xxerfc(B, A)` which is the same mathematical function
526///   computed with different numerical strategy for stability.
527fn gauss_exp_kernel(a: f64, b: f64) -> f64 {
528    if b >= 0.0 {
529        let exp_neg_a2 = (-a * a).exp();
530        if exp_neg_a2 == 0.0 {
531            return 0.0;
532        }
533        exp_neg_a2 * exerfc(b)
534    } else {
535        // Xxerfc(B, A): compute exp(-A² + B²) · erfc(-B) · √π
536        // Using the same rational approximation as exerfc but for negative B.
537        //
538        // SAMMY Ref: `fnc/xxerfc.f90`.
539        xxerfc(b, a)
540    }
541}
542
543/// Compute exp(-xxx² + xx²) · erfc(-xx) · √π for xx assumed negative (B < 0).
544///
545/// SAMMY Ref: `fnc/xxerfc.f90`. Note: SAMMY says "Xx is assumed positive"
546/// but the caller passes B < 0 as Xx. The code handles this by immediately
547/// computing X = -Xx (which is positive).
548///
549/// When x = -xx exceeds XMAX, the rational approximation loses accuracy.
550/// We switch to `exp(-xxx²) · asympt(x)`, mirroring exerfc's large-argument
551/// path.
552fn xxerfc(xx: f64, xxx: f64) -> f64 {
553    const SQRT_PI: f64 = 1.772_453_850_905_516;
554    const XMAX: f64 = 5.01;
555    const A1: f64 = 8.584_076_57e-1;
556    const A2: f64 = 3.078_181_93e-1;
557    const A3: f64 = 6.383_238_91e-2;
558    const A4: f64 = 1.824_050_75e-4;
559    const A5: f64 = 6.509_742_65e-1;
560    const A6: f64 = 2.294_848_19e-1;
561    const A7: f64 = 3.403_018_23e-2;
562
563    let x = -xx; // x is positive (xx is B < 0)
564
565    // For large x, the rational approximation loses accuracy.
566    // exp(-xxx² + x²)·erfc(x)·√π = exp(-xxx²)·[exp(x²)·erfc(x)·√π]
567    //                              = exp(-xxx²)·asympt(x)
568    if x > XMAX {
569        return (-xxx * xxx).exp() * asympt(x);
570    }
571
572    let a_rat = (A1 + x * (A2 + x * (A3 - x * A4))) / (1.0 + x * (A5 + x * (A6 + x * A7)));
573    let b_int = SQRT_PI + x * (2.0 - a_rat);
574    let a_final = b_int / (x * b_int + 1.0);
575    // exp(-xxx² + x²) = exp(-A² + B²) since x = -B, xx = B
576    let exp_term = (-xxx * xxx + x * x).exp();
577    SQRT_PI * 2.0 * exp_term - a_final * (-xxx * xxx).exp()
578}
579
580/// Compute the energy shift for the Gaussian+exponential kernel peak.
581///
582/// Finds the peak of the combined kernel relative to E=0 via Newton-Raphson
583/// iteration. This centers the convolution window on the kernel maximum.
584///
585/// SAMMY Ref: `rsl/mrsl5.f90`, Shftge function.
586///
587/// # Arguments
588/// * `c` — Mixing parameter: Widgau / (2·Widexp)
589/// * `widgau` — Gaussian resolution width (eV)
590///
591/// # Returns
592/// The energy shift Est (eV) to apply to the measurement energy.
593fn shftge(c: f64, widgau: f64) -> f64 {
594    const ONE_OVER_SQRT_PI: f64 = 0.564_189_583_547_756_3;
595    const SMALL: f64 = 0.01;
596
597    let ax = c;
598    let bx = widgau;
599
600    // Initial guess
601    let mut x0 = if ax > ONE_OVER_SQRT_PI { ax } else { 0.0 };
602
603    let f0_initial = ax * exerfc(x0) - 1.0;
604    let mut f0 = f0_initial;
605    let fff = f0;
606
607    for _iter in 0..100 {
608        let f = ax * exerfc(x0) - 1.0;
609        let xma = x0 - ax;
610        let q = 1.0 - 2.0 * x0 * xma;
611        let delx = if q.abs() < NEAR_ZERO_FLOOR {
612            // q ≈ 0: division would overflow; accept current estimate.
613            break;
614        } else if xma * xma - q * f > 0.0 {
615            let disc = (xma * xma - q * f).sqrt();
616            if xma > 0.0 {
617                (-xma + disc) / q
618            } else {
619                (-xma - disc) / q
620            }
621        } else {
622            if xma.abs() < NEAR_ZERO_FLOOR {
623                break;
624            }
625            -f * 0.5 / xma
626        };
627        let x1 = x0 + delx;
628        let shftg = (ax - x1) * bx;
629        if (x1 - x0).abs() / x1.abs().max(1.0) < SMALL
630            && fff.abs() > NEAR_ZERO_FLOOR
631            && (f - f0).abs() / fff.abs() < SMALL
632        {
633            return shftg;
634        }
635        f0 = f;
636        x0 = x1;
637    }
638
639    (ax - x0) * bx
640}
641
642/// Threshold for the ratio C = W_g / (2·W_e) above which the exponential
643/// tail is negligible and the pure Gaussian PW-linear path is used instead.
644///
645/// At C = 2.5, erfc(2.5) ≈ 0.0005, so the exp tail contributes <0.05% of the
646/// kernel integral.  Using the pure Gaussian path at this threshold introduces
647/// negligible systematic error while enabling the more accurate PW-linear
648/// integration and adaptive intermediate point insertion.
649const EXP_TAIL_NEGLIGIBLE_C: f64 = 2.5;
650
651/// Resolution broadening assuming the energy grid is already validated
652/// (sorted ascending, same length as cross_sections).
653///
654/// For each broadening energy, selects the optimal integration method:
655/// - **PW-linear Gaussian** (exact, second-order): when `delta_e == 0` or
656///   the ratio C = W_g/(2·W_e) > [`EXP_TAIL_NEGLIGIBLE_C`] (exp tail negligible).
657/// - **Combined Gaussian+exp kernel** with SAMMY Xcoef quadrature: when the
658///   exponential tail is significant (C ≤ threshold).
659///
660/// SAMMY Ref: `rsl/mrsl1.f90` Resbrd, `convolution/DopplerAndResolutionBroadener.cpp`
661pub(crate) fn resolution_broaden_presorted(
662    energies: &[f64],
663    cross_sections: &[f64],
664    params: &ResolutionParams,
665) -> Vec<f64> {
666    let n = energies.len();
667    if n == 0 {
668        return vec![];
669    }
670
671    // Precompute Xcoef weights (used only by the combined kernel path).
672    // Even if some energies take the PW-linear path, we compute weights for
673    // the full grid — cheaper than branching per-energy.
674    let xcoef = if params.has_exponential_tail() {
675        compute_xcoef_weights(energies)
676    } else {
677        vec![]
678    };
679    let n_sigma = 5.0; // Integrate out to 5σ for Gaussian
680    let mut broadened = vec![0.0f64; n];
681
682    for i in 0..n {
683        let e = energies[i];
684        let widgau = params.gaussian_width(e);
685
686        if widgau < NEAR_ZERO_FLOOR {
687            broadened[i] = cross_sections[i];
688            continue;
689        }
690
691        // Per-energy decision: use combined kernel only when the exp tail
692        // is significant at THIS energy.
693        let widexp = params.exp_width(e);
694        let use_combined =
695            widexp > NEAR_ZERO_FLOOR && widgau / (2.0 * widexp) <= EXP_TAIL_NEGLIGIBLE_C;
696
697        // Compute integration limits.
698        let (e_low, e_high) = if use_combined {
699            // SAMMY Ref: mrsl4.f90 lines 57-65
700            let wlow = n_sigma * widgau;
701            let rwid = widgau / widexp;
702            let wup = if rwid <= 1.0 {
703                6.25 * widexp
704            } else if rwid <= 2.0 {
705                n_sigma * (3.0 - rwid) * widgau
706            } else {
707                n_sigma * widgau
708            };
709            (e - wlow, e + wup)
710        } else {
711            (e - n_sigma * widgau, e + n_sigma * widgau)
712        };
713
714        let j_lo = energies.partition_point(|&ej| ej < e_low);
715        let j_hi = energies.partition_point(|&ej| ej <= e_high);
716
717        if j_hi.saturating_sub(j_lo) <= 1 {
718            broadened[i] = cross_sections[i];
719            continue;
720        }
721
722        let mut sum = 0.0;
723        let mut norm = 0.0;
724
725        if use_combined {
726            // Combined Gaussian + exponential kernel (SAMMY Iesopr=3)
727            // with 4-point Xcoef quadrature weights.
728            // SAMMY Ref: mrsl1.f90 lines 455-484
729            let c = widgau * 0.5 / widexp;
730            let est = shftge(c, widgau);
731            let y = c * widgau + e - est;
732
733            for j in j_lo..j_hi {
734                let ee = energies[j];
735                let a = (e - est - ee) / widgau;
736                let b = (y - ee) / widgau;
737                let z = gauss_exp_kernel(a, b);
738                let wt = xcoef[j] * z;
739                sum += wt * cross_sections[j];
740                norm += wt;
741            }
742        } else {
743            // Pure Gaussian kernel with piecewise-linear exact integration.
744            //
745            // For each interval [E_j, E_{j+1}], integrate G(E_i - E') × σ_linear(E')
746            // exactly, where G(x) = exp(-x²/W²) / (W√π).
747            //
748            // Substituting u = (E' - E_i)/W, dE' = W du:
749            //   ∫ G × [σ_j + slope×(E'-E_j)] dE'
750            //   = (1/√π) ∫ exp(-u²) [σ_j + slope×W×(u - a_j)] du
751            //
752            // With I₀ = erf(a_{j+1}) - erf(a_j) and
753            //      I₁ = (exp(-a_j²) - exp(-a_{j+1}²)) / 2:
754            //
755            // The normalization integral is I₀/2, so after sum/norm (2 cancels):
756            //   sum += σ_j × I₀ + slope × W × (2/√π × I₁ - a_j × I₀)
757            //   norm += I₀
758            //
759            // The factor 2/√π on I₁ comes from the u·exp(-u²) integral
760            // needing to match the normalization convention erf(x) = 2/√π ∫ exp(-t²) dt.
761            const TWO_OVER_SQRT_PI: f64 = std::f64::consts::FRAC_2_SQRT_PI;
762            let inv_w = 1.0 / widgau;
763            for j in j_lo..j_hi.saturating_sub(1) {
764                let e_j = energies[j];
765                let e_j1 = energies[j + 1];
766                let h = e_j1 - e_j;
767                if h < NEAR_ZERO_FLOOR {
768                    continue;
769                }
770
771                let a_j = (e_j - e) * inv_w;
772                let a_j1 = (e_j1 - e) * inv_w;
773
774                // I₀ = erf(a_{j+1}) - erf(a_j) = erfc(a_j) - erfc(a_{j+1})
775                let erfc_aj = erfc_from_exerfc(a_j);
776                let erfc_aj1 = erfc_from_exerfc(a_j1);
777                let i0 = erfc_aj - erfc_aj1;
778
779                if i0 < NEAR_ZERO_FLOOR {
780                    continue;
781                }
782
783                // I₁ = (exp(-a_j²) - exp(-a_{j+1}²)) / 2
784                let i1 = ((-a_j * a_j).exp() - (-a_j1 * a_j1).exp()) * 0.5;
785
786                let slope = (cross_sections[j + 1] - cross_sections[j]) / h;
787
788                // σ_j × I₀ + slope × W × (2/√π × I₁ - a_j × I₀)
789                sum += cross_sections[j] * i0 + slope * widgau * (TWO_OVER_SQRT_PI * i1 - a_j * i0);
790                norm += i0;
791            }
792        }
793
794        if norm > DIVISION_FLOOR {
795            broadened[i] = sum / norm;
796        } else {
797            broadened[i] = cross_sections[i];
798        }
799    }
800
801    broadened
802}
803
804/// A tabulated resolution function from Monte Carlo instrument simulation.
805///
806/// Contains reference kernels R(Δt; E_ref) at discrete energies, stored in
807/// TOF-offset space (μs). Kernels are interpolated between reference energies
808/// and converted from TOF to energy space when applied.
809///
810/// ## File Format (VENUS/FTS)
811///
812/// ```text
813/// FTS BL10 case i00dd folded triang FWHM 350 ns PSR   ← header
814/// -----                                                 ← separator
815///    5.00000e-004   0.00000e+000                        ← energy block start
816/// -53.458917835671329 2.051764258257523e-04             ← (tof_offset_μs, weight)
817/// ...
818///                                                       ← blank line separates blocks
819///    1.00000e-003   0.00000e+000                        ← next energy block
820/// ...
821/// ```
822#[derive(Debug, Clone)]
823pub struct TabulatedResolution {
824    /// Reference energies (eV), sorted ascending.
825    ref_energies: Vec<f64>,
826    /// For each reference energy: (tof_offsets_μs, weights) pairs.
827    /// Weights are peak-normalized (max=1.0).
828    kernels: Vec<(Vec<f64>, Vec<f64>)>,
829    /// Flight path length in meters (needed for TOF↔energy conversion).
830    flight_path_m: f64,
831}
832
833impl TabulatedResolution {
834    /// Reference energies (eV), sorted ascending.
835    pub fn ref_energies(&self) -> &[f64] {
836        &self.ref_energies
837    }
838
839    /// For each reference energy: (tof_offsets_μs, weights) pairs.
840    /// Weights are peak-normalized (max=1.0).
841    pub fn kernels(&self) -> &[(Vec<f64>, Vec<f64>)] {
842        &self.kernels
843    }
844
845    /// Flight path length in meters (needed for TOF↔energy conversion).
846    pub fn flight_path_m(&self) -> f64 {
847        self.flight_path_m
848    }
849
850    /// Kernel support at energy `e_ev`, in eV.
851    ///
852    /// Returns the maximum energy offset over which the tabulated
853    /// kernel has non-zero weight at energy `e_ev`.  Past this
854    /// distance the kernel is exactly zero, so the broadening
855    /// footprint at a given target energy is fully contained within
856    /// `[e_ev − support, e_ev + support]`.
857    ///
858    /// Computation:
859    ///
860    /// 1. Find the bracketing reference kernel(s) for `e_ev` via
861    ///    binary search on the sorted `ref_energies` grid.
862    /// 2. Take `max(|tof_offset|)` over all bracketing kernels'
863    ///    entries with positive weight.  Using both bracketing
864    ///    kernels (rather than the single nearest) is conservative —
865    ///    over-estimating the margin is safer than under-estimating
866    ///    when the kernel is interpolated between references.
867    /// 3. Convert TOF offset to energy offset via
868    ///    `|ΔE| = 2·E^(3/2) / (TOF_FACTOR·L) · |Δt|`,
869    ///    the magnitude of `dE/dt` at `e_ev` along the TOF→E mapping
870    ///    `t = TOF_FACTOR·L/√E` (chain rule).
871    ///
872    /// Returns `0.0` for non-positive `e_ev`, an empty kernel set, or
873    /// a non-positive flight path.  Used by the GUI's
874    /// fit-energy-range slicing to extend the model-evaluation grid
875    /// beyond the user's `[E_min, E_max]` so the SAMMY EMIN/EMAX-
876    /// equivalent broadening at the boundaries is correct (#514).
877    #[must_use]
878    pub fn kernel_support_ev(&self, e_ev: f64) -> f64 {
879        if e_ev <= 0.0 || !e_ev.is_finite() {
880            return 0.0;
881        }
882        if self.kernels.is_empty() || self.flight_path_m <= 0.0 {
883            return 0.0;
884        }
885        // Use binary_search to distinguish exact hits from
886        // between-ref interpolation:
887        //   Ok(idx)  → e_ev exactly matches ref_energies[idx]; use
888        //              that single kernel.
889        //   Err(idx) → idx is the insertion point.  Use the
890        //              bracketing kernels at idx-1 (lower) and idx
891        //              (upper); clip to grid bounds when e_ev falls
892        //              outside the ref range.
893        let n = self.ref_energies.len();
894        let mut max_dt_us: f64 = 0.0;
895        let visit = |idx: usize, max_dt_us: &mut f64| {
896            let (offsets, weights) = &self.kernels[idx];
897            for (&dt, &w) in offsets.iter().zip(weights.iter()) {
898                if w > 0.0 && dt.is_finite() {
899                    *max_dt_us = max_dt_us.max(dt.abs());
900                }
901            }
902        };
903        match self.ref_energies.binary_search_by(|probe| {
904            probe
905                .partial_cmp(&e_ev)
906                .unwrap_or(std::cmp::Ordering::Equal)
907        }) {
908            Ok(idx) => visit(idx, &mut max_dt_us),
909            Err(0) => visit(0, &mut max_dt_us),
910            Err(idx) if idx >= n => visit(n - 1, &mut max_dt_us),
911            Err(idx) => {
912                visit(idx - 1, &mut max_dt_us);
913                visit(idx, &mut max_dt_us);
914            }
915        }
916        // |dE/dt| = 2·E^(3/2)/(TOF_FACTOR·L)
917        let de_per_dt = 2.0 * e_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m);
918        de_per_dt * max_dt_us
919    }
920}
921
922/// Resolution function: either analytical Gaussian or tabulated from Monte Carlo.
923///
924/// The `Tabulated` variant wraps an `Arc` so that cloning (e.g., per-pixel in
925/// spatial mapping) is a cheap reference-count bump rather than a deep copy.
926#[derive(Debug, Clone)]
927pub enum ResolutionFunction {
928    /// Analytical Gaussian resolution from instrument parameters.
929    Gaussian(ResolutionParams),
930    /// Tabulated resolution from Monte Carlo instrument simulation.
931    Tabulated(Arc<TabulatedResolution>),
932}
933
934/// Pre-built resolution-broadening plan for a specific target energy grid.
935///
936/// Encodes every quantity that depends only on the target grid, the
937/// reference kernel, and the flight path — so applying the plan to a
938/// spectrum reduces to a gather + multiply-add loop with no
939/// transcendentals, no allocations, and no binary / pointer search.
940///
941/// Build via [`TabulatedResolution::plan`] — returns a `Result` and
942/// validates the sorted-grid precondition that `broaden` enforces.
943/// Apply via [`ResolutionPlan::apply`].  One plan is tied to one
944/// `(target_energies, ref_energies, flight_path_m)` triple; the plan
945/// owns a copy of the target-energy grid so callers cannot apply it to
946/// a spectrum that was measured on a *different* grid even when the
947/// grid length matches — use [`Self::target_energies`] to verify the
948/// grid identity before applying.
949///
950/// The layout is a flat Struct-of-Arrays (SoA): per-target `(lo_idx,
951/// frac, weight)` tuples packed into three parallel `Vec`s, with
952/// `starts[i]..starts[i+1]` naming the range for target `i`.  SoA keeps
953/// the inner loop memory-access pattern sequential and cache-friendly.
954#[derive(Debug, Clone)]
955pub struct ResolutionPlan {
956    /// Target energy grid the plan was built for (owned copy).
957    ///
958    /// Stored so `apply()` can verify `spectrum.len() == self.len()`
959    /// and expose a cheap grid identity for caller-side caching.
960    /// ~28 KB for the VENUS 3471-point grid — negligible compared to
961    /// the ~8 MB `lo_idx`/`frac`/`weight` footprint of a full plan.
962    target_energies: Vec<f64>,
963    /// `starts[i]..starts[i+1]` indexes into `lo_idx`/`frac`/`weight`
964    /// for target `i`.  `starts` has length `target_energies.len() + 1`.
965    starts: Vec<u32>,
966    /// For each valid (target, kernel-point) entry: the lower bracket
967    /// index into the target grid (spectrum[lo] + frac * (spectrum[lo+1]
968    /// - spectrum[lo])).
969    lo_idx: Vec<u32>,
970    /// Spectrum-interp fraction in [0, 1].  Set to 0 for degenerate
971    /// brackets; the apply-time loop short-circuits `frac == 0.0` so
972    /// degenerate entries never touch `spectrum[lo+1]`.  This matches
973    /// `broaden_presorted` even when `spectrum[lo+1]` is NaN/±∞.
974    frac: Vec<f64>,
975    /// Pre-computed per-entry weight (`w * dt_width.abs()`).  Summing
976    /// these yields the per-target normalisation.
977    weight: Vec<f64>,
978    /// Pre-summed `Σ weight` per target (in the same accumulation order
979    /// as `broaden_presorted` visits the valid entries).  When `norm <=
980    /// DIVISION_FLOOR` the apply path returns `spectrum[i]` directly
981    /// — the exact `broaden_presorted` passthrough behaviour.
982    norm: Vec<f64>,
983}
984
985impl ResolutionPlan {
986    /// Number of target energies this plan covers.
987    pub fn len(&self) -> usize {
988        self.target_energies.len()
989    }
990
991    /// True when the plan covers no target energies.
992    pub fn is_empty(&self) -> bool {
993        self.target_energies.is_empty()
994    }
995
996    /// Target energy grid the plan was built for.
997    ///
998    /// Callers implementing plan caches can compare this against their
999    /// current grid to decide whether the plan is still valid.  Using
1000    /// pointer identity of the returned slice gives an O(1) check when
1001    /// the grid hasn't moved; slice equality is `O(n)` but catches
1002    /// cases where the underlying buffer was reallocated.
1003    pub fn target_energies(&self) -> &[f64] {
1004        &self.target_energies
1005    }
1006
1007    /// Apply the plan to a spectrum on the same target grid the plan
1008    /// was built for.
1009    ///
1010    /// The spectrum length must equal [`Self::len`].  Passing a
1011    /// spectrum on a different grid that happens to have the same
1012    /// length is caller error — verify via [`Self::target_energies`]
1013    /// when in doubt.
1014    ///
1015    /// Bit-exact with `broaden_presorted(target_energies, spectrum)`
1016    /// for finite spectrum values; degenerate-bracket entries
1017    /// short-circuit the interpolation so the equivalence also holds
1018    /// when `spectrum[lo+1]` is NaN or ±∞ (the reference path returns
1019    /// `spectrum[lo]` directly in that case without touching the upper
1020    /// bracket).
1021    pub fn apply(&self, spectrum: &[f64]) -> Vec<f64> {
1022        let n = self.target_energies.len();
1023        assert_eq!(
1024            spectrum.len(),
1025            n,
1026            "spectrum length ({}) must match plan target-grid length ({})",
1027            spectrum.len(),
1028            n,
1029        );
1030        if n == 0 {
1031            return Vec::new();
1032        }
1033
1034        let mut result = vec![0.0f64; n];
1035
1036        // Pre-bind plan slices once per call and pre-slice each
1037        // target's entry range before the hot loop.  This is a
1038        // bounds-check-elimination (BCE) refactor — every per-entry
1039        // index is proven in-bounds by the invariants established in
1040        // `plan_presorted`, so the inner loop uses `get_unchecked`
1041        // with SAFETY comments citing those invariants.  The compiler
1042        // then auto-vectorizes the inner compute where profitable.
1043        //
1044        // We deliberately do NOT use explicit 2-wide SIMD here — an
1045        // experiment via the `wide` crate (commit abandoned;
1046        // `perf-lessons.md`) showed that 2-wide f64x2 with gather
1047        // emulation is net-negative on AArch64 Neon vs the compiler's
1048        // scalar auto-vectorization of the BCE'd inner loop.  On
1049        // wider targets (x86 AVX2 / AVX-512) a SIMD rewrite could
1050        // still pay off but is out of scope here.
1051        //
1052        // Control flow, accumulation order, and the `frac == 0.0`
1053        // NaN-safety short-circuit are all preserved exactly so the
1054        // bit-exact contract with `broaden_presorted` holds for
1055        // finite AND pathological (NaN, ±∞) spectra.
1056        let lo_idx = self.lo_idx.as_slice();
1057        let frac_all = self.frac.as_slice();
1058        let weight_all = self.weight.as_slice();
1059        let starts = self.starts.as_slice();
1060        let norm = self.norm.as_slice();
1061        let spec = spectrum;
1062
1063        // Defence-in-depth: debug-only invariant checks right after
1064        // slice binding, so a future change to `plan_presorted` that
1065        // silently violates the `unsafe { get_unchecked }` SAFETY
1066        // claims below fails loudly in debug builds.  Zero release-
1067        // build cost.  Copilot review finding on PR #470.
1068        debug_assert_eq!(starts.len(), n + 1);
1069        debug_assert_eq!(
1070            starts.last().copied(),
1071            Some(lo_idx.len() as u32),
1072            "plan_presorted invariant: starts.last() must equal lo_idx.len()",
1073        );
1074        debug_assert_eq!(lo_idx.len(), frac_all.len());
1075        debug_assert_eq!(lo_idx.len(), weight_all.len());
1076        debug_assert_eq!(norm.len(), n);
1077        debug_assert_eq!(spec.len(), n);
1078
1079        for i in 0..n {
1080            let norm_i = norm[i];
1081            if norm_i <= DIVISION_FLOOR {
1082                // Passthrough — matches `broaden_presorted`'s
1083                // `spectrum[i]` fallback for e ≤ 0, empty kernel, or
1084                // degenerate norm accumulation.
1085                result[i] = spec[i];
1086                continue;
1087            }
1088            let start = starts[i] as usize;
1089            let end = starts[i + 1] as usize;
1090            // Zip-compatible pre-bound slices of exactly `end - start`
1091            // elements each — the per-j bounds check is elided by the
1092            // compiler because the slice length bounds the loop.
1093            let los = &lo_idx[start..end];
1094            let fracs = &frac_all[start..end];
1095            let ws = &weight_all[start..end];
1096
1097            let mut sum = 0.0f64;
1098            for k in 0..los.len() {
1099                // SAFETY: `k < los.len()` is guaranteed by the range;
1100                // `los`, `fracs`, and `ws` all have length `end - start`
1101                // (same subslice bounds), so each `get_unchecked(k)`
1102                // read is in-bounds.
1103                let lo = unsafe { *los.get_unchecked(k) } as usize;
1104                let frac = unsafe { *fracs.get_unchecked(k) };
1105                let w = unsafe { *ws.get_unchecked(k) };
1106
1107                // Degenerate-bracket short-circuit: when the plan
1108                // built `frac = -0.0` (span < NEAR_ZERO_FLOOR) we skip
1109                // `spectrum[lo+1]` entirely.  Without this branch,
1110                // `0.0 * NaN = NaN` would propagate and diverge from
1111                // the reference `broaden_presorted`, which returns
1112                // `spectrum[lo]` directly for that case.  Branch is
1113                // well-predicted (degenerate brackets are rare on
1114                // real grids) and preserves bit-exactness under
1115                // pathological spectra.
1116                //
1117                // The check MUST use `to_bits()` because the non-
1118                // degenerate path can legitimately produce
1119                // `frac == +0.0` when `e_prime == energies[lo]`
1120                // exactly.  In that case `broaden_presorted` still
1121                // reads `spectrum[lo+1]` (and propagates NaN if
1122                // present there), so the short-circuit MUST NOT
1123                // trigger.  `+0.0 == -0.0` returns `true` but
1124                // `(+0.0).to_bits() != (-0.0).to_bits()`, so the
1125                // bit-pattern check disambiguates exactly which
1126                // semantic `plan_presorted` meant.  Copilot review
1127                // finding on PR #470.
1128                let s = if frac.to_bits() == (-0.0_f64).to_bits() {
1129                    // SAFETY: `lo < n` by plan invariant.
1130                    // `plan_presorted` only pushes `lo = bracket_hi - 1`
1131                    // with `bracket_hi ∈ [1, n - 1]`, so `lo ∈
1132                    // [0, n - 2]`.  `spec.len() == n` by the
1133                    // precondition assert at the top of `apply`.
1134                    unsafe { *spec.get_unchecked(lo) }
1135                } else {
1136                    // SAFETY: same `lo ∈ [0, n - 2]` invariant, so
1137                    // `lo + 1 ∈ [1, n - 1]` is also in-bounds.
1138                    let s_lo = unsafe { *spec.get_unchecked(lo) };
1139                    let s_hi = unsafe { *spec.get_unchecked(lo + 1) };
1140                    s_lo + frac * (s_hi - s_lo)
1141                };
1142                // Serial accumulation preserved — no multi-accumulator
1143                // reassociation, no SIMD lane-wise tree reduce.
1144                // IEEE-754 addition is not associative; changing the
1145                // order would break bit-exactness with
1146                // `broaden_presorted_reference` (and all
1147                // `*_bit_exact_*` unit tests + real-VENUS
1148                // `baseline_dump.py --verify`).
1149                sum += w * s;
1150            }
1151            result[i] = sum / norm_i;
1152        }
1153
1154        result
1155    }
1156
1157    /// Compile this plan into a row-stochastic CSR
1158    /// [`ResolutionMatrix`].
1159    ///
1160    /// The compiled matrix is an explicit sparse representation of
1161    /// the resolution operator `R` on the plan's target grid.  Each
1162    /// row sums to 1.0 to machine precision (passthrough rows store
1163    /// a single `(i, i, 1.0)` entry to match [`ResolutionPlan::apply`]
1164    /// 's `norm ≤ DIVISION_FLOOR` fallback).
1165    ///
1166    /// Degenerate-bracket handling uses the `-0.0` sentinel
1167    /// convention introduced in PR #470: if `plan.frac[e]` has the
1168    /// bit pattern of `-0.0`, the entry contributes `weight / norm`
1169    /// at column `lo` only (no `lo+1` bracket).  A regular `+0.0`
1170    /// frac contributes `weight * 1.0 / norm` at `lo` and
1171    /// `weight * 0.0 / norm = 0.0` at `lo+1` — those zero columns
1172    /// are retained in CSR with `value = 0.0` to preserve
1173    /// downstream NaN-safety if the consumer re-multiplies by a
1174    /// spectrum containing NaN at `lo+1`.
1175    ///
1176    /// # Equivalence contract (finite spectra only)
1177    ///
1178    /// For a spectrum with **all finite values**, [`apply_r`] on the
1179    /// compiled matrix produces per-element output within `1e-12`
1180    /// relative tolerance of [`Self::apply`] on the same spectrum —
1181    /// not bit-exact, because the CSR matvec sums contributions in
1182    /// column order while `apply` sums in entry order and IEEE-754
1183    /// addition is non-associative.  The `1e-12` bound accounts for
1184    /// accumulation error across the ~82 entries per row on the
1185    /// 3471-bin VENUS production grid (500 × 2.22e-16 ≈ 1.1e-13 per
1186    /// row; `1e-12` leaves comfortable headroom).
1187    ///
1188    /// # Non-finite and near-overflow spectra
1189    ///
1190    /// The equivalence bound does **NOT** extend to spectra with
1191    /// `NaN` / `±∞` values, **nor to near-f64::MAX overflow inputs**
1192    /// (Codex round-2 P3).  Both divergences trace back to the same
1193    /// algebraic rewrite:
1194    ///
1195    /// * [`Self::apply`] computes each entry as `spec[lo] + frac *
1196    ///   (spec[lo+1] - spec[lo])`, which can overflow the
1197    ///   subtraction even for finite inputs (opposite-sign
1198    ///   f64::MAX → `-∞`).
1199    /// * The compiled CSR form splits the interp into `(1 - frac) *
1200    ///   spec[lo] + frac * spec[lo + 1]`, which scales before
1201    ///   summing and stays finite in the same case.
1202    ///
1203    /// For bounded finite Beer-Lambert transmissions (`T ∈ [0, 1]`)
1204    /// neither divergence can arise; callers who deliberately pass
1205    /// non-finite or near-overflow spectra (e.g., as debug sentinels
1206    /// or out-of-range diagnostics) must not rely on cross-API
1207    /// equivalence.  See `resolution_matrix_nonfinite_contract` and
1208    /// `resolution_matrix_large_finite_contract` for executable
1209    /// demonstrations.
1210    pub fn compile_to_matrix(&self) -> ResolutionMatrix {
1211        let n = self.target_energies.len();
1212        let mut row_starts: Vec<u32> = Vec::with_capacity(n + 1);
1213        row_starts.push(0);
1214        let mut col_indices: Vec<u32> = Vec::new();
1215        let mut values: Vec<f64> = Vec::new();
1216
1217        // Reusable per-row accumulator.  Columns accumulate into a
1218        // BTreeMap keyed by spectrum index so the final CSR row is
1219        // emitted in ascending column order — the required CSR
1220        // invariant and the condition the `apply_r` equivalence
1221        // bound depends on.
1222        let mut acc: std::collections::BTreeMap<u32, f64> = std::collections::BTreeMap::new();
1223
1224        for i in 0..n {
1225            acc.clear();
1226            let norm_i = self.norm[i];
1227            if norm_i <= DIVISION_FLOOR {
1228                // Passthrough row — matches `apply`'s early return.
1229                col_indices.push(i as u32);
1230                values.push(1.0);
1231                // See u32-overflow `debug_assert!` below — the same
1232                // bound applies after every `push`.
1233                debug_assert!(
1234                    col_indices.len() <= u32::MAX as usize,
1235                    "CSR row_starts/col_indices u32 overflow: nnz = {}",
1236                    col_indices.len(),
1237                );
1238                row_starts.push(col_indices.len() as u32);
1239                continue;
1240            }
1241            let start = self.starts[i] as usize;
1242            let end = self.starts[i + 1] as usize;
1243            for e in start..end {
1244                let lo = self.lo_idx[e];
1245                let frac = self.frac[e];
1246                let w = self.weight[e];
1247                if frac.to_bits() == (-0.0_f64).to_bits() {
1248                    // Degenerate bracket — `apply` reads `spec[lo]`
1249                    // only, so the CSR row contributes only at `lo`.
1250                    *acc.entry(lo).or_insert(0.0) += w / norm_i;
1251                } else {
1252                    // Regular linear-interp entry: `w * ((1 - frac)
1253                    // * spec[lo] + frac * spec[lo + 1]) / norm_i`.
1254                    *acc.entry(lo).or_insert(0.0) += w * (1.0 - frac) / norm_i;
1255                    *acc.entry(lo + 1).or_insert(0.0) += w * frac / norm_i;
1256                }
1257            }
1258            for (&col, &val) in acc.iter() {
1259                col_indices.push(col);
1260                values.push(val);
1261            }
1262            // Defence-in-depth: a future large-grid caller that
1263            // accumulates more than u32::MAX entries would silently
1264            // truncate the `as u32` cast below.  The `plan_presorted`
1265            // helper already has matching `debug_assert!` guards on
1266            // its u32 offsets (resolution.rs, `plan_presorted`).
1267            debug_assert!(
1268                col_indices.len() <= u32::MAX as usize,
1269                "CSR row_starts/col_indices u32 overflow: nnz = {}",
1270                col_indices.len(),
1271            );
1272            row_starts.push(col_indices.len() as u32);
1273        }
1274
1275        ResolutionMatrix {
1276            target_energies: self.target_energies.clone(),
1277            row_starts,
1278            col_indices,
1279            values,
1280        }
1281    }
1282}
1283
1284/// Row-stochastic CSR representation of the resolution operator `R`
1285/// on a fixed target energy grid.
1286///
1287/// Built from a [`ResolutionPlan`] via
1288/// [`ResolutionPlan::compile_to_matrix`].  Exposed so downstream
1289/// surrogates (see epic #472) can access the row-local entries
1290/// `R_{i, j}` directly for LP / quadrature construction.
1291///
1292/// Owns a copy of the target energy grid for the same reason
1293/// [`ResolutionPlan`] does: caller-side grid-identity checks and
1294/// explicit grid-mismatch errors via
1295/// [`ResolutionError::MatrixGridMismatch`].
1296#[derive(Debug, Clone)]
1297pub struct ResolutionMatrix {
1298    /// Target energy grid the matrix was compiled for (owned copy).
1299    target_energies: Vec<f64>,
1300    /// `row_starts[i]..row_starts[i+1]` indexes into
1301    /// `col_indices`/`values` for row `i`.  Length `n + 1`.
1302    row_starts: Vec<u32>,
1303    /// Column indices in ascending order within each row.
1304    col_indices: Vec<u32>,
1305    /// CSR values.  Row `i` sums to 1.0 within machine precision
1306    /// (passthrough rows store exactly `1.0` at column `i`).
1307    values: Vec<f64>,
1308}
1309
1310impl ResolutionMatrix {
1311    /// Number of rows (target-grid size) covered by this matrix.
1312    pub fn len(&self) -> usize {
1313        self.target_energies.len()
1314    }
1315
1316    /// True when the matrix covers no target energies.
1317    pub fn is_empty(&self) -> bool {
1318        self.target_energies.is_empty()
1319    }
1320
1321    /// Total number of stored entries (structural nnz).
1322    ///
1323    /// Regular-bracket entries with `frac == +0.0` retain a
1324    /// zero-valued contribution at the `lo + 1` column to preserve
1325    /// NaN-safety under re-application to spectra with NaN at that
1326    /// column; those stored zeros are counted in this total.
1327    pub fn nnz(&self) -> usize {
1328        self.values.len()
1329    }
1330
1331    /// Target energy grid the matrix was compiled for.
1332    pub fn target_energies(&self) -> &[f64] {
1333        &self.target_energies
1334    }
1335
1336    /// CSR row-start offsets.  `row_starts()[i]..row_starts()[i+1]`
1337    /// names the entry range for row `i`.  Length `len() + 1`.
1338    pub fn row_starts(&self) -> &[u32] {
1339        &self.row_starts
1340    }
1341
1342    /// CSR column indices.  Sorted ascending within each row.
1343    pub fn col_indices(&self) -> &[u32] {
1344        &self.col_indices
1345    }
1346
1347    /// CSR values.  Each row sums to 1.0 to machine precision.
1348    pub fn values(&self) -> &[f64] {
1349        &self.values
1350    }
1351}
1352
1353/// Apply a compiled [`ResolutionMatrix`] to a spectrum on the same
1354/// target grid the matrix was compiled for.
1355///
1356/// For finite spectra, the output is numerically equivalent to
1357/// [`ResolutionPlan::apply`] on the same spectrum within `1e-12`
1358/// relative tolerance per element; not bit-exact, because CSR matvec
1359/// sums in column order while `ResolutionPlan::apply` sums in entry
1360/// order.
1361///
1362/// # Non-finite and near-overflow inputs
1363///
1364/// See [`ResolutionPlan::compile_to_matrix`] for the full contract
1365/// on `NaN` / `±∞` spectra **and on near-f64::MAX finite spectra** —
1366/// the equivalence bound does not extend to either.  Production
1367/// forward models feed Beer-Lambert transmissions (`T ∈ [0, 1]`) so
1368/// the distinction never arises in practice.
1369///
1370/// # Panics
1371///
1372/// Panics if `spectrum.len() != matrix.len()`.  Use
1373/// [`apply_resolution_with_matrix`] for a checked entrypoint that
1374/// returns [`ResolutionError::LengthMismatch`] instead.
1375pub fn apply_r(matrix: &ResolutionMatrix, spectrum: &[f64]) -> Vec<f64> {
1376    let n = matrix.len();
1377    assert_eq!(
1378        spectrum.len(),
1379        n,
1380        "spectrum length ({}) must match matrix grid length ({})",
1381        spectrum.len(),
1382        n,
1383    );
1384    let mut out = vec![0.0f64; n];
1385    for (i, out_i) in out.iter_mut().enumerate() {
1386        let start = matrix.row_starts[i] as usize;
1387        let end = matrix.row_starts[i + 1] as usize;
1388        let mut sum = 0.0f64;
1389        for e in start..end {
1390            let col = matrix.col_indices[e] as usize;
1391            sum += matrix.values[e] * spectrum[col];
1392        }
1393        *out_i = sum;
1394    }
1395    out
1396}
1397
1398/// Checked variant of [`apply_r`] that validates the matrix was
1399/// compiled for `energies` before applying.
1400///
1401/// Returns [`ResolutionError::LengthMismatch`] when either
1402/// `energies` or `spectrum` has a length that disagrees with the
1403/// matrix grid size.  For the `spectrum` check, the `energies` field
1404/// of the returned error holds the matrix grid length (the required
1405/// length) so callers can read it as "expected vs got".  Returns
1406/// [`ResolutionError::MatrixGridMismatch`] when the lengths match
1407/// but the grid contents differ (per-element `to_bits()` compare).
1408///
1409/// Unlike [`apply_resolution_with_plan`], this entrypoint does not
1410/// enforce an ascending `energies` grid through the crate's internal
1411/// `validate_inputs` helper.  That check is redundant here: the plan
1412/// that produced the matrix was itself built on a sorted grid (via
1413/// [`TabulatedResolution::plan`], which validates sortedness), and the
1414/// stored `target_energies` copy is used in the `to_bits()`
1415/// grid-identity check above.  Any `energies` slice that is not
1416/// bit-identical to the matrix's stored copy — including an unsorted
1417/// permutation of the same values — fails with
1418/// [`ResolutionError::MatrixGridMismatch`].
1419pub fn apply_resolution_with_matrix(
1420    energies: &[f64],
1421    matrix: &ResolutionMatrix,
1422    spectrum: &[f64],
1423) -> Result<Vec<f64>, ResolutionError> {
1424    if energies.len() != matrix.len() {
1425        return Err(ResolutionError::LengthMismatch {
1426            energies: energies.len(),
1427            data: matrix.len(),
1428        });
1429    }
1430    if spectrum.len() != matrix.len() {
1431        // Reuse the `LengthMismatch` variant for the spectrum branch:
1432        // `energies` = expected length (matrix grid size), `data` =
1433        // actual spectrum length.  See docstring above.
1434        return Err(ResolutionError::LengthMismatch {
1435            energies: matrix.len(),
1436            data: spectrum.len(),
1437        });
1438    }
1439    for (i, (e_cur, e_ref)) in energies.iter().zip(matrix.target_energies()).enumerate() {
1440        // `to_bits()` equality catches `-0.0 vs +0.0` and NaN-bit
1441        // differences that float `==` silently accepts or rejects.
1442        if e_cur.to_bits() != e_ref.to_bits() {
1443            return Err(ResolutionError::MatrixGridMismatch {
1444                first_diff_index: i,
1445            });
1446        }
1447    }
1448    Ok(apply_r(matrix, spectrum))
1449}
1450
1451impl TabulatedResolution {
1452    /// Parse a VENUS/FTS resolution file.
1453    ///
1454    /// # Arguments
1455    /// * `text` — File contents as a string.
1456    /// * `flight_path_m` — Flight path length in meters.
1457    pub fn from_text(text: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1458        let mut lines = text.lines();
1459
1460        // Skip header and separator
1461        let _header = lines
1462            .next()
1463            .ok_or(ResolutionParseError::InvalidFormat("Empty file".into()))?;
1464        let _sep = lines.next().ok_or(ResolutionParseError::InvalidFormat(
1465            "Missing separator".into(),
1466        ))?;
1467
1468        let mut ref_energies = Vec::new();
1469        let mut kernels = Vec::new();
1470        let mut current_energy: Option<f64> = None;
1471        let mut current_offsets: Vec<f64> = Vec::new();
1472        let mut current_weights: Vec<f64> = Vec::new();
1473
1474        for line in lines {
1475            let trimmed = line.trim();
1476            if trimmed.is_empty() {
1477                // End of current block
1478                if let Some(e) = current_energy.take() {
1479                    ref_energies.push(e);
1480                    kernels.push((
1481                        std::mem::take(&mut current_offsets),
1482                        std::mem::take(&mut current_weights),
1483                    ));
1484                }
1485                continue;
1486            }
1487
1488            let parts: Vec<&str> = trimmed.split_whitespace().collect();
1489            if parts.len() != 2 {
1490                if current_energy.is_some() {
1491                    return Err(ResolutionParseError::InvalidFormat(format!(
1492                        "Expected 2 columns inside energy block, got {}: '{}'",
1493                        parts.len(),
1494                        trimmed
1495                    )));
1496                }
1497                // Outside a data block (e.g. extra header lines) — skip
1498                continue;
1499            }
1500
1501            let x: f64 = parts[0].parse().map_err(|_| {
1502                ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[0]))
1503            })?;
1504            let y: f64 = parts[1].parse().map_err(|_| {
1505                ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[1]))
1506            })?;
1507
1508            if current_energy.is_none() {
1509                // First line of block: energy + 0.0 marker
1510                current_energy = Some(x);
1511            } else {
1512                current_offsets.push(x);
1513                current_weights.push(y);
1514            }
1515        }
1516
1517        // Flush last block
1518        if let Some(e) = current_energy.take() {
1519            ref_energies.push(e);
1520            kernels.push((current_offsets, current_weights));
1521        }
1522
1523        if ref_energies.is_empty() {
1524            return Err(ResolutionParseError::InvalidFormat(
1525                "No energy blocks found".into(),
1526            ));
1527        }
1528
1529        // Validate strictly ascending reference energies
1530        for i in 1..ref_energies.len() {
1531            if ref_energies[i] <= ref_energies[i - 1] {
1532                return Err(ResolutionParseError::InvalidFormat(format!(
1533                    "Reference energies must be strictly ascending, but E[{}]={} <= E[{}]={}",
1534                    i,
1535                    ref_energies[i],
1536                    i - 1,
1537                    ref_energies[i - 1],
1538                )));
1539            }
1540        }
1541
1542        Ok(TabulatedResolution {
1543            ref_energies,
1544            kernels,
1545            flight_path_m,
1546        })
1547    }
1548
1549    /// Parse a VENUS/FTS resolution file from disk.
1550    pub fn from_file(path: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1551        let text = std::fs::read_to_string(path)
1552            .map_err(|e| ResolutionParseError::IoError(format!("Cannot read '{}': {}", path, e)))?;
1553        Self::from_text(&text, flight_path_m)
1554    }
1555
1556    /// Apply tabulated resolution broadening to a spectrum.
1557    ///
1558    /// For each energy point:
1559    /// 1. Find bracketing reference energies and interpolate kernel (log-space)
1560    /// 2. Convert TOF offsets to energy offsets using exact TOF↔energy relation
1561    /// 3. Convolve spectrum with interpolated kernel (trapezoidal integration)
1562    ///
1563    /// # Errors
1564    /// Returns [`ResolutionError::LengthMismatch`] if the arrays differ in
1565    /// length, or [`ResolutionError::UnsortedEnergies`] if the energy grid is
1566    /// not sorted in non-descending order.
1567    pub fn broaden(&self, energies: &[f64], spectrum: &[f64]) -> Result<Vec<f64>, ResolutionError> {
1568        validate_inputs(energies, spectrum)?;
1569        Ok(self.broaden_presorted(energies, spectrum))
1570    }
1571
1572    /// Tabulated resolution broadening assuming the energy grid is already
1573    /// validated (sorted ascending, same length as spectrum).
1574    ///
1575    /// ## Inner-loop optimization
1576    ///
1577    /// The per-kernel-point spectrum interpolation uses a **two-pointer
1578    /// walk** instead of a binary search: `e_prime` is monotonically
1579    /// decreasing in `k` (since `dt = offsets[k]` is non-decreasing,
1580    /// `TOF' = tof_center + dt` is non-decreasing, and `E' = (L/TOF')²`
1581    /// is non-increasing).  We maintain `bracket_hi` as the smallest
1582    /// index into `energies[]` whose value is `>= e_prime`, and walk it
1583    /// downward as `k` advances.  Amortized O(1) per kernel point.
1584    ///
1585    /// Math is identical to the reference implementation pinned by
1586    /// `broaden_presorted_reference` in the test module.
1587    ///
1588    /// For callers that broaden many spectra on the same target grid —
1589    /// LM iterations with fixed TZERO, spatial maps with a pre-calibrated
1590    /// energy axis — [`TabulatedResolution::plan`] +
1591    /// [`ResolutionPlan::apply`] produce bit-exact output while
1592    /// hoisting the per-target invariants (TOF conversion, kernel
1593    /// interpolation, bracket lookup, trapezoidal widths) out of the
1594    /// broadening hot loop.  This `broaden_presorted` entry is the
1595    /// single-broadening path and keeps the original inline
1596    /// implementation to avoid plan-construction overhead on one-shot
1597    /// callers.
1598    pub(crate) fn broaden_presorted(&self, energies: &[f64], spectrum: &[f64]) -> Vec<f64> {
1599        let n = energies.len();
1600        if n == 0 {
1601            return vec![];
1602        }
1603        if n == 1 {
1604            return spectrum.to_vec();
1605        }
1606
1607        let e_min = energies[0];
1608        let e_max = energies[n - 1];
1609
1610        let mut result = vec![0.0f64; n];
1611
1612        for i in 0..n {
1613            let e = energies[i];
1614            if e <= 0.0 {
1615                result[i] = spectrum[i];
1616                continue;
1617            }
1618
1619            let tof_center = TOF_FACTOR * self.flight_path_m / e.sqrt();
1620            let (offsets, weights) = self.interpolated_kernel(e);
1621            let n_k = offsets.len();
1622            let mut bracket_hi: usize = n - 1;
1623
1624            let mut sum = 0.0;
1625            let mut norm = 0.0;
1626
1627            for k in 0..n_k {
1628                let dt = offsets[k];
1629                let w = weights[k];
1630                if w <= 0.0 {
1631                    continue;
1632                }
1633
1634                let tof_prime = tof_center + dt;
1635                if tof_prime <= 0.0 {
1636                    continue;
1637                }
1638
1639                let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
1640
1641                if e_prime < e_min || e_prime > e_max {
1642                    continue;
1643                }
1644
1645                while bracket_hi > 1 && energies[bracket_hi - 1] > e_prime {
1646                    bracket_hi -= 1;
1647                }
1648                while bracket_hi < n - 1 && energies[bracket_hi] <= e_prime {
1649                    bracket_hi += 1;
1650                }
1651
1652                let lo = bracket_hi - 1;
1653                let hi = bracket_hi;
1654                let span = energies[hi] - energies[lo];
1655                let s = if span.abs() < NEAR_ZERO_FLOOR {
1656                    spectrum[lo]
1657                } else {
1658                    let frac = (e_prime - energies[lo]) / span;
1659                    spectrum[lo] + frac * (spectrum[hi] - spectrum[lo])
1660                };
1661
1662                let dt_width = if k > 0 && k < n_k - 1 {
1663                    (offsets[k + 1] - offsets[k - 1]) * 0.5
1664                } else if k == 0 && n_k > 1 {
1665                    offsets[1] - offsets[0]
1666                } else if k == n_k - 1 && n_k > 1 {
1667                    offsets[k] - offsets[k - 1]
1668                } else {
1669                    1.0
1670                };
1671
1672                let weight = w * dt_width.abs();
1673                sum += weight * s;
1674                norm += weight;
1675            }
1676
1677            result[i] = if norm > DIVISION_FLOOR {
1678                sum / norm
1679            } else {
1680                spectrum[i]
1681            };
1682        }
1683
1684        result
1685    }
1686
1687    /// Build a reusable broadening plan for a specific target energy grid.
1688    ///
1689    /// Validates that `energies` is non-descending — the same sorted-grid
1690    /// precondition enforced by [`TabulatedResolution::broaden`] via
1691    /// `validate_inputs`.  An
1692    /// unsorted grid would produce a silently-wrong plan (misbracketed
1693    /// `e_prime` lookups against `e_min` / `e_max`), so it must be
1694    /// caught at build time rather than returning garbage from
1695    /// [`ResolutionPlan::apply`].
1696    ///
1697    /// The plan hoists every quantity that depends only on
1698    /// `(target_energies, self.ref_energies, self.flight_path_m)` —
1699    /// namely the TOF conversion, the log-space kernel interpolation,
1700    /// the per-kernel-point `e_prime` and spectrum-bracket lookup, and
1701    /// the trapezoidal integration widths.  Applying the plan to a
1702    /// spectrum becomes a pure gather + multiply-add loop.
1703    ///
1704    /// Build cost: same as one call to the private `broaden_presorted`
1705    /// helper (O(N_target × N_kernel) TOF / bracket / interp work, plus
1706    /// ~2 × N_kernel log-interp ops per target energy for
1707    /// `interpolated_kernel`).  Apply cost per target: 1 branch +
1708    /// ~3 loads + 3 flops per retained entry, plus the final divide —
1709    /// typically < 10 % of the build cost.  The payoff comes from
1710    /// reusing one plan across many spectra.
1711    ///
1712    /// Bit-exact with `broaden_presorted`: pre-computes the same
1713    /// floating-point sequences (TOF, `e_prime`, `dt_width`, `frac`,
1714    /// `weight`, `norm`) in the same order.
1715    ///
1716    /// # Errors
1717    /// Returns [`ResolutionError::UnsortedEnergies`] if `energies` is
1718    /// not non-descending.
1719    pub fn plan(&self, energies: &[f64]) -> Result<ResolutionPlan, ResolutionError> {
1720        if !energies.windows(2).all(|w| w[0] <= w[1]) {
1721            return Err(ResolutionError::UnsortedEnergies);
1722        }
1723        Ok(self.plan_presorted(energies))
1724    }
1725
1726    /// Build a plan assuming `energies` is already validated as
1727    /// non-descending.  Used internally by `broaden_presorted` (whose
1728    /// caller already validated the grid) and by `plan()` after its
1729    /// validation succeeded.
1730    fn plan_presorted(&self, energies: &[f64]) -> ResolutionPlan {
1731        let n = energies.len();
1732        if n == 0 {
1733            return ResolutionPlan {
1734                target_energies: Vec::new(),
1735                starts: vec![0],
1736                lo_idx: Vec::new(),
1737                frac: Vec::new(),
1738                weight: Vec::new(),
1739                norm: Vec::new(),
1740            };
1741        }
1742        if n == 1 {
1743            // No bracket available; passthrough. Represent as n=1 with
1744            // zero entries and norm=0, which triggers the passthrough
1745            // branch in `ResolutionPlan::apply`.
1746            return ResolutionPlan {
1747                target_energies: energies.to_vec(),
1748                starts: vec![0, 0],
1749                lo_idx: Vec::new(),
1750                frac: Vec::new(),
1751                weight: Vec::new(),
1752                norm: vec![0.0],
1753            };
1754        }
1755
1756        let e_min = energies[0];
1757        let e_max = energies[n - 1];
1758
1759        // Preallocate the entry Vecs to ~n × kernel_len so the inner
1760        // pushes avoid repeated reallocations.  Real VENUS grids push
1761        // ~n × 499 entries total; over-allocating by up to 2× (if some
1762        // kernel points are skipped) is cheap vs. repeated grow-and-
1763        // memcpy during plan build.
1764        let estimated_kernel_len = self.kernels.first().map_or(0, |(off, _)| off.len());
1765        let estimated_entries = n.saturating_mul(estimated_kernel_len);
1766
1767        let mut starts: Vec<u32> = Vec::with_capacity(n + 1);
1768        let mut lo_idx: Vec<u32> = Vec::with_capacity(estimated_entries);
1769        let mut frac: Vec<f64> = Vec::with_capacity(estimated_entries);
1770        let mut weight: Vec<f64> = Vec::with_capacity(estimated_entries);
1771        let mut norm: Vec<f64> = Vec::with_capacity(n);
1772
1773        starts.push(0);
1774
1775        for i in 0..n {
1776            let e = energies[i];
1777            if e <= 0.0 {
1778                // Passthrough: no entries contribute, norm=0.
1779                norm.push(0.0);
1780                // Guard the u32 invariant for diagnostic callers; the
1781                // headroom is enormous for any realistic grid (VENUS
1782                // 3471 × 499 ≈ 1.7M entries, u32::MAX ≈ 4.29B), but
1783                // the debug-only assert documents the contract.
1784                debug_assert!(
1785                    lo_idx.len() <= u32::MAX as usize,
1786                    "plan entry count overflows u32"
1787                );
1788                starts.push(lo_idx.len() as u32);
1789                continue;
1790            }
1791
1792            // TOF at this energy: t = TOF_FACTOR * L / sqrt(E).
1793            // Computed here in the plan build and NOT at apply time — this
1794            // is the main invariant we hoist.
1795            let tof_center = TOF_FACTOR * self.flight_path_m / e.sqrt();
1796
1797            // Interpolated kernel at this target energy.  Allocates two
1798            // ~N_kernel Vecs; those allocations happen once per plan
1799            // build instead of once per broadening call.
1800            let (offsets, weights) = self.interpolated_kernel(e);
1801            let n_k = offsets.len();
1802
1803            // Two-pointer walk state (same invariant as broaden_presorted).
1804            let mut bracket_hi: usize = n - 1;
1805
1806            let mut target_norm = 0.0;
1807
1808            for k in 0..n_k {
1809                let dt = offsets[k];
1810                let w = weights[k];
1811                if w <= 0.0 {
1812                    continue;
1813                }
1814
1815                let tof_prime = tof_center + dt;
1816                if tof_prime <= 0.0 {
1817                    continue;
1818                }
1819
1820                let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
1821
1822                if e_prime < e_min || e_prime > e_max {
1823                    continue;
1824                }
1825
1826                // Two-pointer walk — same logic + invariants as
1827                // broaden_presorted, in the same order, so bracket_hi
1828                // reaches the identical position for each kept (i, k).
1829                while bracket_hi > 1 && energies[bracket_hi - 1] > e_prime {
1830                    bracket_hi -= 1;
1831                }
1832                while bracket_hi < n - 1 && energies[bracket_hi] <= e_prime {
1833                    bracket_hi += 1;
1834                }
1835
1836                let lo = bracket_hi - 1;
1837                let hi = bracket_hi;
1838                let span = energies[hi] - energies[lo];
1839                // Degenerate-bracket guard: if span < NEAR_ZERO_FLOOR,
1840                // broaden_presorted returns `spectrum[lo]` directly
1841                // without the interp arithmetic.  Store `frac = -0.0`
1842                // — the apply path short-circuits on the exact bit
1843                // pattern of `-0.0` and returns `spectrum[lo]` without
1844                // touching `spectrum[lo+1]`, so bit-exactness holds
1845                // even if `spectrum[lo+1]` is NaN or ±∞.
1846                //
1847                // `-0.0` (negative-signed zero) is used as the sentinel
1848                // because the non-degenerate path can legitimately
1849                // produce `frac == +0.0` when `e_prime == energies[lo]`
1850                // exactly — in that case `broaden_presorted` still
1851                // reads `spectrum[lo+1]` (and propagates NaN if present
1852                // there), so the apply path MUST do the same.  `+0.0`
1853                // and `-0.0` compare equal under `==` but differ in
1854                // `to_bits()`, which is what apply uses to disambiguate.
1855                //  Copilot review finding on PR #470.
1856                let entry_frac = if span.abs() < NEAR_ZERO_FLOOR {
1857                    -0.0_f64
1858                } else {
1859                    (e_prime - energies[lo]) / span
1860                };
1861
1862                let dt_width = if k > 0 && k < n_k - 1 {
1863                    (offsets[k + 1] - offsets[k - 1]) * 0.5
1864                } else if k == 0 && n_k > 1 {
1865                    offsets[1] - offsets[0]
1866                } else if k == n_k - 1 && n_k > 1 {
1867                    offsets[k] - offsets[k - 1]
1868                } else {
1869                    1.0
1870                };
1871
1872                let entry_weight = w * dt_width.abs();
1873
1874                debug_assert!(
1875                    lo_idx.len() < u32::MAX as usize,
1876                    "plan entry count overflows u32"
1877                );
1878                lo_idx.push(lo as u32);
1879                frac.push(entry_frac);
1880                weight.push(entry_weight);
1881                target_norm += entry_weight;
1882            }
1883
1884            norm.push(target_norm);
1885            starts.push(lo_idx.len() as u32);
1886        }
1887
1888        ResolutionPlan {
1889            target_energies: energies.to_vec(),
1890            starts,
1891            lo_idx,
1892            frac,
1893            weight,
1894            norm,
1895        }
1896    }
1897
1898    /// Interpolate kernel at an arbitrary energy using log-space linear interpolation
1899    /// between the two nearest reference energies.
1900    ///
1901    /// `ref_energies` is validated as strictly ascending by `from_text()` /
1902    /// `from_file()` at construction time, so no per-call sort check is needed.
1903    fn interpolated_kernel(&self, energy: f64) -> (Vec<f64>, Vec<f64>) {
1904        debug_assert!(
1905            self.ref_energies.windows(2).all(|w| w[0] < w[1]),
1906            "ref_energies must be strictly ascending (invariant broken)"
1907        );
1908        let n_ref = self.ref_energies.len();
1909
1910        // Clamp to nearest reference if outside range
1911        if energy <= self.ref_energies[0] || n_ref == 1 {
1912            return self.kernels[0].clone();
1913        }
1914        if energy >= self.ref_energies[n_ref - 1] {
1915            return self.kernels[n_ref - 1].clone();
1916        }
1917
1918        // Find bracketing indices
1919        let pos = self.ref_energies.partition_point(|&e| e < energy);
1920        let idx = if pos == 0 {
1921            0
1922        } else {
1923            (pos - 1).min(n_ref - 2)
1924        };
1925
1926        let e_lo = self.ref_energies[idx];
1927        let e_hi = self.ref_energies[idx + 1];
1928
1929        // Log-space interpolation fraction
1930        let frac = (energy.ln() - e_lo.ln()) / (e_hi.ln() - e_lo.ln());
1931
1932        let (off_lo, w_lo) = &self.kernels[idx];
1933        let (off_hi, w_hi) = &self.kernels[idx + 1];
1934
1935        // If both kernels have the same number of points, interpolate element-wise
1936        if off_lo.len() == off_hi.len() {
1937            let offsets: Vec<f64> = off_lo
1938                .iter()
1939                .zip(off_hi.iter())
1940                .map(|(&a, &b)| a + frac * (b - a))
1941                .collect();
1942            let weights: Vec<f64> = w_lo
1943                .iter()
1944                .zip(w_hi.iter())
1945                .map(|(&a, &b)| a + frac * (b - a))
1946                .collect();
1947            (offsets, weights)
1948        } else {
1949            // Different sizes: use nearest
1950            if frac < 0.5 {
1951                self.kernels[idx].clone()
1952            } else {
1953                self.kernels[idx + 1].clone()
1954            }
1955        }
1956    }
1957}
1958
1959/// Apply resolution broadening using either Gaussian or tabulated kernel.
1960///
1961/// # Errors
1962/// Returns [`ResolutionError`] if the energy grid is unsorted or array
1963/// lengths do not match.
1964pub fn apply_resolution(
1965    energies: &[f64],
1966    spectrum: &[f64],
1967    resolution: &ResolutionFunction,
1968) -> Result<Vec<f64>, ResolutionError> {
1969    match resolution {
1970        ResolutionFunction::Gaussian(params) => resolution_broaden(energies, spectrum, params),
1971        ResolutionFunction::Tabulated(tab) => tab.broaden(energies, spectrum),
1972    }
1973}
1974
1975/// Apply resolution broadening assuming the energy grid is already validated
1976/// (sorted ascending, same length as spectrum).
1977///
1978/// Used by `transmission.rs` to avoid redundant O(N) sort checks when
1979/// broadening multiple isotopes on the same pre-validated energy grid.
1980pub(crate) fn apply_resolution_presorted(
1981    energies: &[f64],
1982    spectrum: &[f64],
1983    resolution: &ResolutionFunction,
1984) -> Vec<f64> {
1985    match resolution {
1986        ResolutionFunction::Gaussian(params) => {
1987            resolution_broaden_presorted(energies, spectrum, params)
1988        }
1989        ResolutionFunction::Tabulated(tab) => tab.broaden_presorted(energies, spectrum),
1990    }
1991}
1992
1993/// Build a broadening plan for `(energies, resolution)`.
1994///
1995/// Returns `Some(plan)` for [`ResolutionFunction::Tabulated`] — the
1996/// plan hoists the per-target TOF / kernel-interpolation / bracket
1997/// / trap-weight work that would otherwise run on every call to
1998/// [`apply_resolution`].  Returns `None` for
1999/// [`ResolutionFunction::Gaussian`] — the Gaussian path has no
2000/// meaningful pixel-invariant kernel structure to cache at this
2001/// level, so callers fall back to the per-call broadening path with
2002/// no loss.
2003///
2004/// Callers that want a single-branch API can unconditionally call
2005/// [`apply_resolution_with_plan`] passing `plan.as_ref()`; when the
2006/// plan is `None` it transparently forwards to the non-plan path and
2007/// returns byte-identical output.
2008///
2009/// # Errors
2010/// Returns [`ResolutionError::UnsortedEnergies`] if `energies` is not
2011/// non-descending — the same precondition that [`apply_resolution`]
2012/// enforces per-call.
2013pub fn build_resolution_plan(
2014    energies: &[f64],
2015    resolution: &ResolutionFunction,
2016) -> Result<Option<ResolutionPlan>, ResolutionError> {
2017    match resolution {
2018        ResolutionFunction::Gaussian(_) => {
2019            if !energies.windows(2).all(|w| w[0] <= w[1]) {
2020                return Err(ResolutionError::UnsortedEnergies);
2021            }
2022            Ok(None)
2023        }
2024        ResolutionFunction::Tabulated(tab) => tab.plan(energies).map(Some),
2025    }
2026}
2027
2028/// Apply resolution broadening, optionally via a pre-built
2029/// [`ResolutionPlan`].
2030///
2031/// When `plan` is `Some(p)` and `resolution` is a tabulated kernel,
2032/// `p.apply(spectrum)` runs the cached per-target broadening inner
2033/// loop — the expensive TOF / kernel-interpolation / bracket work
2034/// was already captured at plan build time.
2035///
2036/// When `plan` is `None`, or when `resolution` is Gaussian, the call
2037/// forwards to [`apply_resolution`] and is byte-identical to the
2038/// un-planned path.
2039///
2040/// # Errors
2041/// * Returns the same errors as [`apply_resolution`] on the non-plan
2042///   path.
2043/// * Returns [`ResolutionError::LengthMismatch`] if the plan was built
2044///   for a different-length grid than `energies`, or if
2045///   `energies.len() != spectrum.len()`.
2046/// * Returns [`ResolutionError::PlanGridMismatch`] if the plan was
2047///   built for a different grid of the same length — the cached
2048///   `(lo_idx, frac, weight)` entries encode brackets into the old
2049///   grid and would silently produce a wrong broadened spectrum if
2050///   applied.
2051pub fn apply_resolution_with_plan(
2052    plan: Option<&ResolutionPlan>,
2053    energies: &[f64],
2054    spectrum: &[f64],
2055    resolution: &ResolutionFunction,
2056) -> Result<Vec<f64>, ResolutionError> {
2057    if let Some(p) = plan
2058        && matches!(resolution, ResolutionFunction::Tabulated(_))
2059    {
2060        validate_inputs(energies, spectrum)?;
2061        if p.len() != energies.len() {
2062            return Err(ResolutionError::LengthMismatch {
2063                energies: energies.len(),
2064                data: p.len(),
2065            });
2066        }
2067        // Grid-identity check.  A plan built for a different grid of
2068        // the same length would still pass the length check and then
2069        // gather spectrum values at brackets that belong to the old
2070        // grid — silently corrupt output.  Pointer identity is not
2071        // enough here because callers legitimately hold the plan and
2072        // the target grid in separate `Arc`s whose storage may or
2073        // may not alias; bit-exact content equality is the only
2074        // robust invariant.  The cost is one full grid scan per
2075        // broadening call (O(n), ~27 KB of f64 values for the VENUS
2076        // 3471-point grid) — orders of magnitude cheaper than the
2077        // broadening itself and cheap vs the silent-staleness
2078        // failure mode.
2079        let plan_grid = p.target_energies();
2080        for i in 0..plan_grid.len() {
2081            if plan_grid[i].to_bits() != energies[i].to_bits() {
2082                return Err(ResolutionError::PlanGridMismatch {
2083                    first_diff_index: i,
2084                });
2085            }
2086        }
2087        return Ok(p.apply(spectrum));
2088    }
2089    apply_resolution(energies, spectrum, resolution)
2090}
2091
2092/// Errors from resolution file parsing.
2093#[derive(Debug)]
2094pub enum ResolutionParseError {
2095    InvalidFormat(String),
2096    IoError(String),
2097}
2098
2099impl fmt::Display for ResolutionParseError {
2100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2101        match self {
2102            Self::InvalidFormat(msg) => write!(f, "Invalid resolution file format: {}", msg),
2103            Self::IoError(msg) => write!(f, "I/O error: {}", msg),
2104        }
2105    }
2106}
2107
2108impl std::error::Error for ResolutionParseError {}
2109
2110/// Test-only helpers for building synthetic [`ResolutionPlan`] /
2111/// [`TabulatedResolution`] instances without going through the full
2112/// parse-from-text path.  Gated behind `#[cfg(test)]` for in-crate
2113/// tests and the `test-support` feature flag for downstream-crate
2114/// tests.  Never ships in a release build with `test-support =
2115/// false` (the default), so the raw constructors remain out of the
2116/// production API surface.
2117#[cfg(any(test, feature = "test-support"))]
2118pub mod test_support {
2119    use super::{DIVISION_FLOOR, NEAR_ZERO_FLOOR, ResolutionPlan, TabulatedResolution};
2120
2121    /// Build a [`ResolutionPlan`] directly from its SoA fields.
2122    ///
2123    /// The caller is responsible for maintaining the invariants that
2124    /// `plan_presorted` normally enforces (`starts.last() ==
2125    /// lo_idx.len()`, lo_idx in [0, n-2] for regular entries, etc.).
2126    /// Used by surrogate-module tests + downstream crate tests to
2127    /// construct hand-designed plans that exercise specific CSR
2128    /// patterns.
2129    pub fn plan_from_raw_parts(
2130        target_energies: Vec<f64>,
2131        starts: Vec<u32>,
2132        lo_idx: Vec<u32>,
2133        frac: Vec<f64>,
2134        weight: Vec<f64>,
2135        norm: Vec<f64>,
2136    ) -> ResolutionPlan {
2137        ResolutionPlan {
2138            target_energies,
2139            starts,
2140            lo_idx,
2141            frac,
2142            weight,
2143            norm,
2144        }
2145    }
2146
2147    /// Build a minimal [`TabulatedResolution`] with a single
2148    /// reference energy and a trivial delta-like kernel — just
2149    /// enough for tests that need an `InstrumentParams` with a
2150    /// tabulated resolution (e.g., to exercise cubature dispatch
2151    /// guards that refuse Gaussian resolution).  The broadening
2152    /// would be effectively identity if anyone ever called it, but
2153    /// typical consumers (cubature dispatch tests) never invoke the
2154    /// kernel.
2155    pub fn trivial_tabulated_resolution(flight_path_m: f64) -> TabulatedResolution {
2156        TabulatedResolution {
2157            ref_energies: vec![100.0],
2158            kernels: vec![(vec![-1e-6, 0.0, 1e-6], vec![0.0, 1.0, 0.0])],
2159            flight_path_m,
2160        }
2161    }
2162
2163    /// Thin shim exposing the crate-internal
2164    /// [`TabulatedResolution::broaden_presorted`] to integration
2165    /// tests living under `crates/nereids-physics/tests/`.  The
2166    /// internal method stays `pub(crate)` so the broader public
2167    /// API surface (the operator-style `apply_resolution`,
2168    /// `plan` / `apply` / `compile_to_matrix`) remains the
2169    /// recommended entry point; this shim exists solely so the
2170    /// fixture-gated bit-exact regression and microbenchmark
2171    /// tests can call the optimized two-pointer walk directly.
2172    pub fn broaden_presorted(
2173        tab: &TabulatedResolution,
2174        energies: &[f64],
2175        spectrum: &[f64],
2176    ) -> Vec<f64> {
2177        tab.broaden_presorted(energies, spectrum)
2178    }
2179
2180    /// Thin shim exposing the crate-internal
2181    /// `TabulatedResolution::interpolated_kernel` to integration
2182    /// tests.  Needed by the bit-exact equivalence oracle that
2183    /// the fixture-gated regression test runs against the
2184    /// optimized `broaden_presorted` path.
2185    pub fn interpolated_kernel(tab: &TabulatedResolution, energy: f64) -> (Vec<f64>, Vec<f64>) {
2186        tab.interpolated_kernel(energy)
2187    }
2188
2189    /// The TOF↔energy conversion factor used by
2190    /// `broaden_presorted` and its oracle.  Exposed so the
2191    /// integration-test oracle uses the exact same constant as
2192    /// the SUT, preserving bit-exact equivalence.
2193    pub const TOF_FACTOR: f64 = super::TOF_FACTOR;
2194
2195    /// Bit-exact regression oracle: binary-search piecewise-linear
2196    /// interpolation of `spectrum` onto target energy `e`.  Mirror of
2197    /// the pre-optimization in-src reference; called transitively by
2198    /// [`broaden_presorted_reference`] inside its inner convolution
2199    /// loop.
2200    ///
2201    /// **Do not "clean up" this function.** Consumers are bit-exact
2202    /// equivalence tests that pin the optimized two-pointer path in
2203    /// `TabulatedResolution::broaden_presorted` against this byte-
2204    /// identical reference.  A rewrite that shifts edge cases by even
2205    /// one bit (e.g. swapping the upper-bound binary search for
2206    /// `partition_point`, or changing the `<=` to `<` in the midpoint
2207    /// comparison) would flip the comparison and invalidate the
2208    /// regression suite.  See `feedback_bit_exact_oracle_verbatim.md`.
2209    pub fn interp_spectrum(energies: &[f64], spectrum: &[f64], e: f64) -> Option<f64> {
2210        let n = energies.len();
2211        if n == 0 {
2212            return None;
2213        }
2214        if e < energies[0] || e > energies[n - 1] {
2215            return None;
2216        }
2217        let mut lo = 0;
2218        let mut hi = n - 1;
2219        while hi - lo > 1 {
2220            let mid = (lo + hi) / 2;
2221            if energies[mid] <= e {
2222                lo = mid;
2223            } else {
2224                hi = mid;
2225            }
2226        }
2227        let span = energies[hi] - energies[lo];
2228        if span.abs() < NEAR_ZERO_FLOOR {
2229            return Some(spectrum[lo]);
2230        }
2231        let frac = (e - energies[lo]) / span;
2232        Some(spectrum[lo] + frac * (spectrum[hi] - spectrum[lo]))
2233    }
2234
2235    /// Bit-exact regression oracle: pre-optimization reference
2236    /// implementation of [`TabulatedResolution::broaden_presorted`].
2237    /// Used by the in-src + integration + microbench bit-exact test
2238    /// suites that pin the optimized two-pointer path against this
2239    /// reference.
2240    ///
2241    /// Same "do not refactor" caveat as [`interp_spectrum`].  Reads
2242    /// `tab.flight_path_m()` via the public getter so the oracle stays
2243    /// callable from integration tests (the underlying field is
2244    /// private; the getter is a no-op wrapper, so byte-equivalence
2245    /// against the original in-src field access is preserved).
2246    pub fn broaden_presorted_reference(
2247        tab: &TabulatedResolution,
2248        energies: &[f64],
2249        spectrum: &[f64],
2250    ) -> Vec<f64> {
2251        let n = energies.len();
2252        if n == 0 {
2253            return vec![];
2254        }
2255
2256        let mut result = vec![0.0f64; n];
2257
2258        for i in 0..n {
2259            let e = energies[i];
2260            if e <= 0.0 {
2261                result[i] = spectrum[i];
2262                continue;
2263            }
2264
2265            let tof_center = TOF_FACTOR * tab.flight_path_m() / e.sqrt();
2266            let (offsets, weights) = tab.interpolated_kernel(e);
2267
2268            let mut sum = 0.0;
2269            let mut norm = 0.0;
2270
2271            for k in 0..offsets.len() {
2272                let dt = offsets[k];
2273                let w = weights[k];
2274                if w <= 0.0 {
2275                    continue;
2276                }
2277
2278                let tof_prime = tof_center + dt;
2279                if tof_prime <= 0.0 {
2280                    continue;
2281                }
2282
2283                let e_prime = (TOF_FACTOR * tab.flight_path_m() / tof_prime).powi(2);
2284
2285                let s = match interp_spectrum(energies, spectrum, e_prime) {
2286                    Some(v) => v,
2287                    None => continue,
2288                };
2289
2290                let dt_width = if k > 0 && k < offsets.len() - 1 {
2291                    (offsets[k + 1] - offsets[k - 1]) * 0.5
2292                } else if k == 0 && offsets.len() > 1 {
2293                    offsets[1] - offsets[0]
2294                } else if k == offsets.len() - 1 && offsets.len() > 1 {
2295                    offsets[k] - offsets[k - 1]
2296                } else {
2297                    1.0
2298                };
2299
2300                let weight = w * dt_width.abs();
2301                sum += weight * s;
2302                norm += weight;
2303            }
2304
2305            result[i] = if norm > DIVISION_FLOOR {
2306                sum / norm
2307            } else {
2308                spectrum[i]
2309            };
2310        }
2311
2312        result
2313    }
2314}
2315
2316#[cfg(test)]
2317mod tests {
2318    use super::*;
2319    use nereids_core::constants;
2320
2321    // ── Smoke tests for the test_support oracles (`interp_spectrum` +
2322    //    `broaden_presorted_reference`).  The 7+ bit-exact tests below
2323    //    exercise the math thoroughly; these smoke tests just pin the
2324    //    boundary-condition return-shape behavior of the oracles so a
2325    //    future refactor that breaks empty-input or out-of-range
2326    //    handling fails loudly rather than only via bit-exact diffs.
2327
2328    #[test]
2329    fn test_support_interp_spectrum_empty_returns_none() {
2330        assert_eq!(test_support::interp_spectrum(&[], &[], 1.0), None);
2331    }
2332
2333    #[test]
2334    fn test_support_interp_spectrum_out_of_range_returns_none() {
2335        let energies = [1.0, 2.0, 3.0];
2336        let spectrum = [10.0, 20.0, 30.0];
2337        assert_eq!(
2338            test_support::interp_spectrum(&energies, &spectrum, 0.5),
2339            None
2340        );
2341        assert_eq!(
2342            test_support::interp_spectrum(&energies, &spectrum, 3.5),
2343            None
2344        );
2345    }
2346
2347    #[test]
2348    fn test_support_broaden_presorted_reference_empty_returns_empty() {
2349        let tab = test_support::trivial_tabulated_resolution(25.0);
2350        let out = test_support::broaden_presorted_reference(&tab, &[], &[]);
2351        assert!(out.is_empty());
2352    }
2353
2354    #[test]
2355    fn test_tof_factor_consistency() {
2356        // Verify our TOF_FACTOR matches the constants module.
2357        let e = 10.0; // eV
2358        let l = 25.0; // meters
2359        let tof_constants = constants::energy_to_tof(e, l);
2360        let tof_ours = TOF_FACTOR * l / e.sqrt();
2361        let rel_diff = (tof_constants - tof_ours).abs() / tof_constants;
2362        assert!(
2363            rel_diff < 1e-10,
2364            "TOF mismatch: constants={}, ours={}, diff={:.4}%",
2365            tof_constants,
2366            tof_ours,
2367            rel_diff * 100.0
2368        );
2369    }
2370
2371    #[test]
2372    fn test_resolution_width_scaling() {
2373        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
2374
2375        // Resolution width should increase with energy.
2376        let w1 = params.gaussian_width(1.0);
2377        let w10 = params.gaussian_width(10.0);
2378        let w100 = params.gaussian_width(100.0);
2379
2380        assert!(w10 > w1, "Width should increase with energy");
2381        assert!(w100 > w10, "Width should increase with energy");
2382
2383        // At low energies, timing dominates: ΔE ∝ E^(3/2)
2384        // At high energies, path dominates: ΔE ∝ E
2385        // The ratio ΔE(10)/ΔE(1) should be between 10 and 31.6 (= 10^1.5)
2386        let ratio = w10 / w1;
2387        assert!(
2388            ratio > 5.0 && ratio < 40.0,
2389            "Width ratio = {}, expected between 10 and 31.6",
2390            ratio
2391        );
2392    }
2393
2394    #[test]
2395    fn test_zero_width_passthrough() {
2396        // If resolution parameters are zero, output should equal input.
2397        let energies = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2398        let xs = vec![10.0, 20.0, 30.0, 20.0, 10.0];
2399        let params = ResolutionParams::new(25.0, 0.0, 0.0, 0.0).unwrap();
2400        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
2401        assert_eq!(broadened, xs);
2402    }
2403
2404    #[test]
2405    fn test_broadening_reduces_peak() {
2406        // Resolution broadening should reduce peak heights and fill valleys.
2407        let n = 1001;
2408        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.01).collect();
2409        let center = 10.0;
2410        let gamma: f64 = 0.1; // Resonance width
2411        let xs: Vec<f64> = energies
2412            .iter()
2413            .map(|&e| {
2414                let de = e - center;
2415                1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
2416            })
2417            .collect();
2418
2419        let params = ResolutionParams::new(25.0, 5.0, 0.01, 0.0).unwrap();
2420        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
2421
2422        let orig_peak = xs.iter().cloned().fold(0.0_f64, f64::max);
2423        let broad_peak = broadened.iter().cloned().fold(0.0_f64, f64::max);
2424
2425        assert!(
2426            broad_peak < orig_peak,
2427            "Broadened peak ({}) should be < original ({})",
2428            broad_peak,
2429            orig_peak
2430        );
2431        assert!(
2432            broad_peak > 1.0,
2433            "Broadened peak ({}) should still be substantial",
2434            broad_peak
2435        );
2436    }
2437
2438    #[test]
2439    fn test_broadening_conserves_area() {
2440        // Resolution broadening should approximately conserve the area
2441        // under the cross-section curve.
2442        let n = 2001;
2443        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
2444        let center = 10.0;
2445        let gamma: f64 = 0.5;
2446        let xs: Vec<f64> = energies
2447            .iter()
2448            .map(|&e| {
2449                let de = e - center;
2450                1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
2451            })
2452            .collect();
2453
2454        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
2455        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
2456
2457        // Trapezoidal area
2458        let area_orig: f64 = (0..n - 1)
2459            .map(|i| 0.5 * (xs[i] + xs[i + 1]) * (energies[i + 1] - energies[i]))
2460            .sum();
2461        let area_broad: f64 = (0..n - 1)
2462            .map(|i| 0.5 * (broadened[i] + broadened[i + 1]) * (energies[i + 1] - energies[i]))
2463            .sum();
2464
2465        let rel_diff = (area_orig - area_broad).abs() / area_orig;
2466        assert!(
2467            rel_diff < 0.02,
2468            "Area not conserved: orig={:.2}, broad={:.2}, rel_diff={:.4}",
2469            area_orig,
2470            area_broad,
2471            rel_diff
2472        );
2473    }
2474
2475    #[test]
2476    fn test_gaussian_broadening_analytical() {
2477        // Broadening a Gaussian with a Gaussian should give a wider Gaussian.
2478        //
2479        // Input:  exp(-x²/(2σ₁²)) with σ₁ = 0.5 eV (standard Gaussian form)
2480        // Kernel: exp(-x²/W²) with W = 0.3 eV → std dev σ₂ = W/√2 = 0.2121 eV
2481        // Output: Gaussian with σ_out = √(σ₁² + σ₂²) = √(0.25 + 0.045) = 0.543 eV
2482        //
2483        // Note: kernel width varies slightly with energy (σ_E ∝ E for the
2484        // path-length contribution), so we allow ~5% tolerance.
2485        let n = 2001;
2486        let center = 10.0;
2487        let sigma_input = 0.5; // eV (standard deviation)
2488        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
2489        let xs: Vec<f64> = energies
2490            .iter()
2491            .map(|&e| {
2492                let de = e - center;
2493                1000.0 * (-de * de / (2.0 * sigma_input * sigma_input)).exp()
2494            })
2495            .collect();
2496
2497        // Set delta_l such that W = gaussian_width(E=10) ≈ 0.3 eV.
2498        // W = 2·ΔL·E/L, so ΔL = W·L/(2E) = 0.3×25/(20) = 0.375 m
2499        let w_kernel = 0.3; // Kernel parameter W (exp(-x²/W²))
2500        let params =
2501            ResolutionParams::new(25.0, 0.0, w_kernel * 25.0 / (2.0 * center), 0.0).unwrap();
2502
2503        // Verify kernel W at center energy
2504        let w_at_center = params.gaussian_width(center);
2505        assert!(
2506            (w_at_center - w_kernel).abs() / w_kernel < 0.01,
2507            "Kernel W at center: {}, expected {}",
2508            w_at_center,
2509            w_kernel
2510        );
2511
2512        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
2513
2514        // Kernel std dev = W/√2
2515        let sigma_kernel = w_kernel / 2.0_f64.sqrt();
2516        let sigma_expected = (sigma_input * sigma_input + sigma_kernel * sigma_kernel).sqrt();
2517        let fwhm_expected = 2.0 * (2.0_f64.ln() * 2.0).sqrt() * sigma_expected;
2518
2519        // Measure FWHM from the broadened output
2520        let peak_idx = broadened
2521            .iter()
2522            .enumerate()
2523            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
2524            .unwrap()
2525            .0;
2526        let peak_val = broadened[peak_idx];
2527        let half_max = peak_val / 2.0;
2528
2529        let mut left_hm = energies[0];
2530        for i in (0..peak_idx).rev() {
2531            if broadened[i] < half_max {
2532                let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
2533                left_hm = energies[i] + t * (energies[i + 1] - energies[i]);
2534                break;
2535            }
2536        }
2537        let mut right_hm = energies[n - 1];
2538        for i in peak_idx..n - 1 {
2539            if broadened[i + 1] < half_max {
2540                let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
2541                right_hm = energies[i] + t * (energies[i + 1] - energies[i]);
2542                break;
2543            }
2544        }
2545
2546        let fwhm_measured = right_hm - left_hm;
2547        let rel_err = (fwhm_measured - fwhm_expected).abs() / fwhm_expected;
2548
2549        assert!(
2550            rel_err < 0.05,
2551            "FWHM: measured={:.4}, expected={:.4}, rel_err={:.2}%",
2552            fwhm_measured,
2553            fwhm_expected,
2554            rel_err * 100.0
2555        );
2556    }
2557
2558    #[test]
2559    fn test_venus_typical_resolution() {
2560        // Verify resolution width for typical VENUS parameters.
2561        // VENUS: L ≈ 25 m, Δt ≈ 10 μs (pulsed source), ΔL ≈ 0.01 m
2562        let params = ResolutionParams::new(25.0, 10.0, 0.01, 0.0).unwrap();
2563
2564        // At 1 eV: ΔE/E should be small (good resolution)
2565        let de_1 = params.gaussian_width(1.0);
2566        let de_over_e_1 = de_1 / 1.0;
2567        assert!(
2568            de_over_e_1 < 0.05,
2569            "ΔE/E at 1 eV = {:.4}, should be < 5%",
2570            de_over_e_1
2571        );
2572
2573        // At 100 eV: resolution degrades
2574        let de_100 = params.gaussian_width(100.0);
2575        let de_over_e_100 = de_100 / 100.0;
2576        assert!(
2577            de_over_e_100 > de_over_e_1,
2578            "Resolution should degrade at higher energies"
2579        );
2580    }
2581
2582    #[test]
2583    fn test_unsorted_energies_returns_error() {
2584        let energies = vec![1.0, 3.0, 2.0, 4.0]; // not sorted
2585        let xs = vec![10.0, 30.0, 20.0, 40.0];
2586        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
2587        let result = resolution_broaden(&energies, &xs, &params);
2588        assert!(result.is_err());
2589        assert!(matches!(
2590            result.unwrap_err(),
2591            ResolutionError::UnsortedEnergies
2592        ));
2593    }
2594
2595    #[test]
2596    fn test_length_mismatch_returns_error() {
2597        let energies = vec![1.0, 2.0, 3.0];
2598        let xs = vec![10.0, 20.0]; // wrong length
2599        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
2600        let result = resolution_broaden(&energies, &xs, &params);
2601        assert!(result.is_err());
2602        assert!(matches!(
2603            result.unwrap_err(),
2604            ResolutionError::LengthMismatch {
2605                energies: 3,
2606                data: 2
2607            }
2608        ));
2609    }
2610
2611    // --- ResolutionParams validation tests ---
2612
2613    #[test]
2614    fn test_resolution_params_valid() {
2615        let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
2616        assert!((p.flight_path_m() - 25.0).abs() < 1e-15);
2617        assert!((p.delta_t_us() - 1.0).abs() < 1e-15);
2618        assert!((p.delta_l_m() - 0.01).abs() < 1e-15);
2619    }
2620
2621    #[test]
2622    fn test_resolution_params_rejects_zero_flight_path() {
2623        let err = ResolutionParams::new(0.0, 1.0, 0.01, 0.0).unwrap_err();
2624        assert_eq!(err, ResolutionParamsError::InvalidFlightPath(0.0));
2625    }
2626
2627    #[test]
2628    fn test_resolution_params_rejects_negative_flight_path() {
2629        let err = ResolutionParams::new(-1.0, 1.0, 0.01, 0.0).unwrap_err();
2630        assert_eq!(err, ResolutionParamsError::InvalidFlightPath(-1.0));
2631    }
2632
2633    #[test]
2634    fn test_resolution_params_rejects_nan_flight_path() {
2635        let err = ResolutionParams::new(f64::NAN, 1.0, 0.01, 0.0).unwrap_err();
2636        assert!(matches!(err, ResolutionParamsError::InvalidFlightPath(_)));
2637    }
2638
2639    #[test]
2640    fn test_resolution_params_rejects_negative_delta_t() {
2641        let err = ResolutionParams::new(25.0, -1.0, 0.01, 0.0).unwrap_err();
2642        assert_eq!(err, ResolutionParamsError::InvalidDeltaT(-1.0));
2643    }
2644
2645    #[test]
2646    fn test_resolution_params_rejects_nan_delta_t() {
2647        let err = ResolutionParams::new(25.0, f64::NAN, 0.01, 0.0).unwrap_err();
2648        assert!(matches!(err, ResolutionParamsError::InvalidDeltaT(_)));
2649    }
2650
2651    #[test]
2652    fn test_resolution_params_rejects_negative_delta_l() {
2653        let err = ResolutionParams::new(25.0, 1.0, -0.01, 0.0).unwrap_err();
2654        assert_eq!(err, ResolutionParamsError::InvalidDeltaL(-0.01));
2655    }
2656
2657    #[test]
2658    fn test_resolution_params_rejects_inf_delta_l() {
2659        let err = ResolutionParams::new(25.0, 1.0, f64::INFINITY, 0.0).unwrap_err();
2660        assert!(matches!(err, ResolutionParamsError::InvalidDeltaL(_)));
2661    }
2662
2663    #[test]
2664    fn test_resolution_params_rejects_negative_delta_e() {
2665        let err = ResolutionParams::new(25.0, 1.0, 0.01, -0.05).unwrap_err();
2666        assert_eq!(err, ResolutionParamsError::InvalidDeltaE(-0.05));
2667    }
2668
2669    #[test]
2670    fn test_resolution_params_rejects_nan_delta_e() {
2671        let err = ResolutionParams::new(25.0, 1.0, 0.01, f64::NAN).unwrap_err();
2672        assert!(matches!(err, ResolutionParamsError::InvalidDeltaE(_)));
2673    }
2674
2675    #[test]
2676    fn test_resolution_params_accepts_zero_delta_e() {
2677        let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
2678        assert!((p.delta_e_us() - 0.0).abs() < 1e-15);
2679        assert!(!p.has_exponential_tail());
2680    }
2681
2682    // ─── broaden_presorted bit-exact equivalence harness ─────────────────────
2683    //
2684    // The optimized broaden_presorted uses a two-pointer walk instead of
2685    // binary search inside the inner convolution loop.  These tests pin
2686    // the math: the same formula, in the same order, must yield bit-exact
2687    // output against a canonical reference implementation that preserves
2688    // the pre-optimization code path.
2689
2690    // The `interp_spectrum` + `broaden_presorted_reference` oracles
2691    // were promoted to `test_support` so the integration tests
2692    // (`tests/venus_usr_resolution{,_microbench}.rs`) share the same
2693    // byte-identical reference.  Imported below.
2694    use super::test_support::broaden_presorted_reference;
2695
2696    /// Synthetic TabulatedResolution with 3 reference energies and a
2697    /// triangular kernel of varying widths.  Deterministic, no I/O.
2698    fn synthetic_tab_resolution() -> TabulatedResolution {
2699        fn triangle(width_us: f64, n: usize) -> (Vec<f64>, Vec<f64>) {
2700            let half = width_us;
2701            let dt_step = 2.0 * half / (n - 1) as f64;
2702            let offsets: Vec<f64> = (0..n).map(|i| -half + i as f64 * dt_step).collect();
2703            let weights: Vec<f64> = offsets
2704                .iter()
2705                .map(|&dt| (1.0 - dt.abs() / half).max(0.0))
2706                .collect();
2707            (offsets, weights)
2708        }
2709        TabulatedResolution {
2710            ref_energies: vec![5.0, 50.0, 500.0],
2711            kernels: vec![triangle(0.5, 31), triangle(1.0, 41), triangle(2.0, 51)],
2712            flight_path_m: 25.0,
2713        }
2714    }
2715
2716    fn assert_bit_exact(reference: &[f64], actual: &[f64], label: &str) {
2717        assert_eq!(reference.len(), actual.len(), "{label}: length mismatch");
2718        for (i, (&a, &b)) in reference.iter().zip(actual.iter()).enumerate() {
2719            assert_eq!(
2720                a.to_bits(),
2721                b.to_bits(),
2722                "{label}: element {i} mismatch: reference={a:.17e} actual={b:.17e}"
2723            );
2724        }
2725    }
2726
2727    #[test]
2728    fn test_broaden_presorted_bit_exact_synthetic_uniform() {
2729        let tab = synthetic_tab_resolution();
2730        // Uniform log-spaced grid typical of VENUS analysis.
2731        let energies: Vec<f64> = (0..401).map(|i| 7.0 + i as f64 * 0.4825).collect();
2732        // Triangular dip + smooth background (resonance-like spectrum).
2733        let spectrum: Vec<f64> = energies
2734            .iter()
2735            .enumerate()
2736            .map(|(i, &e)| 0.9 - 0.7 * (-((e - 50.0).powi(2) / 4.0)).exp() + 0.001 * (i as f64))
2737            .collect();
2738
2739        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2740        let actual = tab.broaden_presorted(&energies, &spectrum);
2741        assert_bit_exact(&reference, &actual, "synthetic_uniform");
2742    }
2743
2744    #[test]
2745    fn test_broaden_presorted_bit_exact_synthetic_nonuniform() {
2746        let tab = synthetic_tab_resolution();
2747        // Non-uniform: denser near 6.674 eV (resonance-like), sparser far away.
2748        let energies: Vec<f64> = {
2749            let mut e = Vec::new();
2750            for i in 0..200 {
2751                e.push(5.0 + (i as f64) * 0.05);
2752            }
2753            for i in 0..100 {
2754                e.push(15.0 + (i as f64) * 0.5);
2755            }
2756            for i in 0..50 {
2757                e.push(65.0 + (i as f64) * 2.0);
2758            }
2759            e
2760        };
2761        let spectrum: Vec<f64> = energies
2762            .iter()
2763            .map(|&e| 1.0 - 0.5 * (-((e - 6.674).powi(2) / 0.1)).exp())
2764            .collect();
2765
2766        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2767        let actual = tab.broaden_presorted(&energies, &spectrum);
2768        assert_bit_exact(&reference, &actual, "synthetic_nonuniform");
2769    }
2770
2771    #[test]
2772    fn test_broaden_presorted_bit_exact_constant_spectrum() {
2773        // Constant spectrum must pass through unchanged (within trapezoid
2774        // normalization) — preserves integral exactly.
2775        let tab = synthetic_tab_resolution();
2776        let energies: Vec<f64> = (0..501).map(|i| 1.0 + i as f64 * 0.5).collect();
2777        let spectrum = vec![0.42f64; energies.len()];
2778
2779        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2780        let actual = tab.broaden_presorted(&energies, &spectrum);
2781        assert_bit_exact(&reference, &actual, "constant_spectrum");
2782    }
2783
2784    #[test]
2785    fn test_broaden_presorted_bit_exact_short_grid() {
2786        // Edge case: 2-point grid.  Exercises the smallest grid that has
2787        // a valid (lo, hi) bracket — tests bracket_hi bounds handling.
2788        let tab = synthetic_tab_resolution();
2789        let energies = vec![10.0, 12.0];
2790        let spectrum = vec![0.5, 0.8];
2791
2792        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2793        let actual = tab.broaden_presorted(&energies, &spectrum);
2794        assert_bit_exact(&reference, &actual, "short_grid");
2795    }
2796
2797    #[test]
2798    fn test_broaden_presorted_bit_exact_single_point_grid() {
2799        // Edge case: 1-point grid.  Exercises the n == 1 early-return
2800        // pass-through guard that the optimized path adds (no bracket
2801        // available for interpolation).
2802        let tab = synthetic_tab_resolution();
2803        let energies = vec![10.0];
2804        let spectrum = vec![0.5];
2805
2806        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2807        let actual = tab.broaden_presorted(&energies, &spectrum);
2808        assert_bit_exact(&reference, &actual, "single_point_grid");
2809    }
2810
2811    #[test]
2812    fn test_broaden_presorted_bit_exact_exact_equality_target() {
2813        // Regression: exercise the tie-break case where `e_prime` lands
2814        // exactly on a grid point.  The kernel has a point at dt=0, so
2815        // `e_prime == energies[i]` exactly at the center kernel offset
2816        // for every target `i`.  The optimized path must match the
2817        // reference's upper-bound binary-search semantics bit-exactly.
2818        let tab = synthetic_tab_resolution();
2819        // Irregular-spacing grid so the spectrum interp at the equality
2820        // point isn't trivially reducible to the input value.
2821        let mut energies: Vec<f64> = Vec::new();
2822        let mut e = 3.0f64;
2823        for k in 0..800 {
2824            energies.push(e);
2825            e += 0.05 + 0.01 * (k as f64).sin();
2826        }
2827        // Spectrum with large local gradient so `a + (b - a)` vs `b`
2828        // would diverge at 1 ULP if the tie-break were wrong.
2829        let spectrum: Vec<f64> = energies
2830            .iter()
2831            .map(|&e| 1.0e10 * (-(((e - 6.0) / 0.2).powi(2))).exp() + 1.0e-10 * e)
2832            .collect();
2833
2834        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2835        let actual = tab.broaden_presorted(&energies, &spectrum);
2836        assert_bit_exact(&reference, &actual, "exact_equality_target");
2837    }
2838
2839    #[test]
2840    fn test_broaden_presorted_bit_exact_random_spectrum() {
2841        // Random spectrum with varied magnitudes exercises the interpolation
2842        // arithmetic across sign changes and scales.
2843        let tab = synthetic_tab_resolution();
2844        let energies: Vec<f64> = (0..1001).map(|i| 2.0 + i as f64 * 0.2).collect();
2845        // Deterministic pseudo-random via a simple LCG (no external dep).
2846        let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
2847        let spectrum: Vec<f64> = energies
2848            .iter()
2849            .map(|_| {
2850                state = state
2851                    .wrapping_mul(6364136223846793005)
2852                    .wrapping_add(1442695040888963407);
2853                let f = ((state >> 33) as f64) / (u32::MAX as f64);
2854                f * 2.0 - 1.0
2855            })
2856            .collect();
2857
2858        let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2859        let actual = tab.broaden_presorted(&energies, &spectrum);
2860        assert_bit_exact(&reference, &actual, "random_spectrum");
2861    }
2862
2863    // ---------------------------------------------------------------
2864    // VENUS-like regression test moved to
2865    // `crates/nereids-physics/tests/venus_usr_resolution.rs`
2866    // (`test_broaden_presorted_bit_exact_on_venus_usr`) — see issue
2867    // #497.  The integration test parses a synthetic SAMMY USR-format
2868    // kernel via `common::synthetic_venus_usr_tab()` (the real VENUS
2869    // BL10 fixture is not approved for public release; issue #557).
2870    // ---------------------------------------------------------------
2871
2872    #[test]
2873    fn test_plan_reuse_bit_exact_across_multiple_spectra() {
2874        // Core promise of ResolutionPlan: building the plan once and
2875        // applying it to K different spectra must yield the same output
2876        // as K independent `broaden_presorted` calls.
2877        let tab = synthetic_tab_resolution();
2878        let energies: Vec<f64> = (0..401).map(|i| 7.0 + i as f64 * 0.4825).collect();
2879
2880        // Build plan ONCE.
2881        let plan = tab.plan(&energies).expect("sorted grid must validate");
2882        assert_eq!(plan.len(), energies.len());
2883        assert_eq!(plan.target_energies(), &energies[..]);
2884
2885        // Apply across 5 varied spectra.
2886        let mut state: u64 = 0xCAFE_BABE_DEAD_BEEF;
2887        for spec_idx in 0..5 {
2888            let spectrum: Vec<f64> = energies
2889                .iter()
2890                .enumerate()
2891                .map(|(i, &e)| {
2892                    state = state
2893                        .wrapping_mul(6364136223846793005)
2894                        .wrapping_add(1442695040888963407);
2895                    let noise = ((state >> 33) as f64) / (u32::MAX as f64);
2896                    // Varied magnitudes and shapes per spectrum to catch
2897                    // spectrum-dependent arithmetic drift.
2898                    (10.0f64).powi(spec_idx - 2) * (1.0 - 0.5 * noise)
2899                        + 0.3 * (-((e - 50.0).powi(2) / 4.0)).exp()
2900                        + (spec_idx as f64) * 1e-8 * (i as f64)
2901                })
2902                .collect();
2903
2904            let via_plan = plan.apply(&spectrum);
2905            let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2906            assert_bit_exact(
2907                &via_reference,
2908                &via_plan,
2909                &format!("plan_reuse[spec_idx={spec_idx}]"),
2910            );
2911        }
2912    }
2913
2914    #[test]
2915    fn test_plan_passthrough_cases() {
2916        // n == 0, n == 1, and e <= 0.0 must all produce the same
2917        // passthrough behaviour via plan as via broaden_presorted.
2918        let tab = synthetic_tab_resolution();
2919
2920        // n == 0: empty plan, empty result.
2921        let plan = tab.plan(&[]).unwrap();
2922        assert_eq!(plan.len(), 0);
2923        assert!(plan.is_empty());
2924        let out: Vec<f64> = plan.apply(&[]);
2925        assert!(out.is_empty());
2926
2927        // n == 1: passthrough for any spectrum value.
2928        let plan1 = tab.plan(&[5.0]).unwrap();
2929        assert_eq!(plan1.len(), 1);
2930        let out1 = plan1.apply(&[0.42]);
2931        assert_eq!(out1, vec![0.42]);
2932
2933        // e <= 0.0 in the middle of a grid: passthrough at that index.
2934        // Mixed positive / non-positive energies are pathological but
2935        // the current implementation handles them, and the plan must
2936        // match.  Grid is still non-descending (0.0 ≤ 10.0 etc.) so
2937        // plan() accepts it.
2938        let energies = vec![1.0, 1.0, 10.0, 100.0];
2939        let spectrum = vec![0.1, 0.5, 0.9, 0.3];
2940        let via_plan = {
2941            let plan = tab.plan(&energies).unwrap();
2942            plan.apply(&spectrum)
2943        };
2944        let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2945        assert_bit_exact(
2946            &via_reference,
2947            &via_plan,
2948            "mixed_positive_and_zero_energies",
2949        );
2950    }
2951
2952    #[test]
2953    fn test_plan_rejects_unsorted_energies() {
2954        // `broaden()` rejects unsorted grids via validate_inputs; `plan()`
2955        // must do the same so a caller doesn't silently build a plan with
2956        // misbracketed e_prime lookups and then produce wrong σ output
2957        // from `ResolutionPlan::apply`.
2958        let tab = synthetic_tab_resolution();
2959        let result = tab.plan(&[10.0, 1.0, 100.0]);
2960        assert!(matches!(result, Err(ResolutionError::UnsortedEnergies)));
2961    }
2962
2963    #[test]
2964    fn test_plan_apply_is_nan_safe_at_degenerate_bracket() {
2965        // When two adjacent target energies are equal (span = 0), the
2966        // plan encodes `frac = 0.0` and the apply path must short-
2967        // circuit to `spectrum[lo]` without reading `spectrum[lo+1]`.
2968        // A NaN at the upper bracket would propagate through
2969        // `0.0 * NaN = NaN` and corrupt the result otherwise.
2970        let tab = synthetic_tab_resolution();
2971        // Grid has a degenerate duplicate at indices 1 and 2.
2972        let energies = vec![8.0, 10.0, 10.0, 12.0, 50.0, 100.0];
2973        // Spectrum with NaN exactly at the upper-bracket index (2) that
2974        // the degenerate pair maps to; any retained (target, kernel-
2975        // point) entry whose `e_prime` lands inside that duplicate
2976        // bracket MUST NOT pull the NaN into the output.
2977        let spectrum = vec![0.1, 0.5, f64::NAN, 0.9, 0.2, 0.05];
2978        let plan = tab.plan(&energies).unwrap();
2979        let via_plan = plan.apply(&spectrum);
2980        let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
2981        // Both paths must agree on the non-pathological targets.  The
2982        // reference path returns `spectrum[lo]` directly in the
2983        // degenerate case (no touch of `spectrum[lo+1]`) and the plan
2984        // path's `frac == 0.0` short-circuit matches bit-exactly.
2985        // Targets whose kernel legitimately interpolates across index 2
2986        // will pull the NaN in BOTH paths equally — that's physics, not
2987        // a bug — so we compare bit-pattern with a NaN-aware helper.
2988        assert_eq!(via_plan.len(), via_reference.len());
2989        for (i, (&a, &b)) in via_reference.iter().zip(via_plan.iter()).enumerate() {
2990            // Both NaN or both finite and bit-exact.
2991            if a.is_nan() {
2992                assert!(b.is_nan(), "plan[{i}]={b} but reference is NaN");
2993            } else {
2994                assert_eq!(
2995                    a.to_bits(),
2996                    b.to_bits(),
2997                    "nan_safe mismatch at {i}: reference={a} plan={b}"
2998                );
2999            }
3000        }
3001    }
3002
3003    #[test]
3004    fn test_plan_apply_exact_match_frac_plus_zero_propagates_nan() {
3005        // Regression gate for the subtle P1 Copilot caught on PR #470.
3006        //
3007        // When `e_prime` aligns EXACTLY with a grid point `energies[lo]`,
3008        // `plan_presorted`'s interp fraction computes to `+0.0`, yet the
3009        // bracket is NOT degenerate (span is a normal positive float).
3010        // In that case `broaden_presorted` still evaluates
3011        //   s = spectrum[lo] + (+0.0) * (spectrum[lo+1] - spectrum[lo])
3012        // which, for `spectrum[lo+1] = NaN`, reads `0.0 * NaN = NaN` and
3013        // propagates `NaN` into `s`.  The earlier `frac == 0.0` branch
3014        // in `ResolutionPlan::apply` incorrectly treated this case as
3015        // degenerate (since `+0.0 == -0.0` under `==`) and short-circuited
3016        // to `spectrum[lo]`, producing a finite output where the scalar
3017        // reference produced NaN.
3018        //
3019        // Fix: `plan_presorted` now stores `-0.0` (negative-signed zero)
3020        // for the degenerate sentinel, and `apply` disambiguates via
3021        // `to_bits()`, so the non-degenerate `+0.0` path correctly reads
3022        // `spectrum[lo+1]` and propagates NaN.
3023        let tab = synthetic_tab_resolution();
3024
3025        // Grid has a point at energy = 10.0.  We engineer a target grid
3026        // where the broadened kernel at one of the targets produces an
3027        // `e_prime` that aligns exactly with `energies[lo]` of one of its
3028        // retained entries.  Achieved by building a coarse target grid
3029        // and letting the two-pointer walk land on an exact match on at
3030        // least one (target, kernel-point) pair.
3031        let energies: Vec<f64> = (0..32).map(|i| 1.0 + i as f64).collect();
3032        // Spectrum with NaN scattered at multiple lo+1 indices.  At
3033        // least one retained plan entry in this synthetic configuration
3034        // will have `frac == +0.0` from an exact-match case, which must
3035        // propagate NaN in apply.
3036        let mut spectrum = vec![0.5_f64; energies.len()];
3037        for v in &mut spectrum[3..] {
3038            *v = f64::NAN;
3039        }
3040        let plan = tab.plan(&energies).unwrap();
3041        let via_plan = plan.apply(&spectrum);
3042        let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
3043
3044        // Bit-exact equivalence on all targets, including NaN-propagated
3045        // ones.  This test would FAIL pre-fix (plan returns finite where
3046        // reference returns NaN for any exact-match plan entry with a
3047        // NaN at `lo+1`).
3048        assert_eq!(via_plan.len(), via_reference.len());
3049        for (i, (&a, &b)) in via_reference.iter().zip(via_plan.iter()).enumerate() {
3050            if a.is_nan() {
3051                assert!(
3052                    b.is_nan(),
3053                    "target {i}: reference produced NaN (NaN propagated through \
3054                     exact-match `frac = +0.0` path) but plan returned finite {b}",
3055                );
3056            } else {
3057                assert_eq!(
3058                    a.to_bits(),
3059                    b.to_bits(),
3060                    "target {i}: reference={a} plan={b}"
3061                );
3062            }
3063        }
3064    }
3065
3066    #[test]
3067    #[should_panic(expected = "must match plan target-grid length")]
3068    fn test_plan_apply_spectrum_length_mismatch_panics() {
3069        let tab = synthetic_tab_resolution();
3070        let plan = tab.plan(&[1.0, 2.0, 3.0]).unwrap();
3071        // Wrong spectrum length — caller error should panic with a
3072        // clear message rather than silently producing garbage.
3073        let _ = plan.apply(&[0.1, 0.2]);
3074    }
3075
3076    // ─── apply_resolution_with_plan / _presorted_with_plan dispatch harness ───
3077    //
3078    // These gates cover the public/crate-visible wrappers added for
3079    // production plan-caching.  Every production caller (fit-model
3080    // layer, spatial dispatch) goes through one of these two entries;
3081    // their dispatch choices must be byte-identical to the non-plan
3082    // paths they replace.
3083
3084    #[test]
3085    fn test_apply_resolution_with_plan_tabulated_matches_non_plan_path() {
3086        let tab = synthetic_tab_resolution();
3087        let resolution = ResolutionFunction::Tabulated(Arc::new(tab.clone()));
3088        let energies: Vec<f64> = (0..128).map(|i| 1.0 + i as f64 * (200.0 / 128.0)).collect();
3089        let spectrum: Vec<f64> = energies
3090            .iter()
3091            .map(|&e| 1.0 - 0.3 * (-((e - 20.0).powi(2) / 4.0)).exp())
3092            .collect();
3093
3094        let baseline = apply_resolution(&energies, &spectrum, &resolution).unwrap();
3095
3096        let plan = build_resolution_plan(&energies, &resolution).unwrap();
3097        assert!(
3098            plan.is_some(),
3099            "tabulated resolution must produce Some(plan)"
3100        );
3101        let planned =
3102            apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &resolution).unwrap();
3103        assert_eq!(planned.len(), baseline.len());
3104        for (i, (&a, &b)) in baseline.iter().zip(planned.iter()).enumerate() {
3105            assert_eq!(
3106                a.to_bits(),
3107                b.to_bits(),
3108                "apply_resolution_with_plan mismatch at {i}: baseline={a} planned={b}"
3109            );
3110        }
3111    }
3112
3113    #[test]
3114    fn test_apply_resolution_with_plan_gaussian_returns_none_plan_and_matches() {
3115        let resolution =
3116            ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 1.0e-3, 0.02, 0.01).unwrap());
3117        let energies: Vec<f64> = (0..64).map(|i| 1.0 + i as f64 * 3.0).collect();
3118        let spectrum: Vec<f64> = energies.iter().map(|&e| 1.0 / e).collect();
3119
3120        let plan = build_resolution_plan(&energies, &resolution).unwrap();
3121        assert!(
3122            plan.is_none(),
3123            "Gaussian resolution must not produce a plan"
3124        );
3125
3126        let baseline = apply_resolution(&energies, &spectrum, &resolution).unwrap();
3127        let planned =
3128            apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &resolution).unwrap();
3129        assert_eq!(planned.len(), baseline.len());
3130        for (i, (&a, &b)) in baseline.iter().zip(planned.iter()).enumerate() {
3131            assert_eq!(
3132                a.to_bits(),
3133                b.to_bits(),
3134                "gaussian fallback mismatch at {i}: baseline={a} planned={b}"
3135            );
3136        }
3137    }
3138
3139    #[test]
3140    fn test_apply_resolution_with_plan_rejects_same_length_different_grid() {
3141        // Codex finding: `p.len() == energies.len()` is necessary
3142        // but not sufficient.  A plan built for one grid and applied
3143        // to a different same-length grid would silently gather
3144        // spectrum values at brackets belonging to the original grid
3145        // — wrong σ output without any error surfaced.  The grid-
3146        // identity check in `apply_resolution_with_plan` guards this
3147        // failure mode and reports the first differing index.
3148        let tab = synthetic_tab_resolution();
3149        let resolution = ResolutionFunction::Tabulated(Arc::new(tab.clone()));
3150        let energies_plan: Vec<f64> = (0..32).map(|i| 1.0 + i as f64).collect();
3151        let mut energies_apply = energies_plan.clone();
3152        // Perturb a single interior point so lengths still match.
3153        energies_apply[5] += 0.25;
3154        let spectrum = vec![0.7; energies_apply.len()];
3155
3156        let plan = tab.plan(&energies_plan).unwrap();
3157        let result =
3158            apply_resolution_with_plan(Some(&plan), &energies_apply, &spectrum, &resolution);
3159        match result {
3160            Err(ResolutionError::PlanGridMismatch { first_diff_index }) => {
3161                assert_eq!(first_diff_index, 5);
3162            }
3163            other => panic!("expected PlanGridMismatch, got {:?}", other),
3164        }
3165    }
3166
3167    #[test]
3168    fn test_apply_resolution_with_plan_rejects_length_mismatch() {
3169        let tab = synthetic_tab_resolution();
3170        let resolution = ResolutionFunction::Tabulated(Arc::new(tab.clone()));
3171        let energies_plan: Vec<f64> = (0..32).map(|i| 1.0 + i as f64).collect();
3172        let energies_apply: Vec<f64> = (0..48).map(|i| 1.0 + i as f64).collect();
3173        let spectrum = vec![0.5; energies_apply.len()];
3174
3175        let plan = tab.plan(&energies_plan).unwrap();
3176        let result =
3177            apply_resolution_with_plan(Some(&plan), &energies_apply, &spectrum, &resolution);
3178        match result {
3179            Err(ResolutionError::LengthMismatch { energies, data }) => {
3180                assert_eq!(energies, 48);
3181                assert_eq!(data, 32);
3182            }
3183            other => panic!("expected LengthMismatch, got {:?}", other),
3184        }
3185    }
3186
3187    #[test]
3188    fn test_build_resolution_plan_rejects_unsorted_energies_for_gaussian() {
3189        // Gaussian returns None on success, but must still reject an
3190        // unsorted grid — callers use `build_resolution_plan` to
3191        // centralise the sort-check so the downstream `apply` path can
3192        // skip it.
3193        let resolution =
3194            ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 1.0e-3, 0.02, 0.01).unwrap());
3195        let result = build_resolution_plan(&[3.0, 1.0, 2.0], &resolution);
3196        assert!(matches!(result, Err(ResolutionError::UnsortedEnergies)));
3197    }
3198
3199    // ---------------------------------------------------------------
3200    // VENUS-like microbenchmarks moved to
3201    // `crates/nereids-physics/tests/venus_usr_resolution_microbench.rs`
3202    // (`test_broaden_presorted_bench`, `test_plan_reuse_bench`,
3203    // `resolution_matrix_apply_microbench`) — see issue #497.  They
3204    // parse a synthetic SAMMY USR-format kernel via
3205    // `common::synthetic_venus_usr_tab()` (the real VENUS BL10
3206    // fixture is not approved for public release; issue #557).
3207    // ---------------------------------------------------------------
3208
3209    // ---------- ResolutionMatrix (CSR compile) tests ----------
3210    //
3211    // CI-hermetic synthetic tests — use hand-constructed
3212    // `ResolutionPlan`s via `make_synthetic_plan`; no fixture
3213    // dependency, run on every `cargo test` invocation.  Cover
3214    // passthrough rows, `-0.0` sentinel rows, regular linear-interp
3215    // rows, CSR invariants, and the non-finite contract exclusion.
3216    //
3217    // End-to-end equivalence tests against the VENUS-like USR
3218    // operator (synthetic SAMMY-format kernel) at realistic grid
3219    // sizes (512, 3471) live in
3220    // `crates/nereids-physics/tests/venus_usr_resolution.rs` — see
3221    // issues #497 and #557.
3222
3223    /// Hybrid abs+rel tolerance used across equivalence tests.  Guards
3224    /// against the `a ≈ 0` trap where `a.abs().max(1e-300)` produces
3225    /// meaningless relative errors for genuinely-zero reference values.
3226    fn max_hybrid_err(a: &[f64], b: &[f64]) -> f64 {
3227        a.iter()
3228            .zip(b)
3229            .map(|(x, y)| {
3230                let denom = x.abs().max(y.abs()).max(1e-12);
3231                (x - y).abs() / denom
3232            })
3233            .fold(0.0_f64, f64::max)
3234    }
3235
3236    /// Build a synthetic multi-row plan with realistic overlap
3237    /// patterns — used as a CI-hermetic stand-in for the VENUS
3238    /// kernel.  Each target row `i` draws weights from a triangular
3239    /// kernel around column `i`, normalized so the row is
3240    /// row-stochastic.  `half_kernel` controls the spread.
3241    fn make_synthetic_overlap_plan(n_grid: usize, half_kernel: usize) -> ResolutionPlan {
3242        assert!(n_grid > 2 * half_kernel, "grid too small for kernel");
3243        let energies: Vec<f64> = (0..n_grid).map(|i| 10.0 + i as f64).collect();
3244        let mut rows: Vec<SyntheticRow> = Vec::with_capacity(n_grid);
3245        for i in 0..n_grid {
3246            let lo_min = i.saturating_sub(half_kernel);
3247            // Clamp so `lo ∈ [0, n_grid - 2]` — the linear-interp
3248            // branch reads `spec[lo + 1]`, and the `-0.0` sentinel is
3249            // the only way to safely go up to `lo = n_grid - 1`.  We
3250            // keep all synthetic entries on the regular branch here.
3251            let lo_max = (i + half_kernel).min(n_grid - 2);
3252            let entries: Vec<SyntheticEntry> = (lo_min..=lo_max)
3253                .map(|lo| {
3254                    let d = (lo as i64 - i as i64).abs() as f64;
3255                    let w = 1.0 - d / (half_kernel as f64 + 1.0);
3256                    // A uniform `frac = 0.5` distributes each entry's
3257                    // weight evenly across `lo` and `lo + 1`, which
3258                    // exercises the regular linear-interp branch of
3259                    // `compile_to_matrix`.
3260                    SyntheticEntry {
3261                        lo: lo as u32,
3262                        frac: 0.5,
3263                        weight: w,
3264                    }
3265                })
3266                .collect();
3267            let norm: f64 = entries.iter().map(|e| e.weight).sum();
3268            rows.push(SyntheticRow { entries, norm });
3269        }
3270        make_synthetic_plan(energies, rows)
3271    }
3272
3273    /// CI-hermetic: row-stochasticity on a synthetic multi-row plan.
3274    #[test]
3275    fn resolution_matrix_is_row_stochastic_synthetic() {
3276        let plan = make_synthetic_overlap_plan(40, 5);
3277        let matrix = plan.compile_to_matrix();
3278        for i in 0..matrix.len() {
3279            let start = matrix.row_starts()[i] as usize;
3280            let end = matrix.row_starts()[i + 1] as usize;
3281            let row_sum: f64 = matrix.values()[start..end].iter().sum();
3282            assert!(
3283                (row_sum - 1.0).abs() < 1e-13,
3284                "row {} sum = {} (expected 1.0 within 1e-13)",
3285                i,
3286                row_sum,
3287            );
3288        }
3289    }
3290
3291    /// CI-hermetic: equivalence of `apply_r` and `plan.apply` on a
3292    /// synthetic multi-row plan, 40-point grid, half-kernel 5.
3293    #[test]
3294    fn resolution_matrix_apply_equivalent_to_plan_apply_synthetic() {
3295        let plan = make_synthetic_overlap_plan(40, 5);
3296        let matrix = plan.compile_to_matrix();
3297        // Beer-Lambert-shaped synthetic spectrum, bounded [0, 1].
3298        let spec: Vec<f64> = (0..matrix.len())
3299            .map(|i| {
3300                let x = i as f64 / 39.0;
3301                1.0 - 0.7 * (-((x - 0.5).powi(2)) / 0.01).exp()
3302            })
3303            .collect();
3304        let plan_out = plan.apply(&spec);
3305        let matrix_out = apply_r(&matrix, &spec);
3306        let max_err = max_hybrid_err(&plan_out, &matrix_out);
3307        assert!(
3308            max_err < 1e-12,
3309            "synthetic apply_r vs plan.apply max hybrid err = {:.3e} (expected < 1e-12)",
3310            max_err,
3311        );
3312    }
3313
3314    /// CI-hermetic: CSR column indices strictly ascending per row on
3315    /// a synthetic multi-row plan.
3316    #[test]
3317    fn resolution_matrix_csr_column_indices_sorted_per_row_synthetic() {
3318        let plan = make_synthetic_overlap_plan(30, 4);
3319        let matrix = plan.compile_to_matrix();
3320        for i in 0..matrix.len() {
3321            let start = matrix.row_starts()[i] as usize;
3322            let end = matrix.row_starts()[i + 1] as usize;
3323            let row_cols = &matrix.col_indices()[start..end];
3324            for w in row_cols.windows(2) {
3325                assert!(
3326                    w[0] < w[1],
3327                    "row {} col_indices not strictly ascending: {:?}",
3328                    i,
3329                    row_cols,
3330                );
3331            }
3332        }
3333    }
3334
3335    /// CI-hermetic: grid-mismatch / length-mismatch detection via
3336    /// `apply_resolution_with_matrix` on a synthetic plan.
3337    #[test]
3338    fn resolution_matrix_grid_and_length_mismatch_synthetic() {
3339        let plan = make_synthetic_overlap_plan(16, 3);
3340        let matrix = plan.compile_to_matrix();
3341        let n = matrix.len();
3342        let energies: Vec<f64> = (0..n).map(|i| 10.0 + i as f64).collect();
3343        let spec = vec![1.0_f64; n];
3344
3345        // Same grid + length → passes.
3346        assert!(apply_resolution_with_matrix(&energies, &matrix, &spec).is_ok());
3347
3348        // Perturb one energy → MatrixGridMismatch with offending
3349        // index.
3350        let mut mutated = energies.clone();
3351        mutated[7] += 1e-12;
3352        let err = apply_resolution_with_matrix(&mutated, &matrix, &spec)
3353            .expect_err("grid mismatch must error");
3354        assert_eq!(
3355            err,
3356            ResolutionError::MatrixGridMismatch {
3357                first_diff_index: 7,
3358            }
3359        );
3360
3361        // Short spectrum → LengthMismatch.
3362        let short = vec![1.0_f64; n - 1];
3363        let err = apply_resolution_with_matrix(&energies, &matrix, &short)
3364            .expect_err("length mismatch must error");
3365        assert!(matches!(err, ResolutionError::LengthMismatch { .. }));
3366    }
3367
3368    // ---------------------------------------------------------------
3369    // End-to-end VENUS-like USR equivalence tests moved to
3370    // `crates/nereids-physics/tests/venus_usr_resolution.rs`
3371    // (`resolution_matrix_is_row_stochastic_on_venus_kernel`,
3372    //  `resolution_matrix_apply_equivalent_to_plan_apply_on_venus_kernel`,
3373    //  `resolution_matrix_apply_equivalent_at_production_grid`,
3374    //  `resolution_matrix_apply_equivalent_across_densities`,
3375    //  `resolution_matrix_csr_column_indices_sorted_per_row`,
3376    //  `resolution_matrix_grid_mismatch_detected`,
3377    //  `resolution_matrix_length_mismatch_detected`) — see issues
3378    // #497 and #557.  They parse a synthetic SAMMY USR-format kernel
3379    // via `common::synthetic_venus_usr_tab()`.
3380    // ---------------------------------------------------------------
3381
3382    #[test]
3383    fn resolution_matrix_empty_plan() {
3384        // Compile must not panic and must produce a valid empty
3385        // matrix when the plan itself is empty.  Build the empty
3386        // plan synthetically (no fixture needed) — an empty
3387        // `target_energies` plus empty `norm` / `starts = [0]`
3388        // yields the same zero-row plan that
3389        // `TabulatedResolution::plan(&[])` would produce.
3390        let plan = make_synthetic_plan(Vec::new(), Vec::new());
3391        let matrix = plan.compile_to_matrix();
3392        assert_eq!(matrix.len(), 0);
3393        assert!(matrix.is_empty());
3394        assert_eq!(matrix.nnz(), 0);
3395    }
3396
3397    /// Hand-construct a `ResolutionPlan` that deliberately exercises
3398    /// both the passthrough branch (`norm ≤ DIVISION_FLOOR`) and the
3399    /// `-0.0` degenerate-bracket sentinel — neither of which is
3400    /// reached on the VENUS fixture at the tested grid sizes.  The
3401    /// Round-1 audit flagged the earlier fixture-based passthrough
3402    /// test as vacuous, so this replacement verifies the two unreached
3403    /// branches with direct assertions on the resulting CSR.
3404    fn make_synthetic_plan(target_energies: Vec<f64>, rows: Vec<SyntheticRow>) -> ResolutionPlan {
3405        let n = target_energies.len();
3406        assert_eq!(rows.len(), n);
3407        let mut starts: Vec<u32> = Vec::with_capacity(n + 1);
3408        starts.push(0);
3409        let mut lo_idx: Vec<u32> = Vec::new();
3410        let mut frac: Vec<f64> = Vec::new();
3411        let mut weight: Vec<f64> = Vec::new();
3412        let mut norm: Vec<f64> = Vec::with_capacity(n);
3413        for row in &rows {
3414            norm.push(row.norm);
3415            for entry in &row.entries {
3416                lo_idx.push(entry.lo);
3417                frac.push(entry.frac);
3418                weight.push(entry.weight);
3419            }
3420            starts.push(lo_idx.len() as u32);
3421        }
3422        ResolutionPlan {
3423            target_energies,
3424            starts,
3425            lo_idx,
3426            frac,
3427            weight,
3428            norm,
3429        }
3430    }
3431
3432    struct SyntheticRow {
3433        entries: Vec<SyntheticEntry>,
3434        norm: f64,
3435    }
3436
3437    struct SyntheticEntry {
3438        lo: u32,
3439        frac: f64,
3440        weight: f64,
3441    }
3442
3443    #[test]
3444    fn resolution_matrix_passthrough_row_compiles_to_identity_entry() {
3445        // Row 0: passthrough via norm ≤ DIVISION_FLOOR.
3446        // Row 1: regular linear-interp entry (lo=1 → reads cols 1, 2).
3447        // Row 2: degenerate `-0.0` sentinel entry (lo=2 → reads col 2 only).
3448        //
3449        // Grid has 4 cells so `lo ∈ [0, n-2] = [0, 2]` holds for all
3450        // entries — this preserves the `ResolutionPlan::apply` SAFETY
3451        // invariant that `lo + 1 < n` even if a future refactor
3452        // weakens the `-0.0` sentinel short-circuit (round-2 self-
3453        // audit NEW-P2 #1).
3454        let plan = make_synthetic_plan(
3455            vec![10.0, 20.0, 30.0, 40.0],
3456            vec![
3457                SyntheticRow {
3458                    entries: vec![],
3459                    // 0.0 is <= DIVISION_FLOOR, so row 0 goes through
3460                    // the passthrough branch.
3461                    norm: 0.0,
3462                },
3463                SyntheticRow {
3464                    entries: vec![SyntheticEntry {
3465                        lo: 1,
3466                        frac: 0.25,
3467                        weight: 1.0,
3468                    }],
3469                    norm: 1.0,
3470                },
3471                SyntheticRow {
3472                    entries: vec![SyntheticEntry {
3473                        lo: 2,
3474                        frac: -0.0,
3475                        weight: 1.0,
3476                    }],
3477                    norm: 1.0,
3478                },
3479                // Row 3: passthrough too, to round out the 4-cell grid.
3480                SyntheticRow {
3481                    entries: vec![],
3482                    norm: 0.0,
3483                },
3484            ],
3485        );
3486        let matrix = plan.compile_to_matrix();
3487
3488        // Row 0 — single (0, 0, 1.0).
3489        let r0_start = matrix.row_starts()[0] as usize;
3490        let r0_end = matrix.row_starts()[1] as usize;
3491        assert_eq!(r0_end - r0_start, 1, "passthrough row must have 1 entry");
3492        assert_eq!(matrix.col_indices()[r0_start], 0);
3493        assert_eq!(matrix.values()[r0_start].to_bits(), 1.0_f64.to_bits());
3494
3495        // Row 1 — linear-interp: contributes at col 1 and col 2.
3496        let r1_start = matrix.row_starts()[1] as usize;
3497        let r1_end = matrix.row_starts()[2] as usize;
3498        assert_eq!(
3499            r1_end - r1_start,
3500            2,
3501            "linear-interp row must have 2 entries"
3502        );
3503        assert_eq!(matrix.col_indices()[r1_start], 1);
3504        assert_eq!(matrix.col_indices()[r1_start + 1], 2);
3505        assert!((matrix.values()[r1_start] - 0.75).abs() < 1e-14);
3506        assert!((matrix.values()[r1_start + 1] - 0.25).abs() < 1e-14);
3507
3508        // Row 2 — `-0.0` sentinel: single entry at col 2 (no col 3).
3509        let r2_start = matrix.row_starts()[2] as usize;
3510        let r2_end = matrix.row_starts()[3] as usize;
3511        assert_eq!(
3512            r2_end - r2_start,
3513            1,
3514            "-0.0 sentinel row must have exactly 1 entry (not 2)",
3515        );
3516        assert_eq!(matrix.col_indices()[r2_start], 2);
3517        assert_eq!(matrix.values()[r2_start].to_bits(), 1.0_f64.to_bits());
3518
3519        // Cross-check with apply semantics: spec[3] is chosen so the
3520        // sentinel row, if buggy, would contaminate the output.
3521        // Both `plan.apply` and `apply_r` must ignore spec[3] at
3522        // row 2.
3523        let spec = vec![7.0, 11.0, 13.0, 999.0];
3524        let plan_out = plan.apply(&spec);
3525        let matrix_out = apply_r(&matrix, &spec);
3526        // Row 0 passthrough: out[0] = spec[0] = 7.
3527        assert!((matrix_out[0] - 7.0).abs() < 1e-14);
3528        assert!((plan_out[0] - 7.0).abs() < 1e-14);
3529        // Row 1: 0.75 * spec[1] + 0.25 * spec[2] = 0.75*11 + 0.25*13 = 11.5.
3530        assert!((matrix_out[1] - 11.5).abs() < 1e-14);
3531        assert!((plan_out[1] - 11.5).abs() < 1e-14);
3532        // Row 2 sentinel: 1.0 * spec[2] = 13 — NOT 999 (would indicate
3533        // spec[lo+1] was read).
3534        assert!((matrix_out[2] - 13.0).abs() < 1e-14);
3535        assert!((plan_out[2] - 13.0).abs() < 1e-14);
3536        // Row 3 passthrough: out[3] = spec[3] = 999.
3537        assert!((matrix_out[3] - 999.0).abs() < 1e-14);
3538        assert!((plan_out[3] - 999.0).abs() < 1e-14);
3539    }
3540
3541    /// Documents (and guards) the explicit contract exclusion on
3542    /// non-finite spectra between `ResolutionPlan::apply` and
3543    /// `apply_r`.  See [`ResolutionPlan::compile_to_matrix`] docstring
3544    /// for the full reasoning; this test simply pins the divergence
3545    /// so a future unification attempt fails loudly.
3546    #[test]
3547    fn resolution_matrix_nonfinite_contract() {
3548        // 3-cell grid so `lo = 0` for the regular row reads cols 0, 1
3549        // and the sentinel row at `lo = 1` reads col 1 only — `lo ∈
3550        // [0, n-2] = [0, 1]` satisfied.
3551        let plan = make_synthetic_plan(
3552            vec![10.0, 20.0, 30.0],
3553            vec![
3554                SyntheticRow {
3555                    entries: vec![SyntheticEntry {
3556                        lo: 0,
3557                        frac: 0.5,
3558                        weight: 1.0,
3559                    }],
3560                    norm: 1.0,
3561                },
3562                SyntheticRow {
3563                    entries: vec![SyntheticEntry {
3564                        lo: 1,
3565                        frac: -0.0, // sentinel: short-circuit to spec[lo]
3566                        weight: 1.0,
3567                    }],
3568                    norm: 1.0,
3569                },
3570                SyntheticRow {
3571                    entries: vec![],
3572                    norm: 0.0, // passthrough
3573                },
3574            ],
3575        );
3576        let matrix = plan.compile_to_matrix();
3577
3578        // Spectrum with same-sign infinities in both bins of row 0's
3579        // non-degenerate bracket.
3580        let inf_spec = vec![f64::INFINITY, f64::INFINITY, 0.0];
3581        let plan_out = plan.apply(&inf_spec);
3582        let matrix_out = apply_r(&matrix, &inf_spec);
3583
3584        // Row 0: plan.apply evaluates `s_lo + frac * (s_hi - s_lo)`
3585        // = `+∞ + 0.5 * (+∞ - +∞)` = `+∞ + 0.5 * NaN` = NaN.
3586        // apply_r evaluates `0.5 * +∞ + 0.5 * +∞` = `+∞`.
3587        assert!(plan_out[0].is_nan(), "plan.apply must produce NaN on ∞+∞");
3588        assert!(matrix_out[0].is_infinite(), "apply_r collapses ∞+∞ to ∞");
3589
3590        // Row 1 (sentinel): both paths short-circuit to spec[lo] = ∞,
3591        // so there is no divergence here.
3592        assert!(plan_out[1].is_infinite());
3593        assert!(matrix_out[1].is_infinite());
3594    }
3595
3596    /// Round-2 Codex P3: documents (and guards) the analogous
3597    /// divergence on **finite spectra near f64 overflow**.  With
3598    /// opposite-sign neighboring bins at f64::MAX, `plan.apply`'s
3599    /// `s_lo + frac * (s_hi - s_lo)` overflows in the subtraction
3600    /// and returns `±∞`, while `apply_r`'s `(1 - frac) * s_lo +
3601    /// frac * s_hi` stays finite because the overflow is avoided by
3602    /// scaling before summation.  This is why the equivalence
3603    /// contract on [`ResolutionPlan::compile_to_matrix`] is scoped
3604    /// to bounded finite spectra (Beer-Lambert `T ∈ [0, 1]`) — no
3605    /// production forward model can hit this case.
3606    #[test]
3607    fn resolution_matrix_large_finite_contract() {
3608        let plan = make_synthetic_plan(
3609            vec![10.0, 20.0, 30.0],
3610            vec![
3611                SyntheticRow {
3612                    entries: vec![SyntheticEntry {
3613                        lo: 0,
3614                        frac: 0.5,
3615                        weight: 1.0,
3616                    }],
3617                    norm: 1.0,
3618                },
3619                SyntheticRow {
3620                    entries: vec![],
3621                    norm: 0.0, // passthrough
3622                },
3623                SyntheticRow {
3624                    entries: vec![],
3625                    norm: 0.0,
3626                },
3627            ],
3628        );
3629        let matrix = plan.compile_to_matrix();
3630
3631        // Opposite-sign large finite bins at row 0's non-degenerate
3632        // bracket.  `s_hi - s_lo = -f64::MAX - f64::MAX = -∞`.
3633        let big_spec = vec![f64::MAX, -f64::MAX, 0.0];
3634        let plan_out = plan.apply(&big_spec);
3635        let matrix_out = apply_r(&matrix, &big_spec);
3636
3637        // plan.apply: s_lo + frac * (s_hi - s_lo) = MAX + 0.5 * (-∞)
3638        // = MAX + -∞ = -∞.
3639        assert!(
3640            plan_out[0].is_infinite() && plan_out[0] < 0.0,
3641            "plan.apply must overflow to -∞ on opposite-sign MAX bins; got {}",
3642            plan_out[0],
3643        );
3644        // apply_r: 0.5 * MAX + 0.5 * -MAX = 0.
3645        assert!(
3646            matrix_out[0].is_finite(),
3647            "apply_r must stay finite (scaled before summation); got {}",
3648            matrix_out[0],
3649        );
3650        assert!(matrix_out[0].abs() < 1e-280);
3651    }
3652
3653    // ------------------------------------------------------------------
3654    // TabulatedResolution::kernel_support_ev — used by SAMMY EMIN/EMAX
3655    // -equivalent fit-energy-range margin computation (#514).
3656    // ------------------------------------------------------------------
3657
3658    /// `synthetic_tab_resolution` uses triangular kernels whose
3659    /// outermost entries (`weight = 1 - |dt|/half`) are exactly zero
3660    /// at `dt = ±half`; the actual non-zero support is the next-
3661    /// outermost entry at `±half · (1 - 1/(n-1))`.
3662    fn triangle_dt_max(half: f64, n: usize) -> f64 {
3663        // dt_step = 2*half / (n-1); next-outermost = half - dt_step
3664        half - 2.0 * half / (n - 1) as f64
3665    }
3666
3667    /// At a reference energy with a known kernel half-width in TOF, the
3668    /// support in eV must satisfy `|ΔE| = 2·E^(3/2)/(TOF_FACTOR·L)·|Δt|`.
3669    /// At E = 50 eV the triangle kernel has half = 1.0 μs, n = 41, so
3670    /// the largest non-zero offset is `1.0 · (1 − 1/40) = 0.975`.
3671    #[test]
3672    fn test_tabulated_kernel_support_at_ref_energy_matches_chain_rule() {
3673        let r = synthetic_tab_resolution();
3674        let e: f64 = 50.0;
3675        let dt_max = triangle_dt_max(1.0, 41);
3676        let expected = 2.0 * e.powf(1.5) / (TOF_FACTOR * 25.0) * dt_max;
3677        let got = r.kernel_support_ev(e);
3678        assert!(
3679            (got - expected).abs() / expected < 1e-12,
3680            "support at ref energy: got {got}, expected {expected}"
3681        );
3682    }
3683
3684    /// Between two reference energies the support uses the larger of
3685    /// the two bracketing kernels (conservative).  At E = 100 eV the
3686    /// brackets are 50 eV (half = 1.0, n = 41) and 500 eV (half = 2.0,
3687    /// n = 51); the larger non-zero offset is `2.0 · (1 − 1/50) = 1.96`.
3688    #[test]
3689    fn test_tabulated_kernel_support_uses_larger_bracketing_kernel() {
3690        let r = synthetic_tab_resolution();
3691        let e: f64 = 100.0;
3692        let dt_max = triangle_dt_max(2.0, 51);
3693        let expected = 2.0 * e.powf(1.5) / (TOF_FACTOR * 25.0) * dt_max;
3694        let got = r.kernel_support_ev(e);
3695        assert!(
3696            (got - expected).abs() / expected < 1e-12,
3697            "support between refs: got {got}, expected {expected}"
3698        );
3699    }
3700
3701    /// Below the lowest ref energy: use the lowest ref kernel.
3702    /// Above the highest ref energy: use the highest ref kernel.
3703    #[test]
3704    fn test_tabulated_kernel_support_uses_nearest_outside_grid() {
3705        let r = synthetic_tab_resolution();
3706        // Below grid (ref_min = 5 eV; triangle(half=0.5, n=31)).
3707        let e_low: f64 = 1.0;
3708        let exp_low = 2.0 * e_low.powf(1.5) / (TOF_FACTOR * 25.0) * triangle_dt_max(0.5, 31);
3709        assert!((r.kernel_support_ev(e_low) - exp_low).abs() / exp_low < 1e-12);
3710        // Above grid (ref_max = 500 eV; triangle(half=2.0, n=51)).
3711        let e_hi: f64 = 1000.0;
3712        let exp_hi = 2.0 * e_hi.powf(1.5) / (TOF_FACTOR * 25.0) * triangle_dt_max(2.0, 51);
3713        assert!((r.kernel_support_ev(e_hi) - exp_hi).abs() / exp_hi < 1e-12);
3714    }
3715
3716    /// Non-positive / non-finite energy → 0.0 (no broadening footprint).
3717    #[test]
3718    fn test_tabulated_kernel_support_returns_zero_for_invalid_energy() {
3719        let r = synthetic_tab_resolution();
3720        assert_eq!(r.kernel_support_ev(0.0), 0.0);
3721        assert_eq!(r.kernel_support_ev(-1.0), 0.0);
3722        assert_eq!(r.kernel_support_ev(f64::NAN), 0.0);
3723        assert_eq!(r.kernel_support_ev(f64::INFINITY), 0.0);
3724    }
3725
3726    /// Zero-weight tail entries must not inflate the support; only
3727    /// `weights[i] > 0` entries count.
3728    #[test]
3729    fn test_tabulated_kernel_support_ignores_zero_weight_entries() {
3730        // Build a kernel where the outermost entries have weight 0.
3731        let offsets = vec![-10.0, -1.0, 0.0, 1.0, 10.0];
3732        let weights = vec![0.0, 0.5, 1.0, 0.5, 0.0];
3733        let r = TabulatedResolution {
3734            ref_energies: vec![100.0],
3735            kernels: vec![(offsets, weights)],
3736            flight_path_m: 25.0,
3737        };
3738        let e: f64 = 100.0;
3739        // Expected support uses dt_max = 1.0 (the outermost zero-weight
3740        // entries at ±10 are ignored), not 10.0.
3741        let expected = 2.0 * e.powf(1.5) / (TOF_FACTOR * 25.0) * 1.0;
3742        let got = r.kernel_support_ev(e);
3743        assert!(
3744            (got - expected).abs() / expected < 1e-12,
3745            "support should ignore zero-weight entries: got {got}, expected {expected}"
3746        );
3747    }
3748}