Skip to main content

nereids_pipeline/
pipeline.rs

1//! Single-spectrum analysis pipeline.
2//!
3//! Orchestrates the full analysis chain for a single transmission spectrum:
4//! ENDF loading → cross-section calculation → broadening → fitting.
5//!
6//! This is the building block for the spatial mapping pipeline.
7//!
8//! Uses `UnifiedFitConfig` with `SolverConfig` and typed `InputData` variants.
9
10use std::fmt;
11use std::sync::Arc;
12
13use nereids_core::constants::{EV_TO_JOULES, NEUTRON_MASS_KG};
14use nereids_endf::resonance::ResonanceData;
15use nereids_fitting::joint_poisson::{self, JointPoissonFitConfig, JointPoissonObjective};
16use nereids_fitting::lm::{self, FitModel, LmConfig, LmResult};
17use nereids_fitting::parameters::{FitParameter, ParameterSet};
18use nereids_fitting::poisson::{self, PoissonConfig};
19use nereids_fitting::transmission_model::{
20    EnergyScaleTransmissionModel, NormalizedTransmissionModel, PrecomputedTransmissionModel,
21    TransmissionFitModel,
22};
23use nereids_physics::resolution::ResolutionFunction;
24use nereids_physics::transmission::InstrumentParams;
25
26use crate::error::PipelineError;
27
28/// Working-grid σ + its layout (issue #608): Doppler-broadened σ on the working
29/// grid paired with the data-index map back to the data grid.  Injected by
30/// [`spatial_map_typed`] into [`UnifiedFitConfig`] for the precomputed
31/// Gaussian-resolution path.
32type PrecomputedWorkXs = (
33    Arc<Vec<Vec<f64>>>,
34    Arc<nereids_physics::transmission::WorkingGridLayout>,
35);
36
37/// SAMMY-style normalization and background configuration.
38///
39/// When enabled, the transmission model becomes:
40///   T_out(E) = Anorm × T_inner(E) + BackA + BackB / √E + BackC × √E
41///            + BackD × exp(−BackF / √E)
42///
43/// The first 4 background parameters (Anorm, BackA, BackB, BackC) are always
44/// available.  The exponential tail (BackD, BackF) is optional and disabled
45/// by default (`fit_back_d = false`, `fit_back_f = false`).
46///
47/// ## SAMMY Reference
48/// SAMMY manual Sec III.E.2 — NORMAlization and BACKGround cards.
49/// SAMMY fits up to 6 background terms; we implement all 6.
50#[derive(Debug, Clone)]
51pub struct BackgroundConfig {
52    /// Initial value for the normalization factor (default 1.0).
53    pub anorm_init: f64,
54    /// Initial value for the constant background (default 0.0).
55    pub back_a_init: f64,
56    /// Initial value for the 1/√E background term (default 0.0).
57    pub back_b_init: f64,
58    /// Initial value for the √E background term (default 0.0).
59    pub back_c_init: f64,
60    /// Initial value for the exponential amplitude (default 0.01).
61    ///
62    /// Must be > 0 when `fit_back_f` is true, otherwise the Jacobian
63    /// column for BackF is identically zero and BackF cannot be learned.
64    pub back_d_init: f64,
65    /// Initial value for the exponential decay constant (default 1.0).
66    ///
67    /// Units: √eV.  Must be > 0 when `fit_back_d` is true, otherwise
68    /// BackD is indistinguishable from BackA (both become constants).
69    pub back_f_init: f64,
70    /// Whether Anorm is free (true) or fixed (false).
71    pub fit_anorm: bool,
72    /// Whether BackA is free (true) or fixed (false).
73    pub fit_back_a: bool,
74    /// Whether BackB is free (true) or fixed (false).
75    pub fit_back_b: bool,
76    /// Whether BackC is free (true) or fixed (false).
77    pub fit_back_c: bool,
78    /// Whether BackD (exponential amplitude) is free (true) or fixed (false).
79    pub fit_back_d: bool,
80    /// Whether BackF (exponential decay constant) is free (true) or fixed (false).
81    pub fit_back_f: bool,
82}
83
84impl Default for BackgroundConfig {
85    fn default() -> Self {
86        Self {
87            anorm_init: 1.0,
88            back_a_init: 0.0,
89            back_b_init: 0.0,
90            back_c_init: 0.0,
91            back_d_init: 0.01,
92            back_f_init: 1.0,
93            fit_anorm: true,
94            fit_back_a: true,
95            fit_back_b: true,
96            fit_back_c: true,
97            fit_back_d: false,
98            fit_back_f: false,
99        }
100    }
101}
102
103/// Indices of SAMMY background parameters in the full parameter vector.
104///
105/// Replaces the previous 4-tuple representation and supports the optional
106/// exponential tail terms (BackD, BackF).
107#[derive(Debug, Clone, Copy)]
108struct BackgroundIndices {
109    anorm: usize,
110    back_a: usize,
111    back_b: usize,
112    back_c: usize,
113    back_d: Option<usize>,
114    back_f: Option<usize>,
115}
116
117// ── New typed pipeline API (Phase 0) ─────────────────────────────────────
118
119/// Typed input data — makes the data format explicit at the API boundary.
120///
121/// The two variants carry genuinely different data:
122/// - **Counts**: raw detector counts + open beam → Poisson statistics native
123/// - **Transmission**: normalized T = sample/open_beam + uncertainty → Gaussian statistics
124///
125/// `spatial_map_typed` dispatches to the correct fitting engine based on
126/// which variant is provided.  This eliminates the overloaded positional
127/// arguments that caused silent misinterpretation in the old API.
128#[derive(Debug, Clone)]
129pub enum InputData {
130    /// Pre-normalized transmission with Gaussian uncertainties.
131    ///
132    /// Use with LM (default) or Poisson KL (opt-in for low-count T data).
133    Transmission {
134        /// Measured transmission T(E), values typically in [0, 2].
135        transmission: Vec<f64>,
136        /// Per-bin uncertainty σ_T(E).
137        uncertainty: Vec<f64>,
138    },
139    /// Raw detector counts with open beam reference.
140    ///
141    /// Always uses Poisson KL (statistically optimal for count data).
142    /// The fitting engine works directly on counts — no information-losing
143    /// normalization to transmission.
144    Counts {
145        /// Sample counts per energy bin.
146        sample_counts: Vec<f64>,
147        /// Open-beam counts per energy bin (normalization reference).
148        open_beam_counts: Vec<f64>,
149    },
150    /// Counts with pre-estimated nuisance parameters (power users).
151    ///
152    /// Use when you want to inspect or override the flux estimate before fitting.
153    CountsWithNuisance {
154        /// Sample counts per energy bin.
155        sample_counts: Vec<f64>,
156        /// Estimated flux spectrum (from open beam spatial average).
157        flux: Vec<f64>,
158        /// Estimated detector background spectrum.
159        background: Vec<f64>,
160    },
161}
162
163impl InputData {
164    /// Number of energy bins.
165    pub fn n_energies(&self) -> usize {
166        match self {
167            Self::Transmission { transmission, .. } => transmission.len(),
168            Self::Counts { sample_counts, .. } => sample_counts.len(),
169            Self::CountsWithNuisance { sample_counts, .. } => sample_counts.len(),
170        }
171    }
172
173    /// Whether this is count data (Poisson-native).
174    pub fn is_counts(&self) -> bool {
175        matches!(self, Self::Counts { .. } | Self::CountsWithNuisance { .. })
176    }
177}
178
179/// Solver-specific configuration.
180///
181/// Carries the full solver config inside each variant, making invalid
182/// combinations unrepresentable.
183#[derive(Debug, Clone, Default)]
184pub enum SolverConfig {
185    /// Levenberg-Marquardt chi-squared minimizer (transmission path).
186    LevenbergMarquardt(LmConfig),
187    /// Poisson-KL counts-domain fitter.
188    ///
189    /// For **counts** inputs this dispatches to the joint-Poisson profile-
190    /// binomial-deviance path (`joint_poisson_fit`) validated in memo 35
191    /// §P1/§P2 and memo 38.  Uses an explicit `c = Q_s/Q_ob` from
192    /// `CountsBackgroundConfig::c` and reports `D/(n − k)` as the primary
193    /// GOF.  Stage-1 damped Fisher + optional Nelder-Mead polish (see
194    /// [`nereids_fitting::joint_poisson::JointPoissonFitConfig`]).
195    ///
196    /// For **transmission** inputs this dispatches to Poisson NLL on the
197    /// transmission values directly (legacy path, unchanged).  The payload
198    /// `PoissonConfig` carries `max_iter` common to both dispatches.
199    PoissonKL(PoissonConfig),
200    /// Automatic: Counts → PoissonKL (counts-domain joint-Poisson),
201    /// Transmission → LM.
202    #[default]
203    Auto,
204}
205
206/// Background model for the counts fitting engine.
207///
208/// In the counts domain, the forward model is:
209///   Y(E) = α₁ · [Φ(E) · exp(-Σ nᵢσᵢ(E))] + α₂ · B(E)
210///
211/// where Φ(E) is the incident flux and B(E) is detector/gamma background.
212/// The reference Φ(E) / B(E) spectra are supplied by the caller or by
213/// spatial pre-processing; this config only controls the fitted scale factors.
214///
215/// Important distinction:
216/// - This is a detector-space counts background model `B(E)`.
217/// - It is NOT the same as the transmission-lift background used by
218///   `BackgroundConfig`, which models additive uplift of the apparent
219///   transmission curve (for example gamma-tail structure that pushes
220///   transmission upward).
221///
222/// For VENUS MCP/TPX event detectors, the current working assumption is:
223/// - raw/open-beam is the correct normalization baseline
224/// - dark-current / CCD-style electronic offset is not modeled
225/// - rare ghost counts may exist at the hardware level, but are currently
226///   treated as negligible unless a detector-background reference spectrum
227///   is explicitly provided
228///
229/// This is structurally different from the transmission background model
230/// ([`BackgroundConfig`]) because:
231/// - Φ and B are reference spectra, not fitted per pixel
232/// - α₁ and α₂ are optional per-pixel scale corrections
233/// - All terms are non-negative (required for valid Poisson NLL)
234#[derive(Debug, Clone)]
235pub struct CountsBackgroundConfig {
236    /// **Research-only.** Initial α₁ flux-scale value used by
237    /// [`crate::pipeline::evaluate_jacobian_and_fisher`] (Fisher-info
238    /// research helper, Epic #394).  Not honoured by the production fit
239    /// path; `SolverConfig::PoissonKL` profiles the flux via `λ̂` and
240    /// rejects alpha fitting.
241    pub alpha_1_init: f64,
242    /// **Research-only.** Initial α₂ detector-bg-scale value, same
243    /// provisions as `alpha_1_init`.
244    pub alpha_2_init: f64,
245    /// **Research-only.** Fit α₁ flag — only consumed by
246    /// `evaluate_jacobian_and_fisher`.  Passing `true` through
247    /// `SolverConfig::PoissonKL` on counts input yields an error.
248    pub fit_alpha_1: bool,
249    /// **Research-only.** Fit α₂ flag — see `fit_alpha_1`.
250    pub fit_alpha_2: bool,
251    /// Proton-charge ratio `c = Q_s / Q_ob` for the counts-KL solver
252    /// (memo 35 §P1.3 — "make `c` a first-class API parameter").
253    ///
254    /// Default `1.0`, correct only when the caller has already PC-
255    /// normalized the open-beam counts so that `flux = c · O`.  For the
256    /// counts-KL dispatch (`SolverConfig::PoissonKL` on counts input),
257    /// set this to the actual `Q_sample / Q_open_beam` ratio and pass
258    /// raw open-beam counts — the joint-Poisson solver will profile
259    /// `λ̂` itself.  Ignored by LM paths.
260    pub c: f64,
261}
262
263impl Default for CountsBackgroundConfig {
264    fn default() -> Self {
265        Self {
266            alpha_1_init: 1.0,
267            alpha_2_init: 1.0,
268            fit_alpha_1: false,
269            fit_alpha_2: false,
270            c: 1.0,
271        }
272    }
273}
274
275// ── Phase 2: UnifiedFitConfig + fit_spectrum_typed ───────────────────────
276
277/// Unified fit configuration for all data types and solvers.
278///
279/// Carries both transmission and counts background configs, and uses
280/// [`SolverConfig`] (which embeds solver-specific tuning).
281#[derive(Debug, Clone)]
282pub struct UnifiedFitConfig {
283    // ── Physics (shared by both engines) ──
284    energies: Vec<f64>,
285    resonance_data: Vec<ResonanceData>,
286    isotope_names: Vec<String>,
287    temperature_k: f64,
288    resolution: Option<ResolutionFunction>,
289    initial_densities: Vec<f64>,
290    fit_temperature: bool,
291    compute_covariance: bool,
292
293    // ── Solver ──
294    solver: SolverConfig,
295
296    // ── Background models (engine-specific) ──
297    /// SAMMY-style background for the transmission engine.
298    transmission_background: Option<BackgroundConfig>,
299    /// Counts-domain background for the counts engine.
300    counts_background: Option<CountsBackgroundConfig>,
301
302    // ── Joint-Poisson solver knobs (counts path only) ──
303    /// When `Some(false)`, the counts-KL dispatch disables Nelder-Mead polish
304    /// (stage-1 damped Fisher only).  When `Some(true)`, polish is forced on
305    /// regardless of context.  When `None`, the dispatcher picks a default:
306    /// polish on for single-spectrum fits, off for per-pixel spatial maps
307    /// (memo 38 §6 recommendation — 17 min polish per pixel is untenable).
308    counts_enable_polish: Option<bool>,
309
310    // ── Precomputed caches (injected by spatial_map_typed) ──
311    precomputed_cross_sections: Option<Arc<Vec<Vec<f64>>>>,
312    /// Doppler-broadened σ on the **working grid** + the working-grid layout,
313    /// injected by [`spatial_map_typed`] for the fixed-calibration /
314    /// fixed-temperature precomputed path (issue #608).
315    ///
316    /// When a Gaussian resolution function is active, the working grid is the
317    /// auxiliary extended grid (boundary extension + resonance fine-structure);
318    /// storing σ there lets each per-pixel [`PrecomputedTransmissionModel`]
319    /// apply Beer-Lambert + resolution on the working grid and extract the data
320    /// points last — matching `forward_model`.  For tabulated / no resolution
321    /// the working grid IS the data grid and this is `None` (the model uses the
322    /// data-grid `precomputed_cross_sections` directly, preserving the cubature
323    /// / scalar surrogate fast paths).
324    ///
325    /// `precomputed_cross_sections` still carries the **data-grid** σ for the
326    /// surrogate-plan builders and shape validation; this field is the separate
327    /// working-grid copy consumed only by `build_transmission_model`'s
328    /// precomputed branch.
329    precomputed_work_cross_sections: Option<PrecomputedWorkXs>,
330    precomputed_base_xs: Option<Arc<Vec<Vec<f64>>>>,
331    /// Resolution broadening plan built once for `(energies, resolution)`.
332    ///
333    /// Populated by [`spatial_map_typed`] when the data grid is shared
334    /// across every pixel, so each per-pixel fit reuses a single
335    /// hoisted TOF / kernel-interpolation / bracket table instead of
336    /// rebuilding it every broadening call.  `None` ⇒ the fit-model
337    /// layer falls back to the per-call broadening path with byte-
338    /// identical output.
339    precomputed_resolution_plan: Option<Arc<nereids_physics::resolution::ResolutionPlan>>,
340    /// Sparse empirical cubature plan built once for `(energies,
341    /// isotope_set, density_box)` — when present and the dispatch
342    /// conditions hold (k ≥ 2, fixed calibration, fixed temperature),
343    /// the fit model replaces `exp(-Σ n σ) + apply_resolution` with
344    /// `cubature.forward_and_jacobian(n)` directly.  See epic #472.
345    ///
346    /// `None` ⇒ existing path.  The cubature is advisory — if its
347    /// target grid doesn't match `energies` or if `k == 1` or
348    /// temperature/energy-scale fitting is active, the fit model
349    /// silently falls back to the exact path.
350    precomputed_sparse_cubature_plan:
351        Option<Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>>,
352    /// Scalar (k = 1) surrogate plan for the grouped-Hf / single-
353    /// isotope path.  Parallel to `precomputed_sparse_cubature_plan`
354    /// but dispatches at `k == 1`.  Epic #472, PR #475.
355    precomputed_sparse_scalar_plan: Option<Arc<nereids_physics::surrogate::ScalarSurrogatePlan>>,
356
357    // ── Energy-scale calibration (SAMMY TZERO equivalent) ──
358    /// When true, fit t₀ (μs) and L_scale (dimensionless) parameters.
359    /// These adjust the energy axis during fitting:
360    ///   E_corr = (TOF_FACTOR * L * L_scale / (t_nom - t₀))²
361    fit_energy_scale: bool,
362    /// Initial t₀ value in microseconds (default 0.0).
363    t0_init_us: f64,
364    /// Initial L_scale value (dimensionless, default 1.0).
365    l_scale_init: f64,
366    /// Flight path in meters for TOF↔energy conversion (default from resolution or 25.0).
367    flight_path_m: f64,
368    /// Method for the t0 / L_scale Jacobian columns. `None` defers to
369    /// `EnergyScaleJacobianMethod::from_env`: it uses the
370    /// `NEREIDS_TZERO_JACOBIAN` env var when set, and otherwise falls
371    /// back to `PartialGal` (the default since issue #489).
372    /// `Some(_)` bypasses both the env var and the default.
373    tzero_jacobian_method: Option<nereids_fitting::transmission_model::EnergyScaleJacobianMethod>,
374
375    // ── Fit energy range restriction (SAMMY EMIN/EMAX equivalent) ──
376    /// User-specified fit-energy-range restriction.  When `Some((min,
377    /// max))`, the configured `energies` grid is expected to extend
378    /// beyond `[min, max]` by a kernel-margin (~5×FWHM); the GUI / pre-
379    /// processing layer slices the input data to that extended grid
380    /// before constructing this config.  The solver cost functions
381    /// (LM transmission and joint-Poisson PBD) mask residuals outside
382    /// `[min, max]` to zero so resonance contributions just inside the
383    /// boundaries are correctly broadened.
384    ///
385    /// `None` (default) = full grid, no masking.  Reduced χ² / dof
386    /// counts are computed against the active bin count when masking
387    /// is in effect.  SAMMY equivalent: the `EMIN` / `EMAX` analysis
388    /// limits (INPut-file card set 2, manual Table VI A.1); the kernel
389    /// margin follows the same endpoint-extension principle as SAMMY's
390    /// auxiliary grid (general construction Sec. III.A.2(c); the
391    /// quantitative `[Emin − Wmin, Emax + Wmax]` statement is in the
392    /// Leal-Hwang procedure, Sec. III.B.2), with a deliberately
393    /// conservative 5×FWHM margin.
394    fit_energy_range: Option<(f64, f64)>,
395
396    // ── Isotope group mapping (optional) ──
397    /// Maps member isotope index → density parameter index.
398    /// `None` = identity mapping (one param per isotope, backward compat).
399    pub(crate) density_indices: Option<Vec<usize>>,
400    /// Fractional ratio per member isotope.
401    /// `None` = all 1.0 (backward compat).
402    pub(crate) density_ratios: Option<Vec<f64>>,
403    /// Number of density parameters (groups or isotopes).
404    /// `None` = `resonance_data.len()` (backward compat).
405    n_density_params: Option<usize>,
406}
407
408impl UnifiedFitConfig {
409    /// Construct a new config with validation.
410    pub fn new(
411        energies: Vec<f64>,
412        resonance_data: Vec<ResonanceData>,
413        isotope_names: Vec<String>,
414        temperature_k: f64,
415        resolution: Option<ResolutionFunction>,
416        initial_densities: Vec<f64>,
417    ) -> Result<Self, FitConfigError> {
418        if energies.is_empty() {
419            return Err(FitConfigError::EmptyEnergies);
420        }
421        if resonance_data.is_empty() {
422            return Err(FitConfigError::EmptyResonanceData);
423        }
424        if initial_densities.len() != resonance_data.len() {
425            return Err(FitConfigError::DensityCountMismatch {
426                densities: initial_densities.len(),
427                isotopes: resonance_data.len(),
428            });
429        }
430        if isotope_names.len() != resonance_data.len() {
431            return Err(FitConfigError::NameCountMismatch {
432                names: isotope_names.len(),
433                isotopes: resonance_data.len(),
434            });
435        }
436        if !temperature_k.is_finite() {
437            return Err(FitConfigError::NonFiniteTemperature(temperature_k));
438        }
439        if temperature_k < 0.0 {
440            return Err(FitConfigError::NegativeTemperature(temperature_k));
441        }
442        Ok(Self {
443            energies,
444            resonance_data,
445            isotope_names,
446            temperature_k,
447            resolution,
448            initial_densities,
449            fit_temperature: false,
450            compute_covariance: true,
451            solver: SolverConfig::Auto,
452            transmission_background: None,
453            counts_background: None,
454            counts_enable_polish: None,
455            precomputed_cross_sections: None,
456            precomputed_work_cross_sections: None,
457            precomputed_base_xs: None,
458            precomputed_resolution_plan: None,
459            precomputed_sparse_cubature_plan: None,
460            precomputed_sparse_scalar_plan: None,
461            fit_energy_scale: false,
462            t0_init_us: 0.0,
463            l_scale_init: 1.0,
464            flight_path_m: 25.0,
465            density_indices: None,
466            density_ratios: None,
467            n_density_params: None,
468            tzero_jacobian_method: None,
469            fit_energy_range: None,
470        })
471    }
472
473    /// Override the method used for the t0 / L_scale Jacobian columns
474    /// in `EnergyScaleTransmissionModel`. `None` (default) defers to
475    /// the model's own default selection via
476    /// `EnergyScaleJacobianMethod::from_env`: `PartialGal` since issue
477    /// #489 unless overridden by `NEREIDS_TZERO_JACOBIAN`.
478    #[must_use]
479    pub fn with_tzero_jacobian_method(
480        mut self,
481        method: Option<nereids_fitting::transmission_model::EnergyScaleJacobianMethod>,
482    ) -> Self {
483        self.tzero_jacobian_method = method;
484        self
485    }
486
487    // ── Builder methods ──
488
489    #[must_use]
490    pub fn with_solver(mut self, solver: SolverConfig) -> Self {
491        self.solver = solver;
492        self
493    }
494
495    #[must_use]
496    pub fn with_fit_temperature(mut self, v: bool) -> Self {
497        self.fit_temperature = v;
498        self
499    }
500
501    #[must_use]
502    pub fn with_compute_covariance(mut self, v: bool) -> Self {
503        self.compute_covariance = v;
504        self
505    }
506
507    /// Enable energy-scale fitting (SAMMY TZERO equivalent).
508    ///
509    /// Adds t₀ (μs) and L_scale (dimensionless) as fit parameters.
510    /// These adjust the energy axis during fitting to correct for
511    /// flight-path and timing-offset uncertainties.
512    #[must_use]
513    pub fn with_energy_scale(
514        mut self,
515        t0_init_us: f64,
516        l_scale_init: f64,
517        flight_path_m: f64,
518    ) -> Self {
519        self.fit_energy_scale = true;
520        self.t0_init_us = t0_init_us;
521        self.l_scale_init = l_scale_init;
522        self.flight_path_m = flight_path_m;
523        self
524    }
525
526    /// Restrict the fit cost function to bins inside `[min_eV, max_eV]`
527    /// (SAMMY EMIN/EMAX equivalent).  The configured `energies` grid is
528    /// expected to extend by ~5×FWHM beyond `[min, max]` on each side
529    /// (the GUI / pre-processing layer handles this); the LM and
530    /// joint-Poisson cost paths mask residuals outside `[min, max]` to
531    /// zero so resonance broadening at the boundaries is correct.
532    /// `None` (default) = full grid, no masking.
533    ///
534    /// Validates the range up-front so non-finite or reversed bounds
535    /// (which would silently produce an empty active-bin mask and a
536    /// deceptive fit) are rejected at config-build time rather than
537    /// surfacing as a downstream solver error.
538    pub fn with_fit_energy_range(
539        mut self,
540        range: Option<(f64, f64)>,
541    ) -> Result<Self, FitConfigError> {
542        if let Some((lo, hi)) = range {
543            if !lo.is_finite() || !hi.is_finite() {
544                return Err(FitConfigError::InvalidFitEnergyRange(
545                    "fit_energy_range bounds must be finite",
546                ));
547            }
548            if lo >= hi {
549                return Err(FitConfigError::InvalidFitEnergyRange(
550                    "fit_energy_range min must be strictly less than max",
551                ));
552            }
553        }
554        self.fit_energy_range = range;
555        Ok(self)
556    }
557
558    #[must_use]
559    pub fn with_transmission_background(mut self, bg: BackgroundConfig) -> Self {
560        self.transmission_background = Some(bg);
561        self
562    }
563
564    #[must_use]
565    pub fn with_counts_background(mut self, bg: CountsBackgroundConfig) -> Self {
566        self.counts_background = Some(bg);
567        self
568    }
569
570    /// Override the Nelder-Mead polish flag for the counts-KL dispatch.
571    /// `Some(true)` forces polish on, `Some(false)` forces it off, `None`
572    /// (the default) lets the dispatcher pick (polish on for single-spectrum,
573    /// off for spatial maps per memo 38 §6).
574    #[must_use]
575    pub fn with_counts_enable_polish(mut self, v: Option<bool>) -> Self {
576        self.counts_enable_polish = v;
577        self
578    }
579
580    #[must_use]
581    pub fn with_precomputed_cross_sections(mut self, xs: Arc<Vec<Vec<f64>>>) -> Self {
582        self.precomputed_cross_sections = Some(xs);
583        // A new XS cache invalidates any prebuilt cubature — the
584        // cubature's atoms encode the OLD σ stack, so reusing the
585        // plan would silently produce wrong forward / Jacobian
586        // values for the new σ (Codex round-3 P3 on PR #480).
587        self.precomputed_sparse_cubature_plan = None;
588        self.precomputed_sparse_scalar_plan = None;
589        // A new data-grid σ also invalidates the working-grid σ copy
590        // (issue #608): it was Doppler-broadened from the OLD σ on the
591        // OLD grid layout, so reusing it would mix grids.
592        self.precomputed_work_cross_sections = None;
593        self
594    }
595
596    /// Attach the **working-grid** Doppler-broadened σ + its layout for the
597    /// fixed-calibration / fixed-temperature precomputed path (issue #608).
598    ///
599    /// When set, `build_transmission_model` builds a
600    /// [`PrecomputedTransmissionModel`] whose σ live on the working grid and
601    /// whose `evaluate` / `analytical_jacobian` apply resolution on the working
602    /// grid and extract the data points last — matching `forward_model`.  The
603    /// data-grid `precomputed_cross_sections` must still be set (for the
604    /// surrogate-plan builders and shape validation); for tabulated / no
605    /// resolution the working grid equals the data grid and this is left
606    /// `None`.
607    ///
608    /// The σ + layout are not validated here (this is an infallible builder
609    /// setter); shape/consistency are checked once up front in
610    /// `validate_precomputed_cross_sections`, which every public entry point
611    /// (`fit_spectrum_typed`, `fit_transmission_poisson`, `spatial_map_typed`)
612    /// calls before any forward-model build or per-pixel loop — mirroring how
613    /// [`Self::with_precomputed_cross_sections`] is validated.
614    #[must_use]
615    pub fn with_precomputed_work_cross_sections(
616        mut self,
617        xs: Arc<Vec<Vec<f64>>>,
618        layout: Arc<nereids_physics::transmission::WorkingGridLayout>,
619    ) -> Self {
620        self.precomputed_work_cross_sections = Some((xs, layout));
621        self
622    }
623
624    #[must_use]
625    pub fn with_precomputed_base_xs(mut self, xs: Arc<Vec<Vec<f64>>>) -> Self {
626        self.precomputed_base_xs = Some(xs);
627        // Base XS swap implies σ will be re-Doppler-broadened, so
628        // the cubature's σ stack becomes stale.  Invalidate for
629        // the same reason as `with_precomputed_cross_sections`.
630        self.precomputed_sparse_cubature_plan = None;
631        self.precomputed_sparse_scalar_plan = None;
632        self
633    }
634
635    /// Attach a prebuilt resolution plan for the config's energy grid.
636    ///
637    /// The caller (typically `spatial_map_typed`) must ensure that
638    /// `plan.target_energies()` equals `self.energies()`, otherwise
639    /// the fit-model layer will return either a length-mismatch
640    /// error or `ResolutionError::PlanGridMismatch` (for a different
641    /// same-length grid) on the first broadening call.
642    #[must_use]
643    pub fn with_precomputed_resolution_plan(
644        mut self,
645        plan: Arc<nereids_physics::resolution::ResolutionPlan>,
646    ) -> Self {
647        self.precomputed_resolution_plan = Some(plan);
648        self
649    }
650
651    /// Attach a prebuilt sparse empirical cubature plan for the
652    /// config's energy grid + isotope set (see epic #472).
653    ///
654    /// The plan is advisory — the fit model falls back to the exact
655    /// `ResolutionPlan` path when any of these guards fire:
656    ///
657    /// * `plan.target_energies() != self.energies()` (grid mismatch).
658    /// * `plan.k() != n_density_params` (isotope-set mismatch).
659    /// * `self.fit_temperature == true` (σ changes → atoms stale).
660    /// * `self.fit_energy_scale == true` (grid changes → plan stale).
661    /// * `n_density_params == 1` (scalar fast-path belongs to PR #475;
662    ///   for now this path still falls back).
663    ///
664    /// Callers (typically `spatial_map_typed`) are responsible for
665    /// ensuring the plan was built against compatible `sigmas` /
666    /// `training_densities` / `jacobian_anchor`; the fit model cannot
667    /// re-check those at dispatch time.
668    #[must_use]
669    pub fn with_precomputed_sparse_cubature_plan(
670        mut self,
671        plan: Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>,
672    ) -> Self {
673        self.precomputed_sparse_cubature_plan = Some(plan);
674        self
675    }
676
677    /// Attach a prebuilt scalar (k = 1) surrogate plan.  Epic #472,
678    /// PR #475.  Same invalidation discipline as the cubature: the
679    /// plan is cleared on `with_groups` / `with_precomputed_cross_sections`
680    /// / `with_precomputed_base_xs`, so a stale σ cannot silently
681    /// dispatch.
682    #[must_use]
683    pub fn with_precomputed_sparse_scalar_plan(
684        mut self,
685        plan: Arc<nereids_physics::surrogate::ScalarSurrogatePlan>,
686    ) -> Self {
687        self.precomputed_sparse_scalar_plan = Some(plan);
688        self
689    }
690
691    /// Configure isotope groups with ratio constraints.
692    ///
693    /// Each group binds multiple isotopes to one fitted density parameter.
694    /// `groups` is a slice of `(IsotopeGroup, member_resonance_data)` pairs.
695    /// `initial_densities` must have one entry per group.
696    ///
697    /// Replaces the existing per-isotope configuration with the expanded
698    /// group mapping (flattened resonance_data + density_indices + density_ratios).
699    pub fn with_groups(
700        mut self,
701        groups: &[(&nereids_core::types::IsotopeGroup, &[ResonanceData])],
702        initial_densities: Vec<f64>,
703    ) -> Result<Self, FitConfigError> {
704        if groups.is_empty() {
705            return Err(FitConfigError::EmptyResonanceData);
706        }
707        if initial_densities.len() != groups.len() {
708            return Err(FitConfigError::DensityCountMismatch {
709                densities: initial_densities.len(),
710                isotopes: groups.len(),
711            });
712        }
713        let mut all_resonance_data = Vec::new();
714        let mut all_indices = Vec::new();
715        let mut all_ratios = Vec::new();
716        let mut names = Vec::new();
717        for (g_idx, (group, rd_list)) in groups.iter().enumerate() {
718            if rd_list.len() != group.n_members() {
719                return Err(FitConfigError::GroupMemberCountMismatch {
720                    group_name: group.name().to_string(),
721                    rd_count: rd_list.len(),
722                    member_count: group.n_members(),
723                });
724            }
725            names.push(group.name().to_string());
726            for ((isotope, ratio), rd) in group.members().iter().zip(rd_list.iter()) {
727                // Validate that the ResonanceData matches the expected member isotope.
728                if rd.isotope != *isotope {
729                    return Err(FitConfigError::GroupMemberIsotopeMismatch {
730                        group_name: group.name().to_string(),
731                        expected_z: isotope.z(),
732                        expected_a: isotope.a(),
733                        got_z: rd.isotope.z(),
734                        got_a: rd.isotope.a(),
735                    });
736                }
737                all_resonance_data.push(rd.clone());
738                all_indices.push(g_idx);
739                all_ratios.push(*ratio);
740            }
741        }
742        self.resonance_data = all_resonance_data;
743        self.isotope_names = names;
744        self.initial_densities = initial_densities;
745        self.n_density_params = Some(groups.len());
746        self.density_indices = Some(all_indices);
747        self.density_ratios = Some(all_ratios);
748        // Clear stale caches — the isotope set changed.
749        self.precomputed_cross_sections = None;
750        self.precomputed_work_cross_sections = None;
751        self.precomputed_base_xs = None;
752        // Clear the cubature plan too: atoms are σ-coordinates in
753        // ℝ^k and `k` / σ-stack change when groups are reconfigured,
754        // so a stale plan would silently produce wrong forward /
755        // Jacobian values for the new isotope set even if
756        // `cubature_eligible` accepts the same k + grid (Codex round 1
757        // P2 on PR #480).
758        self.precomputed_sparse_cubature_plan = None;
759        self.precomputed_sparse_scalar_plan = None;
760        Ok(self)
761    }
762
763    // ── Accessors ──
764
765    /// Caller-attached sparse empirical cubature plan, if any.
766    /// `spatial_map_typed` reads this so a pre-existing plan is
767    /// preserved instead of being clobbered by the local rebuild
768    /// pathway (Codex round-5 P3 on PR #480).
769    pub fn precomputed_sparse_cubature_plan(
770        &self,
771    ) -> Option<&Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>> {
772        self.precomputed_sparse_cubature_plan.as_ref()
773    }
774
775    /// Caller-attached scalar (k = 1) surrogate plan, if any.  Epic
776    /// #472, PR #475.
777    pub fn precomputed_sparse_scalar_plan(
778        &self,
779    ) -> Option<&Arc<nereids_physics::surrogate::ScalarSurrogatePlan>> {
780        self.precomputed_sparse_scalar_plan.as_ref()
781    }
782
783    pub fn energies(&self) -> &[f64] {
784        &self.energies
785    }
786    pub fn resonance_data(&self) -> &[ResonanceData] {
787        &self.resonance_data
788    }
789    pub fn isotope_names(&self) -> &[String] {
790        &self.isotope_names
791    }
792    pub fn temperature_k(&self) -> f64 {
793        self.temperature_k
794    }
795    pub fn resolution(&self) -> Option<&ResolutionFunction> {
796        self.resolution.as_ref()
797    }
798    pub fn initial_densities(&self) -> &[f64] {
799        &self.initial_densities
800    }
801    pub fn solver(&self) -> &SolverConfig {
802        &self.solver
803    }
804    pub fn fit_temperature(&self) -> bool {
805        self.fit_temperature
806    }
807    pub fn transmission_background(&self) -> Option<&BackgroundConfig> {
808        self.transmission_background.as_ref()
809    }
810    pub fn counts_background(&self) -> Option<&CountsBackgroundConfig> {
811        self.counts_background.as_ref()
812    }
813    /// Counts-KL polish override (see [`Self::with_counts_enable_polish`]).
814    pub fn counts_enable_polish(&self) -> Option<bool> {
815        self.counts_enable_polish
816    }
817    /// Whether SAMMY TZERO energy-scale calibration is enabled
818    /// (see [`Self::with_energy_scale`]).
819    pub fn fit_energy_scale(&self) -> bool {
820        self.fit_energy_scale
821    }
822    /// User-specified fit-energy-range restriction (SAMMY EMIN/EMAX
823    /// equivalent), or `None` for full-grid fitting.
824    /// See [`Self::with_fit_energy_range`].
825    pub fn fit_energy_range(&self) -> Option<(f64, f64)> {
826        self.fit_energy_range
827    }
828    pub fn precomputed_cross_sections(&self) -> Option<&Arc<Vec<Vec<f64>>>> {
829        self.precomputed_cross_sections.as_ref()
830    }
831    /// Number of density parameters (one per group or per isotope).
832    pub fn n_density_params(&self) -> usize {
833        self.n_density_params.unwrap_or(self.resonance_data.len())
834    }
835
836    /// Resolve `SolverConfig::Auto` into a concrete solver for the given input.
837    pub(crate) fn effective_solver(&self, input: &InputData) -> SolverConfig {
838        match &self.solver {
839            SolverConfig::Auto => {
840                if input.is_counts() {
841                    SolverConfig::PoissonKL(PoissonConfig::default())
842                } else {
843                    SolverConfig::LevenbergMarquardt(LmConfig::default())
844                }
845            }
846            other => other.clone(),
847        }
848    }
849}
850
851/// Fit a single spectrum using the typed input data API.
852///
853/// Dispatches to the correct fitting engine based on the `InputData` variant
854/// and solver configuration:
855///
856/// | Input | Solver | Path |
857/// |-------|--------|------|
858/// | Transmission | LM | LM chi-squared with optional SAMMY background |
859/// | Transmission | KL | Poisson NLL on transmission with optional background |
860/// | Counts | KL | Poisson NLL on raw counts (statistically optimal) |
861/// | Counts | LM | Convert to T internally and route to LM |
862/// | CountsWithNuisance | KL | Direct Poisson with user-supplied nuisance |
863pub fn fit_spectrum_typed(
864    input: &InputData,
865    config: &UnifiedFitConfig,
866) -> Result<SpectrumFitResult, PipelineError> {
867    let n_e = config.energies().len();
868
869    // Validate temperature when fitting is requested
870    if config.fit_temperature && config.temperature_k < 1.0 {
871        return Err(PipelineError::InvalidParameter(format!(
872            "temperature must be >= 1.0 K when fit_temperature is true, got {}",
873            config.temperature_k,
874        )));
875    }
876
877    // Validate input length matches energy grid
878    if input.n_energies() != n_e {
879        return Err(PipelineError::ShapeMismatch(format!(
880            "input data has {} energy bins but config.energies has {}",
881            input.n_energies(),
882            n_e,
883        )));
884    }
885
886    // Validate auxiliary array lengths match the primary data
887    match input {
888        InputData::Transmission {
889            transmission,
890            uncertainty,
891        } => {
892            if uncertainty.len() != transmission.len() {
893                return Err(PipelineError::ShapeMismatch(format!(
894                    "uncertainty length {} != transmission length {}",
895                    uncertainty.len(),
896                    transmission.len(),
897                )));
898            }
899        }
900        InputData::Counts {
901            sample_counts,
902            open_beam_counts,
903        } => {
904            if open_beam_counts.len() != sample_counts.len() {
905                return Err(PipelineError::ShapeMismatch(format!(
906                    "open_beam_counts length {} != sample_counts length {}",
907                    open_beam_counts.len(),
908                    sample_counts.len(),
909                )));
910            }
911        }
912        InputData::CountsWithNuisance {
913            sample_counts,
914            flux,
915            background,
916        } => {
917            if flux.len() != sample_counts.len() {
918                return Err(PipelineError::ShapeMismatch(format!(
919                    "flux length {} != sample_counts length {}",
920                    flux.len(),
921                    sample_counts.len(),
922                )));
923            }
924            if background.len() != sample_counts.len() {
925                return Err(PipelineError::ShapeMismatch(format!(
926                    "background length {} != sample_counts length {}",
927                    background.len(),
928                    sample_counts.len(),
929                )));
930            }
931        }
932    }
933
934    // Reject a malformed caller-supplied precomputed cross-section stack here,
935    // before it reaches the forward-model builders (which would otherwise panic
936    // on `xs[0].len()` / an over-long row, or silently mis-fit).
937    validate_precomputed_cross_sections(config)?;
938
939    let effective_solver = config.effective_solver(input);
940
941    match (input, &effective_solver) {
942        // ── Transmission + LM: the well-tested path ──
943        (
944            InputData::Transmission {
945                transmission,
946                uncertainty,
947            },
948            SolverConfig::LevenbergMarquardt(lm_cfg),
949        ) => fit_transmission_lm(transmission, uncertainty, config, lm_cfg),
950
951        // ── Transmission + KL: Poisson NLL on transmission values ──
952        (
953            InputData::Transmission {
954                transmission,
955                uncertainty,
956            },
957            SolverConfig::PoissonKL(poisson_cfg),
958        ) => fit_transmission_poisson(transmission, uncertainty, config, poisson_cfg),
959
960        // ── Counts + KL: joint-Poisson profile-binomial-deviance path ──
961        //
962        // The counts-KL solver is now the joint-Poisson fitter validated in
963        // memo 35 §P1/§P2 and memo 38.  Uses the explicit `c = Q_s/Q_ob` from
964        // `CountsBackgroundConfig::c` and reports `D/(n − k)` as the primary
965        // GOF.  Detector-space counts background `B_det` is assumed zero
966        // here; the `CountsWithNuisance` arm lets callers supply a
967        // detector-bg spectrum.
968        (
969            InputData::Counts {
970                sample_counts,
971                open_beam_counts,
972            },
973            SolverConfig::PoissonKL(poisson_cfg),
974        ) => {
975            let bg = vec![0.0f64; n_e];
976            fit_counts_joint_poisson(
977                sample_counts,
978                open_beam_counts,
979                &bg,
980                config,
981                &poisson_to_joint_poisson_config(poisson_cfg, config),
982            )
983        }
984
985        // ── CountsWithNuisance + KL: user-supplied nuisance ──
986        (
987            InputData::CountsWithNuisance {
988                sample_counts,
989                flux,
990                background,
991            },
992            SolverConfig::PoissonKL(poisson_cfg),
993        ) => fit_counts_joint_poisson(
994            sample_counts,
995            flux,
996            background,
997            config,
998            &poisson_to_joint_poisson_config(poisson_cfg, config),
999        ),
1000
1001        // ── Counts + LM: convert to transmission (approximate path) ──
1002        //
1003        // This is NOT a native counts-domain LM engine.  Counts are divided
1004        // (sample/OB) to produce transmission, with σ ≈ √max(sample,1)/OB
1005        // as a simplified Poisson-to-Gaussian conversion.  Poisson structure
1006        // is lost.  For statistically correct low-count fitting, use the
1007        // Poisson KL solver (`solver="kl"` or `SolverConfig::Auto`), which
1008        // now routes to the joint-Poisson path per memo 35 §P1.
1009        (
1010            InputData::Counts {
1011                sample_counts,
1012                open_beam_counts,
1013            },
1014            SolverConfig::LevenbergMarquardt(lm_cfg),
1015        ) => {
1016            let (transmission, uncertainty) =
1017                counts_to_transmission(sample_counts, open_beam_counts);
1018            fit_transmission_lm(&transmission, &uncertainty, config, lm_cfg)
1019        }
1020
1021        // ── CountsWithNuisance + LM: not meaningful ──
1022        (InputData::CountsWithNuisance { .. }, SolverConfig::LevenbergMarquardt(_)) => {
1023            Err(PipelineError::InvalidParameter(
1024                "CountsWithNuisance requires a counts-domain solver (LM cannot use nuisance parameters)"
1025                    .into(),
1026            ))
1027        }
1028
1029        // Auto should be resolved by effective_solver
1030        (_, SolverConfig::Auto) => unreachable!("Auto should be resolved before dispatch"),
1031    }
1032}
1033
1034/// Translate the user-facing `PoissonConfig` (payload of `SolverConfig::PoissonKL`)
1035/// into the internal `JointPoissonFitConfig` required by `joint_poisson_fit`.
1036///
1037/// Copies `max_iter` (the only field both structures meaningfully share) and
1038/// applies any spatial-level polish override carried on the `UnifiedFitConfig`.
1039fn poisson_to_joint_poisson_config(
1040    poisson_cfg: &PoissonConfig,
1041    config: &UnifiedFitConfig,
1042) -> JointPoissonFitConfig {
1043    let mut jp_cfg = JointPoissonFitConfig {
1044        max_iter: poisson_cfg.max_iter,
1045        ..Default::default()
1046    };
1047    if let Some(override_val) = config.counts_enable_polish() {
1048        jp_cfg.enable_polish = override_val;
1049    }
1050    jp_cfg
1051}
1052
1053/// Convert counts to transmission: T = sample/open_beam, σ = √(max(sample,1))/open_beam.
1054///
1055/// Zero-count bins (sample == 0) get σ = 1e10 so the fitter effectively ignores them.
1056/// Near-zero open beam bins use a floor of 1e-10 to avoid division by zero.
1057/// Convert raw counts to transmission with approximate Poisson uncertainty.
1058///
1059/// This is a simplified conversion for the Counts+LM fallback path.
1060/// The uncertainty σ ≈ √max(sample,1)/OB is a Gaussian approximation of
1061/// Poisson statistics, valid when counts are high (≥ ~20).  At low counts,
1062/// this overestimates confidence relative to the Poisson KL solver.
1063///
1064/// Zero-count and zero-OB bins are marked with sentinel uncertainties
1065/// (1e10 and 1e30 respectively) so the LM solver effectively ignores them.
1066fn counts_to_transmission(sample: &[f64], open_beam: &[f64]) -> (Vec<f64>, Vec<f64>) {
1067    let transmission: Vec<f64> = sample
1068        .iter()
1069        .zip(open_beam.iter())
1070        .map(|(&s, &ob)| if ob > 0.0 { s / ob } else { 0.0 })
1071        .collect();
1072    let uncertainty: Vec<f64> = sample
1073        .iter()
1074        .zip(open_beam.iter())
1075        .map(|(&s, &ob)| {
1076            if ob <= 0.0 {
1077                // No open beam signal — treat as dead bin
1078                1e30
1079            } else if s <= 0.0 {
1080                // Zero sample counts — large σ so the fitter ignores this bin
1081                1e10
1082            } else {
1083                s.max(1.0).sqrt() / ob.max(1e-10)
1084            }
1085        })
1086        .collect();
1087    (transmission, uncertainty)
1088}
1089
1090/// Transmission + LM path.
1091fn fit_transmission_lm(
1092    measured_t: &[f64],
1093    sigma: &[f64],
1094    config: &UnifiedFitConfig,
1095    lm_config: &LmConfig,
1096) -> Result<SpectrumFitResult, PipelineError> {
1097    let n_density_params = config.n_density_params();
1098
1099    // Build parameter vector
1100    let mut param_vec = build_density_params(config);
1101
1102    // Guard: energy-scale + temperature fitting is not yet supported.
1103    // The EnergyScaleTransmissionModel does not wire the temperature parameter.
1104    if config.fit_energy_scale && config.fit_temperature {
1105        return Err(PipelineError::InvalidParameter(
1106            "fit_energy_scale and fit_temperature cannot both be true: \
1107             EnergyScaleTransmissionModel does not support temperature fitting yet"
1108                .into(),
1109        ));
1110    }
1111
1112    let _temperature_index = append_temperature_param(&mut param_vec, config);
1113    let energy_scale_indices = append_energy_scale_params(&mut param_vec, config);
1114
1115    // Issue #608: seed (t0, L_scale) via resonance peak-matching so the
1116    // production cold start lands in the global-min basin of the sharply
1117    // non-convex calibration χ² (the true-σ model's basins are razor-thin).
1118    seed_energy_scale_in_params(&mut param_vec, energy_scale_indices, measured_t, config);
1119
1120    // Background parameters (rejects partial BackD/BackF; see
1121    // validate_transmission_background docstring).
1122    if let Some(bg) = config.transmission_background.as_ref() {
1123        validate_transmission_background(bg)?;
1124    }
1125    let bg_indices = config
1126        .transmission_background
1127        .as_ref()
1128        .map(|bg| append_background_params(&mut param_vec, bg));
1129
1130    let mut params = ParameterSet::new(param_vec);
1131    let mut lm_cfg = lm_config.clone();
1132    lm_cfg.compute_covariance = config.compute_covariance;
1133
1134    // Build model — use EnergyScaleTransmissionModel when energy-scale is enabled
1135    let model: Box<dyn FitModel> = if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1136        build_energy_scale_transmission_model(config, t0_idx, ls_idx)?
1137    } else {
1138        build_transmission_model(config, n_density_params, _temperature_index)?
1139    };
1140
1141    // Build the per-bin active mask (SAMMY EMIN/EMAX-equivalent fit-energy
1142    // -range restriction).  `None` when no range is configured — the
1143    // LM core treats that as "all bins active".
1144    let active_mask = nereids_fitting::active_mask::build_active_mask(
1145        config.energies(),
1146        config.fit_energy_range(),
1147    );
1148
1149    // When a fit-energy-range is configured, reject the call early if
1150    // the user's `[E_min, E_max]` selects fewer active bins than the
1151    // dispatch can solve.  The LM core's `n_active < n_free` check
1152    // would also catch the underdetermined case on the main path, but
1153    // a dispatcher-level rejection gives the user a clear "range too
1154    // narrow" error instead of a confusing non-converged result, and
1155    // adds the `n_active < 2` numerical-stability floor so that even
1156    // an `n_free == 1` fit gets at least one degree of freedom.  See
1157    // [`required_active_bins`] for the combined `max(2, n_free)`
1158    // requirement.
1159    if let Some((e_min, e_max)) = config.fit_energy_range() {
1160        let n_active = nereids_fitting::active_mask::active_count(
1161            active_mask.as_deref(),
1162            config.energies().len(),
1163        );
1164        let required = required_active_bins(config);
1165        if n_active < required {
1166            return Err(PipelineError::InvalidParameter(format!(
1167                "fit_energy_range [{e_min}, {e_max}] eV selects {n_active} active bin(s) \
1168                 on the configured energy grid; at least {required} active bin(s) are \
1169                 required for LM transmission fitting with {n_free} free parameter(s) \
1170                 (underdetermined when n_active < n_free)",
1171                n_free = count_free_params(config),
1172            )));
1173        }
1174    }
1175
1176    // Dispatch with optional background wrapping
1177    let result = if let Some(bi) = bg_indices {
1178        let wrapped = if let (Some(di), Some(fi)) = (bi.back_d, bi.back_f) {
1179            NormalizedTransmissionModel::new_with_exponential(
1180                &*model,
1181                config.energies(),
1182                bi.anorm,
1183                bi.back_a,
1184                bi.back_b,
1185                bi.back_c,
1186                di,
1187                fi,
1188            )
1189        } else {
1190            NormalizedTransmissionModel::new(
1191                &*model,
1192                config.energies(),
1193                bi.anorm,
1194                bi.back_a,
1195                bi.back_b,
1196                bi.back_c,
1197            )
1198        };
1199        lm::levenberg_marquardt_with_mask(
1200            &wrapped,
1201            measured_t,
1202            sigma,
1203            &mut params,
1204            &lm_cfg,
1205            active_mask.as_deref(),
1206        )?
1207    } else {
1208        lm::levenberg_marquardt_with_mask(
1209            &*model,
1210            measured_t,
1211            sigma,
1212            &mut params,
1213            &lm_cfg,
1214            active_mask.as_deref(),
1215        )?
1216    };
1217
1218    let mut sr = extract_result(config, &result, n_density_params, bg_indices)?;
1219
1220    // Populate energy-scale results if fitted.
1221    if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1222        sr.t0_us = Some(result.params[t0_idx]);
1223        sr.l_scale = Some(result.params[ls_idx]);
1224    }
1225
1226    Ok(sr)
1227}
1228
1229/// Transmission + Poisson KL path.
1230///
1231/// Uses the same model architecture as the LM path:
1232/// - `EnergyScaleTransmissionModel` when energy-scale fitting is enabled
1233/// - `NormalizedTransmissionModel` for SAMMY-style background (Anorm + BackA/B/C)
1234/// - Poisson NLL handles negative model predictions via smooth extrapolation
1235fn fit_transmission_poisson(
1236    measured_t: &[f64],
1237    sigma: &[f64],
1238    config: &UnifiedFitConfig,
1239    poisson_cfg: &PoissonConfig,
1240) -> Result<SpectrumFitResult, PipelineError> {
1241    // SAMMY EMIN/EMAX-equivalent fit-energy-range (#514): the legacy
1242    // transmission-domain `poisson_fit` does not honour an active mask,
1243    // so silently routing the data here when the user set a fit range
1244    // would bias the fit by including out-of-range bins (margin region)
1245    // in the cost function.  Hard-reject up-front rather than producing
1246    // a misleading "successful" fit.  Joint-Poisson (counts) and LM
1247    // transmission both honour the mask correctly.
1248    if config.fit_energy_range().is_some() {
1249        return Err(PipelineError::InvalidParameter(
1250            "fit_energy_range is not supported for the transmission + \
1251             Poisson-KL solver path. Use joint-Poisson (provide sample + \
1252             open-beam counts) or switch to the LM transmission solver."
1253                .into(),
1254        ));
1255    }
1256
1257    let mut poisson_cfg = poisson_cfg.clone();
1258    poisson_cfg.compute_covariance = config.compute_covariance;
1259    let poisson_cfg = &poisson_cfg;
1260
1261    let n_density_params = config.n_density_params();
1262    let mut param_vec = build_density_params(config);
1263
1264    if config.fit_temperature && config.fit_energy_scale {
1265        return Err(PipelineError::InvalidParameter(
1266            "fit_energy_scale and fit_temperature cannot both be true: \
1267             EnergyScaleTransmissionModel does not support temperature fitting yet"
1268                .into(),
1269        ));
1270    }
1271    let temperature_index = append_temperature_param(&mut param_vec, config);
1272    let energy_scale_indices = append_energy_scale_params(&mut param_vec, config);
1273
1274    // Issue #608: peak-match seed for (t0, L_scale) — see fit_transmission_lm.
1275    seed_energy_scale_in_params(&mut param_vec, energy_scale_indices, measured_t, config);
1276
1277    // Background parameters — use same SAMMY-style model as LM, with the
1278    // same partial-BackD/BackF rejection.
1279    if let Some(bg) = config.transmission_background.as_ref() {
1280        validate_transmission_background(bg)?;
1281    }
1282    let bg_indices = config
1283        .transmission_background
1284        .as_ref()
1285        .map(|bg| append_background_params(&mut param_vec, bg));
1286
1287    let mut params = ParameterSet::new(param_vec);
1288
1289    // Build inner model (energy-scale or precomputed)
1290    let model: Box<dyn FitModel> = if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1291        build_energy_scale_transmission_model(config, t0_idx, ls_idx)?
1292    } else {
1293        build_transmission_model(config, n_density_params, temperature_index)?
1294    };
1295
1296    // Wrap with NormalizedTransmissionModel for background (same as LM)
1297    let result = if let Some(bi) = bg_indices {
1298        let wrapped = if let (Some(di), Some(fi)) = (bi.back_d, bi.back_f) {
1299            NormalizedTransmissionModel::new_with_exponential(
1300                &*model,
1301                config.energies(),
1302                bi.anorm,
1303                bi.back_a,
1304                bi.back_b,
1305                bi.back_c,
1306                di,
1307                fi,
1308            )
1309        } else {
1310            NormalizedTransmissionModel::new(
1311                &*model,
1312                config.energies(),
1313                bi.anorm,
1314                bi.back_a,
1315                bi.back_b,
1316                bi.back_c,
1317            )
1318        };
1319        let pr = poisson::poisson_fit(&wrapped, measured_t, &mut params, poisson_cfg)?;
1320        poisson_to_lm_result(&wrapped, measured_t, sigma, &pr, &params)
1321    } else {
1322        let pr = poisson::poisson_fit(&*model, measured_t, &mut params, poisson_cfg)?;
1323        poisson_to_lm_result(&*model, measured_t, sigma, &pr, &params)
1324    }?;
1325
1326    let mut sr = extract_result(config, &result, n_density_params, bg_indices)?;
1327
1328    // Populate temperature
1329    if let Some(idx) = temperature_index {
1330        sr.temperature_k = Some(result.params[idx]);
1331        sr.temperature_k_unc = temperature_index.and_then(|i| {
1332            result
1333                .uncertainties
1334                .as_ref()
1335                .and_then(|u| u.get(i).copied())
1336        });
1337    }
1338
1339    // Populate energy-scale results
1340    if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1341        sr.t0_us = Some(result.params[t0_idx]);
1342        sr.l_scale = Some(result.params[ls_idx]);
1343    }
1344
1345    Ok(sr)
1346}
1347
1348/// Joint-Poisson counts-path fitter (memo 35 §P1/§P2).
1349///
1350/// Builds a pure transmission `FitModel` (density + optional temperature +
1351/// optional energy-scale) and feeds it to [`joint_poisson::joint_poisson_fit`]
1352/// together with explicit `(O, S, c)`.  Returns a [`SpectrumFitResult`] with
1353/// `deviance_per_dof = Some(...)` as the primary GOF (memo 35 §P1.2).
1354/// `reduced_chi_squared` is set to the same value so GUI consumers that
1355/// still read the legacy field see a deviance-based metric.
1356///
1357/// Current scope (P1 + P2, including P2.2): `fit_alpha_1`, `fit_alpha_2`,
1358/// and non-zero `detector_background` remain rejected (`λ̂` absorbs the
1359/// global flux scale, `B_det` / alpha_2 wiring is memo 35 §P3.2 deferred).
1360/// `transmission_background` with `A_n` + `B_A` / `B_B` / `B_C` is
1361/// supported as of P2.2, subject to the operational rule that `B_A` must
1362/// be enabled if any of `B_A` / `B_B` / `B_C` is enabled (memo 35 §P2.2,
1363/// EG2 S2 C_An shows A_n alone cannot absorb a constant offset — density
1364/// bias −23%).  Exponential-tail terms `BackD` / `BackF` are rejected
1365/// (memo 35 §P4-deferred).
1366fn fit_counts_joint_poisson(
1367    sample_counts: &[f64],
1368    flux: &[f64],
1369    detector_background: &[f64],
1370    config: &UnifiedFitConfig,
1371    jp_cfg: &JointPoissonFitConfig,
1372) -> Result<SpectrumFitResult, PipelineError> {
1373    // ── Compatibility gates (memo 35 §P3 items are out of scope here) ──
1374    if let Some(bg) = config.counts_background()
1375        && (bg.fit_alpha_1 || bg.fit_alpha_2)
1376    {
1377        return Err(PipelineError::InvalidParameter(
1378            "joint-Poisson solver does not support fit_alpha_1/fit_alpha_2: \
1379             the profile lambda-hat absorbs the global flux scale (alpha_1 redundant); \
1380             alpha_2 / B_det wiring is deferred to memo 35 §P3."
1381                .into(),
1382        ));
1383    }
1384    if detector_background.iter().any(|&v| v.abs() > 1e-12) {
1385        return Err(PipelineError::InvalidParameter(
1386            "joint-Poisson solver with non-zero detector_background is not yet supported \
1387             (B_det wiring deferred to memo 35 §P3.2)."
1388                .into(),
1389        ));
1390    }
1391
1392    // ── §P2.2 operational rule: B_A required if any additive term enabled ──
1393    if let Some(bg) = config.transmission_background.as_ref() {
1394        if bg.fit_back_d || bg.fit_back_f {
1395            return Err(PipelineError::InvalidParameter(
1396                "joint-Poisson solver does not support the BackD/BackF exponential \
1397                 tail (memo 35 §P4-deferred)."
1398                    .into(),
1399            ));
1400        }
1401        if (bg.fit_back_b || bg.fit_back_c) && !bg.fit_back_a {
1402            return Err(PipelineError::InvalidParameter(
1403                "joint-Poisson transmission_background: B_A (fit_back_a) must be \
1404                 enabled whenever any of B_B / B_C is enabled (memo 35 §P2.2 — \
1405                 A_n alone cannot absorb a constant offset; EG2 S2 C_An → −23% \
1406                 density bias)."
1407                    .into(),
1408            ));
1409        }
1410    }
1411
1412    let c = config.counts_background().map(|b| b.c).unwrap_or(1.0);
1413    if !(c.is_finite() && c > 0.0) {
1414        return Err(PipelineError::InvalidParameter(format!(
1415            "joint-Poisson solver requires finite c > 0 in CountsBackgroundConfig, got {c}",
1416        )));
1417    }
1418
1419    // ── Build parameter vector (density → temperature → energy-scale →
1420    // transmission background), using the shared
1421    // append_temperature_param / append_energy_scale_params /
1422    // append_background_params helpers.
1423    let n_density_params = config.n_density_params();
1424    let mut param_vec = build_density_params(config);
1425
1426    if config.fit_temperature && config.fit_energy_scale {
1427        return Err(PipelineError::InvalidParameter(
1428            "fit_energy_scale and fit_temperature cannot both be true: \
1429             EnergyScaleTransmissionModel does not support temperature fitting yet"
1430                .into(),
1431        ));
1432    }
1433    let temperature_index = append_temperature_param(&mut param_vec, config);
1434    let energy_scale_indices = append_energy_scale_params(&mut param_vec, config);
1435
1436    // Issue #608: peak-match seed for (t0, L_scale).  The KL/counts path fits
1437    // the same sharply non-convex calibration landscape as the LM path, so it
1438    // needs the same seed; run it on a (sample − bg)/(flux − bg) transmission
1439    // proxy (the resonance-dip positions are all the seed needs).
1440    if energy_scale_indices.is_some() {
1441        let t_proxy: Vec<f64> = sample_counts
1442            .iter()
1443            .zip(flux.iter())
1444            .zip(detector_background.iter())
1445            .map(|((&s, &f), &b)| {
1446                let den = f - b;
1447                if den > 0.0 {
1448                    ((s - b).max(0.0) / den).min(2.0)
1449                } else {
1450                    1.0
1451                }
1452            })
1453            .collect();
1454        seed_energy_scale_in_params(&mut param_vec, energy_scale_indices, &t_proxy, config);
1455    }
1456
1457    // ── Transmission background (A_n + B_A/B/C) parameters, P2.2 ──
1458    // Use the same SAMMY-style param block as the LM transmission path.
1459    // If BackD/BackF were enabled, we would have already errored out above.
1460    let bg_indices = config
1461        .transmission_background
1462        .as_ref()
1463        .map(|bg| append_background_params(&mut param_vec, bg));
1464
1465    let mut params = ParameterSet::new(param_vec);
1466
1467    // ── Build pure transmission model ──
1468    let t_model: Box<dyn FitModel> = if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1469        build_energy_scale_transmission_model(config, t0_idx, ls_idx)?
1470    } else {
1471        build_transmission_model(config, n_density_params, temperature_index)?
1472    };
1473
1474    // ── Wrap with NormalizedTransmissionModel if bg is active (P2.2) ──
1475    // The wrapper adds `T_out = A_n · T_inner + B_A + B_B/√E + B_C·√E`,
1476    // exactly matching the SAMMY form used by the LM transmission path.
1477    // Its analytical Jacobian chains through the inner model correctly,
1478    // so JointPoissonObjective picks up gradients for both density and
1479    // background parameters without further wiring.
1480
1481    // Build the per-bin active mask (SAMMY EMIN/EMAX-equivalent fit-energy
1482    // -range restriction).  `None` when no range is configured — the
1483    // JP objective treats that as "all bins active".
1484    let active_mask = nereids_fitting::active_mask::build_active_mask(
1485        config.energies(),
1486        config.fit_energy_range(),
1487    );
1488    let active_mask_slice = active_mask.as_deref();
1489
1490    // Reject early when the configured range selects fewer active
1491    // bins than the joint-Poisson dispatch can solve.  `joint_poisson_fit`
1492    // already rejects the `n_active < n_free` case (and the
1493    // `n_active == 0` early-return), but a dispatcher-level rejection
1494    // gives users a clear "range too narrow" error instead of a
1495    // non-converged JP result, and adds the `n_active < 2` numerical-
1496    // stability floor so even an `n_free == 1` fit gets at least one
1497    // degree of freedom.  See [`required_active_bins`] for the
1498    // combined `max(2, n_free)` requirement.
1499    if let Some((e_min, e_max)) = config.fit_energy_range() {
1500        let n_active =
1501            nereids_fitting::active_mask::active_count(active_mask_slice, config.energies().len());
1502        let required = required_active_bins(config);
1503        if n_active < required {
1504            return Err(PipelineError::InvalidParameter(format!(
1505                "fit_energy_range [{e_min}, {e_max}] eV selects {n_active} active bin(s) \
1506                 on the configured energy grid; at least {required} active bin(s) are \
1507                 required for joint-Poisson fitting with {n_free} free parameter(s) \
1508                 (underdetermined when n_active < n_free)",
1509                n_free = count_free_params(config),
1510            )));
1511        }
1512    }
1513
1514    let result;
1515    if let Some(bi) = bg_indices {
1516        let wrapped = NormalizedTransmissionModel::new(
1517            &*t_model,
1518            config.energies(),
1519            bi.anorm,
1520            bi.back_a,
1521            bi.back_b,
1522            bi.back_c,
1523        );
1524        let objective = JointPoissonObjective {
1525            model: &wrapped,
1526            o: flux,
1527            s: sample_counts,
1528            c,
1529            active_mask: active_mask_slice,
1530        };
1531        let mut cfg = jp_cfg.clone();
1532        cfg.compute_covariance = config.compute_covariance;
1533        result = joint_poisson::joint_poisson_fit(&objective, &mut params, &cfg).map_err(|e| {
1534            PipelineError::InvalidParameter(format!("joint-Poisson fit failed: {e}"))
1535        })?;
1536    } else {
1537        let objective = JointPoissonObjective {
1538            model: &*t_model,
1539            o: flux,
1540            s: sample_counts,
1541            c,
1542            active_mask: active_mask_slice,
1543        };
1544        let mut cfg = jp_cfg.clone();
1545        cfg.compute_covariance = config.compute_covariance;
1546        result = joint_poisson::joint_poisson_fit(&objective, &mut params, &cfg).map_err(|e| {
1547            PipelineError::InvalidParameter(format!("joint-Poisson fit failed: {e}"))
1548        })?;
1549    }
1550
1551    // ── Extract fitted quantities ──
1552    let densities: Vec<f64> = (0..n_density_params).map(|i| result.params[i]).collect();
1553
1554    let (uncertainties, temperature_k_unc) = if let Some(ref unc_all) = result.uncertainties {
1555        let dens_unc: Vec<f64> = (0..n_density_params)
1556            .map(|i| *unc_all.get(i).unwrap_or(&f64::NAN))
1557            .collect();
1558        let t_unc = temperature_index.and_then(|idx| {
1559            params
1560                .free_indices()
1561                .iter()
1562                .position(|&fi| fi == idx)
1563                .and_then(|pos| unc_all.get(pos).copied())
1564        });
1565        (Some(dens_unc), t_unc)
1566    } else {
1567        (None, None)
1568    };
1569    let fitted_temp = temperature_index.map(|idx| result.params[idx]);
1570
1571    // Convergence signal per memo 35 §P2.3: the deviance value is the
1572    // acceptance criterion, but we expose a boolean to preserve the
1573    // existing SpectrumFitResult shape.  Report True when EITHER stage
1574    // self-flagged convergence (whichever accepts).
1575    let converged = result.gn_converged || result.polish_converged;
1576
1577    // ── Background parameter readout ──
1578    // When bg is active, read A_n / B_A / B_B / B_C from the fitted
1579    // parameter vector at their registered indices.  When bg is absent,
1580    // use the memo 35 §P1 convention: A_n = 1 (subsumed into λ̂), bg = 0.
1581    let (anorm_out, bg_abc_out) = if let Some(bi) = bg_indices {
1582        (
1583            result.params[bi.anorm],
1584            [
1585                result.params[bi.back_a],
1586                result.params[bi.back_b],
1587                result.params[bi.back_c],
1588            ],
1589        )
1590    } else {
1591        (1.0, [0.0, 0.0, 0.0])
1592    };
1593
1594    Ok(SpectrumFitResult {
1595        densities,
1596        uncertainties,
1597        // Back-compat bridge: reduced_chi_squared carries D/(n−k) for the
1598        // joint-Poisson path.  Memo 35 §P1.2 — Pearson χ² is secondary.
1599        reduced_chi_squared: result.deviance_per_dof,
1600        converged,
1601        iterations: result.gn_iterations + result.polish_iterations,
1602        temperature_k: fitted_temp,
1603        temperature_k_unc,
1604        anorm: anorm_out,
1605        background: bg_abc_out,
1606        // Joint-Poisson never fits the exponential tail.  `None`
1607        // signals "tail not fit" to downstream consumers (GUI overlay
1608        // curve drops the exponential term; PyO3 conversion passes
1609        // through to Python as `None`).
1610        back_d: None,
1611        back_f: None,
1612        t0_us: energy_scale_indices.map(|(t0_idx, _)| result.params[t0_idx]),
1613        l_scale: energy_scale_indices.map(|(_, ls_idx)| result.params[ls_idx]),
1614        deviance_per_dof: Some(result.deviance_per_dof),
1615    })
1616}
1617
1618// ── Shared helpers for fit_spectrum_typed ──
1619
1620fn build_density_params(config: &UnifiedFitConfig) -> Vec<FitParameter> {
1621    config
1622        .initial_densities
1623        .iter()
1624        .enumerate()
1625        .map(|(i, &d)| {
1626            FitParameter::non_negative(
1627                config
1628                    .isotope_names
1629                    .get(i)
1630                    .cloned()
1631                    .unwrap_or_else(|| format!("isotope_{i}")),
1632                d,
1633            )
1634        })
1635        .collect()
1636}
1637
1638/// Append a temperature parameter to the fit vector if
1639/// `config.fit_temperature` is `true`.  Returns the parameter index.
1640///
1641/// Bounds [1.0, 5000.0] K match the transmission / counts fit paths.
1642/// Temperature unit is Kelvin; the initial value is `config.temperature_k`.
1643fn append_temperature_param(
1644    param_vec: &mut Vec<FitParameter>,
1645    config: &UnifiedFitConfig,
1646) -> Option<usize> {
1647    if !config.fit_temperature {
1648        return None;
1649    }
1650    let idx = param_vec.len();
1651    param_vec.push(FitParameter {
1652        name: "temperature_k".into(),
1653        value: config.temperature_k,
1654        lower: 1.0,
1655        upper: 5000.0,
1656        fixed: false,
1657    });
1658    Some(idx)
1659}
1660
1661/// Detect significant transmission dips (resonance signatures) in a measured
1662/// spectrum: returns `(energy_eV, depth)` for each local minimum whose depth
1663/// below the no-absorption baseline is a meaningful fraction of the deepest
1664/// dip.  Light 3-point smoothing suppresses single-bin noise.  Used to seed the
1665/// energy-scale calibration (issue #608).
1666fn detect_transmission_dips(measured: &[f64], energies: &[f64]) -> Vec<(f64, f64)> {
1667    let n = measured.len();
1668    if n < 5 || energies.len() != n {
1669        return Vec::new();
1670    }
1671    let mut sm = measured.to_vec();
1672    for i in 1..n - 1 {
1673        sm[i] = (measured[i - 1] + measured[i] + measured[i + 1]) / 3.0;
1674    }
1675    // No-absorption baseline ≈ a high percentile of the (smoothed) signal.
1676    let mut sorted = sm.clone();
1677    sorted.sort_by(f64::total_cmp);
1678    let baseline = sorted[((0.9 * (n as f64 - 1.0)).round() as usize).min(n - 1)];
1679    let mut dips: Vec<(f64, f64)> = Vec::new();
1680    let mut max_depth = 0.0f64;
1681    for i in 1..n - 1 {
1682        if sm[i] < sm[i - 1] && sm[i] <= sm[i + 1] {
1683            let depth = baseline - sm[i];
1684            if depth > 0.0 {
1685                dips.push((energies[i], depth));
1686                max_depth = max_depth.max(depth);
1687            }
1688        }
1689    }
1690    // Keep dips that are a meaningful fraction of the deepest — filters noise
1691    // wiggles while retaining genuine (even weak) resonance dips.
1692    dips.retain(|&(_, d)| d >= 0.2 * max_depth);
1693    dips
1694}
1695
1696/// Seed `(t0, L_scale)` for the energy-scale fit by resonance peak-matching.
1697///
1698/// A TOF calibration is linear: `tof_measured = t0 + L_scale · tof_nominal`.
1699/// We detect the transmission dips in the measured spectrum, match each to its
1700/// nearest known resonance energy (under the nominal calibration), and
1701/// least-squares-fit the matched `(tof_nominal, tof_measured)` pairs for
1702/// `(t0, L_scale)`.  This is landscape-independent — it seeds the LM into the
1703/// global-minimum basin of the sharply non-convex post-#608 calibration χ²,
1704/// which a cold start (t0=0, L_scale=1) or a grid scan cannot reliably find
1705/// (the physically-exact true-σ model's basins are razor-thin).  The physics is
1706/// unchanged; this only chooses the LM's starting point.
1707///
1708/// Returns `None` (→ caller keeps the configured cold start) when fewer than
1709/// two distinct resonances can be matched, so it is a safe enhancement: it
1710/// improves the seed when it can and never degrades the existing behaviour.
1711fn peak_match_energy_scale_seed(
1712    measured: &[f64],
1713    energies: &[f64],
1714    config: &UnifiedFitConfig,
1715    flight_path_m: f64,
1716    t0_bounds: (f64, f64),
1717    l_scale_bounds: (f64, f64),
1718) -> Option<(f64, f64)> {
1719    let n = energies.len();
1720    if n < 5 || measured.len() != n {
1721        return None;
1722    }
1723    // Resonance centers within the measured energy range (true frame).
1724    let refs: Vec<&ResonanceData> = config.resonance_data().iter().collect();
1725    let (e_lo, e_hi) = (
1726        energies[0].min(energies[n - 1]),
1727        energies[0].max(energies[n - 1]),
1728    );
1729    let res_e: Vec<f64> = nereids_physics::transmission::resonance_center_energies(&refs)
1730        .into_iter()
1731        .filter(|&e| e > e_lo && e < e_hi)
1732        .collect();
1733    if res_e.len() < 2 {
1734        return None;
1735    }
1736    let dips = detect_transmission_dips(measured, energies);
1737    if dips.len() < 2 {
1738        return None;
1739    }
1740    // Match each dip to its nearest resonance (nominal calibration ⇒
1741    // corrected ≈ measured), keeping the deepest dip per resonance.  Reject a
1742    // dip that does not sit UNAMBIGUOUSLY near a resonance — within half the
1743    // minimum inter-resonance spacing — so a spurious/mis-matched dip drops out
1744    // rather than poisoning the linear fit (issue #608 R2).  `res_e` is sorted
1745    // and de-duplicated.
1746    //
1747    // Floor `match_tol` at the energy-grid resolution (Copilot PR #609): a dip
1748    // is localized to ~one grid step, so a single closely-spaced resonance pair
1749    // must not collapse the GLOBAL tolerance toward 0 and reject dips at
1750    // well-separated resonances.  Dedup already stops exact duplicates from
1751    // zeroing it; the floor additionally guards distinct near-degenerate
1752    // energies.  Well-separated resonances keep `0.5·min_spacing ≫ grid_res`, so
1753    // the floor is inert in the common case.
1754    let min_spacing = res_e
1755        .windows(2)
1756        .map(|w| (w[1] - w[0]).abs())
1757        .fold(f64::INFINITY, f64::min);
1758    let mut grid_steps: Vec<f64> = energies.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
1759    grid_steps.sort_by(f64::total_cmp);
1760    let grid_res = grid_steps.get(grid_steps.len() / 2).copied().unwrap_or(0.0);
1761    let match_tol = (0.5 * min_spacing).max(grid_res);
1762    let mut best: Vec<Option<(f64, f64)>> = vec![None; res_e.len()];
1763    for &(e_dip, depth) in &dips {
1764        let Some((k, &re)) = res_e
1765            .iter()
1766            .enumerate()
1767            .min_by(|(_, a), (_, b)| (*a - e_dip).abs().total_cmp(&(*b - e_dip).abs()))
1768        else {
1769            continue;
1770        };
1771        if (re - e_dip).abs() > match_tol {
1772            continue;
1773        }
1774        match best[k] {
1775            Some((_, d)) if d >= depth => {}
1776            _ => best[k] = Some((e_dip, depth)),
1777        }
1778    }
1779    // Matched (tof_nominal, tof_measured) pairs.  The common factor
1780    // `tof_factor · flight_path` must match `EnergyScaleTransmissionModel`'s
1781    // `corrected_energies` (it cancels in the slope but sets the intercept t0).
1782    let tof_factor = (0.5 * NEUTRON_MASS_KG / EV_TO_JOULES).sqrt() * 1.0e6;
1783    let c = tof_factor * flight_path_m;
1784    let pairs: Vec<(f64, f64)> = res_e
1785        .iter()
1786        .zip(best.iter())
1787        .filter_map(|(&re, b)| b.map(|(e_dip, _)| (c / re.sqrt(), c / e_dip.sqrt())))
1788        .collect();
1789    if pairs.len() < 2 {
1790        return None;
1791    }
1792    // Linear least squares: tof_meas = t0 + L_scale · tof_nom.
1793    let m = pairs.len() as f64;
1794    let mean_x = pairs.iter().map(|p| p.0).sum::<f64>() / m;
1795    let mean_y = pairs.iter().map(|p| p.1).sum::<f64>() / m;
1796    let mut sxx = 0.0f64;
1797    let mut sxy = 0.0f64;
1798    for &(x, y) in &pairs {
1799        sxx += (x - mean_x) * (x - mean_x);
1800        sxy += (x - mean_x) * (y - mean_y);
1801    }
1802    if sxx <= 0.0 {
1803        return None;
1804    }
1805    let l_scale = sxy / sxx;
1806    let t0 = mean_y - l_scale * mean_x;
1807    if !t0.is_finite() || !l_scale.is_finite() {
1808        return None;
1809    }
1810    // Reject (→ keep the configured cold start) when the fitted seed falls
1811    // outside the parameter bounds.  Clamping an out-of-range fit onto a bound
1812    // would seed the LM worse than its cold start — the opposite of this seed's
1813    // purpose (issue #608 R2).  An in-range fit is the high-confidence case.
1814    if t0 < t0_bounds.0
1815        || t0 > t0_bounds.1
1816        || l_scale < l_scale_bounds.0
1817        || l_scale > l_scale_bounds.1
1818    {
1819        return None;
1820    }
1821    Some((t0, l_scale))
1822}
1823
1824/// Apply the peak-matching seed to the `(t0, L_scale)` entries of `param_vec`
1825/// in place, when energy-scale fitting is enabled (issue #608).  No-op when the
1826/// seed cannot be computed (→ the configured cold start is kept).
1827fn seed_energy_scale_in_params(
1828    param_vec: &mut [FitParameter],
1829    energy_scale_indices: Option<(usize, usize)>,
1830    measured_transmission: &[f64],
1831    config: &UnifiedFitConfig,
1832) {
1833    let Some((t0_idx, ls_idx)) = energy_scale_indices else {
1834        return;
1835    };
1836    let t0_b = (param_vec[t0_idx].lower, param_vec[t0_idx].upper);
1837    let ls_b = (param_vec[ls_idx].lower, param_vec[ls_idx].upper);
1838    if let Some((t0_seed, ls_seed)) = peak_match_energy_scale_seed(
1839        measured_transmission,
1840        config.energies(),
1841        config,
1842        config.flight_path_m,
1843        t0_b,
1844        ls_b,
1845    ) {
1846        param_vec[t0_idx].value = t0_seed;
1847        param_vec[ls_idx].value = ls_seed;
1848    }
1849}
1850
1851/// Append SAMMY TZERO energy-scale parameters (t_0 and L_scale) when
1852/// `config.fit_energy_scale` is `true`.  Returns `(t0_idx, l_scale_idx)`.
1853///
1854/// Bounds: `t_0 ∈ [-10.0, 10.0] μs` and `L_scale ∈ [0.99, 1.01]`
1855/// (dimensionless).  Matches the LM / KL transmission paths.
1856fn append_energy_scale_params(
1857    param_vec: &mut Vec<FitParameter>,
1858    config: &UnifiedFitConfig,
1859) -> Option<(usize, usize)> {
1860    if !config.fit_energy_scale {
1861        return None;
1862    }
1863    let t0_idx = param_vec.len();
1864    param_vec.push(FitParameter {
1865        name: "t0_us".into(),
1866        value: config.t0_init_us,
1867        lower: -10.0,
1868        upper: 10.0,
1869        fixed: false,
1870    });
1871    let ls_idx = param_vec.len();
1872    param_vec.push(FitParameter {
1873        name: "l_scale".into(),
1874        value: config.l_scale_init,
1875        lower: 0.99,
1876        upper: 1.01,
1877        fixed: false,
1878    });
1879    Some((t0_idx, ls_idx))
1880}
1881
1882/// Validate that the SAMMY-style `BackgroundConfig` is internally
1883/// consistent for the purposes of the production fit dispatch.
1884///
1885/// **BackD / BackF must travel together.**  The
1886/// `NormalizedTransmissionModel` exponential-tail wrapper takes both
1887/// indices or neither (`new_with_exponential` requires both, `new` takes
1888/// neither).  Allowing only one of `fit_back_d` / `fit_back_f` would
1889/// leave the other parameter registered as "free" but absent from the
1890/// objective and Jacobian — silently fitting nothing while reporting a
1891/// misleading initial value back to the caller.  Reject the partial
1892/// configuration up-front with a clear error.
1893///
1894/// Idempotent on valid configs; intended to be called by every
1895/// production fit entry that takes a `transmission_background`.
1896pub(crate) fn validate_transmission_background(bg: &BackgroundConfig) -> Result<(), PipelineError> {
1897    if bg.fit_back_d != bg.fit_back_f {
1898        return Err(PipelineError::InvalidParameter(format!(
1899            "transmission_background: fit_back_d ({}) and fit_back_f ({}) \
1900             must both be true or both be false. The exponential tail \
1901             wrapper (BackD · exp(−BackF / √E)) requires both parameters \
1902             together; enabling only one leaves the other registered but \
1903             unused, silently producing the initial value as the fitted \
1904             result. Either enable both (to fit the exponential tail) or \
1905             disable both (4-term wrapper without exponential).",
1906            bg.fit_back_d, bg.fit_back_f,
1907        )));
1908    }
1909    Ok(())
1910}
1911
1912/// Validate a caller-supplied precomputed cross-section stack against the
1913/// config's grid and isotope/group mapping, BEFORE it reaches the forward-model
1914/// builders or the per-pixel rayon loop.
1915///
1916/// `precomputed_cross_sections` is consumed by `build_transmission_model` /
1917/// `build_energy_scale_transmission_model`, which index `xs[0].len()` (panics
1918/// when empty) and `params[density_indices[i]]` for each row, and write
1919/// `neg_opt[j]` for every `j` in a row (an over-long row writes out of bounds).
1920/// A shape mismatch there either panics deep in the LM iteration or is swallowed
1921/// per-pixel as `n_failed`; validating once up front turns it into a typed
1922/// `ShapeMismatch` (mapped to `PyValueError` at the Python boundary).
1923///
1924/// Accepted shapes (matching the builders' collapse logic):
1925/// * **non-empty** — at least one σ row.
1926/// * every row has length `energies.len()`.
1927/// * row count is either `n_density_params` (already group-collapsed / identity)
1928///   or, when groups are active, `density_indices.len()` (per-member, collapsed
1929///   downstream).
1930///
1931/// When `precomputed_work_cross_sections` is also set (issue #608, Gaussian
1932/// aux-grid path) its working-grid σ + layout are validated against the same
1933/// invariants: non-empty, each row length == `work_layout.energies.len()`,
1934/// finite σ, row count == the data-grid σ row count (same density mapping), and
1935/// a layout whose `data_indices` length == `energies.len()` with every index in
1936/// range for the working grid — so `build_transmission_model`'s Beer-Lambert
1937/// accumulation and `work_layout.extract(..)` cannot write/read out of bounds.
1938pub(crate) fn validate_precomputed_cross_sections(
1939    config: &UnifiedFitConfig,
1940) -> Result<(), PipelineError> {
1941    let Some(xs) = config.precomputed_cross_sections() else {
1942        return Ok(());
1943    };
1944    if xs.is_empty() {
1945        return Err(PipelineError::ShapeMismatch(
1946            "precomputed_cross_sections must not be empty".into(),
1947        ));
1948    }
1949
1950    let n_e = config.energies().len();
1951    for (i, row) in xs.iter().enumerate() {
1952        if row.len() != n_e {
1953            return Err(PipelineError::ShapeMismatch(format!(
1954                "precomputed_cross_sections row {i} has length {} but config.energies \
1955                 has {n_e}",
1956                row.len(),
1957            )));
1958        }
1959        // A correctly-shaped row of NaN / ±∞ σ passes the shape checks but
1960        // poisons the forward model: every transmission sample picks up the
1961        // non-finite σ and the LM residual / Fisher matrix becomes NaN, which
1962        // the per-pixel dispatch then silently swallows as a failed fit.
1963        // Reject non-finite σ at the boundary (`is_finite()` excludes both NaN
1964        // and ±∞; a bare order comparison would let NaN through).
1965        if let Some(j) = row.iter().position(|s| !s.is_finite()) {
1966            return Err(PipelineError::ShapeMismatch(format!(
1967                "precomputed_cross_sections row {i} has non-finite σ at energy index {j}: {}",
1968                row[j],
1969            )));
1970        }
1971    }
1972
1973    let n_params = config.n_density_params();
1974    // When groups are active the per-member form (`density_indices.len()` rows)
1975    // is collapsed to `n_params` rows downstream; accept either.
1976    let member_rows = config.density_indices.as_ref().map(|di| di.len());
1977    let row_ok = xs.len() == n_params || member_rows == Some(xs.len());
1978    if !row_ok {
1979        let expected = match member_rows {
1980            Some(m) if m != n_params => format!("{n_params} (collapsed) or {m} (per-member)"),
1981            _ => format!("{n_params}"),
1982        };
1983        return Err(PipelineError::ShapeMismatch(format!(
1984            "precomputed_cross_sections has {} rows but expected {expected}",
1985            xs.len(),
1986        )));
1987    }
1988
1989    // Issue #608: the working-grid σ + layout attached via
1990    // `with_precomputed_work_cross_sections` flows into
1991    // `build_transmission_model`, where the model applies Beer-Lambert +
1992    // resolution on `work_layout.energies` and then `work_layout.extract(..)`
1993    // indexes the broadened spectrum by `work_layout.data_indices`.  An empty
1994    // σ panics on `xs[0].len()`; a row whose length ≠ the working-grid length
1995    // writes out of bounds in the Beer-Lambert accumulation; a layout whose
1996    // `data_indices` length ≠ the data grid, or that indexes past the working
1997    // grid, panics in `extract`.  Validate the same shape/consistency
1998    // invariants as the data-grid σ above so a malformed setter call surfaces
1999    // as a typed `ShapeMismatch` here rather than a per-pixel panic / swallowed
2000    // failed fit.
2001    if let Some((work_xs, layout)) = &config.precomputed_work_cross_sections {
2002        let n_work = layout.energies.len();
2003        if work_xs.is_empty() {
2004            return Err(PipelineError::ShapeMismatch(
2005                "precomputed_work_cross_sections must not be empty".into(),
2006            ));
2007        }
2008        for (i, row) in work_xs.iter().enumerate() {
2009            if row.len() != n_work {
2010                return Err(PipelineError::ShapeMismatch(format!(
2011                    "precomputed_work_cross_sections row {i} has length {} but \
2012                     work_layout has {n_work} working-grid energies",
2013                    row.len(),
2014                )));
2015            }
2016            if let Some(j) = row.iter().position(|s| !s.is_finite()) {
2017                return Err(PipelineError::ShapeMismatch(format!(
2018                    "precomputed_work_cross_sections row {i} has non-finite σ at \
2019                     working-grid index {j}: {}",
2020                    row[j],
2021                )));
2022            }
2023        }
2024        // Row count must match the data-grid σ row count (same density mapping):
2025        // both feed the SAME `density_indices` in `build_transmission_model`.
2026        if work_xs.len() != xs.len() {
2027            return Err(PipelineError::ShapeMismatch(format!(
2028                "precomputed_work_cross_sections has {} rows but \
2029                 precomputed_cross_sections has {} — both index the same density \
2030                 mapping and must agree",
2031                work_xs.len(),
2032                xs.len(),
2033            )));
2034        }
2035        // The layout maps each data energy to a working-grid index.  Its length
2036        // must equal the data grid, and every index must be in range so
2037        // `extract` cannot panic.
2038        if layout.data_indices.len() != n_e {
2039            return Err(PipelineError::ShapeMismatch(format!(
2040                "precomputed_work_cross_sections layout maps {} data points but \
2041                 config.energies has {n_e}",
2042                layout.data_indices.len(),
2043            )));
2044        }
2045        if let Some(&bad) = layout.data_indices.iter().find(|&&idx| idx >= n_work) {
2046            return Err(PipelineError::ShapeMismatch(format!(
2047                "precomputed_work_cross_sections layout index {bad} is out of \
2048                 range for {n_work} working-grid energies",
2049            )));
2050        }
2051    }
2052    Ok(())
2053}
2054
2055/// Count the number of free parameters the production LM transmission
2056/// or joint-Poisson (counts-KL) dispatch will register for `config`.
2057///
2058/// Mirrors the parameter-vector assembly performed by
2059/// [`fit_transmission_lm`], [`fit_counts_joint_poisson`] and the
2060/// shared `build_density_params` / `append_*_param` helpers below:
2061///
2062/// * `n_density_params` density slots (always free).
2063/// * `+1` if [`UnifiedFitConfig::fit_temperature`] is set.
2064/// * `+2` if [`UnifiedFitConfig::fit_energy_scale`] is set
2065///   (t_0 and L_scale).
2066/// * For a transmission_background, the count of `true` flags on
2067///   `(fit_anorm, fit_back_a, fit_back_b, fit_back_c, fit_back_d,
2068///   fit_back_f)`.  The joint-Poisson dispatch rejects BackD/BackF
2069///   up-front, but this helper counts them as free when set so the
2070///   fail-fast diagnostic ordering (`underdetermined > BackD/BackF
2071///   reject`) does not flip across paths.
2072///
2073/// Used both by [`validate_spatial_fit_preflight`] (whole-map
2074/// rejection) and by the per-pixel LM / JP dispatchers
2075/// (single-spectrum rejection) so the `n_active < required` bound
2076/// stays in lockstep across the spatial / single-spectrum boundary.
2077/// The research-only `fit_alpha_1` / `fit_alpha_2` flags on
2078/// `CountsBackgroundConfig` are *not* counted: the joint-Poisson
2079/// dispatch rejects them up-front and the LM path never wires them.
2080pub(crate) fn count_free_params(config: &UnifiedFitConfig) -> usize {
2081    let mut n_free = config.n_density_params();
2082    if config.fit_temperature {
2083        n_free += 1;
2084    }
2085    if config.fit_energy_scale {
2086        n_free += 2;
2087    }
2088    if let Some(bg) = config.transmission_background.as_ref() {
2089        n_free += usize::from(bg.fit_anorm);
2090        n_free += usize::from(bg.fit_back_a);
2091        n_free += usize::from(bg.fit_back_b);
2092        n_free += usize::from(bg.fit_back_c);
2093        n_free += usize::from(bg.fit_back_d);
2094        n_free += usize::from(bg.fit_back_f);
2095    }
2096    n_free
2097}
2098
2099/// Minimum number of active bins the LM / joint-Poisson dispatch
2100/// requires for a fit on `config`.  Encodes two distinct constraints:
2101///
2102/// 1. **Underdetermined-system rejection**: a fit with `n_active <
2103///    n_free` cannot be solved (the Jacobian cannot be full rank).
2104///    Lifting this check out of the LM / joint-Poisson cores into
2105///    the dispatcher gives the user an actionable error instead of
2106///    silent NaN propagation through the spatial map.
2107/// 2. **Numerical-stability floor**: even with `n_free == 1`, a
2108///    one-active-bin fit (`n_active == 1`) is exactly determined
2109///    (`dof == 0`) and gives no curvature for the LM step / no
2110///    deviance signal for the joint-Poisson reduced GOF.  We keep
2111///    the previous `n_active < 2` minimum so callers that relied
2112///    on it (e.g. the spatial-preflight regression tests for
2113///    narrow `fit_energy_range` windows) continue to receive the
2114///    same actionable diagnostic.
2115///
2116/// The combined requirement is `max(2, count_free_params(config))`.
2117pub(crate) fn required_active_bins(config: &UnifiedFitConfig) -> usize {
2118    count_free_params(config).max(2)
2119}
2120
2121fn append_background_params(
2122    param_vec: &mut Vec<FitParameter>,
2123    bg: &BackgroundConfig,
2124) -> BackgroundIndices {
2125    // Anorm bounded to [0.5, 2.0] — physically reasonable normalization range.
2126    // Previously unbounded [0, ∞), which allowed the fitter to absorb signal
2127    // into anorm (e.g., anorm=15.9 with density=0.03×true).
2128    // SAMMY also bounds normalization to a reasonable range.
2129    let anorm = param_vec.len();
2130    param_vec.push(if bg.fit_anorm {
2131        FitParameter {
2132            name: "anorm".into(),
2133            value: bg.anorm_init,
2134            lower: 0.5,
2135            upper: 2.0,
2136            fixed: false,
2137        }
2138    } else {
2139        FitParameter::fixed("anorm", bg.anorm_init)
2140    });
2141    // Background terms bounded to [-0.5, 0.5].
2142    // These are small corrections to the transmission baseline.
2143    // Unbounded background allows the fitter to absorb resonance signal
2144    // into the background polynomial, producing meaningless densities.
2145    // SAMMY also constrains background to reasonable ranges.
2146    let back_a = param_vec.len();
2147    param_vec.push(if bg.fit_back_a {
2148        FitParameter {
2149            name: "back_a".into(),
2150            value: bg.back_a_init,
2151            lower: -0.5,
2152            upper: 0.5,
2153            fixed: false,
2154        }
2155    } else {
2156        FitParameter::fixed("back_a", bg.back_a_init)
2157    });
2158    let back_b = param_vec.len();
2159    param_vec.push(if bg.fit_back_b {
2160        FitParameter {
2161            name: "back_b".into(),
2162            value: bg.back_b_init,
2163            lower: -0.5,
2164            upper: 0.5,
2165            fixed: false,
2166        }
2167    } else {
2168        FitParameter::fixed("back_b", bg.back_b_init)
2169    });
2170    let back_c = param_vec.len();
2171    param_vec.push(if bg.fit_back_c {
2172        FitParameter {
2173            name: "back_c".into(),
2174            value: bg.back_c_init,
2175            lower: -0.5,
2176            upper: 0.5,
2177            fixed: false,
2178        }
2179    } else {
2180        FitParameter::fixed("back_c", bg.back_c_init)
2181    });
2182
2183    // Exponential tail: BackD × exp(−BackF / √E).
2184    // SAMMY manual Sec III.E.2 — terms 5-6.
2185    // BackD (amplitude): non-negative, bounded [0, 1].
2186    // BackF (decay constant in √eV units): non-negative, bounded [0, 100].
2187    // Note: if BackD_init = 0, the Jacobian column for BackF is identically
2188    // zero and the optimizer cannot learn BackF.  The default init (0.01)
2189    // avoids this.
2190    let back_d = if bg.fit_back_d {
2191        let idx = param_vec.len();
2192        param_vec.push(FitParameter {
2193            name: "back_d".into(),
2194            value: bg.back_d_init,
2195            lower: 0.0,
2196            upper: 1.0,
2197            fixed: false,
2198        });
2199        Some(idx)
2200    } else {
2201        None
2202    };
2203    let back_f = if bg.fit_back_f {
2204        let idx = param_vec.len();
2205        param_vec.push(FitParameter {
2206            name: "back_f".into(),
2207            value: bg.back_f_init,
2208            lower: 0.0,
2209            upper: 100.0,
2210            fixed: false,
2211        });
2212        Some(idx)
2213    } else {
2214        None
2215    };
2216
2217    BackgroundIndices {
2218        anorm,
2219        back_a,
2220        back_b,
2221        back_c,
2222        back_d,
2223        back_f,
2224    }
2225}
2226
2227/// Build an [`EnergyScaleTransmissionModel`] for the energy-scale fit branch
2228/// of `fit_transmission_lm` / `fit_transmission_poisson` /
2229/// `fit_counts_joint_poisson`.
2230///
2231/// Absorbs the 3 byte-identical construction blocks that used to live inline
2232/// in each fitter (see commit `1b4131f` for the pre-extraction pattern).
2233/// Stays private — internal pipeline construction detail, not part of the
2234/// crate's public surface.
2235///
2236/// Issue #608: the energy-scale model evaluates the TRUE cross-section at the
2237/// corrected energies from resonance data (matching `forward_model`) rather
2238/// than interpolating a precomputed σ grid, so it is constructed from the
2239/// per-isotope `config.resonance_data`, the density mapping
2240/// (`config.density_indices` / `config.density_ratios`, defaulting to the
2241/// identity mapping with ratio 1.0 when no groups are configured), and
2242/// `config.temperature_k` for Doppler broadening.  Resolution is NOT applied
2243/// here — the model applies it inside `evaluate()` on the working grid via the
2244/// wrapped [`InstrumentParams`].  The per-isotope density accumulation
2245/// (`params[density_indices[i]] * density_ratios[i]`) happens inside the model,
2246/// so no cross-section pre-collapse is done here.
2247///
2248/// `t0_idx` and `ls_idx` are the parameter indices for `t0` and `l_scale`,
2249/// produced by [`append_energy_scale_params`] at each fitter's setup.
2250fn build_energy_scale_transmission_model(
2251    config: &UnifiedFitConfig,
2252    t0_idx: usize,
2253    ls_idx: usize,
2254) -> Result<Box<dyn FitModel>, PipelineError> {
2255    let instrument = config
2256        .resolution
2257        .clone()
2258        .map(|r| Arc::new(InstrumentParams { resolution: r }));
2259    // Issue #608: EnergyScale evaluates the TRUE σ at the corrected energies
2260    // from resonance data (matching forward_model) rather than interpolating a
2261    // precomputed σ grid, so it takes the per-isotope resonance data + density
2262    // mapping + temperature.  When no groups are configured, each isotope is its
2263    // own density parameter (identity mapping, ratio 1.0).
2264    let n_iso = config.resonance_data.len();
2265    let density_indices = config
2266        .density_indices
2267        .clone()
2268        .unwrap_or_else(|| (0..n_iso).collect());
2269    let density_ratios = config
2270        .density_ratios
2271        .clone()
2272        .unwrap_or_else(|| vec![1.0; n_iso]);
2273    let mut es_model = EnergyScaleTransmissionModel::new(
2274        Arc::new(config.resonance_data.clone()),
2275        Arc::new(density_indices),
2276        Arc::new(density_ratios),
2277        config.temperature_k,
2278        config.energies.clone(),
2279        config.flight_path_m,
2280        t0_idx,
2281        ls_idx,
2282        instrument,
2283    );
2284    if let Some(method) = config.tzero_jacobian_method {
2285        es_model = es_model.with_jacobian_method(method);
2286    }
2287    Ok(Box::new(es_model))
2288}
2289
2290/// Build the transmission forward model, selecting precomputed or full path.
2291fn build_transmission_model(
2292    config: &UnifiedFitConfig,
2293    n_density_params: usize,
2294    temperature_index: Option<usize>,
2295) -> Result<Box<dyn FitModel>, PipelineError> {
2296    let n_params = config.n_density_params();
2297    if !config.fit_temperature
2298        && let Some(xs) = &config.precomputed_cross_sections
2299    {
2300        // When groups are active, compute σ_eff per group from member XS.
2301        // For ungrouped isotopes, this is a no-op (identity mapping, ratio=1.0).
2302        // Only collapse when XS is per-member (shape matches mapping); if XS is
2303        // already group-collapsed (len == n_params), this is a clone.
2304        let collapse_by_groups = |xs: &Arc<Vec<Vec<f64>>>| -> Arc<Vec<Vec<f64>>> {
2305            if let (Some(di), Some(dr)) = (&config.density_indices, &config.density_ratios)
2306                && xs.len() == di.len()
2307                && di.len() == dr.len()
2308            {
2309                let n_e = xs[0].len();
2310                let mut eff = vec![vec![0.0f64; n_e]; n_params];
2311                for ((&idx, &ratio), member_xs) in di.iter().zip(dr.iter()).zip(xs.iter()) {
2312                    for (j, &sigma) in member_xs.iter().enumerate() {
2313                        eff[idx][j] += ratio * sigma;
2314                    }
2315                }
2316                return Arc::new(eff);
2317            }
2318            Arc::clone(xs)
2319        };
2320
2321        // Issue #608: prefer the WORKING-grid σ + layout when the spatial
2322        // builder injected it (Gaussian resolution → auxiliary extended grid).
2323        // The model then applies resolution on the working grid and extracts
2324        // the data points last.  When absent (tabulated / no resolution) the
2325        // working grid is the data grid: use the data-grid σ with no layout, so
2326        // the surrogate fast paths and data-grid `resolution_plan` are
2327        // unaffected.
2328        let (effective_xs, work_layout): (
2329            Arc<Vec<Vec<f64>>>,
2330            Option<Arc<nereids_physics::transmission::WorkingGridLayout>>,
2331        ) = match &config.precomputed_work_cross_sections {
2332            Some((work_xs, layout)) => (collapse_by_groups(work_xs), Some(Arc::clone(layout))),
2333            None => (collapse_by_groups(xs), None),
2334        };
2335        // Issue #442: pass energies + instrument so evaluate() applies
2336        // resolution after Beer-Lambert on total transmission.
2337        let instrument = config
2338            .resolution
2339            .clone()
2340            .map(|r| Arc::new(InstrumentParams { resolution: r }));
2341        let resolution_plan = if instrument.is_some() {
2342            config.precomputed_resolution_plan.clone()
2343        } else {
2344            None
2345        };
2346        let sparse_cubature_plan = if instrument.is_some() {
2347            config.precomputed_sparse_cubature_plan.clone()
2348        } else {
2349            None
2350        };
2351        let sparse_scalar_plan = if instrument.is_some() {
2352            config.precomputed_sparse_scalar_plan.clone()
2353        } else {
2354            None
2355        };
2356        return Ok(Box::new(PrecomputedTransmissionModel {
2357            cross_sections: effective_xs,
2358            density_indices: Arc::new((0..n_params).collect()),
2359            energies: instrument
2360                .as_ref()
2361                .map(|_| Arc::new(config.energies.clone())),
2362            instrument,
2363            resolution_plan,
2364            sparse_cubature_plan,
2365            sparse_scalar_plan,
2366            work_layout,
2367        }));
2368    }
2369
2370    let instrument = config
2371        .resolution
2372        .clone()
2373        .map(|r| Arc::new(InstrumentParams { resolution: r }));
2374
2375    let base_xs = config.precomputed_base_xs.clone();
2376    let density_ratios = config
2377        .density_ratios
2378        .clone()
2379        .unwrap_or_else(|| vec![1.0; n_density_params]);
2380    let density_indices = config
2381        .density_indices
2382        .clone()
2383        .unwrap_or_else(|| (0..n_density_params).collect());
2384    let resolution_plan = if instrument.is_some() {
2385        config.precomputed_resolution_plan.clone()
2386    } else {
2387        None
2388    };
2389    let sparse_cubature_plan = if instrument.is_some() {
2390        config.precomputed_sparse_cubature_plan.clone()
2391    } else {
2392        None
2393    };
2394    let sparse_scalar_plan = if instrument.is_some() {
2395        config.precomputed_sparse_scalar_plan.clone()
2396    } else {
2397        None
2398    };
2399    Ok(Box::new(
2400        TransmissionFitModel::new(
2401            config.energies.clone(),
2402            config.resonance_data.clone(),
2403            config.temperature_k,
2404            instrument,
2405            (density_indices, density_ratios),
2406            temperature_index,
2407            base_xs,
2408        )?
2409        .with_resolution_plan(resolution_plan)
2410        .with_sparse_cubature_plan(sparse_cubature_plan)
2411        .with_sparse_scalar_plan(sparse_scalar_plan),
2412    ))
2413}
2414
2415/// Convert PoissonResult to LmResult with Pearson chi-squared.
2416fn poisson_to_lm_result(
2417    model: &dyn FitModel,
2418    measured_t: &[f64],
2419    sigma: &[f64],
2420    pr: &poisson::PoissonResult,
2421    params: &ParameterSet,
2422) -> Result<LmResult, PipelineError> {
2423    let n_free = params.n_free();
2424    let dof = measured_t.len().saturating_sub(n_free).max(1);
2425    let y_model = model.evaluate(&pr.params)?;
2426    let chi_sq: f64 = measured_t
2427        .iter()
2428        .zip(y_model.iter())
2429        .zip(sigma.iter())
2430        .map(|((obs, mdl), s)| {
2431            let residual = obs - mdl;
2432            (residual * residual) / (s * s).max(1e-30)
2433        })
2434        .sum();
2435    Ok(LmResult {
2436        chi_squared: chi_sq,
2437        reduced_chi_squared: chi_sq / dof as f64,
2438        iterations: pr.iterations,
2439        converged: pr.converged,
2440        params: pr.params.clone(),
2441        covariance: pr.covariance.clone(),
2442        uncertainties: pr.uncertainties.clone(),
2443    })
2444}
2445
2446/// Extract SpectrumFitResult from solver output.
2447fn extract_result(
2448    config: &UnifiedFitConfig,
2449    result: &LmResult,
2450    n_density_params: usize,
2451    bg_indices: Option<BackgroundIndices>,
2452) -> Result<SpectrumFitResult, PipelineError> {
2453    let densities: Vec<f64> = (0..n_density_params).map(|i| result.params[i]).collect();
2454
2455    let (anorm, background, back_d, back_f): (f64, [f64; 3], Option<f64>, Option<f64>) =
2456        if let Some(bi) = bg_indices {
2457            // `Option<f64>` distinguishes "exponential tail not fit"
2458            // (`None`) from "fit produced zero" (`Some(0.0)`).
2459            let bd = bi.back_d.map(|i| result.params[i]);
2460            let bf = bi.back_f.map(|i| result.params[i]);
2461            (
2462                result.params[bi.anorm],
2463                [
2464                    result.params[bi.back_a],
2465                    result.params[bi.back_b],
2466                    result.params[bi.back_c],
2467                ],
2468                bd,
2469                bf,
2470            )
2471        } else {
2472            (1.0, [0.0, 0.0, 0.0], None, None)
2473        };
2474
2475    let (uncertainties, temperature_k, temperature_k_unc) = if result.converged {
2476        match &result.uncertainties {
2477            Some(unc_all) => {
2478                let (temp_k, temp_unc) = if config.fit_temperature {
2479                    (
2480                        Some(result.params[n_density_params]),
2481                        Some(*unc_all.get(n_density_params).unwrap_or(&f64::NAN)),
2482                    )
2483                } else {
2484                    (None, None)
2485                };
2486                let unc = unc_all
2487                    .get(..n_density_params)
2488                    .map(|s| s.to_vec())
2489                    .unwrap_or_else(|| vec![f64::NAN; n_density_params]);
2490                (Some(unc), temp_k, temp_unc)
2491            }
2492            None => {
2493                let temp_k = if config.fit_temperature {
2494                    Some(result.params[n_density_params])
2495                } else {
2496                    None
2497                };
2498                (None, temp_k, None)
2499            }
2500        }
2501    } else {
2502        let temp_k = if config.fit_temperature {
2503            Some(result.params[n_density_params])
2504        } else {
2505            None
2506        };
2507        (None, temp_k, None)
2508    };
2509
2510    Ok(SpectrumFitResult {
2511        densities,
2512        uncertainties,
2513        reduced_chi_squared: result.reduced_chi_squared,
2514        converged: result.converged,
2515        iterations: result.iterations,
2516        temperature_k,
2517        temperature_k_unc,
2518        anorm,
2519        background,
2520        back_d,
2521        back_f,
2522        t0_us: None,
2523        l_scale: None,
2524        deviance_per_dof: None,
2525    })
2526}
2527
2528// ── Research: exact Jacobian/Fisher at arbitrary parameters ──────────────
2529
2530/// Result of Jacobian/Fisher evaluation at given parameters.
2531///
2532/// Produced by [`evaluate_jacobian_and_fisher`], which builds the same model
2533/// chain as the production fitting pipeline but evaluates at the caller's
2534/// parameter values instead of optimising.
2535pub struct ModelJacobianResult {
2536    /// Analytical Jacobian J (n_data × n_free), row-major.
2537    pub jacobian: lm::FlatMatrix,
2538    /// Expected Poisson Fisher F = Jᵀ diag(1/μ) J (n_free × n_free).
2539    pub fisher: lm::FlatMatrix,
2540    /// Model prediction μ(E) at the evaluation point.
2541    pub model_prediction: Vec<f64>,
2542    /// Names of free parameters, in Jacobian column order.
2543    pub param_names: Vec<String>,
2544}
2545
2546/// Evaluate the exact resolved analytical Jacobian and expected Poisson Fisher
2547/// at given parameter values, using the same model construction as the
2548/// production counts-domain fitting pipeline.
2549///
2550/// This is a research-oriented function: it builds the full model chain
2551/// (transmission model → optional background wrappers → counts model),
2552/// evaluates once at the provided parameters, computes the analytical
2553/// Jacobian, and assembles the expected Fisher information matrix.
2554///
2555/// No optimisation is performed.
2556///
2557/// # Arguments
2558///
2559/// * `config` — Unified fit configuration (energies, resonance data,
2560///   resolution, initial_densities used as evaluation densities, etc.)
2561/// * `flux` — Open-beam counts Φ(E) (length = n_energy)
2562/// * `background` — Detector background B(E) (length = n_energy, zeros if none)
2563///
2564/// Density evaluation values come from `config.initial_densities`.
2565/// Temperature evaluation value comes from `config.temperature_k`.
2566/// α₁/α₂ evaluation values come from `config.counts_background` init fields.
2567///
2568/// # Errors
2569/// Returns [`PipelineError::ShapeMismatch`] if `config.precomputed_cross_sections`
2570/// is set but malformed (empty, wrong row count, wrong row length, or contains a
2571/// non-finite σ), consistent with `fit_spectrum_typed` and `spatial_map_typed`.
2572pub fn evaluate_jacobian_and_fisher(
2573    config: &UnifiedFitConfig,
2574    flux: &[f64],
2575    background: &[f64],
2576) -> Result<ModelJacobianResult, PipelineError> {
2577    // Reject a malformed caller-supplied precomputed cross-section stack here,
2578    // before it reaches `build_transmission_model`.  Without this, an empty
2579    // stack is caught only deep in `PrecomputedTransmissionModel` (as an
2580    // `InvalidConfig`, not a `ShapeMismatch`) and a wrong-shaped / non-finite
2581    // stack indexes `xs[0]` / `neg_opt[j]` directly.  This is the third public
2582    // entry point that consumes `config.precomputed_cross_sections`; it must
2583    // guard the same way as `fit_spectrum_typed` / `spatial_map_typed` so all
2584    // three surface the same typed `ShapeMismatch` at the boundary.
2585    validate_precomputed_cross_sections(config)?;
2586
2587    let n_density_params = config.n_density_params();
2588
2589    // ── Build parameter vector — preserves the pre-collapse fixed-flux
2590    // layout (density → temperature → transmission_background → α₁/α₂)
2591    // that the research Fisher helper has historically used.  NOT
2592    // equivalent to the production counts-KL dispatch's parameter
2593    // layout; kept unchanged for Epic #394 research-script compatibility.
2594    //     ─────────────────────────────────────────────────────────
2595    let mut param_vec = build_density_params(config);
2596
2597    let temperature_index = if config.fit_temperature {
2598        let idx = param_vec.len();
2599        param_vec.push(FitParameter {
2600            name: "temperature_k".into(),
2601            value: config.temperature_k,
2602            lower: 1.0,
2603            upper: 5000.0,
2604            fixed: false,
2605        });
2606        Some(idx)
2607    } else {
2608        None
2609    };
2610
2611    let kl_bg = if config.transmission_background.is_some() {
2612        let base = param_vec.len();
2613        param_vec.push(FitParameter {
2614            name: "kl_b0".into(),
2615            value: 0.0,
2616            lower: 0.0,
2617            upper: 0.5,
2618            fixed: false,
2619        });
2620        param_vec.push(FitParameter {
2621            name: "kl_b1".into(),
2622            value: 0.0,
2623            lower: 0.0,
2624            upper: 0.5,
2625            fixed: false,
2626        });
2627        Some((base, base + 1))
2628    } else {
2629        None
2630    };
2631
2632    let counts_bg = if let Some(bg) = config.counts_background() {
2633        let alpha1_idx = param_vec.len();
2634        param_vec.push(if bg.fit_alpha_1 {
2635            FitParameter {
2636                name: "alpha_1".into(),
2637                value: bg.alpha_1_init,
2638                lower: 0.0,
2639                upper: 10.0,
2640                fixed: false,
2641            }
2642        } else {
2643            FitParameter::fixed("alpha_1", bg.alpha_1_init)
2644        });
2645        let alpha2_idx = param_vec.len();
2646        param_vec.push(if bg.fit_alpha_2 {
2647            FitParameter {
2648                name: "alpha_2".into(),
2649                value: bg.alpha_2_init,
2650                lower: 0.0,
2651                upper: 10.0,
2652                fixed: false,
2653            }
2654        } else {
2655            FitParameter::fixed("alpha_2", bg.alpha_2_init)
2656        });
2657        Some((alpha1_idx, alpha2_idx))
2658    } else {
2659        None
2660    };
2661
2662    let params = ParameterSet::new(param_vec);
2663    let all_vals = params.all_values();
2664    let free_idx = params.free_indices();
2665    let n_free = free_idx.len();
2666
2667    // Collect free parameter names.
2668    let param_names: Vec<String> = free_idx
2669        .iter()
2670        .map(|&i| params.params[i].name.to_string())
2671        .collect();
2672
2673    // ── Precompute cross-sections so that analytical Jacobian is available ──
2674    // For the density-only case (no temperature fitting), the model uses
2675    // PrecomputedTransmissionModel which requires precomputed XS.
2676    // For the temperature case, TransmissionFitModel computes base_xs in
2677    // its constructor.  Either way, precomputing here ensures the analytical
2678    // Jacobian path is always available.
2679    // Issue #608: the non-temperature path uses `PrecomputedTransmissionModel`,
2680    // which must apply resolution on the WORKING grid (auxiliary extended grid
2681    // under Gaussian resolution) and extract the data points last — the same
2682    // #608 path as production fitting + spatial mapping, not the old coarse
2683    // data-grid path.  Derive σ from `resonance_data` (the source of truth) on
2684    // the working grid here whenever it is not already set up:
2685    //   * `fit_temperature` → skip: `TransmissionFitModel` builds its own
2686    //     working-grid base σ internally.
2687    //   * working-grid σ already attached (a caller did the #608 setup) → skip.
2688    //   * otherwise (caller passed no σ, OR only a data-grid σ) → (re)build the
2689    //     data-grid σ = `extract(work σ)` AND the working-grid σ + layout from
2690    //     `resonance_data`.  A malformed caller-supplied data-grid σ has already
2691    //     been rejected by the up-front `validate_precomputed_cross_sections`;
2692    //     here a valid caller σ is superseded by the resonance-data-derived
2693    //     working-grid σ — the only #608-correct source under Gaussian
2694    //     resolution.  Without this, a caller that pre-supplied a data-grid σ
2695    //     plus Gaussian resolution silently got coarse-grid broadening in the
2696    //     Jacobian/Fisher (R3 finding).
2697    let config_with_xs;
2698    let effective_config =
2699        if config.fit_temperature || config.precomputed_work_cross_sections.is_some() {
2700            config
2701        } else {
2702            let instrument = config
2703                .resolution
2704                .clone()
2705                .map(|r| Arc::new(InstrumentParams { resolution: r }));
2706            let working = nereids_physics::transmission::broadened_cross_sections_on_working_grid(
2707                config.energies(),
2708                &config.resonance_data,
2709                config.temperature_k,
2710                instrument.as_deref(),
2711                None,
2712            )
2713            .map_err(PipelineError::Transmission)?;
2714            config_with_xs = if working.layout.is_identity() {
2715                // Tabulated / no resolution: the working grid IS the data grid.
2716                config
2717                    .clone()
2718                    .with_precomputed_cross_sections(Arc::new(working.sigma))
2719            } else {
2720                // Gaussian aux grid: attach BOTH the extracted data-grid σ (for the
2721                // surrogate-plan builders + shape validation) and the working-grid σ
2722                // + layout (AFTER `with_precomputed_cross_sections`, which clears any
2723                // stale work σ).
2724                let data_xs: Vec<Vec<f64>> = working
2725                    .sigma
2726                    .iter()
2727                    .map(|s| working.layout.extract(s))
2728                    .collect();
2729                config
2730                    .clone()
2731                    .with_precomputed_cross_sections(Arc::new(data_xs))
2732                    .with_precomputed_work_cross_sections(
2733                        Arc::new(working.sigma),
2734                        Arc::new(working.layout),
2735                    )
2736            };
2737            &config_with_xs
2738        };
2739
2740    // ── Build transmission model (same as production path) ──────────
2741    let t_model = build_transmission_model(effective_config, n_density_params, temperature_index)?;
2742
2743    // ── Build counts model chain and evaluate ───────────────────────
2744    // Use a closure that evaluates and computes Jacobian for any FitModel.
2745    let evaluate_and_jacobian =
2746        |model: &dyn FitModel| -> Result<(Vec<f64>, lm::FlatMatrix), PipelineError> {
2747            let y_model = model.evaluate(&all_vals)?;
2748            let jac = model
2749                .analytical_jacobian(&all_vals, &free_idx, &y_model)
2750                .ok_or_else(|| {
2751                    PipelineError::InvalidParameter(
2752                        "analytical Jacobian not available for this model configuration".into(),
2753                    )
2754                })?;
2755            Ok((y_model, jac))
2756        };
2757
2758    let (y_model, jac) = if let Some((b0_idx, b1_idx)) = kl_bg {
2759        let inv_sqrt_e: Vec<f64> = config
2760            .energies()
2761            .iter()
2762            .map(|&e| 1.0 / e.max(1e-10).sqrt())
2763            .collect();
2764        let wrapped = poisson::TransmissionKLBackgroundModel {
2765            inner: &*t_model,
2766            inv_sqrt_energies: inv_sqrt_e,
2767            b0_index: b0_idx,
2768            b1_index: b1_idx,
2769            n_params: params.params.len(),
2770        };
2771        if let Some((a1, a2)) = counts_bg {
2772            let cm = poisson::CountsBackgroundScaleModel {
2773                transmission_model: &wrapped,
2774                flux,
2775                background,
2776                alpha1_index: a1,
2777                alpha2_index: a2,
2778                n_params: params.params.len(),
2779            };
2780            evaluate_and_jacobian(&cm)?
2781        } else {
2782            let cm = poisson::CountsModel {
2783                transmission_model: &wrapped,
2784                flux,
2785                background,
2786                n_params: params.params.len(),
2787            };
2788            evaluate_and_jacobian(&cm)?
2789        }
2790    } else if let Some((a1, a2)) = counts_bg {
2791        let cm = poisson::CountsBackgroundScaleModel {
2792            transmission_model: &*t_model,
2793            flux,
2794            background,
2795            alpha1_index: a1,
2796            alpha2_index: a2,
2797            n_params: params.params.len(),
2798        };
2799        evaluate_and_jacobian(&cm)?
2800    } else {
2801        let cm = poisson::CountsModel {
2802            transmission_model: &*t_model,
2803            flux,
2804            background,
2805            n_params: params.params.len(),
2806        };
2807        evaluate_and_jacobian(&cm)?
2808    };
2809
2810    // ── Assemble expected Poisson Fisher: F = Jᵀ diag(1/μ) J ───────
2811    let mut fisher = lm::FlatMatrix::zeros(n_free, n_free);
2812    for (i, &mu_i) in y_model.iter().enumerate() {
2813        let mu_inv = 1.0 / mu_i.max(1e-30);
2814        for a in 0..n_free {
2815            let ja = jac.get(i, a);
2816            for b in 0..=a {
2817                let jb = jac.get(i, b);
2818                *fisher.get_mut(a, b) += ja * jb * mu_inv;
2819                if a != b {
2820                    *fisher.get_mut(b, a) += ja * jb * mu_inv;
2821                }
2822            }
2823        }
2824    }
2825
2826    Ok(ModelJacobianResult {
2827        jacobian: jac,
2828        fisher,
2829        model_prediction: y_model,
2830        param_names,
2831    })
2832}
2833
2834// ── End Phase 2 ──────────────────────────────────────────────────────────
2835
2836/// Errors from `FitConfig` construction.
2837#[derive(Debug, PartialEq)]
2838pub enum FitConfigError {
2839    /// Energy grid must be non-empty.
2840    EmptyEnergies,
2841    /// Resonance data must be non-empty.
2842    EmptyResonanceData,
2843    /// initial_densities length must match resonance_data length.
2844    DensityCountMismatch { densities: usize, isotopes: usize },
2845    /// isotope_names length must match resonance_data length.
2846    NameCountMismatch { names: usize, isotopes: usize },
2847    /// resonance_data count must match group member count.
2848    GroupMemberCountMismatch {
2849        group_name: String,
2850        rd_count: usize,
2851        member_count: usize,
2852    },
2853    /// ResonanceData isotope doesn't match expected group member.
2854    GroupMemberIsotopeMismatch {
2855        group_name: String,
2856        expected_z: u32,
2857        expected_a: u32,
2858        got_z: u32,
2859        got_a: u32,
2860    },
2861    /// Temperature must be finite.
2862    NonFiniteTemperature(f64),
2863    /// Temperature must be non-negative.
2864    NegativeTemperature(f64),
2865    /// `fit_energy_range` bounds were non-finite or reversed/empty.
2866    InvalidFitEnergyRange(&'static str),
2867}
2868
2869impl fmt::Display for FitConfigError {
2870    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2871        match self {
2872            Self::EmptyEnergies => write!(f, "energy grid must be non-empty"),
2873            Self::EmptyResonanceData => write!(f, "resonance_data must be non-empty"),
2874            Self::DensityCountMismatch {
2875                densities,
2876                isotopes,
2877            } => write!(
2878                f,
2879                "initial_densities length ({densities}) must match number of density parameters ({isotopes})"
2880            ),
2881            Self::NameCountMismatch { names, isotopes } => write!(
2882                f,
2883                "isotope_names length ({names}) must match resonance_data length ({isotopes})"
2884            ),
2885            Self::GroupMemberCountMismatch {
2886                group_name,
2887                rd_count,
2888                member_count,
2889            } => write!(
2890                f,
2891                "group '{group_name}': provided {rd_count} ResonanceData but group has {member_count} members"
2892            ),
2893            Self::GroupMemberIsotopeMismatch {
2894                group_name,
2895                expected_z,
2896                expected_a,
2897                got_z,
2898                got_a,
2899            } => write!(
2900                f,
2901                "group '{group_name}': expected Z={expected_z} A={expected_a} but got Z={got_z} A={got_a}"
2902            ),
2903            Self::NonFiniteTemperature(v) => {
2904                write!(f, "temperature must be finite, got {v}")
2905            }
2906            Self::NegativeTemperature(v) => {
2907                write!(f, "temperature must be non-negative, got {v}")
2908            }
2909            Self::InvalidFitEnergyRange(msg) => {
2910                write!(f, "invalid fit_energy_range: {msg}")
2911            }
2912        }
2913    }
2914}
2915
2916impl std::error::Error for FitConfigError {}
2917
2918/// Result of fitting a single spectrum.
2919#[derive(Debug, Clone)]
2920pub struct SpectrumFitResult {
2921    /// Fitted areal densities (atoms/barn), one per isotope.
2922    pub densities: Vec<f64>,
2923    /// Uncertainty on each density.
2924    ///
2925    /// `None` when covariance computation was skipped.
2926    pub uncertainties: Option<Vec<f64>>,
2927    /// Reduced chi-squared of the fit.
2928    pub reduced_chi_squared: f64,
2929    /// Whether the fit converged.
2930    pub converged: bool,
2931    /// Number of iterations.
2932    pub iterations: usize,
2933    /// Fitted temperature in Kelvin (only when `fit_temperature` is true).
2934    pub temperature_k: Option<f64>,
2935    /// 1-sigma uncertainty on the fitted temperature (from covariance matrix).
2936    pub temperature_k_unc: Option<f64>,
2937    /// Fitted normalization scale (SAMMY `Anorm`).  When background
2938    /// fitting is disabled, the pipeline emits `1.0` (memo 35 §P1
2939    /// convention — `λ̂` absorbs the scale).
2940    pub anorm: f64,
2941    /// Fitted background polynomial coefficients `[BackA, BackB, BackC]`
2942    /// for the SAMMY-style 6-term background:
2943    ///
2944    /// ```text
2945    /// bg(E) = BackA + BackB / √E + BackC · √E + BackD · exp(-BackF / √E)
2946    /// ```
2947    ///
2948    /// Both LM-transmission and counts-KL solver paths use the same
2949    /// SAMMY semantics here — the legacy alpha-fitting `[b0, b1,
2950    /// alpha_2]` layout was removed when `fit_counts_poisson` was
2951    /// retired in PR #450.  When background fitting is disabled, the
2952    /// pipeline emits `[0.0, 0.0, 0.0]`.
2953    pub background: [f64; 3],
2954    /// Fitted exponential background amplitude (SAMMY BackD).
2955    /// `None` when the exponential tail is not fitted; `Some(value)`
2956    /// when the LM transmission background was active with
2957    /// `fit_back_d=true`.  Mirrors [`Self::t0_us`] /
2958    /// [`Self::l_scale`] semantics so the GUI overlay can
2959    /// distinguish "unfit" from "fitted to zero" without an ambiguous
2960    /// `0.0` sentinel.
2961    pub back_d: Option<f64>,
2962    /// Fitted exponential background decay constant (SAMMY BackF).
2963    /// `None` when the exponential tail is not fitted; `Some(value)`
2964    /// when the LM transmission background was active with
2965    /// `fit_back_f=true`.  Paired with [`Self::back_d`] — SAMMY
2966    /// requires both flags toggled together (see
2967    /// `validate_transmission_background`).
2968    pub back_f: Option<f64>,
2969    /// Fitted TOF offset in microseconds (SAMMY TZERO t₀).
2970    /// `None` when energy-scale fitting is not enabled.
2971    pub t0_us: Option<f64>,
2972    /// Fitted flight-path scale factor (SAMMY TZERO L₀, dimensionless).
2973    /// `None` when energy-scale fitting is not enabled.
2974    pub l_scale: Option<f64>,
2975    /// Conditional binomial deviance divided by `(n − k)`
2976    /// (memo 35 §P1.2 — primary GOF for the counts-KL dispatch, i.e.
2977    /// `SolverConfig::PoissonKL` on `InputData::Counts` or
2978    /// `InputData::CountsWithNuisance`).
2979    ///
2980    /// `Some(D/dof)` when the counts-KL (joint-Poisson) path was used;
2981    /// `None` for the LM path and for transmission + PoissonKL (those
2982    /// populate `reduced_chi_squared` with Pearson χ² / (n−k) instead).
2983    pub deviance_per_dof: Option<f64>,
2984}
2985
2986#[cfg(test)]
2987mod tests {
2988    use super::*;
2989    use nereids_endf::resonance::test_support::{
2990        hf178_mlbw_two_resonances, synthetic_single_resonance, u238_single_resonance,
2991    };
2992    use nereids_fitting::lm::FitModel;
2993    use nereids_physics::transmission as phys_transmission;
2994
2995    /// Issue #608: the dip detector finds the resonance signatures (local
2996    /// transmission minima) the energy-scale peak-match seed needs.
2997    #[test]
2998    fn detect_transmission_dips_finds_clear_dips() {
2999        let energies: Vec<f64> = (0..120).map(|i| 1.0 + (i as f64) * 0.5).collect();
3000        let mut t = vec![1.0_f64; 120];
3001        // Two clear, multi-bin dips so 3-point smoothing keeps them as minima.
3002        for v in t.iter_mut().take(33).skip(28) {
3003            *v = 0.4;
3004        }
3005        for v in t.iter_mut().take(83).skip(78) {
3006            *v = 0.6;
3007        }
3008        let dips = detect_transmission_dips(&t, &energies);
3009        assert_eq!(dips.len(), 2, "expected 2 dips, got {dips:?}");
3010        let mut de: Vec<f64> = dips.iter().map(|&(e, _)| e).collect();
3011        de.sort_by(f64::total_cmp);
3012        // Centres near energies[30] (16.0 eV) and energies[80] (41.0 eV).
3013        assert!(
3014            (de[0] - energies[30]).abs() <= 1.0,
3015            "first dip at {} eV",
3016            de[0]
3017        );
3018        assert!(
3019            (de[1] - energies[80]).abs() <= 1.0,
3020            "second dip at {} eV",
3021            de[1]
3022        );
3023    }
3024
3025    /// Issue #608: the energy-scale peak-match seed recovers the identity
3026    /// calibration (t0≈0, L_scale≈1) when the transmission dips sit at the known
3027    /// resonance energies — exercising the full seed path (dip detection,
3028    /// nearest-resonance matching, linear TOF fit).
3029    #[test]
3030    fn peak_match_energy_scale_seed_identity() {
3031        let data = hf178_mlbw_two_resonances(); // s-waves at 7.8 and 16.9 eV
3032        let energies: Vec<f64> = (0..400).map(|i| 4.0 + (i as f64) * 0.05).collect();
3033        let (t_obs, _sigma) = synthetic_transmission(&data, 0.1, &energies);
3034        let config = UnifiedFitConfig::new(
3035            energies.clone(),
3036            vec![data],
3037            vec!["Hf-178".into()],
3038            293.6,
3039            None,
3040            vec![0.1],
3041        )
3042        .unwrap()
3043        .with_energy_scale(0.0, 1.0, 25.0);
3044        let (t0, l_scale) = peak_match_energy_scale_seed(
3045            &t_obs,
3046            config.energies(),
3047            &config,
3048            25.0,
3049            (-10.0, 10.0),
3050            (0.99, 1.01),
3051        )
3052        .expect("seed should be Some with 2 resonances and detectable dips");
3053        // Dips at the un-shifted resonance energies ⇒ identity calibration.
3054        assert!(
3055            t0.abs() < 0.5,
3056            "t0 should be ≈0 for un-shifted data, got {t0}"
3057        );
3058        assert!(
3059            (l_scale - 1.0).abs() < 5e-3,
3060            "L_scale should be ≈1 for un-shifted data, got {l_scale}"
3061        );
3062    }
3063
3064    /// Issue #608 (R4): the peak-match seed must recover a NON-identity
3065    /// calibration, not just confirm identity.  Generate measured data with a
3066    /// known injected `(t0, L_scale)` via the `EnergyScaleTransmissionModel`
3067    /// (which shifts the resonance positions), then assert the seed recovers it.
3068    #[test]
3069    fn peak_match_energy_scale_seed_recovers_nonidentity() {
3070        let data = hf178_mlbw_two_resonances(); // s-waves at 7.8 and 16.9 eV
3071        // Finer grid than the identity test so the discrete dip positions pin
3072        // the slope (L_scale) tightly enough to distinguish it from 1.0.
3073        let energies: Vec<f64> = (0..900).map(|i| 4.0 + (i as f64) * 0.02).collect();
3074        let density = 0.1_f64;
3075        let flight_path = 25.0_f64;
3076        let (t0_true, l_scale_true) = (2.0_f64, 1.006_f64);
3077        // EnergyScale model (no resolution: dip POSITIONS, not shapes, drive the
3078        // seed) evaluated at the injected calibration ⇒ shifted measured data.
3079        let model = EnergyScaleTransmissionModel::new(
3080            Arc::new(vec![data.clone()]),
3081            Arc::new(vec![0]),
3082            Arc::new(vec![1.0]),
3083            293.6,
3084            energies.clone(),
3085            flight_path,
3086            1, // t0 index
3087            2, // l_scale index
3088            None,
3089        );
3090        let t_obs = model.evaluate(&[density, t0_true, l_scale_true]).unwrap();
3091        let config = UnifiedFitConfig::new(
3092            energies.clone(),
3093            vec![data],
3094            vec!["Hf-178".into()],
3095            293.6,
3096            None,
3097            vec![density],
3098        )
3099        .unwrap()
3100        .with_energy_scale(0.0, 1.0, flight_path);
3101        let (t0, l_scale) = peak_match_energy_scale_seed(
3102            &t_obs,
3103            config.energies(),
3104            &config,
3105            flight_path,
3106            (-10.0, 10.0),
3107            (0.99, 1.01),
3108        )
3109        .expect("seed should be Some for a clean shifted two-resonance spectrum");
3110        // The seed is a coarse peak-match (dip energies quantized to the grid),
3111        // so tolerances are looser than a converged LM — it just needs to land
3112        // in the global-min basin, clearly distinct from the cold start (0, 1).
3113        assert!(
3114            (t0 - t0_true).abs() < 0.6,
3115            "seed should recover t0 ≈ {t0_true}, got {t0}"
3116        );
3117        assert!(
3118            (l_scale - l_scale_true).abs() < 4e-3,
3119            "seed should recover L_scale ≈ {l_scale_true}, got {l_scale}"
3120        );
3121        // Sanity: the recovered seed is strictly closer to truth than cold start.
3122        assert!(
3123            (t0 - t0_true).abs() < (0.0 - t0_true).abs()
3124                && (l_scale - l_scale_true).abs() < (1.0 - l_scale_true).abs(),
3125            "seed must improve on the cold start"
3126        );
3127    }
3128
3129    /// Issue #608 (R4): a heavily-absorbing but FEATURELESS spectrum (no
3130    /// resonance dips) yields fewer than two detectable dips, so the seed must
3131    /// return `None` and the caller keeps the cold start rather than fitting a
3132    /// spurious calibration.
3133    #[test]
3134    fn peak_match_energy_scale_seed_none_on_featureless_spectrum() {
3135        let data = hf178_mlbw_two_resonances(); // 2 resonances in range (gate passes)
3136        let energies: Vec<f64> = (0..400).map(|i| 4.0 + (i as f64) * 0.05).collect();
3137        // Strong smooth 1/√E absorption, monotonic ⇒ NO local minima ⇒ < 2 dips.
3138        let t_obs: Vec<f64> = energies.iter().map(|&e| (-5.0 / e.sqrt()).exp()).collect();
3139        let config = UnifiedFitConfig::new(
3140            energies.clone(),
3141            vec![data],
3142            vec!["Hf-178".into()],
3143            293.6,
3144            None,
3145            vec![0.1],
3146        )
3147        .unwrap()
3148        .with_energy_scale(0.0, 1.0, 25.0);
3149        assert!(
3150            peak_match_energy_scale_seed(
3151                &t_obs,
3152                config.energies(),
3153                &config,
3154                25.0,
3155                (-10.0, 10.0),
3156                (0.99, 1.01),
3157            )
3158            .is_none(),
3159            "a featureless heavily-absorbing spectrum has no resonance dips ⇒ \
3160             seed must return None (cold-start fallback)"
3161        );
3162    }
3163
3164    /// Issue #608 (Copilot PR #609): duplicate resonance center energies — two
3165    /// isotopes with a resonance at the SAME energy in a grouped fit — must not
3166    /// collapse the seed's `match_tol` to 0.  Pre-fix `resonance_center_energies`
3167    /// returned `[7.8, 7.8, 16.9]` ⇒ minimum spacing 0 ⇒ `match_tol` 0 ⇒ every
3168    /// dip rejected ⇒ seed silently `None` (cold start).  With the dedup + grid
3169    /// floor the seed recovers the (identity) calibration.
3170    #[test]
3171    fn peak_match_energy_scale_seed_handles_duplicate_resonance_energies() {
3172        use nereids_physics::transmission::{SampleParams, forward_model};
3173
3174        // Two isotopes share an EXACT resonance energy (7.8); a third is distinct.
3175        let iso_a = synthetic_single_resonance(72, 178, 176.0, 7.8);
3176        let iso_b = synthetic_single_resonance(74, 184, 182.0, 7.8); // duplicate energy
3177        let iso_c = synthetic_single_resonance(40, 90, 89.0, 16.9); // distinct
3178        let energies: Vec<f64> = (0..900).map(|i| 4.0 + (i as f64) * 0.02).collect();
3179        let density = 0.05_f64;
3180        let sample = SampleParams::new(
3181            293.6,
3182            vec![
3183                (iso_a.clone(), density),
3184                (iso_b.clone(), density),
3185                (iso_c.clone(), density),
3186            ],
3187        )
3188        .unwrap();
3189        let t_obs = forward_model(&energies, &sample, None).unwrap();
3190        let config = UnifiedFitConfig::new(
3191            energies.clone(),
3192            vec![iso_a, iso_b, iso_c],
3193            vec!["A".into(), "B".into(), "C".into()],
3194            293.6,
3195            None,
3196            vec![density, density, density],
3197        )
3198        .unwrap()
3199        .with_energy_scale(0.0, 1.0, 25.0);
3200        let (t0, l_scale) = peak_match_energy_scale_seed(
3201            &t_obs,
3202            config.energies(),
3203            &config,
3204            25.0,
3205            (-10.0, 10.0),
3206            (0.99, 1.01),
3207        )
3208        .expect(
3209            "duplicate resonance energies must not collapse match_tol to 0 — \
3210             seed should be Some (was silently None pre-fix)",
3211        );
3212        assert!(t0.abs() < 0.5, "identity calibration: t0 ≈ 0, got {t0}");
3213        assert!(
3214            (l_scale - 1.0).abs() < 5e-3,
3215            "identity calibration: L_scale ≈ 1, got {l_scale}"
3216        );
3217    }
3218
3219    // ── Phase 0: InputData + SolverConfig + CountsBackgroundConfig tests ──
3220
3221    #[test]
3222    fn test_input_data_transmission_n_energies() {
3223        let data = InputData::Transmission {
3224            transmission: vec![0.9, 0.8, 0.7],
3225            uncertainty: vec![0.01, 0.01, 0.01],
3226        };
3227        assert_eq!(data.n_energies(), 3);
3228        assert!(!data.is_counts());
3229    }
3230
3231    #[test]
3232    fn test_input_data_counts_n_energies() {
3233        let data = InputData::Counts {
3234            sample_counts: vec![10.0, 20.0, 30.0, 40.0],
3235            open_beam_counts: vec![100.0, 100.0, 100.0, 100.0],
3236        };
3237        assert_eq!(data.n_energies(), 4);
3238        assert!(data.is_counts());
3239    }
3240
3241    #[test]
3242    fn test_input_data_counts_with_nuisance() {
3243        let data = InputData::CountsWithNuisance {
3244            sample_counts: vec![5.0, 6.0],
3245            flux: vec![100.0, 100.0],
3246            background: vec![0.5, 0.5],
3247        };
3248        assert_eq!(data.n_energies(), 2);
3249        assert!(data.is_counts());
3250    }
3251
3252    #[test]
3253    fn test_solver_config_default_is_auto() {
3254        let cfg = SolverConfig::default();
3255        assert!(matches!(cfg, SolverConfig::Auto));
3256    }
3257
3258    #[test]
3259    fn test_counts_background_config_default() {
3260        let cfg = CountsBackgroundConfig::default();
3261        assert!((cfg.alpha_1_init - 1.0).abs() < f64::EPSILON);
3262        assert!((cfg.alpha_2_init - 1.0).abs() < f64::EPSILON);
3263        assert!(!cfg.fit_alpha_1);
3264        assert!(!cfg.fit_alpha_2);
3265    }
3266
3267    // ── Phase 2: fit_spectrum_typed tests ──
3268
3269    /// Helper: build synthetic transmission data from known density.
3270    fn synthetic_transmission(
3271        data: &ResonanceData,
3272        true_density: f64,
3273        energies: &[f64],
3274    ) -> (Vec<f64>, Vec<f64>) {
3275        let model = PrecomputedTransmissionModel {
3276            cross_sections: Arc::new(vec![
3277                phys_transmission::broadened_cross_sections(
3278                    energies,
3279                    std::slice::from_ref(data),
3280                    0.0,
3281                    None,
3282                    None,
3283                )
3284                .unwrap()
3285                .into_iter()
3286                .next()
3287                .unwrap(),
3288            ]),
3289            density_indices: Arc::new(vec![0]),
3290            energies: None,
3291            instrument: None,
3292            resolution_plan: None,
3293            sparse_cubature_plan: None,
3294            sparse_scalar_plan: None,
3295            work_layout: None,
3296        };
3297        let t = model.evaluate(&[true_density]).unwrap();
3298        let sigma: Vec<f64> = t.iter().map(|&v| 0.01 * v.max(0.01)).collect();
3299        (t, sigma)
3300    }
3301
3302    /// Helper: build synthetic counts from known density.
3303    fn synthetic_counts(
3304        data: &ResonanceData,
3305        true_density: f64,
3306        energies: &[f64],
3307        i0: f64,
3308    ) -> (Vec<f64>, Vec<f64>) {
3309        let (t, _) = synthetic_transmission(data, true_density, energies);
3310        let open_beam: Vec<f64> = vec![i0; energies.len()];
3311        let sample: Vec<f64> = t.iter().map(|&v| (v * i0).round().max(0.0)).collect();
3312        (sample, open_beam)
3313    }
3314
3315    #[test]
3316    fn test_typed_transmission_lm_recovers_density() {
3317        let data = u238_single_resonance();
3318        let true_density = 0.002;
3319        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3320        let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
3321
3322        let config = UnifiedFitConfig::new(
3323            energies,
3324            vec![data],
3325            vec!["U-238".into()],
3326            0.0,
3327            None,
3328            vec![0.001],
3329        )
3330        .unwrap()
3331        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3332
3333        let input = InputData::Transmission {
3334            transmission: t,
3335            uncertainty: sigma,
3336        };
3337
3338        let result = fit_spectrum_typed(&input, &config).unwrap();
3339        assert!(result.converged, "LM should converge");
3340        let fitted = result.densities[0];
3341        assert!(
3342            (fitted - true_density).abs() / true_density < 0.05,
3343            "density: fitted={fitted}, true={true_density}"
3344        );
3345    }
3346
3347    /// Helper: a valid single-isotope transmission config + input on a short
3348    /// grid, for precomputed-cross-section shape-validation tests.
3349    fn precomputed_xs_fixture() -> (UnifiedFitConfig, InputData) {
3350        let data = u238_single_resonance();
3351        let energies: Vec<f64> = (0..11).map(|i| 1.0 + (i as f64) * 0.1).collect();
3352        let (t, sigma) = synthetic_transmission(&data, 0.001, &energies);
3353        let config = UnifiedFitConfig::new(
3354            energies,
3355            vec![data],
3356            vec!["U-238".into()],
3357            0.0,
3358            None,
3359            vec![0.001],
3360        )
3361        .unwrap()
3362        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3363        let input = InputData::Transmission {
3364            transmission: t,
3365            uncertainty: sigma,
3366        };
3367        (config, input)
3368    }
3369
3370    #[test]
3371    fn test_fit_rejects_empty_precomputed_cross_sections() {
3372        // An empty XS stack used to panic on `xs[0].len()` deep in the
3373        // forward-model builder; it must now be a typed up-front error.
3374        let (config, input) = precomputed_xs_fixture();
3375        let config = config.with_precomputed_cross_sections(Arc::new(Vec::new()));
3376        let err = fit_spectrum_typed(&input, &config).unwrap_err();
3377        assert!(
3378            matches!(err, PipelineError::ShapeMismatch(_)),
3379            "expected ShapeMismatch, got {err:?}"
3380        );
3381        assert!(err.to_string().contains("must not be empty"));
3382    }
3383
3384    #[test]
3385    fn test_fit_rejects_wrong_row_count_precomputed_cross_sections() {
3386        // 1 isotope → exactly 1 σ row expected; supply 2.
3387        let (config, input) = precomputed_xs_fixture();
3388        let n_e = config.energies().len();
3389        let bad = vec![vec![1.0; n_e], vec![1.0; n_e]];
3390        let config = config.with_precomputed_cross_sections(Arc::new(bad));
3391        let err = fit_spectrum_typed(&input, &config).unwrap_err();
3392        assert!(
3393            matches!(err, PipelineError::ShapeMismatch(_)),
3394            "expected ShapeMismatch, got {err:?}"
3395        );
3396        assert!(err.to_string().contains("rows"));
3397    }
3398
3399    #[test]
3400    fn test_fit_rejects_wrong_energy_length_precomputed_cross_sections() {
3401        // Correct row count (1) but the row is the wrong length.
3402        let (config, input) = precomputed_xs_fixture();
3403        let n_e = config.energies().len();
3404        let bad = vec![vec![1.0; n_e + 3]];
3405        let config = config.with_precomputed_cross_sections(Arc::new(bad));
3406        let err = fit_spectrum_typed(&input, &config).unwrap_err();
3407        assert!(
3408            matches!(err, PipelineError::ShapeMismatch(_)),
3409            "expected ShapeMismatch, got {err:?}"
3410        );
3411        assert!(err.to_string().contains("config.energies"));
3412    }
3413
3414    #[test]
3415    fn test_fit_accepts_correct_precomputed_cross_sections() {
3416        // A correctly-shaped precomputed stack must still fit (no false
3417        // rejection): 1 row of length n_e.
3418        let (config, input) = precomputed_xs_fixture();
3419        let n_e = config.energies().len();
3420        let xs = phys_transmission::broadened_cross_sections(
3421            config.energies(),
3422            config.resonance_data(),
3423            0.0,
3424            None,
3425            None,
3426        )
3427        .unwrap();
3428        assert_eq!(xs.len(), 1);
3429        assert_eq!(xs[0].len(), n_e);
3430        let config = config.with_precomputed_cross_sections(Arc::new(xs));
3431        // Must not error on shape; the fit itself may or may not converge,
3432        // but the call must reach the solver rather than fail validation.
3433        let result = fit_spectrum_typed(&input, &config);
3434        assert!(
3435            result.is_ok(),
3436            "correctly-shaped precomputed XS must pass validation, got {result:?}"
3437        );
3438    }
3439
3440    #[test]
3441    fn test_fit_rejects_non_finite_precomputed_cross_sections() {
3442        // A correctly-shaped row whose σ values are NaN passes the shape
3443        // checks but poisons the forward model (NaN residual swallowed as a
3444        // failed pixel).  It must be rejected at the boundary.  `NaN < x` is
3445        // `false`, so a bare order comparison would let it through — the
3446        // `is_finite()` half of the guard is what catches it.
3447        let (config, input) = precomputed_xs_fixture();
3448        let n_e = config.energies().len();
3449        let bad = vec![vec![f64::NAN; n_e]];
3450        let config = config.with_precomputed_cross_sections(Arc::new(bad));
3451        let err = fit_spectrum_typed(&input, &config).unwrap_err();
3452        assert!(
3453            matches!(err, PipelineError::ShapeMismatch(_)),
3454            "expected ShapeMismatch, got {err:?}"
3455        );
3456        assert!(
3457            err.to_string().contains("non-finite"),
3458            "error should mention non-finite σ, got: {err}"
3459        );
3460
3461        // ±∞ is equally rejected.
3462        let (config, input) = precomputed_xs_fixture();
3463        let mut row = vec![1.0; n_e];
3464        row[n_e / 2] = f64::INFINITY;
3465        let config = config.with_precomputed_cross_sections(Arc::new(vec![row]));
3466        let err = fit_spectrum_typed(&input, &config).unwrap_err();
3467        assert!(
3468            matches!(err, PipelineError::ShapeMismatch(_)),
3469            "expected ShapeMismatch for +inf σ, got {err:?}"
3470        );
3471    }
3472
3473    #[test]
3474    fn test_evaluate_jacobian_rejects_malformed_precomputed_cross_sections() {
3475        // `evaluate_jacobian_and_fisher` is a third public entry point that
3476        // consumes `config.precomputed_cross_sections`.  An empty stack used
3477        // to reach `build_transmission_model` and panic on `xs[0].len()`; it
3478        // must now fail the shared up-front validator instead.
3479        let (config, _input) = precomputed_xs_fixture();
3480        let n_e = config.energies().len();
3481        let flux = vec![1.0; n_e];
3482        let background = vec![0.0; n_e];
3483
3484        // `ModelJacobianResult` (the Ok type) is not `Debug`, so match on the
3485        // result rather than calling `unwrap_err()`.
3486        let empty = config
3487            .clone()
3488            .with_precomputed_cross_sections(Arc::new(Vec::new()));
3489        match evaluate_jacobian_and_fisher(&empty, &flux, &background) {
3490            Err(PipelineError::ShapeMismatch(msg)) => {
3491                assert!(msg.contains("must not be empty"), "got: {msg}");
3492            }
3493            Err(other) => panic!("expected ShapeMismatch for empty XS, got {other:?}"),
3494            Ok(_) => panic!("empty precomputed XS must be rejected"),
3495        }
3496
3497        // Non-finite σ is rejected on this path too.
3498        let nan = config.with_precomputed_cross_sections(Arc::new(vec![vec![f64::NAN; n_e]]));
3499        match evaluate_jacobian_and_fisher(&nan, &flux, &background) {
3500            Err(PipelineError::ShapeMismatch(msg)) => {
3501                assert!(msg.contains("non-finite"), "got: {msg}");
3502            }
3503            Err(other) => panic!("expected ShapeMismatch for NaN σ, got {other:?}"),
3504            Ok(_) => panic!("non-finite precomputed σ must be rejected"),
3505        }
3506    }
3507
3508    #[test]
3509    fn test_extract_result_drops_uncertainties_when_unconverged() {
3510        let data = u238_single_resonance();
3511        let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
3512        let config = UnifiedFitConfig::new(
3513            energies,
3514            vec![data],
3515            vec!["U-238".into()],
3516            293.6,
3517            None,
3518            vec![0.001],
3519        )
3520        .unwrap();
3521
3522        let result = LmResult {
3523            chi_squared: 1.0,
3524            reduced_chi_squared: 1.0,
3525            iterations: 5,
3526            converged: false,
3527            params: vec![0.001],
3528            covariance: Some(lm::FlatMatrix::zeros(1, 1)),
3529            uncertainties: Some(vec![0.123]),
3530        };
3531
3532        let extracted = extract_result(&config, &result, 1, None).unwrap();
3533        assert!(!extracted.converged);
3534        assert!(
3535            extracted.uncertainties.is_none(),
3536            "pipeline must not surface uncertainties from an unconverged fit"
3537        );
3538        assert!(extracted.temperature_k_unc.is_none());
3539    }
3540
3541    #[test]
3542    fn test_typed_counts_kl_recovers_density() {
3543        let data = u238_single_resonance();
3544        let true_density = 0.002;
3545        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3546        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
3547
3548        let config = UnifiedFitConfig::new(
3549            energies,
3550            vec![data],
3551            vec!["U-238".into()],
3552            0.0,
3553            None,
3554            vec![0.001],
3555        )
3556        .unwrap()
3557        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3558
3559        let input = InputData::Counts {
3560            sample_counts: sample,
3561            open_beam_counts: open_beam,
3562        };
3563
3564        let result = fit_spectrum_typed(&input, &config).unwrap();
3565        assert!(result.converged, "KL on counts should converge");
3566        let fitted = result.densities[0];
3567        assert!(
3568            (fitted - true_density).abs() / true_density < 0.10,
3569            "density: fitted={fitted}, true={true_density}"
3570        );
3571    }
3572
3573    #[test]
3574    fn test_typed_counts_kl_low_counts_recovers_density() {
3575        // I0=10 counts per bin — the regime where KL excels and LM fails
3576        let data = u238_single_resonance();
3577        let true_density = 0.0005;
3578        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3579        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 10.0);
3580
3581        let config = UnifiedFitConfig::new(
3582            energies,
3583            vec![data],
3584            vec!["U-238".into()],
3585            0.0,
3586            None,
3587            vec![0.001],
3588        )
3589        .unwrap()
3590        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3591
3592        let input = InputData::Counts {
3593            sample_counts: sample,
3594            open_beam_counts: open_beam,
3595        };
3596
3597        let result = fit_spectrum_typed(&input, &config).unwrap();
3598        assert!(result.converged, "KL on low counts should converge");
3599        let fitted = result.densities[0];
3600        // Wider tolerance for low counts
3601        assert!(
3602            (fitted - true_density).abs() / true_density < 0.30,
3603            "density: fitted={fitted}, true={true_density}"
3604        );
3605    }
3606
3607    #[test]
3608    fn test_typed_transmission_kl_recovers_density() {
3609        let data = u238_single_resonance();
3610        let true_density = 0.0005;
3611        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3612        let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
3613
3614        let config = UnifiedFitConfig::new(
3615            energies,
3616            vec![data],
3617            vec!["U-238".into()],
3618            0.0,
3619            None,
3620            vec![0.001],
3621        )
3622        .unwrap()
3623        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3624
3625        let input = InputData::Transmission {
3626            transmission: t,
3627            uncertainty: sigma,
3628        };
3629
3630        let result = fit_spectrum_typed(&input, &config).unwrap();
3631        assert!(result.converged, "KL on transmission should converge");
3632        let fitted = result.densities[0];
3633        assert!(
3634            (fitted - true_density).abs() / true_density < 0.05,
3635            "density: fitted={fitted}, true={true_density}"
3636        );
3637    }
3638
3639    #[test]
3640    fn test_typed_counts_lm_auto_converts() {
3641        // Counts + LM should auto-convert to transmission and fit
3642        let data = u238_single_resonance();
3643        let true_density = 0.0005;
3644        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3645        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
3646
3647        let config = UnifiedFitConfig::new(
3648            energies,
3649            vec![data],
3650            vec!["U-238".into()],
3651            0.0,
3652            None,
3653            vec![0.001],
3654        )
3655        .unwrap()
3656        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3657
3658        let input = InputData::Counts {
3659            sample_counts: sample,
3660            open_beam_counts: open_beam,
3661        };
3662
3663        let result = fit_spectrum_typed(&input, &config).unwrap();
3664        assert!(
3665            result.converged,
3666            "LM on auto-converted counts should converge"
3667        );
3668        let fitted = result.densities[0];
3669        assert!(
3670            (fitted - true_density).abs() / true_density < 0.10,
3671            "density: fitted={fitted}, true={true_density}"
3672        );
3673    }
3674
3675    #[test]
3676    fn test_typed_auto_solver_selects_kl_for_counts() {
3677        let data = u238_single_resonance();
3678        let true_density = 0.0005;
3679        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3680        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
3681
3682        // Auto solver (default)
3683        let config = UnifiedFitConfig::new(
3684            energies,
3685            vec![data],
3686            vec!["U-238".into()],
3687            0.0,
3688            None,
3689            vec![0.001],
3690        )
3691        .unwrap(); // SolverConfig::Auto by default
3692
3693        let input = InputData::Counts {
3694            sample_counts: sample,
3695            open_beam_counts: open_beam,
3696        };
3697
3698        let result = fit_spectrum_typed(&input, &config).unwrap();
3699        assert!(
3700            result.converged,
3701            "Auto solver on counts should use KL and converge"
3702        );
3703    }
3704
3705    #[test]
3706    fn test_typed_transmission_with_background_lm() {
3707        let data = u238_single_resonance();
3708        let true_density = 0.0005;
3709        let true_anorm = 0.95;
3710        let true_back_a = 0.02;
3711        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3712
3713        // Generate synthetic data with background
3714        let (t_pure, _) = synthetic_transmission(&data, true_density, &energies);
3715        let t_bg: Vec<f64> = t_pure
3716            .iter()
3717            .map(|&v| true_anorm * v + true_back_a)
3718            .collect();
3719        let sigma: Vec<f64> = t_bg.iter().map(|&v| 0.01 * v.max(0.01)).collect();
3720
3721        let config = UnifiedFitConfig::new(
3722            energies,
3723            vec![data],
3724            vec!["U-238".into()],
3725            0.0,
3726            None,
3727            vec![0.001],
3728        )
3729        .unwrap()
3730        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
3731            max_iter: 500,
3732            ..LmConfig::default()
3733        }))
3734        .with_transmission_background(BackgroundConfig::default());
3735
3736        let input = InputData::Transmission {
3737            transmission: t_bg,
3738            uncertainty: sigma,
3739        };
3740
3741        let result = fit_spectrum_typed(&input, &config).unwrap();
3742        assert!(
3743            result.converged,
3744            "LM+BG should converge on noiseless synthetic data (chi2r={}, iter={})",
3745            result.reduced_chi_squared, result.iterations
3746        );
3747        assert!(
3748            (result.densities[0] - true_density).abs() / true_density < 0.05,
3749            "density: fitted={}, true={true_density}",
3750            result.densities[0]
3751        );
3752        assert!(
3753            (result.anorm - true_anorm).abs() / true_anorm < 0.05,
3754            "anorm: fitted={}, true={true_anorm}",
3755            result.anorm
3756        );
3757    }
3758
3759    #[test]
3760    fn test_typed_counts_with_nuisance_rejects_lm() {
3761        let data = u238_single_resonance();
3762        let energies: Vec<f64> = (0..10).map(|i| 1.0 + (i as f64) * 0.5).collect();
3763
3764        let config = UnifiedFitConfig::new(
3765            energies,
3766            vec![data],
3767            vec!["U-238".into()],
3768            0.0,
3769            None,
3770            vec![0.001],
3771        )
3772        .unwrap()
3773        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3774
3775        let input = InputData::CountsWithNuisance {
3776            sample_counts: vec![10.0; 10],
3777            flux: vec![100.0; 10],
3778            background: vec![0.0; 10],
3779        };
3780
3781        let result = fit_spectrum_typed(&input, &config);
3782        assert!(result.is_err(), "CountsWithNuisance + LM should error");
3783    }
3784
3785    /// Helper: build synthetic transmission at a given temperature.
3786    fn synthetic_transmission_at_temp(
3787        data: &ResonanceData,
3788        true_density: f64,
3789        temperature_k: f64,
3790        energies: &[f64],
3791    ) -> (Vec<f64>, Vec<f64>) {
3792        let xs = phys_transmission::broadened_cross_sections(
3793            energies,
3794            std::slice::from_ref(data),
3795            temperature_k,
3796            None,
3797            None,
3798        )
3799        .unwrap();
3800        let model = PrecomputedTransmissionModel {
3801            cross_sections: Arc::new(xs),
3802            density_indices: Arc::new(vec![0]),
3803            energies: None,
3804            instrument: None,
3805            resolution_plan: None,
3806            sparse_cubature_plan: None,
3807            sparse_scalar_plan: None,
3808            work_layout: None,
3809        };
3810        let t = model.evaluate(&[true_density]).unwrap();
3811        let sigma: Vec<f64> = t.iter().map(|&v| 0.01 * v.max(0.01)).collect();
3812        (t, sigma)
3813    }
3814
3815    #[test]
3816    fn test_typed_poisson_kl_with_temperature() {
3817        let data = u238_single_resonance();
3818        let true_density = 0.0005;
3819        let true_temp = 350.0;
3820        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3821        let (t, sigma) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
3822
3823        let config = UnifiedFitConfig::new(
3824            energies,
3825            vec![data],
3826            vec!["U-238".into()],
3827            300.0, // initial guess (off by 50 K)
3828            None,
3829            vec![0.001],
3830        )
3831        .unwrap()
3832        .with_solver(SolverConfig::PoissonKL(PoissonConfig {
3833            max_iter: 500,
3834            ..PoissonConfig::default()
3835        }))
3836        .with_fit_temperature(true);
3837
3838        let input = InputData::Transmission {
3839            transmission: t,
3840            uncertainty: sigma,
3841        };
3842
3843        let result = fit_spectrum_typed(&input, &config).unwrap();
3844
3845        // Check density recovery (within 1%)
3846        let fitted_density = result.densities[0];
3847        assert!(
3848            (fitted_density - true_density).abs() / true_density < 0.01,
3849            "density: fitted={fitted_density}, true={true_density}, ratio={}",
3850            (fitted_density - true_density).abs() / true_density,
3851        );
3852
3853        // Check temperature recovery (within 1 K)
3854        let fitted_temp = result
3855            .temperature_k
3856            .expect("temperature_k should be Some when fit_temperature=true");
3857        assert!(
3858            (fitted_temp - true_temp).abs() < 1.0,
3859            "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
3860            (fitted_temp - true_temp).abs(),
3861        );
3862    }
3863
3864    #[test]
3865    fn test_typed_poisson_kl_with_temperature_and_background() {
3866        let data = u238_single_resonance();
3867        let true_density = 0.0005;
3868        let true_temp = 350.0;
3869        let true_b0 = 0.012;
3870        let true_b1 = 0.008;
3871        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3872        let (t, sigma) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
3873        let measured_t: Vec<f64> = t
3874            .iter()
3875            .zip(energies.iter())
3876            .map(|(&ti, &e)| ti + true_b0 + true_b1 / e.sqrt())
3877            .collect();
3878
3879        let config = UnifiedFitConfig::new(
3880            energies,
3881            vec![data],
3882            vec!["U-238".into()],
3883            300.0,
3884            None,
3885            vec![0.001],
3886        )
3887        .unwrap()
3888        .with_solver(SolverConfig::PoissonKL(PoissonConfig {
3889            max_iter: 120,
3890            gauss_newton_lambda: 1e-4,
3891            ..PoissonConfig::default()
3892        }))
3893        .with_fit_temperature(true)
3894        .with_transmission_background(BackgroundConfig::default());
3895
3896        let input = InputData::Transmission {
3897            transmission: measured_t,
3898            uncertainty: sigma,
3899        };
3900
3901        let result = fit_spectrum_typed(&input, &config).unwrap();
3902
3903        assert!(result.converged, "fit did not converge: {result:?}");
3904        assert!(
3905            result.iterations <= 80,
3906            "expected KL background+temperature fit to converge well before max_iter; got {}",
3907            result.iterations,
3908        );
3909
3910        let fitted_density = result.densities[0];
3911        assert!(
3912            (fitted_density - true_density).abs() / true_density < 0.02,
3913            "density: fitted={fitted_density}, true={true_density}, ratio={}",
3914            (fitted_density - true_density).abs() / true_density,
3915        );
3916
3917        let fitted_temp = result
3918            .temperature_k
3919            .expect("temperature_k should be Some when fit_temperature=true");
3920        assert!(
3921            (fitted_temp - true_temp).abs() < 3.0,
3922            "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
3923            (fitted_temp - true_temp).abs(),
3924        );
3925
3926        assert!(
3927            (result.background[0] - true_b0).abs() < 5e-3,
3928            "background b0: fitted={}, true={}",
3929            result.background[0],
3930            true_b0,
3931        );
3932        assert!(
3933            (result.background[1] - true_b1).abs() < 5e-3,
3934            "background b1: fitted={}, true={}",
3935            result.background[1],
3936            true_b1,
3937        );
3938    }
3939
3940    /// Round-trip test: create a group of 2 isotopes with known ratios,
3941    /// generate synthetic transmission, fit with group constraints,
3942    /// verify the fitted group density matches the true value.
3943    #[test]
3944    fn test_grouped_fit_spectrum_round_trip() {
3945        use nereids_core::types::IsotopeGroup;
3946
3947        // Two synthetic isotopes with resonances at different energies
3948        let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
3949        let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
3950
3951        // Group with 60/40 ratio
3952        let iso1 = nereids_core::types::Isotope::new(92, 235).unwrap();
3953        let iso2 = nereids_core::types::Isotope::new(92, 238).unwrap();
3954        let group =
3955            IsotopeGroup::custom("U (60/40)".into(), vec![(iso1, 0.6), (iso2, 0.4)]).unwrap();
3956
3957        let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.05).collect();
3958        let true_density = 0.0005;
3959
3960        // Generate synthetic transmission using effective densities
3961        let sample = nereids_physics::transmission::SampleParams::new(
3962            0.0,
3963            vec![
3964                (rd1.clone(), true_density * 0.6),
3965                (rd2.clone(), true_density * 0.4),
3966            ],
3967        )
3968        .unwrap();
3969        let transmission =
3970            nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
3971        let uncertainty: Vec<f64> = transmission.iter().map(|&t| 0.01 * t.max(0.01)).collect();
3972
3973        // Build config with group
3974        let config = UnifiedFitConfig::new(
3975            energies.clone(),
3976            vec![rd1.clone()],
3977            vec!["placeholder".into()],
3978            0.0,
3979            None,
3980            vec![0.001],
3981        )
3982        .unwrap()
3983        .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
3984        .unwrap()
3985        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3986
3987        let input = InputData::Transmission {
3988            transmission,
3989            uncertainty,
3990        };
3991
3992        let result = fit_spectrum_typed(&input, &config).unwrap();
3993
3994        // Should recover true density within 1%
3995        assert_eq!(result.densities.len(), 1, "should have 1 group density");
3996        let fitted = result.densities[0];
3997        let rel_error = (fitted - true_density).abs() / true_density;
3998        assert!(
3999            rel_error < 0.01,
4000            "group density: fitted={fitted}, true={true_density}, rel_error={rel_error}"
4001        );
4002        assert!(result.converged, "fit should converge");
4003    }
4004
4005    #[test]
4006    fn test_grouped_poisson_kl_with_temperature_and_background_noiseless() {
4007        use nereids_core::types::IsotopeGroup;
4008
4009        let rd1 = synthetic_single_resonance(72, 176, 8.5, 5.0);
4010        let rd2 = synthetic_single_resonance(72, 178, 17.0, 7.5);
4011        let rd3 = synthetic_single_resonance(72, 180, 29.0, 6.0);
4012
4013        let hf176 = nereids_core::types::Isotope::new(72, 176).unwrap();
4014        let hf178 = nereids_core::types::Isotope::new(72, 178).unwrap();
4015        let hf180 = nereids_core::types::Isotope::new(72, 180).unwrap();
4016        let group = IsotopeGroup::custom(
4017            "Hf-like (3 member)".into(),
4018            vec![(hf176, 0.2), (hf178, 0.5), (hf180, 0.3)],
4019        )
4020        .unwrap();
4021
4022        let energies: Vec<f64> = (0..300).map(|i| 1.0 + (49.0 * i as f64) / 299.0).collect();
4023        let true_density = 0.001;
4024        let true_temp = 400.0;
4025        let true_b0 = 0.012;
4026        let true_b1 = 0.008;
4027
4028        let sample = nereids_physics::transmission::SampleParams::new(
4029            true_temp,
4030            vec![
4031                (rd1.clone(), true_density * 0.2),
4032                (rd2.clone(), true_density * 0.5),
4033                (rd3.clone(), true_density * 0.3),
4034            ],
4035        )
4036        .unwrap();
4037        let pure_t =
4038            nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
4039        let measured_t: Vec<f64> = pure_t
4040            .iter()
4041            .zip(energies.iter())
4042            .map(|(&t, &e)| t + true_b0 + true_b1 / e.sqrt())
4043            .collect();
4044        let sigma = vec![0.001; energies.len()];
4045
4046        let config = UnifiedFitConfig::new(
4047            energies.clone(),
4048            vec![rd1.clone()],
4049            vec!["placeholder".into()],
4050            293.6,
4051            None,
4052            vec![0.0008],
4053        )
4054        .unwrap()
4055        .with_groups(&[(&group, &[rd1, rd2, rd3])], vec![0.0008])
4056        .unwrap()
4057        .with_solver(SolverConfig::PoissonKL(PoissonConfig {
4058            max_iter: 200,
4059            gauss_newton_lambda: 1e-4,
4060            ..PoissonConfig::default()
4061        }))
4062        .with_fit_temperature(true)
4063        .with_transmission_background(BackgroundConfig::default());
4064
4065        let input = InputData::Transmission {
4066            transmission: measured_t,
4067            uncertainty: sigma,
4068        };
4069
4070        let result = fit_spectrum_typed(&input, &config).unwrap();
4071
4072        assert!(result.converged, "fit did not converge: {result:?}");
4073        // Tolerance: 1% — the NormalizedTransmissionModel (4 background params)
4074        // has slightly different convergence than the old 2-param KL model.
4075        assert!(
4076            (result.densities[0] - true_density).abs() / true_density < 0.01,
4077            "density: fitted={}, true={true_density}",
4078            result.densities[0]
4079        );
4080        let fitted_temp = result
4081            .temperature_k
4082            .expect("temperature_k should be Some when fit_temperature=true");
4083        assert!(
4084            (fitted_temp - true_temp).abs() < 8.0,
4085            "temperature: fitted={fitted_temp}, true={true_temp}",
4086        );
4087        // Background: NormalizedTransmissionModel distributes the additive
4088        // background across Anorm + BackA/B/C.  Check that the total background
4089        // contribution is reasonable, not individual parameters.
4090        let e_mid: f64 = 10.0;
4091        let bg_total = (result.anorm - 1.0)
4092            + result.background[0]
4093            + result.background[1] / e_mid.sqrt()
4094            + result.background[2] * e_mid.sqrt();
4095        let true_bg_mid = true_b0 + true_b1 / e_mid.sqrt();
4096        assert!(
4097            (bg_total - true_bg_mid).abs() < 0.02,
4098            "total bg at E={e_mid}: fitted={bg_total:.6}, true={true_bg_mid:.6}",
4099        );
4100    }
4101
4102    // ── Phase 2: KL fitting uncertainty tests ──────────────────────────────
4103
4104    #[test]
4105    fn test_kl_counts_returns_density_uncertainty() {
4106        let data = u238_single_resonance();
4107        let true_density = 0.002;
4108        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4109        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
4110
4111        let config = UnifiedFitConfig::new(
4112            energies,
4113            vec![data],
4114            vec!["U-238".into()],
4115            0.0,
4116            None,
4117            vec![0.001],
4118        )
4119        .unwrap()
4120        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
4121
4122        let input = InputData::Counts {
4123            sample_counts: sample,
4124            open_beam_counts: open_beam,
4125        };
4126        let result = fit_spectrum_typed(&input, &config).unwrap();
4127        assert!(result.converged);
4128        let unc = result
4129            .uncertainties
4130            .as_ref()
4131            .expect("KL 1D fit should return density uncertainties");
4132        assert_eq!(unc.len(), 1);
4133        assert!(
4134            unc[0].is_finite() && unc[0] > 0.0,
4135            "density unc = {}",
4136            unc[0]
4137        );
4138        assert!(
4139            unc[0] < result.densities[0],
4140            "unc ({}) should be < density ({}) for high-count data",
4141            unc[0],
4142            result.densities[0]
4143        );
4144    }
4145
4146    #[test]
4147    fn test_kl_counts_returns_temperature_uncertainty() {
4148        let data = u238_single_resonance();
4149        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.05).collect();
4150        let (sample, open_beam) = synthetic_counts(&data, 0.001, &energies, 1000.0);
4151
4152        let config = UnifiedFitConfig::new(
4153            energies,
4154            vec![data],
4155            vec!["U-238".into()],
4156            350.0,
4157            None,
4158            vec![0.0005],
4159        )
4160        .unwrap()
4161        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4162        .with_fit_temperature(true);
4163
4164        let input = InputData::Counts {
4165            sample_counts: sample,
4166            open_beam_counts: open_beam,
4167        };
4168        let result = fit_spectrum_typed(&input, &config).unwrap();
4169        assert!(result.converged);
4170        let unc = result
4171            .uncertainties
4172            .as_ref()
4173            .expect("KL+temp fit should return density uncertainties");
4174        assert!(
4175            unc[0].is_finite() && unc[0] > 0.0,
4176            "density unc = {}",
4177            unc[0]
4178        );
4179        let t_unc = result
4180            .temperature_k_unc
4181            .expect("KL+temp fit should return temperature uncertainty");
4182        assert!(
4183            t_unc.is_finite() && t_unc > 0.0,
4184            "temperature unc = {t_unc}"
4185        );
4186    }
4187
4188    #[test]
4189    fn test_kl_counts_with_background_returns_uncertainty() {
4190        let data = u238_single_resonance();
4191        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.05).collect();
4192        let (sample, open_beam) = synthetic_counts(&data, 0.001, &energies, 1000.0);
4193
4194        let config = UnifiedFitConfig::new(
4195            energies,
4196            vec![data],
4197            vec!["U-238".into()],
4198            300.0,
4199            None,
4200            vec![0.0005],
4201        )
4202        .unwrap()
4203        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4204        .with_fit_temperature(true)
4205        .with_transmission_background(BackgroundConfig::default());
4206
4207        let input = InputData::Counts {
4208            sample_counts: sample,
4209            open_beam_counts: open_beam,
4210        };
4211        let result = fit_spectrum_typed(&input, &config).unwrap();
4212        assert!(result.converged);
4213        let unc = result
4214            .uncertainties
4215            .as_ref()
4216            .expect("KL+bg fit should return density uncertainties");
4217        assert!(
4218            unc[0].is_finite() && unc[0] > 0.0,
4219            "density unc = {}",
4220            unc[0]
4221        );
4222    }
4223
4224    #[test]
4225    fn test_lm_uncertainty_not_regressed() {
4226        let data = u238_single_resonance();
4227        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4228        let (t, sigma) = synthetic_transmission(&data, 0.001, &energies);
4229
4230        let config = UnifiedFitConfig::new(
4231            energies,
4232            vec![data],
4233            vec!["U-238".into()],
4234            0.0,
4235            None,
4236            vec![0.0005],
4237        )
4238        .unwrap()
4239        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
4240
4241        let input = InputData::Transmission {
4242            transmission: t,
4243            uncertainty: sigma,
4244        };
4245        let result = fit_spectrum_typed(&input, &config).unwrap();
4246        assert!(result.converged);
4247        let unc = result
4248            .uncertainties
4249            .as_ref()
4250            .expect("LM should still return uncertainties");
4251        assert!(unc[0].is_finite() && unc[0] > 0.0);
4252    }
4253
4254    // ── Energy-scale fitting tests ──
4255
4256    /// fit_energy_scale + fit_temperature must be rejected.
4257    #[test]
4258    fn test_energy_scale_rejects_temperature() {
4259        let data = u238_single_resonance();
4260        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
4261        let (t_obs, sigma) = synthetic_transmission(&data, 0.002, &energies);
4262
4263        let config = UnifiedFitConfig::new(
4264            energies,
4265            vec![data],
4266            vec!["U-238".into()],
4267            293.6,
4268            None,
4269            vec![0.001],
4270        )
4271        .unwrap()
4272        .with_solver(SolverConfig::LevenbergMarquardt(Default::default()))
4273        .with_fit_temperature(true)
4274        .with_energy_scale(0.0, 1.0, 25.0);
4275
4276        let input = InputData::Transmission {
4277            transmission: t_obs,
4278            uncertainty: sigma,
4279        };
4280        let err = fit_spectrum_typed(&input, &config);
4281        assert!(err.is_err(), "energy_scale + temperature should fail");
4282        let msg = err.unwrap_err().to_string();
4283        assert!(
4284            msg.contains("fit_energy_scale") && msg.contains("fit_temperature"),
4285            "error should mention both flags: {msg}"
4286        );
4287    }
4288
4289    /// Energy-scale fitting returns t0_us and l_scale in the result.
4290    #[test]
4291    fn test_energy_scale_returns_fitted_params() {
4292        let data = u238_single_resonance();
4293        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4294        let (t_obs, sigma) = synthetic_transmission(&data, 0.002, &energies);
4295
4296        let config = UnifiedFitConfig::new(
4297            energies,
4298            vec![data],
4299            vec!["U-238".into()],
4300            293.6,
4301            None,
4302            vec![0.001],
4303        )
4304        .unwrap()
4305        .with_solver(SolverConfig::LevenbergMarquardt(Default::default()))
4306        .with_transmission_background(BackgroundConfig::default())
4307        .with_energy_scale(0.0, 1.0, 25.0);
4308
4309        let input = InputData::Transmission {
4310            transmission: t_obs,
4311            uncertainty: sigma,
4312        };
4313        let result = fit_spectrum_typed(&input, &config).unwrap();
4314        assert!(result.converged, "Fit should converge");
4315        assert!(
4316            result.t0_us.is_some(),
4317            "t0_us should be Some when energy-scale is fitted"
4318        );
4319        assert!(
4320            result.l_scale.is_some(),
4321            "l_scale should be Some when energy-scale is fitted"
4322        );
4323        let t0 = result.t0_us.unwrap();
4324        let ls = result.l_scale.unwrap();
4325        // Values should be finite (not NaN/Inf) and within bounds
4326        assert!(t0.is_finite(), "t0 should be finite, got {t0}");
4327        assert!(ls.is_finite(), "l_scale should be finite, got {ls}");
4328        assert!(t0.abs() < 10.0, "t0 should be within bounds, got {t0}");
4329        assert!(
4330            ls > 0.98 && ls < 1.02,
4331            "l_scale should be within bounds, got {ls}"
4332        );
4333    }
4334
4335    /// Without energy-scale fitting, t0_us and l_scale should be None.
4336    #[test]
4337    fn test_no_energy_scale_returns_none() {
4338        let data = u238_single_resonance();
4339        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4340        let (t_obs, sigma) = synthetic_transmission(&data, 0.002, &energies);
4341
4342        let config = UnifiedFitConfig::new(
4343            energies,
4344            vec![data],
4345            vec!["U-238".into()],
4346            293.6,
4347            None,
4348            vec![0.001],
4349        )
4350        .unwrap()
4351        .with_solver(SolverConfig::LevenbergMarquardt(Default::default()))
4352        .with_transmission_background(BackgroundConfig::default());
4353
4354        let input = InputData::Transmission {
4355            transmission: t_obs,
4356            uncertainty: sigma,
4357        };
4358        let result = fit_spectrum_typed(&input, &config).unwrap();
4359        assert!(
4360            result.t0_us.is_none(),
4361            "t0_us should be None without energy-scale"
4362        );
4363        assert!(
4364            result.l_scale.is_none(),
4365            "l_scale should be None without energy-scale"
4366        );
4367    }
4368
4369    // ==================================================================
4370    // Joint-Poisson solver integration tests (memo 35 §P1/§P2)
4371    // ==================================================================
4372
4373    /// End-to-end: joint-Poisson density recovery at c = 5.98 on synthetic
4374    /// matched-model counts, via `fit_spectrum_typed`.  Verifies that
4375    /// `SpectrumFitResult.deviance_per_dof` is populated (P1.2) and that
4376    /// density is recovered to within 5% on a single-resonance spectrum
4377    /// under expected (noise-free) counts.
4378    #[test]
4379    fn test_joint_poisson_density_recovery_c_5_98() {
4380        let data = u238_single_resonance();
4381        let true_density = 0.0005_f64;
4382        let c = 5.98_f64;
4383        let lam_ob = 1000.0_f64; // expected open-beam counts per bin (O rate)
4384        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4385        let (t, _) = synthetic_transmission(&data, true_density, &energies);
4386
4387        // Noise-free expectations under joint-Poisson model:
4388        //   E[O] = lam_ob, E[S] = c · lam_ob · T
4389        let open_beam_counts: Vec<f64> = vec![lam_ob; energies.len()];
4390        let sample_counts: Vec<f64> = t.iter().map(|&ti| c * lam_ob * ti).collect();
4391
4392        let config = UnifiedFitConfig::new(
4393            energies,
4394            vec![data],
4395            vec!["U-238".into()],
4396            0.0,
4397            None,
4398            vec![0.001],
4399        )
4400        .unwrap()
4401        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4402        .with_counts_background(CountsBackgroundConfig {
4403            c,
4404            ..Default::default()
4405        });
4406
4407        let input = InputData::Counts {
4408            sample_counts,
4409            open_beam_counts,
4410        };
4411        let result = fit_spectrum_typed(&input, &config).unwrap();
4412
4413        // Deviance-based GOF is populated (P1.2).
4414        let d_per_dof = result
4415            .deviance_per_dof
4416            .expect("joint-Poisson solver must populate deviance_per_dof");
4417        assert!(
4418            d_per_dof.is_finite() && d_per_dof >= 0.0,
4419            "deviance_per_dof = {d_per_dof} is not a valid GOF"
4420        );
4421        // On noise-free expected counts, D should be very small (approaches
4422        // zero in the matched-model limit; allow some slack for numerical
4423        // error in the forward model).
4424        assert!(
4425            d_per_dof < 0.5,
4426            "noise-free expected-counts fit should give D/dof ≈ 0, got {d_per_dof}"
4427        );
4428        // Density recovery.
4429        let rel_bias = (result.densities[0] - true_density) / true_density;
4430        assert!(
4431            rel_bias.abs() < 0.05,
4432            "density bias {rel_bias} > 5%: fitted={} truth={true_density}",
4433            result.densities[0]
4434        );
4435        // Back-compat: reduced_chi_squared mirrors deviance_per_dof.
4436        assert!((result.reduced_chi_squared - d_per_dof).abs() < 1e-12);
4437    }
4438
4439    /// Counts-KL dispatch rejects `fit_alpha_1` / `fit_alpha_2` — the
4440    /// profile `λ̂` absorbs the global flux scale (alpha_1 redundant);
4441    /// alpha_2 / B_det wiring is P3-deferred per memo 35 §P3.
4442    #[test]
4443    fn test_joint_poisson_rejects_alpha_fit() {
4444        let data = u238_single_resonance();
4445        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4446        let (t, _) = synthetic_transmission(&data, 0.0005, &energies);
4447        let open_beam_counts: Vec<f64> = vec![500.0; energies.len()];
4448        let sample_counts: Vec<f64> = t.iter().map(|&ti| 500.0 * ti).collect();
4449
4450        let config = UnifiedFitConfig::new(
4451            energies,
4452            vec![data],
4453            vec!["U-238".into()],
4454            0.0,
4455            None,
4456            vec![0.001],
4457        )
4458        .unwrap()
4459        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4460        .with_counts_background(CountsBackgroundConfig {
4461            fit_alpha_1: true,
4462            c: 1.0,
4463            ..Default::default()
4464        });
4465
4466        let input = InputData::Counts {
4467            sample_counts,
4468            open_beam_counts,
4469        };
4470        let err = fit_spectrum_typed(&input, &config).unwrap_err();
4471        let msg = err.to_string();
4472        assert!(
4473            msg.contains("fit_alpha_1") || msg.contains("alpha_1"),
4474            "expected alpha_1 rejection message, got: {msg}"
4475        );
4476    }
4477
4478    /// Transmission + PoissonKL is a valid combination (routes to
4479    /// `fit_transmission_poisson`, unchanged by the counts-KL collapse).
4480    /// This test asserts the transmission-KL path still works end-to-end.
4481    #[test]
4482    fn test_transmission_poisson_kl_dispatches_to_transmission_path() {
4483        let data = u238_single_resonance();
4484        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4485        let (t, u) = synthetic_transmission(&data, 0.0005, &energies);
4486
4487        let config = UnifiedFitConfig::new(
4488            energies,
4489            vec![data],
4490            vec!["U-238".into()],
4491            0.0,
4492            None,
4493            vec![0.001],
4494        )
4495        .unwrap()
4496        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
4497
4498        let input = InputData::Transmission {
4499            transmission: t,
4500            uncertainty: u,
4501        };
4502        let r = fit_spectrum_typed(&input, &config).unwrap();
4503        // Transmission-KL path does NOT report deviance_per_dof (that's
4504        // the counts-domain joint-Poisson GOF only); reduced_chi_squared
4505        // is the transmission Poisson NLL measure.
4506        assert!(r.deviance_per_dof.is_none());
4507        assert!(r.reduced_chi_squared.is_finite());
4508    }
4509
4510    // ──────────────────────────────────────────────────────────────────
4511    // P2.2: transmission_background through the joint-Poisson path.
4512    // ──────────────────────────────────────────────────────────────────
4513
4514    /// End-to-end: joint-Poisson with A_n + B_A + B_B + B_C free on
4515    /// noise-free synthetic counts with known background.  On 201 bins
4516    /// with 5 free params the (n, A_n) correlation is non-trivial so we
4517    /// assert the *wiring* is correct (bg reaches the fit, D/dof → 0,
4518    /// A_n + B_A near truth, density within 10%) rather than EG2-grade
4519    #[test]
4520    fn test_joint_poisson_with_transmission_background() {
4521        let data = u238_single_resonance();
4522        let true_density = 0.0005_f64;
4523        let true_anorm = 0.85_f64;
4524        let true_ba = 0.03_f64;
4525        let true_bb = -0.01_f64;
4526        let true_bc = 0.0_f64;
4527        let c = 5.98_f64;
4528        let lam_ob = 2000.0_f64;
4529        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4530        let (t_inner, _) = synthetic_transmission(&data, true_density, &energies);
4531        let t_out: Vec<f64> = t_inner
4532            .iter()
4533            .zip(energies.iter())
4534            .map(|(&ti, &e)| true_anorm * ti + true_ba + true_bb / e.sqrt() + true_bc * e.sqrt())
4535            .collect();
4536        let open_beam_counts: Vec<f64> = vec![lam_ob; energies.len()];
4537        let sample_counts: Vec<f64> = t_out.iter().map(|&ti| c * lam_ob * ti).collect();
4538
4539        let bg = BackgroundConfig {
4540            anorm_init: 1.0,
4541            back_a_init: 0.0,
4542            back_b_init: 0.0,
4543            back_c_init: 0.0,
4544            back_d_init: 0.01,
4545            back_f_init: 1.0,
4546            fit_anorm: true,
4547            fit_back_a: true,
4548            fit_back_b: true,
4549            fit_back_c: true,
4550            fit_back_d: false,
4551            fit_back_f: false,
4552        };
4553
4554        let config = UnifiedFitConfig::new(
4555            energies,
4556            vec![data],
4557            vec!["U-238".into()],
4558            0.0,
4559            None,
4560            vec![0.001],
4561        )
4562        .unwrap()
4563        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4564        .with_counts_background(CountsBackgroundConfig {
4565            c,
4566            ..Default::default()
4567        })
4568        .with_transmission_background(bg)
4569        // #486: polish defaults off for real-data regimes where its
4570        // `fatol = 1e-10` is sub-ULP.  This noise-free synthetic fit
4571        // is exactly the regime polish was designed for (D → 0
4572        // achievable, `fatol` physically meaningful) and the test
4573        // asserts `D/dof < 1.0` which Gauss-Newton alone cannot reach
4574        // on this 5-free-parameter fit due to the documented n ↔ A_n
4575        // correlation stall.  Opt in explicitly.
4576        .with_counts_enable_polish(Some(true));
4577
4578        let input = InputData::Counts {
4579            sample_counts,
4580            open_beam_counts,
4581        };
4582        let r = fit_spectrum_typed(&input, &config).unwrap();
4583
4584        // The invariant P2.2 wiring is supposed to produce is: the 4 bg
4585        // parameters *actually reach the objective* (the fit produces a
4586        // near-zero deviance on noise-free expected counts) and the
4587        // fitter moves them off their initial values.  Density / A_n /
4588        // B_A recovery at unit-test scale (201 bins, 5 free params)
4589        // inherits the classic n ↔ A_n correlation — the realistic
4590
4591        // Deviance-based GOF populated and → 0 on noise-free expected counts.
4592        // Requires polish (see `with_counts_enable_polish(Some(true))` above).
4593        let dpd = r.deviance_per_dof.expect("joint-Poisson must report D/dof");
4594        assert!(
4595            dpd < 1.0,
4596            "D/dof = {dpd} unexpectedly large on noise-free fit — bg params not reaching objective?"
4597        );
4598        // Density didn't rail to zero.
4599        assert!(r.densities[0] > 1e-5, "density railed: {}", r.densities[0]);
4600        // A_n moved off its initial 1.0 toward truth 0.85.
4601        assert!(
4602            (r.anorm - 1.0).abs() > 0.05,
4603            "A_n did not move from init 1.0 (fitted={})",
4604            r.anorm
4605        );
4606        // Background triplet moved off zero at least in one component.
4607        let bg_moved = r.background.iter().any(|v| v.abs() > 1e-4);
4608        assert!(
4609            bg_moved,
4610            "no bg parameter moved from init 0: {:?}",
4611            r.background
4612        );
4613    }
4614
4615    /// §P2.2 operational rule: `B_B` or `B_C` free → `B_A` must be free too.
4616    #[test]
4617    fn test_joint_poisson_p2_2_requires_back_a_when_back_b_enabled() {
4618        let data = u238_single_resonance();
4619        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4620        let (t, _) = synthetic_transmission(&data, 0.0005, &energies);
4621        let ob: Vec<f64> = vec![500.0; energies.len()];
4622        let s: Vec<f64> = t.iter().map(|&ti| 500.0 * ti).collect();
4623
4624        let bg = BackgroundConfig {
4625            // B_B enabled without B_A → must be rejected.
4626            fit_anorm: true,
4627            fit_back_a: false,
4628            fit_back_b: true,
4629            fit_back_c: false,
4630            fit_back_d: false,
4631            fit_back_f: false,
4632            ..BackgroundConfig::default()
4633        };
4634
4635        let config = UnifiedFitConfig::new(
4636            energies,
4637            vec![data],
4638            vec!["U-238".into()],
4639            0.0,
4640            None,
4641            vec![0.001],
4642        )
4643        .unwrap()
4644        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4645        .with_counts_background(CountsBackgroundConfig {
4646            c: 1.0,
4647            ..Default::default()
4648        })
4649        .with_transmission_background(bg);
4650
4651        let input = InputData::Counts {
4652            sample_counts: s,
4653            open_beam_counts: ob,
4654        };
4655        let err = fit_spectrum_typed(&input, &config).unwrap_err();
4656        let msg = err.to_string();
4657        assert!(
4658            msg.contains("§P2.2") || msg.contains("B_A"),
4659            "expected §P2.2 rejection message, got: {msg}"
4660        );
4661    }
4662
4663    /// Joint-Poisson rejects BackD/BackF exponential tail (§P4-deferred).
4664    #[test]
4665    fn test_joint_poisson_rejects_back_d_f() {
4666        let data = u238_single_resonance();
4667        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4668        let (t, _) = synthetic_transmission(&data, 0.0005, &energies);
4669        let ob: Vec<f64> = vec![500.0; energies.len()];
4670        let s: Vec<f64> = t.iter().map(|&ti| 500.0 * ti).collect();
4671
4672        let bg = BackgroundConfig {
4673            fit_back_d: true,
4674            ..BackgroundConfig::default()
4675        };
4676
4677        let config = UnifiedFitConfig::new(
4678            energies,
4679            vec![data],
4680            vec!["U-238".into()],
4681            0.0,
4682            None,
4683            vec![0.001],
4684        )
4685        .unwrap()
4686        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4687        .with_counts_background(CountsBackgroundConfig {
4688            c: 1.0,
4689            ..Default::default()
4690        })
4691        .with_transmission_background(bg);
4692
4693        let input = InputData::Counts {
4694            sample_counts: s,
4695            open_beam_counts: ob,
4696        };
4697        let err = fit_spectrum_typed(&input, &config).unwrap_err();
4698        assert!(
4699            err.to_string().contains("BackD") || err.to_string().contains("§P4"),
4700            "expected BackD/BackF rejection, got: {err}"
4701        );
4702    }
4703
4704    /// Partial BackD/BackF configurations are rejected with a clear error
4705    /// on the LM transmission path.  Regression for code-review finding:
4706    /// pre-fix, `append_background_params` allocated a free index for the
4707    /// enabled tail parameter but `NormalizedTransmissionModel::new`
4708    /// (4-term wrapper, fall-back when only one of back_d/back_f was
4709    /// `Some`) ignored it — the parameter sat at its initial value and
4710    /// fitter reported it as the "fitted" result.
4711    #[test]
4712    fn test_lm_transmission_rejects_partial_back_d_only() {
4713        let data = u238_single_resonance();
4714        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4715        let (t, u) = synthetic_transmission(&data, 0.0005, &energies);
4716
4717        let bg = BackgroundConfig {
4718            // BackD enabled, BackF disabled — the partial config the
4719            // pre-fix code silently accepted.
4720            fit_back_d: true,
4721            fit_back_f: false,
4722            ..BackgroundConfig::default()
4723        };
4724
4725        let config = UnifiedFitConfig::new(
4726            energies,
4727            vec![data],
4728            vec!["U-238".into()],
4729            0.0,
4730            None,
4731            vec![0.001],
4732        )
4733        .unwrap()
4734        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4735        .with_transmission_background(bg);
4736
4737        let input = InputData::Transmission {
4738            transmission: t,
4739            uncertainty: u,
4740        };
4741        let err = fit_spectrum_typed(&input, &config).unwrap_err();
4742        let msg = err.to_string();
4743        assert!(
4744            msg.contains("fit_back_d") && msg.contains("fit_back_f"),
4745            "expected partial-BackD/F rejection mentioning both flags, got: {msg}"
4746        );
4747    }
4748
4749    /// Symmetric case: BackF enabled without BackD — same rejection.
4750    #[test]
4751    fn test_lm_transmission_rejects_partial_back_f_only() {
4752        let data = u238_single_resonance();
4753        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4754        let (t, u) = synthetic_transmission(&data, 0.0005, &energies);
4755
4756        let bg = BackgroundConfig {
4757            fit_back_d: false,
4758            fit_back_f: true,
4759            ..BackgroundConfig::default()
4760        };
4761
4762        let config = UnifiedFitConfig::new(
4763            energies,
4764            vec![data],
4765            vec!["U-238".into()],
4766            0.0,
4767            None,
4768            vec![0.001],
4769        )
4770        .unwrap()
4771        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4772        .with_transmission_background(bg);
4773
4774        let input = InputData::Transmission {
4775            transmission: t,
4776            uncertainty: u,
4777        };
4778        let err = fit_spectrum_typed(&input, &config).unwrap_err();
4779        let msg = err.to_string();
4780        assert!(
4781            msg.contains("fit_back_d") && msg.contains("fit_back_f"),
4782            "expected partial-BackD/F rejection mentioning both flags, got: {msg}"
4783        );
4784    }
4785
4786    /// Same rule on the transmission Poisson-KL path.
4787    #[test]
4788    fn test_transmission_poisson_kl_rejects_partial_back_d_f() {
4789        let data = u238_single_resonance();
4790        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4791        let (t, u) = synthetic_transmission(&data, 0.0005, &energies);
4792
4793        let bg = BackgroundConfig {
4794            fit_back_d: true,
4795            fit_back_f: false,
4796            ..BackgroundConfig::default()
4797        };
4798
4799        let config = UnifiedFitConfig::new(
4800            energies,
4801            vec![data],
4802            vec!["U-238".into()],
4803            0.0,
4804            None,
4805            vec![0.001],
4806        )
4807        .unwrap()
4808        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4809        .with_transmission_background(bg);
4810
4811        let input = InputData::Transmission {
4812            transmission: t,
4813            uncertainty: u,
4814        };
4815        let err = fit_spectrum_typed(&input, &config).unwrap_err();
4816        assert!(
4817            err.to_string().contains("fit_back_d"),
4818            "expected partial-BackD/F rejection on transmission-PoissonKL path"
4819        );
4820    }
4821
4822    // ------------------------------------------------------------------
4823    // Fit-energy-range provenance + masked equivalence (#514).
4824    // ------------------------------------------------------------------
4825
4826    /// `with_fit_energy_range(...)` round-trips through the accessor.
4827    #[test]
4828    fn test_unified_fit_config_fit_energy_range_round_trips() {
4829        let data = u238_single_resonance();
4830        let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
4831        let cfg = UnifiedFitConfig::new(
4832            energies,
4833            vec![data],
4834            vec!["U-238".into()],
4835            0.0,
4836            None,
4837            vec![0.001],
4838        )
4839        .unwrap();
4840        assert_eq!(cfg.fit_energy_range(), None);
4841
4842        let cfg = cfg.with_fit_energy_range(Some((5.0, 50.0))).unwrap();
4843        assert_eq!(cfg.fit_energy_range(), Some((5.0, 50.0)));
4844
4845        // Setting back to None clears it.
4846        let cfg = cfg.with_fit_energy_range(None).unwrap();
4847        assert_eq!(cfg.fit_energy_range(), None);
4848    }
4849
4850    /// `with_fit_energy_range` rejects non-finite or reversed bounds
4851    /// rather than silently producing an empty active-bin mask
4852    /// downstream.
4853    #[test]
4854    fn test_unified_fit_config_fit_energy_range_rejects_invalid() {
4855        let data = u238_single_resonance();
4856        let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
4857        let cfg = UnifiedFitConfig::new(
4858            energies,
4859            vec![data],
4860            vec!["U-238".into()],
4861            0.0,
4862            None,
4863            vec![0.001],
4864        )
4865        .unwrap();
4866
4867        // Reversed range (lo > hi) is rejected.
4868        let err = cfg
4869            .clone()
4870            .with_fit_energy_range(Some((10.0, 5.0)))
4871            .unwrap_err();
4872        assert!(matches!(err, FitConfigError::InvalidFitEnergyRange(_)));
4873
4874        // Empty range (lo == hi) is rejected — `lo < hi` strictly required.
4875        let err = cfg
4876            .clone()
4877            .with_fit_energy_range(Some((5.0, 5.0)))
4878            .unwrap_err();
4879        assert!(matches!(err, FitConfigError::InvalidFitEnergyRange(_)));
4880
4881        // Non-finite bounds are rejected.
4882        let err = cfg
4883            .clone()
4884            .with_fit_energy_range(Some((f64::NAN, 5.0)))
4885            .unwrap_err();
4886        assert!(matches!(err, FitConfigError::InvalidFitEnergyRange(_)));
4887
4888        let err = cfg
4889            .clone()
4890            .with_fit_energy_range(Some((5.0, f64::INFINITY)))
4891            .unwrap_err();
4892        assert!(matches!(err, FitConfigError::InvalidFitEnergyRange(_)));
4893    }
4894
4895    /// LM fit with `fit_energy_range = Some((min, max))` on the full
4896    /// grid must yield the same density as the same fit run on the
4897    /// `[min, max]` slice directly when the residual outside the range
4898    /// is negligible (SAMMY EMIN/EMAX semantics, paper-acceptance test).
4899    #[test]
4900    fn test_fit_energy_range_lm_matches_subset_when_outside_negligible() {
4901        let data = u238_single_resonance();
4902        let true_density = 0.002;
4903        // Wide grid: 0.5..10.5 eV in 0.05 eV steps.  The U-238 single
4904        // resonance sits well inside [4, 8] eV, so a fit restricted to
4905        // that range should recover the same density as the full-grid
4906        // fit (residual outside is negligible).
4907        let energies: Vec<f64> = (0..201).map(|i| 0.5 + (i as f64) * 0.05).collect();
4908        let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
4909
4910        let cfg_full = UnifiedFitConfig::new(
4911            energies.clone(),
4912            vec![data.clone()],
4913            vec!["U-238".into()],
4914            0.0,
4915            None,
4916            vec![0.001],
4917        )
4918        .unwrap()
4919        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
4920
4921        let cfg_masked = UnifiedFitConfig::new(
4922            energies.clone(),
4923            vec![data],
4924            vec!["U-238".into()],
4925            0.0,
4926            None,
4927            vec![0.001],
4928        )
4929        .unwrap()
4930        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4931        .with_fit_energy_range(Some((4.0, 8.0)))
4932        .unwrap();
4933
4934        let input = InputData::Transmission {
4935            transmission: t,
4936            uncertainty: sigma,
4937        };
4938        let r_full = fit_spectrum_typed(&input, &cfg_full).unwrap();
4939        let r_masked = fit_spectrum_typed(&input, &cfg_masked).unwrap();
4940        assert!(r_full.converged && r_masked.converged);
4941
4942        let d_full = r_full.densities[0];
4943        let d_masked = r_masked.densities[0];
4944        let rel_err = (d_full - d_masked).abs() / d_full.abs();
4945        assert!(
4946            rel_err < 0.01,
4947            "fit_energy_range LM density {d_masked} should match full-grid {d_full} \
4948             within 1% (got rel_err = {rel_err})"
4949        );
4950    }
4951
4952    /// Transmission + PoissonKL with `fit_energy_range` set must be
4953    /// hard-rejected at dispatch.  The legacy `poisson_fit` does not
4954    /// honour the active-bin mask, so silently fitting on the
4955    /// (margin-extended) grid would bias the result.  Joint-Poisson
4956    /// (counts) and LM transmission both honour the mask correctly.
4957    /// Regression for Round-2 review fix #1 (#514).
4958    #[test]
4959    fn test_fit_energy_range_rejected_on_transmission_poisson_path() {
4960        let data = u238_single_resonance();
4961        let true_density = 0.002;
4962        let energies: Vec<f64> = (0..201).map(|i| 0.5 + (i as f64) * 0.05).collect();
4963        let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
4964
4965        let cfg = UnifiedFitConfig::new(
4966            energies,
4967            vec![data],
4968            vec!["U-238".into()],
4969            0.0,
4970            None,
4971            vec![0.001],
4972        )
4973        .unwrap()
4974        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4975        .with_fit_energy_range(Some((4.0, 8.0)))
4976        .unwrap();
4977
4978        let input = InputData::Transmission {
4979            transmission: t,
4980            uncertainty: sigma,
4981        };
4982        let err = fit_spectrum_typed(&input, &cfg).unwrap_err();
4983        let msg = err.to_string();
4984        assert!(
4985            msg.contains("fit_energy_range")
4986                && (msg.contains("Poisson") || msg.contains("poisson")),
4987            "expected rejection message mentioning fit_energy_range and the \
4988             Poisson-KL path; got: {msg}"
4989        );
4990    }
4991
4992    /// LM dispatcher must reject `fit_energy_range` that selects fewer
4993    /// than 2 active bins on the configured grid with a clear
4994    /// "range too narrow" error — instead of a confusing non-converged
4995    /// LM result.  Regression for #517 R3 P2 (Copilot finding #2).
4996    #[test]
4997    fn test_fit_energy_range_lm_rejects_too_narrow() {
4998        let data = u238_single_resonance();
4999        // 0.5..10.5 eV in 0.5 eV steps.  Range [4.6, 4.7] selects
5000        // exactly zero bins (no grid point inside; closest are 4.5
5001        // and 5.0).
5002        let energies: Vec<f64> = (0..21).map(|i| 0.5 + (i as f64) * 0.5).collect();
5003        let (t, sigma) = synthetic_transmission(&data, 0.002, &energies);
5004        let cfg = UnifiedFitConfig::new(
5005            energies,
5006            vec![data],
5007            vec!["U-238".into()],
5008            0.0,
5009            None,
5010            vec![0.001],
5011        )
5012        .unwrap()
5013        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
5014        .with_fit_energy_range(Some((4.6, 4.7)))
5015        .unwrap();
5016        let input = InputData::Transmission {
5017            transmission: t,
5018            uncertainty: sigma,
5019        };
5020        let err = fit_spectrum_typed(&input, &cfg).unwrap_err();
5021        let msg = err.to_string();
5022        assert!(
5023            msg.contains("fit_energy_range") && msg.contains("active bin"),
5024            "expected too-narrow-range rejection; got: {msg}"
5025        );
5026    }
5027
5028    /// Joint-Poisson dispatcher must reject too-narrow ranges with the
5029    /// same clear error.  Regression for #517 R3 P2 (Copilot #3).
5030    #[test]
5031    fn test_fit_energy_range_jp_rejects_too_narrow() {
5032        let data = u238_single_resonance();
5033        let energies: Vec<f64> = (0..21).map(|i| 0.5 + (i as f64) * 0.5).collect();
5034        let (sample, open_beam) = synthetic_counts(&data, 0.002, &energies, 1000.0);
5035        let cfg = UnifiedFitConfig::new(
5036            energies,
5037            vec![data],
5038            vec!["U-238".into()],
5039            0.0,
5040            None,
5041            vec![0.001],
5042        )
5043        .unwrap()
5044        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5045        .with_fit_energy_range(Some((4.6, 4.7)))
5046        .unwrap();
5047        let input = InputData::Counts {
5048            sample_counts: sample,
5049            open_beam_counts: open_beam,
5050        };
5051        let err = fit_spectrum_typed(&input, &cfg).unwrap_err();
5052        let msg = err.to_string();
5053        assert!(
5054            msg.contains("fit_energy_range") && msg.contains("active bin"),
5055            "expected too-narrow-range rejection; got: {msg}"
5056        );
5057    }
5058
5059    // ── Issue #608 (PR #609): Rust coverage for paths previously exercised only
5060    //    via Python / the spatial-identity path (codecov/patch gap) ───────────
5061
5062    /// `evaluate_jacobian_and_fisher` (the research Fisher / `compute_model_jacobian`
5063    /// entry point) was exercised only through the Python bindings, which the
5064    /// Rust-only coverage run excludes — so its body, and the #608
5065    /// compute-working-grid-σ branch, were uncovered.  Drive it with a
5066    /// Gaussian-resolution config carrying NO precomputed σ: that exercises the
5067    /// aux-grid σ build + the model construction + the analytical Jacobian /
5068    /// Fisher assembly.
5069    #[test]
5070    fn evaluate_jacobian_and_fisher_gaussian_aux_grid() {
5071        use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
5072        let data = u238_single_resonance();
5073        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5074        let n_e = energies.len();
5075        let config = UnifiedFitConfig::new(
5076            energies,
5077            vec![data],
5078            vec!["U-238".into()],
5079            300.0,
5080            Some(ResolutionFunction::Gaussian(
5081                ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
5082            )),
5083            vec![0.001],
5084        )
5085        .unwrap();
5086        let flux = vec![5000.0; n_e];
5087        let background = vec![10.0; n_e];
5088        let result = evaluate_jacobian_and_fisher(&config, &flux, &background).unwrap();
5089        assert_eq!(result.model_prediction.len(), n_e);
5090        assert!(result.model_prediction.iter().all(|v| v.is_finite()));
5091        assert_eq!(result.param_names.len(), 1, "one free density parameter");
5092        // Density Fisher information must be finite + positive (well-posed
5093        // measurement); the Jacobian endpoints must be finite.
5094        let f00 = result.fisher.get(0, 0);
5095        assert!(f00.is_finite() && f00 > 0.0, "Fisher[0,0] = {f00}");
5096        assert!(result.jacobian.get(0, 0).is_finite());
5097        assert!(result.jacobian.get(n_e - 1, 0).is_finite());
5098    }
5099
5100    /// Non-spatial energy-scale fit through `fit_spectrum_typed` (LM
5101    /// transmission): exercises `seed_energy_scale_in_params` +
5102    /// `build_energy_scale_transmission_model` + the energy-scale LM path +
5103    /// `t0_us` / `l_scale` result population.  Prior Rust coverage was
5104    /// spatial-only on IDENTITY data; here we inject a NON-identity (t0,
5105    /// L_scale) and confirm the wiring recovers the density (the (t0, L_scale)
5106    /// valley is shallow, so we assert the physically-observable density, not
5107    /// tight individual parameters — cf. the Python `TestFitEnergyScaleRecovery`
5108    /// rationale).
5109    #[test]
5110    fn fit_spectrum_typed_energy_scale_lm_recovers_calibration() {
5111        let data = hf178_mlbw_two_resonances(); // s-waves @ 7.8 + 16.9 eV
5112        let energies: Vec<f64> = (0..700).map(|i| 4.0 + (i as f64) * 0.025).collect();
5113        let true_density = 0.05_f64;
5114        let (t0_true, ls_true) = (1.5_f64, 1.004_f64);
5115        // Synthesize measured data with the injected calibration via the model
5116        // (no resolution: dip POSITIONS drive the seed).
5117        let model = EnergyScaleTransmissionModel::new(
5118            Arc::new(vec![data.clone()]),
5119            Arc::new(vec![0]),
5120            Arc::new(vec![1.0]),
5121            293.6,
5122            energies.clone(),
5123            25.0,
5124            1,
5125            2,
5126            None,
5127        );
5128        let measured = model.evaluate(&[true_density, t0_true, ls_true]).unwrap();
5129        let uncertainty = vec![1e-3; energies.len()];
5130        let config = UnifiedFitConfig::new(
5131            energies,
5132            vec![data],
5133            vec!["Hf-178".into()],
5134            293.6,
5135            None,
5136            vec![0.04], // start density away from truth (0.05) so recovery is real
5137        )
5138        .unwrap()
5139        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
5140        .with_energy_scale(0.0, 1.0, 25.0); // cold (t0,L) start ⇒ the seed must correct it
5141        let input = InputData::Transmission {
5142            transmission: measured,
5143            uncertainty,
5144        };
5145        let result = fit_spectrum_typed(&input, &config).unwrap();
5146        let t0 = result
5147            .t0_us
5148            .expect("t0_us populated when fit_energy_scale=true");
5149        let ls = result
5150            .l_scale
5151            .expect("l_scale populated when fit_energy_scale=true");
5152        assert!(t0.is_finite() && ls.is_finite(), "t0={t0}, L={ls}");
5153        assert!(result.converged, "energy-scale LM fit should converge");
5154        assert!(
5155            (result.densities[0] - true_density).abs() / true_density < 0.10,
5156            "density: fitted={}, true={true_density}",
5157            result.densities[0]
5158        );
5159    }
5160
5161    /// Non-spatial counts-KL energy-scale fit through `fit_spectrum_typed`:
5162    /// exercises `seed_energy_scale_in_params`' KL transmission proxy
5163    /// `(sample − bg)/(flux − bg)` + the counts-KL energy-scale dispatch —
5164    /// uncovered by the Rust suite (Python + spatial-identity only).
5165    #[test]
5166    fn fit_spectrum_typed_energy_scale_counts_kl_seeds_via_proxy() {
5167        let data = hf178_mlbw_two_resonances();
5168        let energies: Vec<f64> = (0..700).map(|i| 4.0 + (i as f64) * 0.025).collect();
5169        let true_density = 0.05_f64;
5170        let (t0_true, ls_true) = (1.0_f64, 1.003_f64);
5171        let model = EnergyScaleTransmissionModel::new(
5172            Arc::new(vec![data.clone()]),
5173            Arc::new(vec![0]),
5174            Arc::new(vec![1.0]),
5175            293.6,
5176            energies.clone(),
5177            25.0,
5178            1,
5179            2,
5180            None,
5181        );
5182        let t = model.evaluate(&[true_density, t0_true, ls_true]).unwrap();
5183        // Counts: sample = flux·T.  Zero detector background — the joint-Poisson
5184        // KL path does not yet wire B_det (memo 35 §P3.2); the seed proxy
5185        // (sample − 0)/(flux − 0) = T still reconstructs the dip positions.
5186        let flux: Vec<f64> = vec![5000.0; energies.len()];
5187        let background: Vec<f64> = vec![0.0; energies.len()];
5188        let sample: Vec<f64> = t
5189            .iter()
5190            .zip(flux.iter())
5191            .map(|(&ti, &fi)| fi * ti)
5192            .collect();
5193        let config = UnifiedFitConfig::new(
5194            energies,
5195            vec![data],
5196            vec!["Hf-178".into()],
5197            293.6,
5198            None,
5199            vec![0.04],
5200        )
5201        .unwrap()
5202        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5203        .with_energy_scale(0.0, 1.0, 25.0);
5204        let input = InputData::CountsWithNuisance {
5205            sample_counts: sample,
5206            flux,
5207            background,
5208        };
5209        let result = fit_spectrum_typed(&input, &config).unwrap();
5210        let t0 = result
5211            .t0_us
5212            .expect("t0_us populated when fit_energy_scale=true");
5213        let ls = result
5214            .l_scale
5215            .expect("l_scale populated when fit_energy_scale=true");
5216        assert!(t0.is_finite() && ls.is_finite(), "t0={t0}, L={ls}");
5217    }
5218
5219    /// The #608 working-grid σ + layout validation in
5220    /// `validate_precomputed_cross_sections` (the up-front guard for the
5221    /// Gaussian aux-grid path) — every malformed-input error branch, so a bad
5222    /// `with_precomputed_work_cross_sections` setter call surfaces a typed
5223    /// `ShapeMismatch` instead of a per-pixel panic.
5224    #[test]
5225    fn validate_precomputed_work_cross_sections_error_branches() {
5226        use nereids_physics::transmission::WorkingGridLayout;
5227        let data = u238_single_resonance();
5228        let energies: Vec<f64> = (0..11).map(|i| 1.0 + (i as f64) * 0.1).collect();
5229        let n_e = energies.len();
5230        let n_work = n_e + 2; // a non-identity aux grid
5231        let good_layout = || {
5232            Arc::new(WorkingGridLayout {
5233                energies: (0..n_work).map(|i| 1.0 + (i as f64) * 0.09).collect(),
5234                data_indices: (0..n_e).collect(),
5235            })
5236        };
5237        let cfg = |work_xs: Vec<Vec<f64>>, layout: Arc<WorkingGridLayout>| {
5238            UnifiedFitConfig::new(
5239                energies.clone(),
5240                vec![data.clone()],
5241                vec!["U-238".into()],
5242                0.0,
5243                None,
5244                vec![0.001],
5245            )
5246            .unwrap()
5247            .with_precomputed_cross_sections(Arc::new(vec![vec![1.0f64; n_e]])) // valid data-grid σ
5248            .with_precomputed_work_cross_sections(Arc::new(work_xs), layout)
5249        };
5250        let expect =
5251            |c: &UnifiedFitConfig, needle: &str| match validate_precomputed_cross_sections(c) {
5252                Err(PipelineError::ShapeMismatch(m)) => {
5253                    assert!(m.contains(needle), "expected {needle:?}, got: {m}")
5254                }
5255                other => panic!("expected ShapeMismatch({needle:?}), got {other:?}"),
5256            };
5257        // empty working σ
5258        expect(&cfg(vec![], good_layout()), "must not be empty");
5259        // working-σ row length != working-grid length
5260        expect(
5261            &cfg(vec![vec![1.0; n_work - 1]], good_layout()),
5262            "working-grid energies",
5263        );
5264        // non-finite working σ
5265        let mut nan_row = vec![1.0; n_work];
5266        nan_row[3] = f64::NAN;
5267        expect(&cfg(vec![nan_row], good_layout()), "non-finite");
5268        // working-σ row count != data-grid σ row count (2 work rows vs 1 data row)
5269        expect(
5270            &cfg(vec![vec![1.0; n_work], vec![1.0; n_work]], good_layout()),
5271            "rows but",
5272        );
5273        // layout maps a different number of data points than config.energies
5274        let short = Arc::new(WorkingGridLayout {
5275            energies: (0..n_work).map(|i| 1.0 + (i as f64) * 0.09).collect(),
5276            data_indices: (0..n_e - 1).collect(),
5277        });
5278        expect(&cfg(vec![vec![1.0; n_work]], short), "layout maps");
5279        // layout index out of range for the working grid
5280        let oor = Arc::new(WorkingGridLayout {
5281            energies: (0..n_work).map(|i| 1.0 + (i as f64) * 0.09).collect(),
5282            data_indices: {
5283                let mut v: Vec<usize> = (0..n_e).collect();
5284                v[0] = n_work + 5;
5285                v
5286            },
5287        });
5288        expect(&cfg(vec![vec![1.0; n_work]], oor), "out of");
5289    }
5290
5291    /// Cover the OTHER branches of the #608 `evaluate_jacobian_and_fisher` σ
5292    /// restructure: the identity-layout path (no resolution ⇒ working grid ==
5293    /// data grid) and the early-return when `fit_temperature` is set (the
5294    /// `TransmissionFitModel` builds its own working-grid base σ).
5295    #[test]
5296    fn evaluate_jacobian_and_fisher_identity_and_temperature_paths() {
5297        let data = u238_single_resonance();
5298        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5299        let n_e = energies.len();
5300        let flux = vec![5000.0; n_e];
5301        let background = vec![10.0; n_e];
5302        // (a) No resolution ⇒ identity working-grid layout.
5303        let cfg_id = UnifiedFitConfig::new(
5304            energies.clone(),
5305            vec![data.clone()],
5306            vec!["U-238".into()],
5307            300.0,
5308            None,
5309            vec![0.001],
5310        )
5311        .unwrap();
5312        let r_id = evaluate_jacobian_and_fisher(&cfg_id, &flux, &background).unwrap();
5313        assert_eq!(r_id.model_prediction.len(), n_e);
5314        let f00 = r_id.fisher.get(0, 0);
5315        assert!(
5316            f00.is_finite() && f00 > 0.0,
5317            "identity-path Fisher[0,0]={f00}"
5318        );
5319        // (b) fit_temperature ⇒ the σ-branch early-returns `config`;
5320        //     TransmissionFitModel builds its own working-grid base σ.
5321        let cfg_t = UnifiedFitConfig::new(
5322            energies,
5323            vec![data],
5324            vec!["U-238".into()],
5325            300.0,
5326            None,
5327            vec![0.001],
5328        )
5329        .unwrap()
5330        .with_fit_temperature(true);
5331        let r_t = evaluate_jacobian_and_fisher(&cfg_t, &flux, &background).unwrap();
5332        assert_eq!(
5333            r_t.param_names.len(),
5334            2,
5335            "density + temperature free params"
5336        );
5337        assert!(r_t.model_prediction.iter().all(|v| v.is_finite()));
5338    }
5339
5340    /// Cover `build_transmission_model`'s group-collapse path: a grouped config
5341    /// (two isotopes → one density param) carrying PRECOMPUTED per-member σ is
5342    /// collapsed to per-group effective σ before the model build (the grouped +
5343    /// precomputed path the spatial pipeline drives per pixel).
5344    #[test]
5345    fn fit_spectrum_typed_grouped_precomputed_collapses_by_groups() {
5346        use nereids_core::types::{Isotope, IsotopeGroup};
5347        let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
5348        let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
5349        let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.05).collect();
5350        let true_density = 0.0005_f64;
5351        let sample = phys_transmission::SampleParams::new(
5352            0.0,
5353            vec![
5354                (rd1.clone(), true_density * 0.6),
5355                (rd2.clone(), true_density * 0.4),
5356            ],
5357        )
5358        .unwrap();
5359        let transmission = phys_transmission::forward_model(&energies, &sample, None).unwrap();
5360        let uncertainty: Vec<f64> = transmission.iter().map(|&t| 0.01 * t.max(0.01)).collect();
5361        // Per-member precomputed σ (2 rows) ⇒ triggers the group collapse.
5362        let per_member = phys_transmission::broadened_cross_sections(
5363            &energies,
5364            &[rd1.clone(), rd2.clone()],
5365            0.0,
5366            None,
5367            None,
5368        )
5369        .unwrap();
5370        let iso1 = Isotope::new(92, 235).unwrap();
5371        let iso2 = Isotope::new(92, 238).unwrap();
5372        let group = IsotopeGroup::custom("U".into(), vec![(iso1, 0.6), (iso2, 0.4)]).unwrap();
5373        let config = UnifiedFitConfig::new(
5374            energies,
5375            vec![rd1.clone()],
5376            vec!["placeholder".into()],
5377            0.0,
5378            None,
5379            vec![0.001],
5380        )
5381        .unwrap()
5382        .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
5383        .unwrap()
5384        .with_precomputed_cross_sections(Arc::new(per_member))
5385        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
5386        let input = InputData::Transmission {
5387            transmission,
5388            uncertainty,
5389        };
5390        let result = fit_spectrum_typed(&input, &config).unwrap();
5391        assert_eq!(result.densities.len(), 1, "one group density");
5392        assert!(
5393            (result.densities[0] - true_density).abs() / true_density < 0.01,
5394            "grouped+precomputed density: fitted={}, true={true_density}",
5395            result.densities[0]
5396        );
5397    }
5398
5399    /// `peak_match_energy_scale_seed` rejects (→ keeps the cold start) a fit that
5400    /// lands OUTSIDE the parameter bounds rather than clamping onto a bound
5401    /// (issue #608 R2): inject an L_scale (1.03) beyond the seed's (0.99, 1.01).
5402    #[test]
5403    fn peak_match_energy_scale_seed_rejects_out_of_bounds() {
5404        let data = hf178_mlbw_two_resonances();
5405        let energies: Vec<f64> = (0..900).map(|i| 4.0 + (i as f64) * 0.02).collect();
5406        let model = EnergyScaleTransmissionModel::new(
5407            Arc::new(vec![data.clone()]),
5408            Arc::new(vec![0]),
5409            Arc::new(vec![1.0]),
5410            293.6,
5411            energies.clone(),
5412            25.0,
5413            1,
5414            2,
5415            None,
5416        );
5417        // L_scale = 1.03 is well outside (0.99, 1.01); the seed would fit ≈1.03.
5418        let t_obs = model.evaluate(&[0.05, 0.0, 1.03]).unwrap();
5419        let config = UnifiedFitConfig::new(
5420            energies.clone(),
5421            vec![data],
5422            vec!["Hf-178".into()],
5423            293.6,
5424            None,
5425            vec![0.05],
5426        )
5427        .unwrap()
5428        .with_energy_scale(0.0, 1.0, 25.0);
5429        assert!(
5430            peak_match_energy_scale_seed(
5431                &t_obs,
5432                config.energies(),
5433                &config,
5434                25.0,
5435                (-10.0, 10.0),
5436                (0.99, 1.01),
5437            )
5438            .is_none(),
5439            "an out-of-bounds calibration fit must be rejected (cold-start fallback)"
5440        );
5441    }
5442}