Skip to main content

nereids_physics/
ikeda_carpenter.rs

1//! Ikeda–Carpenter analytical moderator resolution model.
2//!
3//! A third instrument-resolution model alongside the analytical Gaussian
4//! ([`crate::resolution::ResolutionParams`]) and the Monte-Carlo tabulated
5//! kernel ([`crate::resolution::TabulatedResolution`]). It exists to settle a
6//! methodological dispute about the VENUS instrument resolution: one camp
7//! trusts the MC-simulated tabulated kernel (UDR/FTS file); the instrument
8//! scientist distrusts the unproven MC and prefers an analytical
9//! Ikeda–Carpenter moderator model. NEREIDS implements IC as a first-class
10//! model so all three can be cross-validated against synthetic loop-closure
11//! and real VENUS data.
12//!
13//! # Physics — the Ikeda–Carpenter pulse
14//!
15//! Reference: S. Ikeda & J. M. Carpenter, *Nucl. Instrum. Methods* **A239**
16//! (1985) 536–544. The neutron emission-time distribution from a pulsed
17//! spallation moderator is
18//!
19//! ```text
20//!   I(τ) = (1−R)·g₃(τ;α)  +  R·[g₃(·;α) ⊛ β e^{−β·}](τ),    τ ≥ 0
21//! ```
22//!
23//! where the prompt (slowing-down) term is a Gamma/Erlang density of shape 3:
24//!
25//! ```text
26//!   g₃(τ;α) = α³ τ² e^{−ατ} / 2          (mode 2/α, mean 3/α, ∫ = 1)
27//! ```
28//!
29//! and the delayed (storage) term is `g₃` convolved with an exponential of
30//! rate β. The convolution has the closed form
31//!
32//! ```text
33//!   g₃(·;α) ⊛ β e^{−β·} (τ) = β (α/γ)³ [ e^{−βτ} − e^{−ατ}(1 + γτ + ½γ²τ²) ],  γ = α−β
34//! ```
35//!
36//! (re-derived and confirmed against the Codex independent derivation and the
37//! SAMMY-RPI χ²+double-exp moderator form). Both terms are individually
38//! unit-area, so `I` is unit-area for any α,β>0 and 0≤R≤1, with first moment
39//! `⟨τ⟩ = 3/α + R/β`. The asymmetry — sharp rise, long tail toward larger τ
40//! (later TOF, lower apparent energy) — is the physical origin of the
41//! asymmetric MC kernel.
42//!
43//! ## Parameters and their energy dependence
44//!
45//! - `α(E)` [1/µs]: fast moderation/leakage rate; sets the prompt width.
46//!   Leading epithermal scaling `α ∝ √E` (Mantid `α = 1/(α₀+α₁λ)`, λ ∝ 1/√E).
47//! - `β(E)` [1/µs]: slow storage rate; sets the delayed tail. Constant by
48//!   default; optionally energy-dependent through an [`EnergyLaw`].
49//! - `R(E)`, 0 ≤ R ≤ 1: storage mixing fraction; `R ≈ exp(−E_meV/κ)` → **R→0 in
50//!   the 1–200 eV resonance regime**, so IC there is dominated by the
51//!   one-parameter prompt Gamma(3, α(E)) term.
52//!
53//! Parameters are **fixed** when the instrument scientist provides them, or
54//! **fit** from a known calibration foil otherwise (general case).
55//!
56//! ## Time → energy kernel and centering
57//!
58//! For a flight path `L`, a resonance at `E_r` has nominal TOF
59//! `t_r = TOF_FACTOR·L/√E_r`. A neutron with emission delay τ arrives at TOF
60//! `t_r+τ`, apparent energy `E' = (TOF_FACTOR·L/(t_r+τ))²` — that is the kernel's
61//! *definition* (its positive-τ tail is delayed emission). Sampling `I(τ)` on
62//! a τ-grid and mapping to TOF-offsets yields exactly the `(offset, weight)`
63//! kernel representation that [`crate::resolution::TabulatedResolution`]
64//! consumes — so IC rides the *same* verified broadening machinery, whose
65//! *application* is the convolution gather: the broadened value at measured TOF
66//! `t` reads theory at `t−τ` (a neutron measured at `t` with delay τ really flew
67//! `t−τ`; see `resolution::broaden` and SAMMY `udr/mudr4.f90` `Ud_Convolute`).
68//! At apply time
69//! `interpolated_kernel` blends the two bracketing reference kernels as a
70//! width-normalized shape blend (offsets scaled to the geometrically
71//! interpolated width, shapes merged on the union grid) — unequal point counts
72//! from IC's per-kernel tail trimming no longer trigger a nearest-reference
73//! fallback, so the between-reference width follows the physical power law
74//! smoothly. IC also synthesizes a dense reference
75//! grid (default 64 energies), so the between-reference error is negligible. The
76//! kernel keeps its offsets on the **emission clock** (0 = pulse start), which
77//! is where the Ikeda–Carpenter function itself places them; a loaded UDR file
78//! is anchored on its peak instead and `interpolated_kernel` re-centers
79//! neither. Because the IC pulse is right-skewed, its *mean* lags its mode by
80//! ~1/α(E) in TOF, so the centroid — and even the minimum — of a broadened
81//! resonance shifts toward **lower apparent energy** by an α(E)-dependent amount
82//! (order 1e-2 eV, ~1e-3 relative, for α≈1.5 in the eV regime; larger toward
83//! lower energy). So it *does* move the broadened dip off the nominal energy,
84//! by a small amount.
85//!
86//! For the prompt-only law `α(E) = a0·√E + a1` with `R≈0`, the lag `~1/α(E)` is
87//! **exactly** the `1/√E` basis of a flight-path (`L_scale`) error *iff* `a1 = 0`:
88//! then the centroid offset scales as `c/√E`, and an `L → L(1+δ)` change shifts
89//! every TOF by `δ·K·L/√E`, also `∝ 1/√E`, so the two are degenerate. With `a1 ≠ 0`
90//! the lag `1/(a0√E + a1)` only *approximately* follows that basis (and the
91//! storage term `R/β`, negligible in the eV regime, adds a further small
92//! departure). To leading order, then, the lag is **confounded with the energy
93//! scale**, and is handled by the SHARED `(t0, L_scale)` energy scale, not by any
94//! per-family knob:
95//! - **Run-time fitting** fits the `t0`/`L_scale` energy-scale, which absorbs the
96//!   constant-`L` part of the lag exactly (same basis); only the *shape*
97//!   (skew/tail) of the asymmetry is not absorbable by position.
98//! - **Resolution calibration** (the `nereids-fitting` calibrator) **pins** the
99//!   energy scale by default — a pure shape/width fit. It can optionally fit a
100//!   SHARED `(t0, L_scale)` under a metrology prior (`with_position_prior`) for
101//!   joint energy-scale / identifiability work. A *free, per-family* position knob
102//!   is deliberately NOT used: because the lag is the same basis as `L_scale`, a
103//!   free position lets a wrong (symmetric) family imitate the asymmetric shift
104//!   and erodes the model-selection χ² (the discriminator is then only the
105//!   position-independent skew/tail). See `nereids-fitting`'s
106//!   `free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination`.
107//!
108//! `ic_centering_shifts_broadened_symmetric_dip_with_alpha` quantifies the bare
109//! mode→centroid shift; the calibrator's `fit_t0_recovers_injected_energy_scale_shift`
110//! checks the energy-scale fit recovers an injected offset. Re-centering the kernel
111//! on its centroid (a future convention change spanning the UDR path too) would
112//! remove the shift at the source.
113//!
114//! ## Optional instrument convolutions
115//!
116//! The full instrument function folds the moderator with a proton-burst
117//! (Gaussian σ) and a chopper/channel (triangle, FWHM) term. Both are optional
118//! here (`None` ⇒ omitted).
119//!
120//! **Provenance of the triangle (`channel_fwhm_us`).** SAMMY broadens for the
121//! accelerator burst either as a Gaussian of FWHM `DELTAG` (SAMMY Manual R8
122//! Sec. III.C.1.a, eq. III C1 a.12) or as a square pulse of width `BURST`
123//! (Sec. III.C.2.a). At SNS the proton pulse delivered to the target is shaped
124//! by the accumulator ring (Proton Storage Ring, PSR) into an approximately
125//! triangular ~700 ns base — FWHM ≈ 350 ns — which is what the VENUS tabulated
126//! FTS kernel header records as "folded triang FWHM 350 ns PSR". NEREIDS folds
127//! that PSR triangle via `channel_fwhm_us` (symmetric triangle, half-base =
128//! FWHM, `triangle_kernel`). Note: a tabulated file whose header says the
129//! triangle is already "folded" in must NOT be double-counted against an IC
130//! model that also applies it (the `nereids-fitting` calibrator therefore
131//! applies its `psr_fwhm_ns` fold to the IC family only, never to
132//! tabulated/UDR kernels).
133
134use std::sync::Arc;
135
136use crate::resolution::{
137    ResolutionParseError, TOF_FACTOR, TabulatedResolution, piecewise_linear_bin_masses,
138};
139
140/// de Broglie wavelength factor: λ (Å) = `LAMBDA_ANGSTROM_FACTOR` / √(E in eV).
141///
142/// `λ = h/√(2·m_n·E)`; with CODATA 2018 `h`, `m_n` and the 2019-SI eV this is
143/// `0.285993 Å·√eV`. Used only by [`EnergyLaw::InverseLambda`]; its precise
144/// value folds into the fitted `α₀,α₁`, so high precision is not load-bearing.
145const LAMBDA_ANGSTROM_FACTOR: f64 = 0.285_993;
146
147/// Rates below this (1/µs) are clamped to keep the pulse well-defined.
148const MIN_RATE: f64 = 1e-9;
149
150/// Relative-to-peak weight below which kernel tails are trimmed.
151const TRIM_REL: f64 = 1e-7;
152
153/// Default number of log-spaced reference energies in a synthesized table.
154pub const DEFAULT_N_ENERGIES: usize = 64;
155
156/// Default number of τ-samples spanning the prompt core of each kernel.
157pub const DEFAULT_N_TAU: usize = 600;
158
159/// Minimum accepted `n_tau` (τ-samples across the prompt core). Doubles as
160/// the module's prompt-core **resolution floor**: when [`MAX_TAU_SAMPLES`]
161/// widens the τ-step (see [`tau_geometry`]), the step may never exceed
162/// `fast_reach / (MIN_N_TAU − 1)` — the coarsest prompt sampling
163/// [`IkedaCarpenter::new`] has ever accepted as valid via its `n_tau ≥ 8`
164/// check.
165const MIN_N_TAU: usize = 8;
166
167/// Minimum samples per side SPANNING a sampled channel triangle
168/// (`dtau ≤ FWHM / 3`, half-base = FWHM). At the exactly-admitted boundary
169/// `dtau = FWHM/3` the per-side samples sit at `{FWHM/3, 2FWHM/3, FWHM}` —
170/// triangle weights `{2/3, 1/3, 0}` — so each side carries ≥ 2 strictly
171/// interior (nonzero) samples while the endpoint lands ON the triangle
172/// zero; the sampled fold is distinctly non-delta (discrete variance
173/// 4·FWHM²/27 ≈ 89 % of the analytic FWHM²/6). Coarser steps degenerate
174/// the discrete triangle toward the exact delta `[0, 1, 0]`, silently
175/// erasing a requested fold — [`tau_geometry`] rejects that instead
176/// (strictly: `capped_step > FWHM/3`).
177///
178/// This floor guarantees MOMENT-level accuracy (the calibration consumer's
179/// contract); per-bin detector probabilities need the far stricter
180/// [`TRI_BIN_SAMPLES_PER_SIDE`], enforced at the detector-bin gate rather
181/// than here so realistic long-storage-tail calibrations stay buildable
182/// under the [`MAX_TAU_SAMPLES`] cap.
183const TRI_MIN_SAMPLES_PER_SIDE: f64 = 3.0;
184
185/// Per-bin accuracy floor for the SAMPLED detector-bin path
186/// (`dtau ≤ FWHM / TRI_BIN_SAMPLES_PER_SIDE` required by
187/// `detector_bin_probabilities` when a channel fold is active). The
188/// point-sampled discrete convolution mis-assigns individual detector-bin
189/// probability as O(dtau²) even while the total mass is conserved (the
190/// triangle kernel's kink drives the error; measured at α = 1 µs⁻¹,
191/// FWHM = 10 µs: 7.5e-3 max per-bin error at 3 samples per side, ~1.2e-4 at
192/// 24, ~3e-7 at the 600-sample default). The per-bin gate takes the
193/// STRICTEST of the applicable floors — this one, the burst
194/// [`GAUSS_BIN_SAMPLES_PER_SIGMA`], and the prompt-core
195/// [`PROMPT_BIN_SAMPLES`] — so every accepted per-bin call is bounded at
196/// ~1e-4 whichever feature binds; a coarser sampled grid is rejected loudly
197/// rather than silently redistributing leading-edge mass. SAMMY's UDR
198/// convolution integrates piecewise-linear segment products analytically
199/// (`udr/mudr4.f90` `Ud_Convolute`/`Udr_Add`) and needs no such floor; the
200/// sampled route keeps one and enforces it at the consumer whose contract
201/// is per-bin.
202const TRI_BIN_SAMPLES_PER_SIDE: f64 = 24.0;
203
204/// Per-bin accuracy floor for a sampled Gaussian burst
205/// (`dtau ≤ σ / GAUSS_BIN_SAMPLES_PER_SIGMA` at the detector-bin gate).
206/// Measured at α = 1 µs⁻¹, σ = 2 µs, admitted `dtau = σ`: 1.46e-2 max
207/// per-bin error while the total conserved to 5e-10; the O(dtau²) scaling
208/// puts twelve samples per σ at ~1e-4. SAMMY integrates the Gaussian burst
209/// analytically over piecewise-linear segments (`Ud_Burst`) and needs no
210/// floor; the sampled sibling of the triangle gate keeps one.
211const GAUSS_BIN_SAMPLES_PER_SIGMA: f64 = 12.0;
212
213/// Per-bin accuracy floor for the PROMPT core on the sampled fold path
214/// (`dtau ≤ fast_reach / PROMPT_BIN_SAMPLES`). A wide fold can set a bin
215/// floor far coarser than the Γ₃ pulse's own structure (scale `1/α`), so
216/// the fold floors alone would admit prompt-undersampled grids; the α = 1,
217/// FWHM = 10 µs measurement (1.2e-4 at dtau = fast_reach/43) anchors
218/// forty-eight samples across the prompt reach at ~1e-4. Only the sampled
219/// fold path needs this — the fold-free branch is analytic.
220const PROMPT_BIN_SAMPLES: f64 = 48.0;
221
222/// Reach of the sampled/folded Gaussian burst in standard deviations. At
223/// ±`GAUSS_REACH_SIGMAS`·σ = ±8σ the truncated two-sided Gaussian mass is
224/// erfc(8/√2) ≈ 1.2e-15, so the retained-mass bookkeeping in
225/// [`gaussian_kernel`] stays exact at f64 scale and no physically meaningful
226/// mass is discarded for any detector window. Also fixes the burst
227/// resolution floor `dtau ≤ σ` (≥ `GAUSS_REACH_SIGMAS` samples per side).
228const GAUSS_REACH_SIGMAS: f64 = 8.0;
229
230/// Prompt-tail reach in e-folds: the τ-grid spans `FAST_REACH_E_FOLDS / α`
231/// so the prompt Gamma(3) tail `τ²e^{−ατ}` is < ~1e-8 of peak at the edge.
232const FAST_REACH_E_FOLDS: f64 = 18.0;
233
234/// Slow/storage-tail reach in e-folds (`SLOW_REACH_E_FOLDS / β` when storage
235/// is active). Sits AT the trim horizon: `e^{−16} ≈ 1.1e-7 ≈` [`TRIM_REL`]
236/// (`ln(1/TRIM_REL) ≈ 16.1`), so the reach cannot be truncated shorter
237/// without discarding tail weight the [`TRIM_REL`] trim would keep — no
238/// hidden approximation lives in this constant.
239const SLOW_REACH_E_FOLDS: f64 = 16.0;
240
241/// Storage fractions below this are treated as "no storage tail" when sizing
242/// the τ-grid. DELIBERATELY two decades below [`TRIM_REL`], not derived from
243/// it: whether a slow tail actually survives the [`TRIM_REL`] trim depends on
244/// the α/β contrast (the trim threshold is relative to the prompt-dominated
245/// peak, and the slow term's peak weight scales with both R and β/α), so no
246/// single constant derived from [`TRIM_REL`] is exact for every admitted
247/// (α, β) — treating a larger R as absent would be a hidden approximation.
248/// The margin's only consequence is conservatism: for R ∈ (1e-9, ~1e-7) the
249/// τ-grid is sized — and the [`MAX_TAU_SAMPLES`] cap gate applied — for a
250/// slow tail whose weight the trim then discards anyway, spending samples
251/// (or rejecting a configuration) for physics that cannot appear in the
252/// kernel. It can never drop tail weight the trim would have kept.
253const R_NEGLIGIBLE: f64 = 1e-9;
254
255/// Cap on the τ-sample count spanning the PULSE BODY (`[0, τ_max]`) of one
256/// synthesized kernel. The τ-step is anchored to the prompt core and refined
257/// to resolve active folds (see [`tau_geometry`]), so a long storage tail
258/// (β ≪ α with R > 0) grows the sample COUNT rather than the step; this cap
259/// bounds that growth (CPU/memory) by widening the step to
260/// `tau_max / (MAX_TAU_SAMPLES − 1)` — but never past the resolution floor
261/// (prompt core: `fast_reach / (MIN_N_TAU − 1)`; triangle: FWHM /
262/// [`TRI_MIN_SAMPLES_PER_SIDE`]; burst: σ). A parameter/grid combination
263/// whose floor cannot be met within the cap is REJECTED loudly by
264/// [`IkedaCarpenter::new`] instead of silently under-sampled. The actual
265/// guarantee is on the pulse body only: the symmetric burst/channel fold
266/// margin (± `GAUSS_REACH_SIGMAS·σ + FWHM` at the resolved step) adds its
267/// samples ON TOP of the cap, so the final grid can exceed
268/// `MAX_TAU_SAMPLES` by the margin sample count.
269const MAX_TAU_SAMPLES: usize = 8192;
270
271/// Taylor expansion of `h(u)/u³` where `h(u) = 1 − e^{−u}(1 + u + ½u²)`, for
272/// `|u|` small (the `α ≈ β` limit), where direct evaluation cancels
273/// catastrophically. As `u → 0`, `h(u)/u³ → 1/6`.
274#[inline]
275fn h_over_cube_taylor(u: f64) -> f64 {
276    // h(u)/u³ = 1/6 − u/8 + u²/20 − u³/72 + u⁴/336 + O(u⁵). Carrying the u⁴ term
277    // makes the bounded↔Taylor branch boundary (|u|=0.05) continuous to ~1e-11,
278    // below any tolerance that consumes the synthesized kernel.
279    let u2 = u * u;
280    1.0 / 6.0 - u / 8.0 + u2 / 20.0 - u2 * u / 72.0 + u2 * u2 / 336.0
281}
282
283/// Ikeda–Carpenter moderator emission density `I(τ)`.
284///
285/// `τ` in µs, rates `α,β` in 1/µs, mixing `r ∈ [0,1]`. Returns 0 for `τ < 0`.
286/// Unit-area over `τ ∈ [0,∞)`; first moment `3/α + r/β`. **NaN-free** for all
287/// `α,β > 0` (including `α ≈ β` and `β ≫ α`): the slow/storage term is evaluated
288/// from the bounded bracket `e^{−βτ} − e^{−ατ}(1+u+½u²)` (both exponentials ≤ 1
289/// for `τ,α,β > 0`, so no `e^{|u|}` overflow), falling back to a Taylor limit
290/// near `u = α−β·τ → 0` where that bracket cancels.
291#[must_use]
292pub fn ic_pulse(alpha: f64, beta: f64, r: f64, tau: f64) -> f64 {
293    // Same domain contract as ic_cdf: non-finite parameters return NaN so
294    // garbage in stays visible instead of flooring into a plausible pulse.
295    if !alpha.is_finite() || !beta.is_finite() || !r.is_finite() {
296        return f64::NAN;
297    }
298    if !tau.is_finite() || tau < 0.0 {
299        return 0.0;
300    }
301    let alpha = alpha.max(MIN_RATE);
302    let beta = beta.max(MIN_RATE);
303    let at = alpha * tau;
304    // prompt Gamma(3,α): α³τ²/2 · e^{−ατ} = (α/2)(ατ)² e^{−ατ}
305    let fast = 0.5 * alpha * at * at * (-at).exp();
306    if r <= 0.0 {
307        return fast;
308    }
309    // slow/storage term = β(α/γ)³[e^{−βτ} − e^{−ατ}(1+u+½u²)], u = γτ = (α−β)τ.
310    let u = (alpha - beta) * tau;
311    let coeff = beta * alpha.powi(3) * tau.powi(3);
312    let slow = if u.abs() < 0.05 {
313        // α ≈ β: bracket/u³ → e^{−βτ}·h(u)/u³ (Taylor); avoids 0/0 cancellation.
314        coeff * (-beta * tau).exp() * h_over_cube_taylor(u)
315    } else {
316        // Bounded form: both exponentials are ≤ 1, so β ≫ α (u ≪ 0) cannot
317        // overflow (the old `e^{−βτ}·h(u)` factored an `e^{|u|}` → 0·∞ = NaN).
318        let bracket = (-beta * tau).exp() - (-alpha * tau).exp() * (1.0 + u + 0.5 * u * u);
319        coeff * bracket / (u * u * u)
320    };
321    (1.0 - r) * fast + r * slow
322}
323
324/// Cumulative probability of the Ikeda–Carpenter moderator pulse.
325///
326/// Returns `P(U <= tau)` for moderator delay `U`. `tau` is in µs and rates
327/// are in 1/µs. The prompt term is the Gamma(3, α) cumulative distribution;
328/// the storage term is the cumulative distribution of Gamma(3, α) plus an
329/// independent exponential delay with rate β.
330///
331/// The expression is evaluated without subtracting nearly equal exponentials
332/// when `α ≈ β`. This is the bin-integral companion to [`ic_pulse`].
333///
334/// Domain contract: non-finite `alpha`, `beta`, or `r` returns NaN so garbage
335/// in stays visible; finite non-positive rates are floored to `MIN_RATE`
336/// (1e-9 µs⁻¹) and `r <= 0` disables the storage term, matching
337/// [`ic_pulse`]. A NaN `tau`
338/// returns 0 — `tau` is the integration coordinate, not a parameter, and a
339/// non-arriving coordinate contributes no mass.
340#[must_use]
341pub fn ic_cdf(alpha: f64, beta: f64, r: f64, tau: f64) -> f64 {
342    if !alpha.is_finite() || !beta.is_finite() || !r.is_finite() {
343        return f64::NAN;
344    }
345    if tau.is_nan() || tau <= 0.0 {
346        return 0.0;
347    }
348    if tau == f64::INFINITY {
349        return 1.0;
350    }
351
352    let alpha = alpha.max(MIN_RATE);
353    let beta = beta.max(MIN_RATE);
354    let at = alpha * tau;
355    let fast = gamma3_cdf(at);
356    if r <= 0.0 {
357        return fast;
358    }
359
360    // Far in the tails the general expressions overflow — `at³` or `u³`
361    // reach f64::INFINITY and produce inf·0 / inf/inf = NaN — while the
362    // limits are exact to full f64 precision, so return them directly.
363    // For at ≥ 1e100 the prompt CDF is 1 and the storage correction is
364    // exp(−βτ) with relative error ≤ 3βτ/at; for βτ ≥ 1e100 the storage
365    // delay is instantaneous and the correction is 0.
366    const CDF_TAIL_LIMIT: f64 = 1.0e100;
367    if at >= CDF_TAIL_LIMIT {
368        return (1.0 - r * (-beta * tau).exp()).clamp(0.0, 1.0);
369    }
370    if beta * tau >= CDF_TAIL_LIMIT {
371        return fast;
372    }
373
374    // The storage CDF is the prompt CDF minus the positive survival
375    // correction below. Written this way, the α=β limit is Gamma(4, α).
376    let u = (alpha - beta) * tau;
377    let correction = if u.abs() < 0.05 {
378        at.powi(3) * (-beta * tau).exp() * h_over_cube_taylor(u)
379    } else {
380        // Bounded form: neither exponential can overflow even when β >> α.
381        let bracket = (-beta * tau).exp() - (-alpha * tau).exp() * (1.0 + u + 0.5 * u * u);
382        at.powi(3) * bracket / u.powi(3)
383    };
384    (fast - r * correction).clamp(0.0, 1.0)
385}
386
387/// Gamma(3, rate=1) cumulative distribution at dimensionless time `x`.
388fn gamma3_cdf(x: f64) -> f64 {
389    if x <= 0.0 {
390        return 0.0;
391    }
392    // Beyond x ≈ 750 the survival term exp(−x)·(1+x+x²/2) underflows to an
393    // exact zero long before the polynomial can overflow (x² reaches
394    // f64::INFINITY only at x ~ 1.3e154, where 0·inf would be NaN).
395    if x >= 750.0 {
396        return 1.0;
397    }
398    if x < 0.5 {
399        // Integral of x² exp(-x) / 2 as an alternating series. The direct
400        // `1 - exp(-x)(1+x+x²/2)` loses most digits for small x.
401        let mut n = 0_u32;
402        let mut term = x.powi(3) / 6.0;
403        let mut sum = term;
404        loop {
405            let n_f = f64::from(n);
406            term *= -x * (n_f + 3.0) / ((n_f + 1.0) * (n_f + 4.0));
407            let next = sum + term;
408            n += 1;
409            if next == sum || n >= 128 {
410                return next.clamp(0.0, 1.0);
411            }
412            sum = next;
413        }
414    }
415    (1.0 - (-x).exp() * (1.0 + x + 0.5 * x * x)).clamp(0.0, 1.0)
416}
417
418/// Energy-dependence law for an Ikeda–Carpenter parameter.
419///
420/// A small closed set so the *fixed-or-fit* cases share one representation:
421/// fixed ⇒ [`Const`](EnergyLaw::Const); fit ⇒ a parametric law whose
422/// coefficients are the fit variables.
423#[derive(Debug, Clone, PartialEq)]
424pub enum EnergyLaw {
425    /// Energy-independent constant.
426    Const(f64),
427    /// `a0·√(E[eV]) + a1` — leading epithermal scaling of the fast rate `α(E)`.
428    SqrtE { a0: f64, a1: f64 },
429    /// Mantid IC form `1/(a0 + a1·λ)`, λ (Å) = `LAMBDA_ANGSTROM_FACTOR`/√E.
430    /// Behaves as `α ∝ √E` at low E and saturates to `1/a0` at high E.
431    InverseLambda { a0: f64, a1: f64 },
432    /// `exp(−E[meV]/kappa)` — storage fraction `R(E)`, → 0 in the eV regime.
433    ExpMilliEv { kappa: f64 },
434}
435
436impl EnergyLaw {
437    /// Evaluate the law at `energy_ev` (eV). Non-positive energy yields the
438    /// `E→0` limit where well-defined (and a clamped value otherwise).
439    #[must_use]
440    pub fn eval(&self, energy_ev: f64) -> f64 {
441        let e = energy_ev.max(0.0);
442        match *self {
443            EnergyLaw::Const(c) => c,
444            EnergyLaw::SqrtE { a0, a1 } => a0 * e.sqrt() + a1,
445            EnergyLaw::InverseLambda { a0, a1 } => {
446                let denom = inverse_lambda_denom(a0, a1, e);
447                if denom.abs() < MIN_RATE {
448                    1.0 / MIN_RATE
449                } else {
450                    1.0 / denom
451                }
452            }
453            EnergyLaw::ExpMilliEv { kappa } => {
454                if kappa.abs() < MIN_RATE {
455                    0.0
456                } else {
457                    (-(e * 1000.0) / kappa).exp()
458                }
459            }
460        }
461    }
462
463    /// True when the law is numerically singular at `energy_ev`: the raw
464    /// `InverseLambda` denominator lies inside the ±[`MIN_RATE`] window that
465    /// [`EnergyLaw::eval`] floors away. The floor keeps a fit trial step from
466    /// dividing by zero mid-optimization, but a *configured* law inside that
467    /// window is a mathematically undefined rate, not a large one — without
468    /// this check the floor converts an undefined (or tiny-negative)
469    /// denominator into a plausible huge positive rate before the range
470    /// validation below can see it.
471    #[must_use]
472    pub(crate) fn is_singular_at(&self, energy_ev: f64) -> bool {
473        match *self {
474            EnergyLaw::InverseLambda { a0, a1 } => {
475                inverse_lambda_denom(a0, a1, energy_ev.max(0.0)).abs() < MIN_RATE
476            }
477            // κ = 0 is undefined and a tiny NEGATIVE κ is a divergent law
478            // (exp(+E/|κ|)); eval's floor maps both to 0.0, which is the
479            // legitimate κ → 0⁺ limit only for positive κ.
480            EnergyLaw::ExpMilliEv { kappa } => (-MIN_RATE..=0.0).contains(&kappa),
481            _ => false,
482        }
483    }
484}
485
486/// Raw `InverseLambda` denominator `a0 + a1·λ(E)` — shared by [`EnergyLaw::eval`]
487/// and the singularity check so the two can never disagree on the window.
488fn inverse_lambda_denom(a0: f64, a1: f64, e: f64) -> f64 {
489    let lambda = if e > 0.0 {
490        LAMBDA_ANGSTROM_FACTOR / e.sqrt()
491    } else {
492        f64::INFINITY
493    };
494    a0 + a1 * lambda
495}
496
497/// Parameters of the Ikeda–Carpenter resolution model.
498#[derive(Debug, Clone)]
499pub struct IkedaCarpenterParams {
500    /// Fast (slowing-down) rate `α(E)`, 1/µs. Must evaluate to > 0.
501    pub alpha: EnergyLaw,
502    /// Slow (storage) rate `β(E)`, 1/µs. Must evaluate to > 0.
503    pub beta: EnergyLaw,
504    /// Storage mixing fraction `R(E)`, 0 ≤ R ≤ 1.
505    pub r: EnergyLaw,
506    /// Optional proton-burst Gaussian standard deviation (µs).
507    pub burst_sigma_us: Option<f64>,
508    /// Optional chopper/channel triangle FWHM (µs).
509    pub channel_fwhm_us: Option<f64>,
510}
511
512impl IkedaCarpenterParams {
513    /// A pure-moderator parameter set (no burst, no channel) with constant
514    /// rates — the simplest case, useful for fixed-parameter or unit-test use.
515    #[must_use]
516    pub fn constant(alpha: f64, beta: f64, r: f64) -> Self {
517        Self {
518            alpha: EnergyLaw::Const(alpha),
519            beta: EnergyLaw::Const(beta),
520            r: EnergyLaw::Const(r),
521            burst_sigma_us: None,
522            channel_fwhm_us: None,
523        }
524    }
525}
526
527/// Configuration of the energy / time grids used to synthesize the IC kernel
528/// table. The energy grid spans the data range densely enough that interref
529/// interpolation error is negligible.
530#[derive(Debug, Clone)]
531pub struct SynthesisGrid {
532    /// Lowest reference energy (eV), > 0.
533    pub e_min_ev: f64,
534    /// Highest reference energy (eV), > `e_min_ev`.
535    pub e_max_ev: f64,
536    /// Number of log-spaced reference energies (≥ 2).
537    pub n_energies: usize,
538    /// Number of τ-samples spanning the prompt core of each kernel
539    /// (≥ [`MIN_N_TAU`](crate::ikeda_carpenter) = 8). A long storage tail
540    /// (β ≪ α with R > 0) grows the per-kernel sample count beyond `n_tau`;
541    /// that count is capped at 8192 (`MAX_TAU_SAMPLES`), past which the
542    /// τ-step widens — never below the resolution floor (prompt core at the
543    /// `n_tau = 8` density, folds at ≥ 3 triangle samples per side / ≥ 1
544    /// sample per burst σ): a combination that cannot be resolved within the
545    /// cap is rejected by [`IkedaCarpenter::new`]. Active burst/channel folds
546    /// add their ±(`GAUSS_REACH_SIGMAS`·σ + FWHM) margin samples on top of
547    /// the cap.
548    pub n_tau: usize,
549}
550
551impl SynthesisGrid {
552    /// A sensible default grid: `[e_min, e_max]` log-spaced over
553    /// [`DEFAULT_N_ENERGIES`] points with [`DEFAULT_N_TAU`] τ-samples.
554    #[must_use]
555    pub fn new(e_min_ev: f64, e_max_ev: f64) -> Self {
556        Self {
557            e_min_ev,
558            e_max_ev,
559            n_energies: DEFAULT_N_ENERGIES,
560            n_tau: DEFAULT_N_TAU,
561        }
562    }
563}
564
565/// Analytical Ikeda–Carpenter resolution model.
566///
567/// Synthesizes a [`TabulatedResolution`] at construction and applies it through
568/// the same broadening path as a Monte-Carlo file. Cloning is cheap (the
569/// synthesized table is the only large field). Re-synthesize (construct anew)
570/// when fitting changes the parameters.
571#[derive(Debug, Clone)]
572pub struct IkedaCarpenter {
573    params: IkedaCarpenterParams,
574    flight_path_m: f64,
575    /// Shared: rebinding the flight path must not copy it.
576    ref_energies: Arc<Vec<f64>>,
577    n_tau: usize,
578    tabulated: TabulatedResolution,
579}
580
581impl IkedaCarpenter {
582    /// Build the model, synthesizing its kernel table over `grid`.
583    ///
584    /// # Errors
585    /// Returns [`ResolutionParseError::InvalidFormat`] for a non-positive
586    /// flight path, a degenerate grid (`n_energies < 2`, `n_tau < 8`,
587    /// `e_min ≤ 0`, `e_max ≤ e_min`), a non-positive `β(E)`, a parameter/grid
588    /// combination whose τ-grid cannot resolve the prompt core and requested
589    /// folds within the `MAX_TAU_SAMPLES` cap at some reference energy (see
590    /// `tau_geometry` — remedy: larger `β`, `R = 0`, or a wider/disabled
591    /// fold), or if the synthesized kernels fail
592    /// [`TabulatedResolution::from_kernels`] validation.
593    pub fn new(
594        params: IkedaCarpenterParams,
595        flight_path_m: f64,
596        grid: &SynthesisGrid,
597    ) -> Result<Self, ResolutionParseError> {
598        if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
599            return Err(ResolutionParseError::InvalidFormat(format!(
600                "Flight path must be a positive finite number, got {flight_path_m}"
601            )));
602        }
603        if grid.n_energies < 2 {
604            return Err(ResolutionParseError::InvalidFormat(format!(
605                "n_energies must be >= 2, got {}",
606                grid.n_energies
607            )));
608        }
609        if grid.n_tau < MIN_N_TAU {
610            return Err(ResolutionParseError::InvalidFormat(format!(
611                "n_tau must be >= {MIN_N_TAU}, got {}",
612                grid.n_tau
613            )));
614        }
615        if !grid.e_min_ev.is_finite()
616            || !grid.e_max_ev.is_finite()
617            || grid.e_min_ev <= 0.0
618            || grid.e_max_ev <= grid.e_min_ev
619        {
620            return Err(ResolutionParseError::InvalidFormat(format!(
621                "Require finite 0 < e_min < e_max, got [{}, {}]",
622                grid.e_min_ev, grid.e_max_ev
623            )));
624        }
625        let ln_lo = grid.e_min_ev.ln();
626        let ln_hi = grid.e_max_ev.ln();
627        let denom = (grid.n_energies - 1) as f64;
628        let ref_energies: Vec<f64> = (0..grid.n_energies)
629            .map(|i| (ln_lo + (i as f64 / denom) * (ln_hi - ln_lo)).exp())
630            .collect();
631
632        // Reject singular rate laws before the range checks: a near-zero
633        // InverseLambda denominator is an undefined configuration, and eval's
634        // numerical floor would otherwise convert it into a plausible huge
635        // positive rate that the α > 0 / β > 0 checks below cannot distinguish
636        // from genuine physics.
637        for (name, law) in [("α", &params.alpha), ("β", &params.beta), ("R", &params.r)] {
638            if let Some(&bad) = ref_energies.iter().find(|&&e| law.is_singular_at(e)) {
639                return Err(ResolutionParseError::InvalidFormat(format!(
640                    "Ikeda–Carpenter {name}(E) law is singular at E = {bad} eV \
641                     (an InverseLambda denominator within \
642                     ±{MIN_RATE} of zero, or an ExpMilliEv κ in [−{MIN_RATE}, 0])"
643                )));
644            }
645        }
646        // Reject parameter laws that yield a non-positive fast rate α(E): the
647        // pulse would otherwise degenerate (synthesis clamps α to a tiny floor,
648        // producing a meaningless near-flat kernel rather than failing loudly).
649        if let Some(&bad) = ref_energies.iter().find(|&&e| {
650            let a = params.alpha.eval(e);
651            !a.is_finite() || a <= 0.0
652        }) {
653            return Err(ResolutionParseError::InvalidFormat(format!(
654                "Ikeda–Carpenter α(E) must be > 0, but α({bad}) = {} is not",
655                params.alpha.eval(bad)
656            )));
657        }
658        // β is a rate, so every synthesized reference energy must give a
659        // positive finite value. Reject invalid laws instead of clamping them
660        // into a different pulse.
661        if let Some(&bad) = ref_energies.iter().find(|&&e| {
662            let beta = params.beta.eval(e);
663            !beta.is_finite() || beta <= 0.0
664        }) {
665            return Err(ResolutionParseError::InvalidFormat(format!(
666                "Ikeda–Carpenter β(E) must be > 0, but β({bad}) = {} is not",
667                params.beta.eval(bad)
668            )));
669        }
670        // Reject storage-fraction laws that fall outside [0, 1] (synthesis clamps
671        // R, masking a mis-specified law); a physical mixing fraction is in [0,1].
672        if let Some(&bad) = ref_energies.iter().find(|&&e| {
673            let r = params.r.eval(e);
674            !r.is_finite() || !(0.0..=1.0).contains(&r)
675        }) {
676            return Err(ResolutionParseError::InvalidFormat(format!(
677                "Ikeda–Carpenter R(E) must be in [0, 1], but R({bad}) = {} is not",
678                params.r.eval(bad)
679            )));
680        }
681        // Reject invalid optional instrument-convolution widths up front; synthesis
682        // otherwise masks a negative width via `.abs()` and silently swallows a NaN.
683        for (name, v) in [
684            ("burst_sigma_us", params.burst_sigma_us),
685            ("channel_fwhm_us", params.channel_fwhm_us),
686        ] {
687            if let Some(x) = v
688                && (!x.is_finite() || x < 0.0)
689            {
690                return Err(ResolutionParseError::InvalidFormat(format!(
691                    "Ikeda–Carpenter {name} must be finite and >= 0, got {x}"
692                )));
693            }
694        }
695
696        // Synthesis is fallible: a kernel whose τ-grid cannot resolve the
697        // requested physics within MAX_TAU_SAMPLES (long slow tail vs a fine
698        // fold / fast prompt core) errs loudly here instead of silently
699        // degrading — see `tau_geometry`.
700        let kernels: Vec<(Vec<f64>, Vec<f64>)> = ref_energies
701            .iter()
702            .map(|&e| synth_kernel(&params, grid.n_tau, e))
703            .collect::<Result<_, _>>()?;
704
705        let tabulated =
706            TabulatedResolution::from_kernels(ref_energies.clone(), kernels, flight_path_m)?;
707
708        Ok(Self {
709            params,
710            flight_path_m,
711            ref_energies: Arc::new(ref_energies),
712            n_tau: grid.n_tau,
713            tabulated,
714        })
715    }
716
717    /// The synthesized tabulated kernel set (the broadening engine).
718    #[must_use]
719    pub fn tabulated(&self) -> &TabulatedResolution {
720        &self.tabulated
721    }
722
723    /// The IC parameters.
724    #[must_use]
725    pub fn params(&self) -> &IkedaCarpenterParams {
726        &self.params
727    }
728
729    /// Flight-path length (m).
730    #[must_use]
731    pub fn flight_path_m(&self) -> f64 {
732        self.flight_path_m
733    }
734
735    /// The same pulse read against a different flight path.
736    ///
737    /// The synthesized kernels are emission-time distributions built from
738    /// α(E), β(E) and R(E) — no flight path appears in the synthesis. The
739    /// flight path enters only the TOF↔energy map and the nominal arrival
740    /// time, so rebinding it is exact and does not resynthesize the table.
741    ///
742    /// # Errors
743    /// Returns [`ResolutionParseError::InvalidFormat`] if `flight_path_m` is
744    /// not positive and finite.
745    pub fn with_flight_path(&self, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
746        Ok(Self {
747            params: self.params.clone(),
748            flight_path_m,
749            ref_energies: Arc::clone(&self.ref_energies),
750            n_tau: self.n_tau,
751            tabulated: self.tabulated.with_flight_path(flight_path_m)?,
752        })
753    }
754
755    /// Reference energies (eV, ascending) the table was synthesized on.
756    #[must_use]
757    pub fn ref_energies(&self) -> &[f64] {
758        &self.ref_energies
759    }
760
761    /// Evaluate the (burst/channel-folded) IC kernel at one energy.
762    ///
763    /// Returns ascending TOF-offsets (µs, 0 = pulse start) and peak-normalized
764    /// weights (max = 1), matching the [`TabulatedResolution`] storage
765    /// convention.
766    ///
767    /// # Errors
768    /// Returns [`ResolutionParseError::InvalidFormat`] when the requested
769    /// energy or an energy-dependent rate/fraction is non-physical, or when
770    /// the τ-grid cannot resolve the prompt core and requested folds within
771    /// `MAX_TAU_SAMPLES` at this energy. Construction validates every
772    /// *reference* energy, but a probe outside `[e_min, e_max]` can still leave
773    /// the physical or resolvable region.
774    pub fn kernel_at(&self, energy_ev: f64) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
775        self.validate_probe_energy(energy_ev)?;
776        synth_kernel(&self.params, self.n_tau, energy_ev)
777    }
778
779    fn folded(&self) -> bool {
780        self.params.burst_sigma_us.unwrap_or(0.0) != 0.0
781            || self.params.channel_fwhm_us.unwrap_or(0.0) != 0.0
782    }
783
784    /// The first and last delay, in µs after the nominal arrival, of a
785    /// neutron of `energy_ev`, outside which [`Self::detector_bin_probabilities`]
786    /// gives it less than [`NEGLIGIBLE_ARRIVAL_PROBABILITY`] chance of
787    /// arriving: without a fold, 0 and the delay where `1 − ic_cdf` falls to
788    /// that chance; with a fold, the fold's reach before 0 and a bound on the
789    /// end of the sampled pulse those probabilities integrate.
790    ///
791    /// # Errors
792    /// [`ResolutionParseError::InvalidFormat`] when `energy_ev` is not
793    /// positive and finite, or a law is singular or out of range there.
794    pub fn delays_us(&self, energy_ev: f64) -> Result<(f64, f64), ResolutionParseError> {
795        self.validate_probe_energy(energy_ev)?;
796        let alpha = self.params.alpha.eval(energy_ev);
797        let beta = self.params.beta.eval(energy_ev);
798        let r = self.params.r.eval(energy_ev);
799        if !self.folded() {
800            return Ok((0.0, tail_delay(alpha, beta, r)));
801        }
802        let (alpha, beta, r) = (alpha.max(MIN_RATE), beta.max(MIN_RATE), r.clamp(0.0, 1.0));
803        let tau_max = tau_reach(alpha, beta, r);
804        let widest_step = (FAST_REACH_E_FOLDS / alpha / (self.n_tau as f64 - 1.0))
805            .max(tau_max / (MAX_TAU_SAMPLES as f64 - 1.0));
806        let margin = margin_of(&self.params);
807        Ok((-margin, tau_max + margin + widest_step))
808    }
809
810    /// The time in µs from the first sample of the pulse at `energy_ev` to
811    /// its peak.
812    ///
813    /// # Errors
814    /// As [`Self::source_pulse_at`].
815    pub fn rise_us(&self, energy_ev: f64) -> Result<f64, ResolutionParseError> {
816        self.validate_probe_energy(energy_ev)?;
817        let (times, densities) = synth_source_pulse_density(&self.params, self.n_tau, energy_ev)?;
818        Ok(times[argmax(&densities)] - times[0])
819    }
820
821    /// Evaluate the physical source pulse at one true neutron energy.
822    ///
823    /// Returns sampled moderator-delay coordinates in µs and peak-normalized
824    /// densities. Unlike [`Self::kernel_at`], this method does not move the
825    /// pulse mode to zero. With no symmetric proton/channel fold, the delay is
826    /// causal and starts at zero. A symmetric fold may extend the sampled
827    /// support below zero relative to its stated time origin.
828    ///
829    /// # Errors
830    /// Returns [`ResolutionParseError::InvalidFormat`] if `energy_ev` is not a
831    /// positive finite true energy, if an energy law is unphysical at that
832    /// energy, or if the requested pulse cannot be resolved by the configured
833    /// sampling limits.
834    pub fn source_pulse_at(
835        &self,
836        energy_ev: f64,
837    ) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
838        self.validate_probe_energy(energy_ev)?;
839        synth_source_pulse(&self.params, self.n_tau, energy_ev)
840    }
841
842    /// Probability that a neutron of known true energy is recorded in each
843    /// supplied detector-time bin.
844    ///
845    /// `detector_time_edges_us` are the actual measured bin edges. The nominal
846    /// arrival time is
847    /// `timing_offset_us + TOF_FACTOR * flight_path_m / sqrt(true_energy_ev)`.
848    /// `timing_offset_us` represents the shared clock/detector offset; it does
849    /// not absorb or remove the moderator pulse's physical mode.
850    ///
851    /// The returned vector has one entry per adjacent edge pair and is not
852    /// renormalized to the supplied window: bins that do not cover the full
853    /// pulse correctly sum to less than one.
854    ///
855    /// With no burst or channel fold, probabilities come directly from the
856    /// analytical IC CDF. With either optional fold, the continuous pulse is
857    /// represented on the configured synthesis grid and integrated as a
858    /// piecewise-linear density. The finite numerical support is not silently
859    /// renormalized; omitted physical tail probability remains omitted.
860    ///
861    /// # Errors
862    /// Returns [`ResolutionParseError::InvalidFormat`] unless the true energy
863    /// is physical, the timing offset is finite, and at least two finite bin
864    /// edges are supplied in strictly increasing order.
865    pub fn detector_bin_probabilities(
866        &self,
867        true_energy_ev: f64,
868        detector_time_edges_us: &[f64],
869        timing_offset_us: f64,
870    ) -> Result<Vec<f64>, ResolutionParseError> {
871        self.validate_probe_energy(true_energy_ev)?;
872        if !timing_offset_us.is_finite() {
873            return Err(ResolutionParseError::InvalidFormat(format!(
874                "timing_offset_us must be finite, got {timing_offset_us}"
875            )));
876        }
877        if detector_time_edges_us.len() < 2
878            || detector_time_edges_us.iter().any(|x| !x.is_finite())
879            || detector_time_edges_us.windows(2).any(|w| w[0] >= w[1])
880        {
881            return Err(ResolutionParseError::InvalidFormat(
882                "detector time edges must contain at least two finite, strictly increasing values"
883                    .to_string(),
884            ));
885        }
886
887        let nominal_arrival =
888            timing_offset_us + TOF_FACTOR * self.flight_path_m / true_energy_ev.sqrt();
889        let relative_edges: Vec<f64> = detector_time_edges_us
890            .iter()
891            .map(|edge| edge - nominal_arrival)
892            .collect();
893
894        if !self.folded() {
895            let alpha = self.params.alpha.eval(true_energy_ev);
896            let beta = self.params.beta.eval(true_energy_ev);
897            let r = self.params.r.eval(true_energy_ev);
898            return Ok(relative_edges
899                .windows(2)
900                .map(|edge| {
901                    (ic_cdf(alpha, beta, r, edge[1]) - ic_cdf(alpha, beta, r, edge[0])).max(0.0)
902                })
903                .collect());
904        }
905
906        let (times, densities) =
907            synth_source_pulse_density(&self.params, self.n_tau, true_energy_ev)?;
908        // Per-bin accuracy gate: the point-sampled fold convolution
909        // mis-assigns individual bins as O(dtau²) even while conserving the
910        // total (leading-edge mass below the sampled support silently
911        // redistributes into the window). Synthesis accepts the moment-level
912        // steps for the calibration consumer; this per-bin consumer requires
913        // the STRICTEST applicable bin floor — prompt core, channel
914        // triangle, Gaussian burst — and rejects a coarser grid loudly.
915        if times.len() >= 2 {
916            let dtau = times[1] - times[0];
917            let alpha_probe = self.params.alpha.eval(true_energy_ev);
918            let mut bin_floor = FAST_REACH_E_FOLDS / alpha_probe.max(MIN_RATE) / PROMPT_BIN_SAMPLES;
919            let mut binding = "prompt core".to_string();
920            if let Some(fwhm) = self.params.channel_fwhm_us.filter(|&f| f > 0.0) {
921                let tri = fwhm / TRI_BIN_SAMPLES_PER_SIDE;
922                if tri < bin_floor {
923                    bin_floor = tri;
924                    binding = format!("{fwhm} µs channel triangle");
925                }
926            }
927            if let Some(sigma) = self.params.burst_sigma_us.filter(|&s| s > 0.0) {
928                let gauss = sigma / GAUSS_BIN_SAMPLES_PER_SIGMA;
929                if gauss < bin_floor {
930                    bin_floor = gauss;
931                    binding = format!("{sigma} µs Gaussian burst");
932                }
933            }
934            if dtau > bin_floor * (1.0 + 1e-12) {
935                return Err(ResolutionParseError::InvalidFormat(format!(
936                    "Ikeda–Carpenter detector-bin probabilities at E = \
937                     {true_energy_ev} eV: the sampled τ-step {dtau:.4} µs \
938                     exceeds the per-bin accuracy floor {bin_floor:.4} µs \
939                     set by the {binding}; increase n_tau (or shorten the \
940                     storage tail) so the sampled fold meets the per-bin bound"
941                )));
942            }
943        }
944        piecewise_linear_bin_masses(&times, &densities, &relative_edges).ok_or_else(|| {
945            ResolutionParseError::InvalidFormat(format!(
946                "Ikeda–Carpenter pulse at E = {true_energy_ev} eV has zero sampled area"
947            ))
948        })
949    }
950
951    fn validate_probe_energy(&self, energy_ev: f64) -> Result<(), ResolutionParseError> {
952        if !energy_ev.is_finite() || energy_ev <= 0.0 {
953            return Err(ResolutionParseError::InvalidFormat(format!(
954                "true energy must be positive and finite, got {energy_ev}"
955            )));
956        }
957        for (name, law) in [
958            ("alpha", &self.params.alpha),
959            ("beta", &self.params.beta),
960            ("R", &self.params.r),
961        ] {
962            if law.is_singular_at(energy_ev) {
963                return Err(ResolutionParseError::InvalidFormat(format!(
964                    "Ikeda–Carpenter {name}({energy_ev}) law is singular (an \
965                     InverseLambda denominator within ±{MIN_RATE} of zero, \
966                     or an ExpMilliEv κ in [−{MIN_RATE}, 0])"
967                )));
968            }
969        }
970        let alpha = self.params.alpha.eval(energy_ev);
971        let beta = self.params.beta.eval(energy_ev);
972        let r = self.params.r.eval(energy_ev);
973        if !alpha.is_finite() || alpha <= 0.0 {
974            return Err(ResolutionParseError::InvalidFormat(format!(
975                "Ikeda–Carpenter alpha({energy_ev}) must be positive and finite, got {alpha}"
976            )));
977        }
978        if !beta.is_finite() || beta <= 0.0 {
979            return Err(ResolutionParseError::InvalidFormat(format!(
980                "Ikeda–Carpenter beta({energy_ev}) must be positive and finite, got {beta}"
981            )));
982        }
983        if !r.is_finite() || !(0.0..=1.0).contains(&r) {
984            return Err(ResolutionParseError::InvalidFormat(format!(
985                "Ikeda–Carpenter R({energy_ev}) must be in [0, 1], got {r}"
986            )));
987        }
988        Ok(())
989    }
990}
991
992/// The chance of a later arrival below which an Ikeda–Carpenter pulse's
993/// tail is treated as ended.
994pub const NEGLIGIBLE_ARRIVAL_PROBABILITY: f64 = 1e-7;
995
996fn tail_delay(alpha: f64, beta: f64, r: f64) -> f64 {
997    let later = |tau: f64| 1.0 - ic_cdf(alpha, beta, r, tau);
998    let mut high = 1.0 / alpha.min(beta).max(MIN_RATE);
999    while later(high) > NEGLIGIBLE_ARRIVAL_PROBABILITY {
1000        high *= 2.0;
1001    }
1002    let mut low = 0.0;
1003    while high - low > f64::EPSILON * high {
1004        let middle = 0.5 * (low + high);
1005        if later(middle) > NEGLIGIBLE_ARRIVAL_PROBABILITY {
1006            low = middle;
1007        } else {
1008            high = middle;
1009        }
1010    }
1011    high
1012}
1013
1014/// τ-grid geometry for one kernel: `(dtau, tau_max, margin)`, or a
1015/// descriptive error when no exact sampled representation fits the cap.
1016///
1017/// The step is anchored to the PROMPT core — `n_tau` samples across the fast
1018/// Gamma(3) pulse (`fast_reach / (n_tau − 1)`) — and REFINED to resolve any
1019/// requested instrument fold (triangle: ≥ [`TRI_MIN_SAMPLES_PER_SIDE`]
1020/// samples per side, i.e. `dtau ≤ FWHM/TRI_MIN_SAMPLES_PER_SIDE`; Gaussian burst: `dtau ≤ σ`, i.e.
1021/// ≥ [`GAUSS_REACH_SIGMAS`] samples per ±`GAUSS_REACH_SIGMAS`·σ side). A longer storage tail
1022/// (β ≪ α, R > 0) extends the SAMPLE COUNT (`j_hi ∝ tau_max/dtau`) instead
1023/// of the step; [`MAX_TAU_SAMPLES`] bounds that count by widening the step —
1024/// but never past the resolution FLOOR (`fast_reach / (MIN_N_TAU − 1)` for
1025/// the prompt core, the fold minima above for folds). A combination whose
1026/// floor cannot be met within the cap has no faithful sampled representation
1027/// here, so it is rejected loudly: a capped step above the fold width would
1028/// degenerate the sampled triangle to an exact delta `[0,1,0]` (the fold
1029/// silently vanishes), and a capped step above the prompt scale steps OVER
1030/// the prompt pulse entirely (probe: α = 250, β = 0.02, R = 0.1 loses the
1031/// prompt's 0.9 weight share).
1032///
1033/// Bit-identical to the pre-#642-review `max(fast_reach/(n_tau−1),
1034/// tau_max/(MAX_TAU_SAMPLES−1))` step whenever no fold is finer than the
1035/// prompt design step and the capped step stays at or below the floor.
1036fn tau_geometry(
1037    params: &IkedaCarpenterParams,
1038    n_tau: usize,
1039    alpha: f64,
1040    beta: f64,
1041    r: f64,
1042) -> Result<(f64, f64, f64), String> {
1043    let fast_reach = FAST_REACH_E_FOLDS / alpha;
1044    let tau_max = tau_reach(alpha, beta, r);
1045
1046    // Requested step and resolution floor. `floor ≥ dtau_req` always: the
1047    // prompt terms satisfy MIN_N_TAU ≤ n_tau (validated by `new`) and the
1048    // fold terms are common to both.
1049    let mut dtau_req = fast_reach / (n_tau as f64 - 1.0);
1050    let mut floor = fast_reach / (MIN_N_TAU as f64 - 1.0);
1051    let mut fold_desc = String::new();
1052    // With any fold active, the REQUESTED step targets the per-bin accuracy
1053    // floors (prompt core, triangle, burst — see the *_BIN_* constants) so
1054    // the detector-bin path is accurate whenever the sample cap affords it;
1055    // the HARD floors stay at the moment level (MIN_N_TAU prompt density,
1056    // FWHM/TRI_MIN_SAMPLES_PER_SIDE, σ) so cap-limited long-tail
1057    // configurations still synthesize for the moment-level consumers
1058    // (calibration), and only the per-bin gate in
1059    // `detector_bin_probabilities` rejects them.
1060    let any_fold = params.channel_fwhm_us.filter(|&f| f > 0.0).is_some()
1061        || params.burst_sigma_us.filter(|&s| s > 0.0).is_some();
1062    if any_fold {
1063        dtau_req = dtau_req.min(fast_reach / PROMPT_BIN_SAMPLES);
1064    }
1065    if let Some(fwhm) = params.channel_fwhm_us
1066        && fwhm > 0.0
1067    {
1068        dtau_req = dtau_req.min(fwhm / TRI_BIN_SAMPLES_PER_SIDE);
1069        floor = floor.min(fwhm / TRI_MIN_SAMPLES_PER_SIDE);
1070        fold_desc.push_str(&format!(", channel triangle FWHM = {fwhm} µs"));
1071    }
1072    if let Some(sigma) = params.burst_sigma_us
1073        && sigma > 0.0
1074    {
1075        dtau_req = dtau_req.min(sigma / GAUSS_BIN_SAMPLES_PER_SIGMA);
1076        floor = floor.min(sigma);
1077        fold_desc.push_str(&format!(", burst σ = {sigma} µs"));
1078    }
1079
1080    let capped_step = tau_max / (MAX_TAU_SAMPLES as f64 - 1.0);
1081    if capped_step > floor {
1082        return Err(format!(
1083            "the {MAX_TAU_SAMPLES}-sample τ-grid cap cannot resolve the requested physics: \
1084             the pulse spans τ_max = {tau_max:.3} µs (α = {alpha:.4} µs⁻¹, β = {beta:.4} µs⁻¹, \
1085             R = {r:.3}{fold_desc}), forcing a τ-step of {capped_step:.4} µs — above the \
1086             finest-feature resolution floor of {floor:.4} µs. Increase β (shorter storage \
1087             tail), set R = 0 (drop the storage term), or widen/disable the burst/channel fold"
1088        ));
1089    }
1090    Ok((dtau_req.max(capped_step), tau_max, margin_of(params)))
1091}
1092
1093fn tau_reach(alpha: f64, beta: f64, r: f64) -> f64 {
1094    let fast_reach = FAST_REACH_E_FOLDS / alpha;
1095    if r > R_NEGLIGIBLE {
1096        fast_reach.max(SLOW_REACH_E_FOLDS / beta)
1097    } else {
1098        fast_reach
1099    }
1100}
1101
1102/// Symmetric τ-grid margin for the burst/channel folds:
1103/// ±([`GAUSS_REACH_SIGMAS`]·σ + FWHM), the folds' full reach. These samples
1104/// come ON TOP of [`MAX_TAU_SAMPLES`] (the cap governs the pulse body only).
1105fn margin_of(params: &IkedaCarpenterParams) -> f64 {
1106    params
1107        .burst_sigma_us
1108        .map_or(0.0, |s| GAUSS_REACH_SIGMAS * s)
1109        + params.channel_fwhm_us.unwrap_or(0.0)
1110}
1111
1112/// Synthesize one `(offsets, weights)` kernel for `energy_ev` from the IC
1113/// parameters: sample `I(τ)`, fold in burst + channel, trim negligible tails,
1114/// peak-normalize.
1115///
1116/// Offsets are on the emission clock: `0` is when the pulse begins, which is
1117/// where the Ikeda–Carpenter function puts it. A loaded SAMMY UDR file is
1118/// anchored differently and never passes through here.
1119///
1120/// # Errors
1121/// [`ResolutionParseError::InvalidFormat`] when [`tau_geometry`] cannot
1122/// resolve the prompt core and requested folds within [`MAX_TAU_SAMPLES`].
1123fn synth_kernel(
1124    params: &IkedaCarpenterParams,
1125    n_tau: usize,
1126    energy_ev: f64,
1127) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1128    synth_source_pulse(params, n_tau, energy_ev)
1129}
1130
1131/// Synthesize one physical-time source pulse without moving its mode.
1132fn synth_source_pulse_density(
1133    params: &IkedaCarpenterParams,
1134    n_tau: usize,
1135    energy_ev: f64,
1136) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1137    let alpha = params.alpha.eval(energy_ev).max(MIN_RATE);
1138    let beta = params.beta.eval(energy_ev).max(MIN_RATE);
1139    let r = params.r.eval(energy_ev).clamp(0.0, 1.0);
1140
1141    let (dtau, tau_max, margin) = tau_geometry(params, n_tau, alpha, beta, r).map_err(|msg| {
1142        ResolutionParseError::InvalidFormat(format!(
1143            "Ikeda–Carpenter kernel at E = {energy_ev} eV: {msg}"
1144        ))
1145    })?;
1146
1147    // Extend the grid to slightly negative τ so a symmetric burst/channel can
1148    // spread the leading edge correctly (the moderator pulse itself is 0 there).
1149    // Widths are validated finite and >= 0 by `IkedaCarpenter::new`, so they are
1150    // used directly (no `.abs()` masking of a sign error).
1151    let j_lo: isize = -((margin / dtau).ceil() as isize);
1152    let j_hi: isize = ((tau_max + margin) / dtau).ceil() as isize;
1153
1154    let taus: Vec<f64> = (j_lo..=j_hi).map(|j| j as f64 * dtau).collect();
1155    let mut weights: Vec<f64> = taus.iter().map(|&t| ic_pulse(alpha, beta, r, t)).collect();
1156
1157    // Correct only the sampled quadrature error of the analytical moderator
1158    // density. The target is its exact CDF at the finite grid endpoint, not
1159    // one, so physical moderator probability beyond the grid is not moved
1160    // back into the sampled support. This matters for the coarsest admitted
1161    // n_tau values, where peak-normalization used to hide a large area error.
1162    let sampled_area = dtau
1163        * (0.5 * weights[0]
1164            + weights[1..weights.len() - 1].iter().sum::<f64>()
1165            + 0.5 * weights[weights.len() - 1]);
1166    let moderator_mass = ic_cdf(alpha, beta, r, *taus.last().expect("non-empty tau grid"));
1167    if !sampled_area.is_finite() || sampled_area <= 0.0 {
1168        return Err(ResolutionParseError::InvalidFormat(format!(
1169            "Ikeda–Carpenter pulse at E = {energy_ev} eV has zero sampled area"
1170        )));
1171    }
1172    let area_correction = moderator_mass / sampled_area;
1173    for weight in &mut weights {
1174        *weight *= area_correction;
1175    }
1176
1177    if let Some(sigma) = params.burst_sigma_us
1178        && sigma > 0.0
1179    {
1180        let (kernel, retained_mass) = gaussian_kernel(dtau, sigma);
1181        weights = convolve_same(&weights, &kernel);
1182        for weight in &mut weights {
1183            *weight *= retained_mass;
1184        }
1185    }
1186    if let Some(fwhm) = params.channel_fwhm_us
1187        && fwhm > 0.0
1188    {
1189        weights = convolve_same(&weights, &triangle_kernel(dtau, fwhm));
1190    }
1191
1192    let peak_idx = argmax(&weights);
1193    let peak_val = weights[peak_idx].max(f64::MIN_POSITIVE);
1194
1195    // Trim tails below TRIM_REL of peak, keeping one guard sample each side so
1196    // the convolution's neighbor-difference trapezoid widths stay defined.
1197    let thresh = TRIM_REL * peak_val;
1198    let lo = weights
1199        .iter()
1200        .position(|&w| w > thresh)
1201        .map_or(0, |i| i.saturating_sub(1));
1202    let hi = weights
1203        .iter()
1204        .rposition(|&w| w > thresh)
1205        .map_or(weights.len() - 1, |i| (i + 1).min(weights.len() - 1));
1206
1207    let offsets: Vec<f64> = (lo..=hi).map(|j| taus[j]).collect();
1208    let densities: Vec<f64> = (lo..=hi).map(|j| weights[j]).collect();
1209    Ok((offsets, densities))
1210}
1211
1212/// Synthesize the public peak-normalized source-pulse representation.
1213fn synth_source_pulse(
1214    params: &IkedaCarpenterParams,
1215    n_tau: usize,
1216    energy_ev: f64,
1217) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1218    let (offsets, densities) = synth_source_pulse_density(params, n_tau, energy_ev)?;
1219    let peak = densities
1220        .iter()
1221        .copied()
1222        .fold(0.0_f64, f64::max)
1223        .max(f64::MIN_POSITIVE);
1224    let weights = densities.into_iter().map(|value| value / peak).collect();
1225    Ok((offsets, weights))
1226}
1227
1228/// Index of the maximum element (first on ties). Slice is non-empty by
1229/// construction in [`synth_kernel`].
1230fn argmax(xs: &[f64]) -> usize {
1231    let mut best = 0;
1232    let mut best_v = xs[0];
1233    for (i, &x) in xs.iter().enumerate().skip(1) {
1234        if x > best_v {
1235            best_v = x;
1236            best = i;
1237        }
1238    }
1239    best
1240}
1241
1242/// Symmetric, unit-sum Gaussian kernel sampled on a `dtau`-spaced grid out to
1243/// ±[`GAUSS_REACH_SIGMAS`]·σ.
1244fn gaussian_kernel(dtau: f64, sigma: f64) -> (Vec<f64>, f64) {
1245    let half = ((GAUSS_REACH_SIGMAS * sigma / dtau).ceil() as isize).max(1);
1246    let mut k: Vec<f64> = (-half..=half)
1247        .map(|j| {
1248            let t = j as f64 * dtau / sigma;
1249            (-0.5 * t * t).exp()
1250        })
1251        .collect();
1252    let raw_sum: f64 = k.iter().sum();
1253    let retained_mass = (raw_sum * dtau / (sigma * std::f64::consts::TAU.sqrt())).clamp(0.0, 1.0);
1254    normalize_sum(&mut k);
1255    (k, retained_mass)
1256}
1257
1258/// Symmetric, unit-sum triangle kernel of FWHM `fwhm` (half-base = FWHM;
1259/// variance FWHM²/6) sampled on a `dtau`-spaced grid.
1260fn triangle_kernel(dtau: f64, fwhm: f64) -> Vec<f64> {
1261    let a = fwhm; // half-base equals FWHM for a symmetric triangle
1262    let half = ((a / dtau).ceil() as isize).max(1);
1263    let mut k: Vec<f64> = (-half..=half)
1264        .map(|j| (1.0 - (j as f64 * dtau).abs() / a).max(0.0))
1265        .collect();
1266    normalize_sum(&mut k);
1267    k
1268}
1269
1270fn normalize_sum(k: &mut [f64]) {
1271    let s: f64 = k.iter().sum();
1272    if s > 0.0 {
1273        for v in k.iter_mut() {
1274            *v /= s;
1275        }
1276    }
1277}
1278
1279/// Discrete convolution with a centered symmetric `kernel` (odd length),
1280/// returning an output the same length as `input` (zero-padded edges).
1281fn convolve_same(input: &[f64], kernel: &[f64]) -> Vec<f64> {
1282    let n = input.len();
1283    let kh = (kernel.len() / 2) as isize;
1284    let mut out = vec![0.0f64; n];
1285    for (i, o) in out.iter_mut().enumerate() {
1286        let mut acc = 0.0;
1287        for (kk, &kv) in kernel.iter().enumerate() {
1288            let src = i as isize + (kk as isize - kh);
1289            if src >= 0 && (src as usize) < n {
1290                acc += input[src as usize] * kv;
1291            }
1292        }
1293        *o = acc;
1294    }
1295    out
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300    use super::*;
1301    use crate::resolution::{
1302        ResolutionFunction, apply_resolution, apply_resolution_with_plan, build_resolution_plan,
1303    };
1304    use std::sync::Arc;
1305
1306    /// Trapezoidal integral of `I(τ)` over a fine grid out to many decay times.
1307    fn pulse_area(alpha: f64, beta: f64, r: f64) -> f64 {
1308        let tau_max = (18.0 / alpha).max(if r > 0.0 { 18.0 / beta } else { 0.0 });
1309        let n = 200_000;
1310        let dt = tau_max / n as f64;
1311        let mut area = 0.0;
1312        for i in 0..n {
1313            let t0 = i as f64 * dt;
1314            let t1 = (i + 1) as f64 * dt;
1315            area += 0.5 * (ic_pulse(alpha, beta, r, t0) + ic_pulse(alpha, beta, r, t1)) * dt;
1316        }
1317        area
1318    }
1319
1320    fn pulse_mean(alpha: f64, beta: f64, r: f64) -> f64 {
1321        let tau_max = (24.0 / alpha).max(if r > 0.0 { 24.0 / beta } else { 0.0 });
1322        let n = 400_000;
1323        let dt = tau_max / n as f64;
1324        let mut m = 0.0;
1325        for i in 0..n {
1326            let t = (i as f64 + 0.5) * dt;
1327            m += t * ic_pulse(alpha, beta, r, t) * dt;
1328        }
1329        m
1330    }
1331
1332    #[test]
1333    fn pulse_is_unit_area() {
1334        for &(a, b, r) in &[
1335            (0.5, 0.05, 0.0),
1336            (1.0, 0.1, 0.3),
1337            (2.0, 0.2, 0.6),
1338            (0.8, 0.5, 0.9),
1339        ] {
1340            let area = pulse_area(a, b, r);
1341            assert!(
1342                (area - 1.0).abs() < 1e-3,
1343                "area for (α={a},β={b},R={r}) = {area}, expected 1"
1344            );
1345        }
1346    }
1347
1348    #[test]
1349    fn pulse_mean_matches_formula() {
1350        // ⟨τ⟩ = 3/α + R/β
1351        for &(a, b, r) in &[(1.0, 0.1, 0.0), (1.0, 0.1, 0.4), (2.0, 0.25, 0.7)] {
1352            let want = 3.0 / a + r / b;
1353            let got = pulse_mean(a, b, r);
1354            assert!(
1355                (got - want).abs() / want < 2e-3,
1356                "mean (α={a},β={b},R={r}) = {got}, expected {want}"
1357            );
1358        }
1359    }
1360
1361    #[test]
1362    fn pulse_mode_is_two_over_alpha_for_pure_fast() {
1363        // r=0 ⇒ Gamma(3,α): mode at τ=2/α.
1364        let alpha = 1.3;
1365        let mode = 2.0 / alpha;
1366        let here = ic_pulse(alpha, 0.1, 0.0, mode);
1367        for d in [-0.3, -0.1, 0.1, 0.3] {
1368            assert!(ic_pulse(alpha, 0.1, 0.0, mode + d) <= here + 1e-12);
1369        }
1370    }
1371
1372    #[test]
1373    fn pulse_alpha_equals_beta_is_finite_gamma4() {
1374        // α=β limit: slow term → α⁴τ³/6·e^{−ατ} (Gamma(4)). r=1 isolates it.
1375        let a = 1.0;
1376        let tau = 2.5;
1377        let got = ic_pulse(a, a, 1.0, tau);
1378        let want = a.powi(4) * tau.powi(3) / 6.0 * (-a * tau).exp();
1379        assert!(got.is_finite());
1380        assert!(
1381            (got - want).abs() < 1e-9,
1382            "α=β pulse {got} != Gamma(4) {want}"
1383        );
1384        // Near-degenerate (β just below α) must also be finite and close.
1385        let near = ic_pulse(a, a - 1e-9, 1.0, tau);
1386        assert!(near.is_finite() && (near - want).abs() < 1e-6);
1387    }
1388
1389    #[test]
1390    fn pulse_is_finite_for_beta_much_greater_than_alpha() {
1391        // Regression: β ≫ α with storage active previously produced NaN (the
1392        // old e^{−βτ}·h(u) form factored an e^{|u|} that overflowed → 0·∞ = NaN).
1393        for &tau in &[0.0, 1.0, 50.0, 400.0, 1000.0] {
1394            let v = ic_pulse(0.05, 4.0, 0.5, tau);
1395            assert!(
1396                v.is_finite() && v >= 0.0,
1397                "ic_pulse(0.05,4,0.5,{tau}) = {v}"
1398            );
1399        }
1400        // The synthesized kernel must contain no non-finite entries.
1401        let p = IkedaCarpenterParams {
1402            alpha: EnergyLaw::Const(0.05),
1403            beta: EnergyLaw::Const(4.0),
1404            r: EnergyLaw::Const(0.5),
1405            burst_sigma_us: None,
1406            channel_fwhm_us: None,
1407        };
1408        let (offs, wts) = synth_kernel(&p, 600, 1.0).unwrap();
1409        assert!(offs.iter().chain(wts.iter()).all(|v| v.is_finite()));
1410    }
1411
1412    #[test]
1413    fn pulse_is_nonnegative_and_zero_before_t0() {
1414        assert_eq!(ic_pulse(1.0, 0.1, 0.5, -0.5), 0.0);
1415        for i in 0..200 {
1416            let t = i as f64 * 0.1;
1417            assert!(ic_pulse(1.0, 0.1, 0.5, t) >= 0.0);
1418        }
1419    }
1420
1421    #[test]
1422    fn energy_law_eval() {
1423        assert_eq!(EnergyLaw::Const(3.0).eval(50.0), 3.0);
1424        let s = EnergyLaw::SqrtE { a0: 0.2, a1: 0.1 };
1425        assert!((s.eval(100.0) - (0.2 * 10.0 + 0.1)).abs() < 1e-12);
1426        // InverseLambda: α grows with E (λ shrinks).
1427        let il = EnergyLaw::InverseLambda { a0: 0.1, a1: 0.5 };
1428        assert!(il.eval(200.0) > il.eval(5.0));
1429        // R = exp(−E_meV/κ) → ~0 at eV-scale energies, ~1 at sub-meV.
1430        let rr = EnergyLaw::ExpMilliEv { kappa: 25.0 };
1431        assert!(rr.eval(10.0) < 1e-6); // 10 eV
1432        assert!(rr.eval(0.001) > 0.9); // 1 meV
1433    }
1434
1435    /// The synthesized table and the model that produced it place the pulse at
1436    /// the same instant.
1437    ///
1438    /// Both are evaluated on the detector clock for the same `timing_offset_us`,
1439    /// so their mean arrival and their shape must agree.
1440    #[test]
1441    fn synthesized_table_agrees_with_the_model_it_came_from() {
1442        let model = IkedaCarpenter::new(
1443            IkedaCarpenterParams {
1444                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
1445                beta: EnergyLaw::Const(0.25),
1446                r: EnergyLaw::Const(0.15),
1447                burst_sigma_us: None,
1448                channel_fwhm_us: None,
1449            },
1450            25.0,
1451            &SynthesisGrid {
1452                e_min_ev: 1.0,
1453                e_max_ev: 100.0,
1454                n_energies: 16,
1455                n_tau: 512,
1456            },
1457        )
1458        .expect("valid IC");
1459        let table = model.tabulated();
1460
1461        // Between reference energies, so the width blend is exercised: it
1462        // scales offsets about 0, which is the emission instant.
1463        for energy in [3.7_f64, 17.3, 55.0] {
1464            let tof = TOF_FACTOR * 25.0 / energy.sqrt();
1465            let edges: Vec<f64> = (0..=3000).map(|i| tof - 5.0 + i as f64 * 0.01).collect();
1466            let mean = |p: &[f64]| -> f64 {
1467                let mass: f64 = p.iter().sum();
1468                p.iter()
1469                    .enumerate()
1470                    .map(|(i, w)| w * 0.5 * (edges[i] + edges[i + 1]))
1471                    .sum::<f64>()
1472                    / mass
1473            };
1474            let from_model = model
1475                .detector_bin_probabilities(energy, &edges, 0.0)
1476                .expect("model evaluates");
1477            let from_table = table
1478                .detector_bin_probabilities(energy, &edges, 0.0)
1479                .expect("table evaluates");
1480            let peak = from_model.iter().copied().fold(0.0_f64, f64::max);
1481            let shape = from_model
1482                .iter()
1483                .zip(&from_table)
1484                .map(|(a, b)| (a - b).abs())
1485                .fold(0.0_f64, f64::max);
1486            assert!(
1487                shape < 0.01 * peak,
1488                "E={energy}: table differs from the analytic pulse by {:.2}% of peak",
1489                100.0 * shape / peak
1490            );
1491            let gap = mean(&from_model) - mean(&from_table);
1492            assert!(
1493                gap.abs() < 0.01,
1494                "E={energy}: model puts the pulse at {:.4} µs, its own table at \
1495                 {:.4} µs ({gap:+.4} µs apart)",
1496                mean(&from_model),
1497                mean(&from_table)
1498            );
1499        }
1500    }
1501
1502    #[test]
1503    fn kernel_tail_points_to_positive_offset() {
1504        // Asymmetry: longer/heavier tail toward +offset (later TOF, lower E).
1505        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
1506        let (offsets, weights) = synth_kernel(&p, 600, 10.0).unwrap();
1507        let max_pos = offsets.iter().cloned().fold(f64::MIN, f64::max);
1508        let min_neg = offsets.iter().cloned().fold(f64::MAX, f64::min);
1509        assert!(
1510            max_pos > min_neg.abs(),
1511            "expected longer +offset tail: +{max_pos} vs −{}",
1512            min_neg.abs()
1513        );
1514        let pos_w: f64 = offsets
1515            .iter()
1516            .zip(&weights)
1517            .filter(|(o, _)| **o > 0.0)
1518            .map(|(_, w)| *w)
1519            .sum();
1520        let neg_w: f64 = offsets
1521            .iter()
1522            .zip(&weights)
1523            .filter(|(o, _)| **o < 0.0)
1524            .map(|(_, w)| *w)
1525            .sum();
1526        assert!(pos_w > neg_w, "expected more weight at +offset");
1527    }
1528
1529    #[test]
1530    fn higher_energy_gives_narrower_kernel() {
1531        // α(E) ∝ √E ⇒ prompt width 1/α shrinks with E ⇒ smaller TOF support.
1532        let p = IkedaCarpenterParams {
1533            alpha: EnergyLaw::SqrtE { a0: 0.3, a1: 0.0 },
1534            beta: EnergyLaw::Const(0.1),
1535            r: EnergyLaw::Const(0.0),
1536            burst_sigma_us: None,
1537            channel_fwhm_us: None,
1538        };
1539        let support = |e: f64| {
1540            let (o, _) = synth_kernel(&p, 600, e).unwrap();
1541            o.iter().cloned().fold(f64::MIN, f64::max) - o.iter().cloned().fold(f64::MAX, f64::min)
1542        };
1543        assert!(support(100.0) < support(5.0));
1544    }
1545
1546    #[test]
1547    fn synthesize_builds_valid_ascending_table() {
1548        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
1549        let grid = SynthesisGrid {
1550            e_min_ev: 0.5e-3,
1551            e_max_ev: 1000.0,
1552            n_energies: 32,
1553            n_tau: 400,
1554        };
1555        let ic = IkedaCarpenter::new(p, 25.0, &grid).expect("synthesis");
1556        assert_eq!(ic.ref_energies().len(), 32);
1557        assert_eq!(ic.tabulated().ref_energies().len(), 32);
1558        for w in ic.ref_energies().windows(2) {
1559            assert!(w[1] > w[0]);
1560        }
1561    }
1562
1563    #[test]
1564    fn rejects_bad_config() {
1565        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
1566        let bad = SynthesisGrid {
1567            e_min_ev: 1.0,
1568            e_max_ev: 0.5,
1569            n_energies: 16,
1570            n_tau: 100,
1571        };
1572        assert!(IkedaCarpenter::new(p.clone(), 25.0, &bad).is_err());
1573        assert!(IkedaCarpenter::new(p, -1.0, &SynthesisGrid::new(1.0, 10.0)).is_err());
1574        // A parameter law that yields α(E) ≤ 0 is rejected, not silently clamped.
1575        let neg_alpha = IkedaCarpenterParams {
1576            alpha: EnergyLaw::Const(-1.0),
1577            ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1578        };
1579        assert!(IkedaCarpenter::new(neg_alpha, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err());
1580        // Negative / non-finite burst or channel widths are rejected up front
1581        // (not `.abs()`-masked or NaN-swallowed during synthesis).
1582        for bad_width in [-1.0, f64::NAN, f64::INFINITY] {
1583            let neg_burst = IkedaCarpenterParams {
1584                burst_sigma_us: Some(bad_width),
1585                ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1586            };
1587            assert!(
1588                IkedaCarpenter::new(neg_burst, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
1589                "burst_sigma_us={bad_width} should be rejected"
1590            );
1591            let neg_chan = IkedaCarpenterParams {
1592                channel_fwhm_us: Some(bad_width),
1593                ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1594            };
1595            assert!(
1596                IkedaCarpenter::new(neg_chan, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
1597                "channel_fwhm_us={bad_width} should be rejected"
1598            );
1599        }
1600    }
1601
1602    #[test]
1603    fn broadens_constant_to_constant() {
1604        // An area-normalized kernel preserves a flat spectrum.
1605        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.3);
1606        let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
1607        let energies: Vec<f64> = (0..400).map(|i| 1.0 + i as f64 * 0.5).collect();
1608        let spectrum = vec![0.7f64; energies.len()];
1609        let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1610        let out = apply_resolution(&energies, &spectrum, &res).unwrap();
1611        // Interior points (away from grid edges where the kernel is clipped).
1612        for v in &out[40..energies.len() - 40] {
1613            assert!((v - 0.7).abs() < 1e-3, "flat broadening drifted: {v}");
1614        }
1615    }
1616
1617    #[test]
1618    fn ic_centering_shifts_broadened_symmetric_dip_with_alpha() {
1619        // The IC kernel anchors its MODE at offset 0, but the right-skewed pulse's
1620        // intensity centroid lags the mode by ~1/α in TOF (module docstring).
1621        // Broadening a symmetric-in-energy resonance therefore moves the dip
1622        // minimum off the nominal energy by an α-dependent amount. This guards the
1623        // documented bias (the loop-closure calibration tests cannot see it) and
1624        // pins it against a silent "fix" (e.g. re-centering on the centroid).
1625        const E0: f64 = 20.0;
1626        // Absorption-weighted centroid of the broadened dip — a robust position
1627        // estimator (the bare minimum is fragile under the wide low-α kernel).
1628        fn dip_centroid_energy(alpha: f64) -> f64 {
1629            // Constant-α kernel so the shape (hence the mode→mean lag) is uniform.
1630            let p = IkedaCarpenterParams::constant(alpha, 0.1, 0.0);
1631            let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
1632            let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1633            // Fine uniform grid; symmetric (in energy) Gaussian dip centered at E0.
1634            let energies: Vec<f64> = (0..4000).map(|i| 10.0 + i as f64 * 0.005).collect();
1635            let spectrum: Vec<f64> = energies
1636                .iter()
1637                .map(|&e| 1.0 - 0.8 * (-((e - E0) / 0.1).powi(2)).exp())
1638                .collect();
1639            let out = apply_resolution(&energies, &spectrum, &res).unwrap();
1640            let (mut num, mut den) = (0.0, 0.0);
1641            for (&e, &t) in energies.iter().zip(&out) {
1642                if (e - E0).abs() <= 2.0 {
1643                    let a = (1.0 - t).max(0.0); // absorption weight
1644                    num += e * a;
1645                    den += a;
1646                }
1647            }
1648            num / den
1649        }
1650        let signed_small_alpha = dip_centroid_energy(0.8) - E0;
1651        let signed_large_alpha = dip_centroid_energy(2.0) - E0;
1652        // The shift is toward LOWER apparent energy: the delayed-emission
1653        // tail gathers theory from earlier TOF (higher E), so the theory
1654        // dip at E0 surfaces at measured energies below E0. A positive
1655        // shift here means the kernel was applied time-mirrored.
1656        assert!(
1657            signed_small_alpha < 0.0 && signed_large_alpha < 0.0,
1658            "centering shift must be toward lower energy: \
1659             α=0.8 {signed_small_alpha:+e}, α=2.0 {signed_large_alpha:+e}"
1660        );
1661        let shift_small_alpha = signed_small_alpha.abs();
1662        let shift_large_alpha = signed_large_alpha.abs();
1663        // The bias is real (resolvable at the 1e-3 eV level)…
1664        assert!(
1665            shift_small_alpha > 1e-3,
1666            "centering shift vanished: {shift_small_alpha}"
1667        );
1668        // …and shrinks with increasing α (the ~1/α scaling of the mode→mean lag).
1669        assert!(
1670            shift_small_alpha > 1.3 * shift_large_alpha,
1671            "shift should scale ~1/α: α=0.8 {shift_small_alpha} vs α=2.0 {shift_large_alpha}"
1672        );
1673    }
1674
1675    #[test]
1676    fn plan_path_matches_direct_path() {
1677        let p = IkedaCarpenterParams::constant(1.2, 0.15, 0.25);
1678        let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
1679        let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1680        let energies: Vec<f64> = (0..300).map(|i| 1.0 + i as f64 * 0.6).collect();
1681        // A localized dip to exercise the convolution non-trivially.
1682        let spectrum: Vec<f64> = energies
1683            .iter()
1684            .map(|&e| if (e - 90.0).abs() < 5.0 { 0.2 } else { 1.0 })
1685            .collect();
1686        let direct = apply_resolution(&energies, &spectrum, &res).unwrap();
1687        let plan = build_resolution_plan(&energies, &res).unwrap();
1688        let planned =
1689            apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &res).unwrap();
1690        assert_eq!(direct.len(), planned.len());
1691        for (a, b) in direct.iter().zip(&planned) {
1692            assert!((a - b).abs() < 1e-12, "plan vs direct mismatch: {a} vs {b}");
1693        }
1694    }
1695
1696    #[test]
1697    fn tau_step_anchors_to_prompt_core_not_storage_tail() {
1698        // Regression for the τ-grid fix: with a slow storage tail active
1699        // (β ≪ α, R > 0) the τ-step must stay anchored to the prompt core —
1700        // the old `tau_max/(n_tau−1)` step let the tail dilate dtau and
1701        // degenerate the 0.35 µs channel triangle toward a delta.
1702        let n_tau = 600;
1703        let alpha = 2.0; // fast_reach = 18/α = 9 µs
1704        let prompt_step = (18.0 / alpha) / (n_tau as f64 - 1.0);
1705
1706        // (a) Moderate tail (β = 0.25 ⇒ slow_reach = 64 µs): the cap does not
1707        // bite and the 0.5 µs triangle's per-bin refinement target
1708        // (FWHM/24 ≈ 0.021 µs) sits above the prompt-core step, so the step
1709        // equals the prompt-core step exactly and the grid still spans the
1710        // full tail.
1711        let p = IkedaCarpenterParams {
1712            channel_fwhm_us: Some(0.5),
1713            ..IkedaCarpenterParams::constant(alpha, 0.25, 0.5)
1714        };
1715        let (offs, wts) = synth_kernel(&p, n_tau, 10.0).unwrap();
1716        let dtau = offs[1] - offs[0];
1717        assert!(
1718            (dtau - prompt_step).abs() < 1e-12,
1719            "uncapped step {dtau} != prompt-core step {prompt_step}"
1720        );
1721        assert!(
1722            dtau <= prompt_step + 1e-12,
1723            "prompt-core spacing {dtau} > fast_reach/(n_tau−1) = {prompt_step}"
1724        );
1725        // ≥ 3 nonzero triangle samples per side at this step.
1726        let tri = triangle_kernel(dtau, 0.5);
1727        let nonzero_per_side = tri.iter().take(tri.len() / 2).filter(|&&v| v > 0.0).count();
1728        assert!(
1729            nonzero_per_side >= 3,
1730            "triangle degenerated: {nonzero_per_side} nonzero samples per side"
1731        );
1732        // The tail is still reached: for β = 0.25 the slow tail crosses the
1733        // TRIM_REL = 1e-7 trim level near τ ≈ 63 µs (e^{−βτ}·slow-amplitude ÷
1734        // peak = 1e-7 at τ ≈ 62.6 µs), so the trimmed kernel must still span
1735        // well past 40 µs — a step anchored to τ_max instead of the prompt
1736        // core would pass a narrow-span check, but a grid that stops short of
1737        // the storage tail would not survive this one.
1738        let span = offs.last().unwrap() - offs.first().unwrap();
1739        let peak = wts.iter().cloned().fold(f64::MIN, f64::max);
1740        assert!((peak - 1.0).abs() < 1e-12, "weights not peak-normalized");
1741        assert!(span > 3.0 / alpha, "kernel span {span} lost the pulse body");
1742        assert!(
1743            span > 40.0,
1744            "kernel span {span} µs stops short of the β = 0.25 storage tail \
1745             (trim horizon ≈ 63 µs)"
1746        );
1747
1748        // (b) Extreme admitted tail (β = 0.02 ⇒ slow_reach = 800 µs): the
1749        // MAX_TAU_SAMPLES cap bites; the step widens to tau_max/(cap−1) but
1750        // must still resolve the 0.35 µs triangle with ≥ 3 samples per side
1751        // (the old tail-anchored step was 800/599 ≈ 1.34 µs — a delta).
1752        let p_ext = IkedaCarpenterParams {
1753            channel_fwhm_us: Some(0.35),
1754            ..IkedaCarpenterParams::constant(alpha, 0.02, 0.5)
1755        };
1756        let (offs_ext, _) = synth_kernel(&p_ext, n_tau, 10.0).unwrap();
1757        let dtau_ext = offs_ext[1] - offs_ext[0];
1758        let capped_step = 800.0 / (MAX_TAU_SAMPLES as f64 - 1.0);
1759        assert!(
1760            (dtau_ext - capped_step).abs() < 1e-9,
1761            "capped step {dtau_ext} != tau_max/(MAX_TAU_SAMPLES−1) = {capped_step}"
1762        );
1763        assert!(
1764            dtau_ext <= 0.35 / 3.0,
1765            "capped step {dtau_ext} cannot resolve the 0.35 µs triangle"
1766        );
1767        let tri_ext = triangle_kernel(dtau_ext, 0.35);
1768        let nonzero_ext = tri_ext
1769            .iter()
1770            .take(tri_ext.len() / 2)
1771            .filter(|&&v| v > 0.0)
1772            .count();
1773        assert!(
1774            nonzero_ext >= 3,
1775            "triangle degenerated under cap: {nonzero_ext} nonzero samples per side"
1776        );
1777    }
1778
1779    #[test]
1780    fn burst_and_channel_broaden_further() {
1781        // Folding in burst + channel widens the kernel TOF support.
1782        let base = IkedaCarpenterParams::constant(1.0, 0.1, 0.0);
1783        let (o0, _) = synth_kernel(&base, 600, 10.0).unwrap();
1784        let support0 = o0.iter().cloned().fold(f64::MIN, f64::max)
1785            - o0.iter().cloned().fold(f64::MAX, f64::min);
1786        let folded = IkedaCarpenterParams {
1787            burst_sigma_us: Some(0.3),
1788            channel_fwhm_us: Some(0.35),
1789            ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1790        };
1791        let (o1, _) = synth_kernel(&folded, 600, 10.0).unwrap();
1792        let support1 = o1.iter().cloned().fold(f64::MIN, f64::max)
1793            - o1.iter().cloned().fold(f64::MAX, f64::min);
1794        assert!(support1 > support0, "folded {support1} !> bare {support0}");
1795    }
1796
1797    /// Sum-weighted variance of a sampled `(offsets, weights)` kernel on a
1798    /// uniform τ-grid. Normalization-independent (divides by Σw).
1799    fn kernel_variance(offsets: &[f64], weights: &[f64]) -> f64 {
1800        let w_sum: f64 = weights.iter().sum();
1801        let mean: f64 = offsets.iter().zip(weights).map(|(o, w)| o * w).sum::<f64>() / w_sum;
1802        offsets
1803            .iter()
1804            .zip(weights)
1805            .map(|(o, w)| (o - mean).powi(2) * w)
1806            .sum::<f64>()
1807            / w_sum
1808    }
1809
1810    #[test]
1811    fn unresolvable_tau_grid_is_rejected_loudly() {
1812        // Review #645 F1, probe 1: β = 0.005 ⇒ slow reach 16/β = 3200 µs ⇒
1813        // capped step 3200/8191 ≈ 0.39 µs > the 0.35 µs triangle FWHM — the
1814        // sampled triangle would be the exact delta [0, 1, 0] and the
1815        // requested fold would silently vanish from the kernel. Must be a
1816        // loud construction error, not silent physics degradation.
1817        let p1 = IkedaCarpenterParams {
1818            channel_fwhm_us: Some(0.35),
1819            ..IkedaCarpenterParams::constant(2.0, 0.005, 0.5)
1820        };
1821        let err = IkedaCarpenter::new(p1.clone(), 25.0, &SynthesisGrid::new(1.0, 100.0))
1822            .expect_err("a delta-degenerate fold must be rejected");
1823        let msg = format!("{err:?}");
1824        assert!(
1825            msg.contains("cannot resolve") && msg.contains("Increase"),
1826            "error must diagnose the cap and name remedies: {msg}"
1827        );
1828        // The per-energy synthesis (kernel_at path) refuses identically.
1829        assert!(synth_kernel(&p1, 600, 10.0).is_err());
1830
1831        // Probe 2: α = 250 ⇒ the whole prompt pulse spans 18/α ≈ 0.07 µs,
1832        // less than ONE capped step (800/8191 ≈ 0.098 µs): sampling would
1833        // step over the prompt term entirely (0.9 of the pulse weight at
1834        // R = 0.1). Must also be rejected.
1835        let p2 = IkedaCarpenterParams::constant(250.0, 0.02, 0.1);
1836        assert!(
1837            IkedaCarpenter::new(p2, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
1838            "a capped step wider than the prompt core must be rejected"
1839        );
1840    }
1841
1842    #[test]
1843    fn requested_fold_is_never_a_silent_no_op() {
1844        // Adjacent to probe 1 but resolvable (β = 0.05 ⇒ capped step
1845        // 320/8191 ≈ 0.039 µs ≤ FWHM/3): when synthesis is Ok and a fold is
1846        // requested, the folded kernel must genuinely differ from the
1847        // unfolded one — by approximately the analytic fold variance
1848        // FWHM²/6 — never by ~0 (the silent delta no-op this guards against).
1849        let base = IkedaCarpenterParams::constant(2.0, 0.05, 0.5);
1850        let folded = IkedaCarpenterParams {
1851            channel_fwhm_us: Some(0.35),
1852            ..base.clone()
1853        };
1854        let (o0, w0) = synth_kernel(&base, 600, 10.0).unwrap();
1855        let (o1, w1) = synth_kernel(&folded, 600, 10.0).unwrap();
1856        let dv = kernel_variance(&o1, &w1) - kernel_variance(&o0, &w0);
1857        let expected = 0.35f64.powi(2) / 6.0;
1858        assert!(
1859            dv > 0.5 * expected,
1860            "fold variance increment {dv} µs² vs analytic {expected} µs²: \
1861             the requested fold (nearly) vanished"
1862        );
1863    }
1864
1865    #[test]
1866    fn boundary_step_at_triangle_floor_is_admitted_and_fold_survives() {
1867        // Pin the exactly-admitted uncapped step `dtau = FWHM /
1868        // TRI_BIN_SAMPLES_PER_SIDE` (the bin-eager refinement target),
1869        // written in terms of the constant so the pin survives value
1870        // changes. For N samples per side the discrete triangle's variance
1871        // is (1 − 1/N²)·FWHM²/6 exactly (N = 3 gives the historical
1872        // 4·FWHM²/27 of the moment-level floor), and each side keeps N − 1
1873        // strictly interior nonzero samples with the endpoint ON the
1874        // triangle zero.
1875        //
1876        // Route to the boundary: n_tau = MIN_N_TAU = 8 with α = 1 gives a
1877        // prompt design step 18/7 ≈ 2.57 µs, far coarser than the target for
1878        // a 1 µs triangle, so the fold refinement pins dtau to exactly the
1879        // target. R = 0 keeps the cap inert (capped step 18/8191 ≪ target).
1880        let n = TRI_BIN_SAMPLES_PER_SIDE;
1881        let fwhm = 1.0;
1882        let p = IkedaCarpenterParams {
1883            channel_fwhm_us: Some(fwhm),
1884            ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1885        };
1886        let (offs, _) = synth_kernel(&p, MIN_N_TAU, 10.0)
1887            .expect("the dtau = floor boundary must be admitted, not rejected");
1888        let dtau = offs[1] - offs[0];
1889        let boundary = fwhm / n;
1890        assert!(
1891            (dtau - boundary).abs() < 1e-12,
1892            "step {dtau} µs is not the FWHM/{n} boundary {boundary} µs"
1893        );
1894
1895        // The sampled triangle at the boundary: N − 1 strictly interior
1896        // nonzero samples per side, center far below the delta's 1.
1897        let tri = triangle_kernel(dtau, fwhm);
1898        let per_side = tri
1899            .iter()
1900            .take(tri.len() / 2)
1901            .filter(|&&v| v > 1e-9)
1902            .count();
1903        assert_eq!(
1904            per_side,
1905            n as usize - 1,
1906            "boundary triangle must keep N − 1 strictly interior nonzero \
1907             samples per side: {tri:?}"
1908        );
1909        let center = tri[tri.len() / 2];
1910        assert!(
1911            center < 0.5,
1912            "center weight {center} — boundary triangle degenerated toward a delta"
1913        );
1914
1915        // Fold effectiveness: discrete variance (1 − 1/N²)·FWHM²/6.
1916        let tri_offs: Vec<f64> = (0..tri.len())
1917            .map(|i| (i as f64 - (tri.len() / 2) as f64) * dtau)
1918            .collect();
1919        let v = kernel_variance(&tri_offs, &tri);
1920        let want = (1.0 - 1.0 / (n * n)) * fwhm * fwhm / 6.0;
1921        assert!(
1922            ((v - want) / want).abs() < 0.02,
1923            "boundary triangle variance {v} µs² vs discrete analytic {want} µs²"
1924        );
1925        assert!(
1926            v > 0.5 * (fwhm * fwhm / 6.0),
1927            "boundary fold variance {v} µs² collapsed below half the \
1928             continuous FWHM²/6 — fold (nearly) vanished"
1929        );
1930    }
1931
1932    #[test]
1933    fn fold_variance_matches_analytic_oracle() {
1934        // Independent analytic oracle for the fold convention (#645 F8): a
1935        // convolution adds second central moments, so the sampled kernel's
1936        // variance must grow by EXACTLY the fold kernel's analytic variance —
1937        // FWHM²/6 for the symmetric channel triangle (half-base = FWHM),
1938        // σ² for the Gaussian burst. Unlike the closed-loop calibration
1939        // tests, the expectation here comes from analysis, not from the
1940        // shared synthesis code. Pure-prompt pulse (R = 0) keeps trim /
1941        // edge-truncation error far below the increments.
1942        let base = IkedaCarpenterParams::constant(2.0, 0.1, 0.0);
1943        let (o0, w0) = synth_kernel(&base, 600, 10.0).unwrap();
1944        let v0 = kernel_variance(&o0, &w0);
1945        // Sanity: the bare Gamma(3, α) variance is 3/α².
1946        let v_gamma = 3.0 / (2.0f64 * 2.0);
1947        assert!(
1948            (v0 - v_gamma).abs() < 0.01 * v_gamma,
1949            "bare pulse variance {v0}, Gamma(3) analytic {v_gamma}"
1950        );
1951
1952        let fwhm = 0.35;
1953        let tri = IkedaCarpenterParams {
1954            channel_fwhm_us: Some(fwhm),
1955            ..base.clone()
1956        };
1957        let (o1, w1) = synth_kernel(&tri, 600, 10.0).unwrap();
1958        let dv_tri = kernel_variance(&o1, &w1) - v0;
1959        let want_tri = fwhm * fwhm / 6.0;
1960        assert!(
1961            ((dv_tri - want_tri) / want_tri).abs() < 0.02,
1962            "triangle fold added {dv_tri} µs², analytic FWHM²/6 = {want_tri} µs²"
1963        );
1964
1965        let sigma = 0.3;
1966        let gau = IkedaCarpenterParams {
1967            burst_sigma_us: Some(sigma),
1968            ..base.clone()
1969        };
1970        let (o2, w2) = synth_kernel(&gau, 600, 10.0).unwrap();
1971        let dv_gau = kernel_variance(&o2, &w2) - v0;
1972        let want_gau = sigma * sigma;
1973        assert!(
1974            ((dv_gau - want_gau) / want_gau).abs() < 0.02,
1975            "Gaussian burst added {dv_gau} µs², analytic σ² = {want_gau} µs²"
1976        );
1977    }
1978}