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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms);
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, ¶ms);
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}