Skip to main content

nereids_fitting/
joint_poisson.rs

1//! Joint-Poisson counts-path objective with profiled flux.
2//!
3//! This module implements the **joint-Poisson conditional binomial deviance**:
4//! the per-bin flux is profiled out of a two-arm Poisson model analytically
5//! (derivation below).  The deviance is validated against synthetic
6//! counts benchmarks and locked by a real-VENUS counts regression test
7//! on the committed aggregated-Hf fixture.  It supersedes
8//! the fixed-flux Poisson NLL (`poisson.rs`) for the counts-path fitter.
9//!
10//! ## Model
11//!
12//! Under the λ-at-sample convention with proton-charge ratio `c = Q_s / Q_ob`:
13//!
14//! - `O_i ~ Poisson(λ_i / c)`  (open-beam counts)
15//! - `S_i ~ Poisson(λ_i · T_i)` (sample counts)
16//!
17//! Profiling out `λ_i` bin-by-bin gives the closed-form MLE
18//!
19//! ```text
20//! λ̂_i = c · (O_i + S_i) / (1 + c · T_i)
21//! ```
22//!
23//! The profile-conditional log-likelihood is equivalent (up to constants) to
24//! a Binomial `S_i | N_i = O_i + S_i ~ Binomial(N_i, p_i)` with
25//!
26//! ```text
27//! p_i = c · T_i / (1 + c · T_i)
28//! ```
29//!
30//! The conditional deviance is
31//!
32//! ```text
33//! D(θ) = 2 · Σ_i [ S_i · ln(S_i / (N_i · p_i))
34//!                + O_i · ln(O_i / (N_i · (1 − p_i))) ]
35//! ```
36//!
37//! with the `x · ln(x / 0) → 0` convention when `x = 0`.
38//!
39//! Under the correct model, `D / (n − k)` → 1 as n → ∞ — this replaces the
40//! fixed-flux Pearson χ²/dof reported from the old Poisson path (which
41//! scaled with the proton-charge ratio `c` at constant density fidelity).
42
43use nereids_core::constants::{PIVOT_FLOOR, POISSON_EPSILON};
44
45use crate::error::FittingError;
46use crate::lm::{FitModel, FlatMatrix};
47use crate::parameters::ParameterSet;
48
49/// Joint-Poisson objective.
50///
51/// Wraps an effective count-ratio `FitModel` (which produces
52/// `T_eff,i = model.evaluate(θ)`) together with the observed open-beam counts
53/// `O_i`, sample counts `S_i`, and proton-charge ratio `c = Q_s / Q_ob`.
54///
55/// ## Physical count-response contract
56///
57/// This is a low-level API: it cannot inspect a `dyn FitModel` to determine
58/// how instrument resolution was applied.  The supplied model must already
59/// return the physically valid effective sample/open count response on the
60/// observed bins.  With an instrument response operator `R` and incident
61/// spectrum `Φ`, that response is
62///
63/// ```text
64/// T_eff = R[Φ · T] / R[Φ].
65/// ```
66///
67/// A post-hoc broadened transmission `R[T]` is not this response and must
68/// never be supplied as though it were.  In particular, do not directly wrap
69/// a resolution-bearing
70/// [`TransmissionFitModel`](crate::transmission_model::TransmissionFitModel),
71/// because that model returns `R[T]`.  A no-resolution transmission model
72/// remains valid because `R` is then the identity; a custom model that already
73/// returns the exact effective ratio is also valid.  Otherwise, callers must
74/// model the open and sample response arms separately or use the guarded
75/// pipeline entry point, which rejects unsupported counts-plus-resolution
76/// combinations.
77///
78/// The caller is responsible for ensuring `o`, `s`, and `model.evaluate()`
79/// output all have the same length.
80pub struct JointPoissonObjective<'a> {
81    /// Effective count-ratio model: `evaluate(θ) → T_eff(E)`.
82    ///
83    /// See the struct-level physical count-response contract.  In particular,
84    /// this must not be a post-hoc broadened transmission `R[T]`.
85    pub model: &'a dyn FitModel,
86    /// Open-beam counts per bin.
87    pub o: &'a [f64],
88    /// Sample counts per bin.
89    pub s: &'a [f64],
90    /// Proton-charge ratio `c = Q_s / Q_ob`.  Must be strictly positive.
91    pub c: f64,
92    /// Optional per-bin active mask (SAMMY EMIN/EMAX-equivalent
93    /// fit-energy-range restriction).  When `Some(m)`, only bins where
94    /// `m[i]` is `true` contribute to the deviance / gradient / Fisher
95    /// information; the model is still evaluated on the full grid so
96    /// resolution broadening at the boundaries is correct.  When
97    /// `None`, all bins are active (default behaviour).
98    ///
99    /// Length must equal `o.len()`; the GUI / pipeline dispatch builds
100    /// it from the configured `[E_min, E_max]` against the energy grid.
101    pub active_mask: Option<&'a [bool]>,
102    /// Expected background counts in the OPEN-beam acquisition, per bin.
103    ///
104    /// `None` is the background-free case and keeps the binomial reduction
105    /// documented above. `Some` switches to the Poisson form, because with
106    /// a background in either arm the flux no longer cancels out of the
107    /// conditional likelihood.
108    ///
109    /// These are counts for the acquisition they belong to, already binned —
110    /// a measured dark or blocked-beam reference after its own run
111    /// normalization, not a transmission-level curve.
112    pub open_background: Option<&'a [f64]>,
113    /// Expected background counts in the SAMPLE acquisition, per bin.
114    ///
115    /// Distinct from [`Self::open_background`]: the sample scatters neutrons
116    /// and emits gammas, so equal backgrounds is not a physical case. Their
117    /// difference is exactly what the two arms can separate.
118    pub sample_background: Option<&'a [f64]>,
119}
120
121impl<'a> JointPoissonObjective<'a> {
122    /// Number of data bins.
123    pub fn n_data(&self) -> usize {
124        self.o.len()
125    }
126
127    /// Number of *active* data bins — `n_data` when no mask is set,
128    /// or the count of `true` entries in `active_mask` otherwise.
129    pub fn n_active(&self) -> usize {
130        crate::active_mask::active_count(self.active_mask, self.o.len())
131    }
132
133    /// Number of *informative* active bins: active bins with a nonzero
134    /// count total `O_i + S_i > 0`.  A zero-total bin is degenerate under
135    /// the conditional-binomial model — its profiled rate is zero and it
136    /// contributes exactly zero deviance for every parameter value — so
137    /// counting it as a degree of freedom deflates `deviance_per_dof`
138    /// (and the opt-in `scale_by_chi2` σ inflation) by the empty-bin
139    /// fraction.  The exact detector-time route makes wide acquisition
140    /// windows with many empty bins routine, so deviance-per-dof
141    /// reporting must use THIS count.
142    pub fn n_informative(&self) -> usize {
143        self.o
144            .iter()
145            .zip(self.s.iter())
146            .enumerate()
147            .filter(|&(i, (&o, &s))| self.bin_active(i) && o + s > 0.0)
148            .count()
149    }
150
151    /// Predicate: is bin `i` active?  Returns `true` when no mask is
152    /// set (full-grid default).
153    ///
154    /// A mask shorter than the data (rejected by [`Self::validate_inputs`]
155    /// on every path that optimizes) reads as INACTIVE past its end rather
156    /// than panicking, so the infallible public accessors — which by
157    /// signature cannot report a `LengthMismatch` — degrade predictably on
158    /// a malformed caller-built objective instead of aborting.
159    #[inline]
160    fn bin_active(&self, i: usize) -> bool {
161        self.active_mask
162            .is_none_or(|m| m.get(i).copied().unwrap_or(false))
163    }
164
165    /// Runtime guard for the public methods that bypass `joint_poisson_fit`'s
166    /// up-front validation (callers may invoke `deviance_from_transmission`,
167    /// `deviance_gradient_analytical`, `fisher_information[_fd]`, etc.
168    /// directly for diagnostics).  Mirrors the entry-point checks in
169    /// `joint_poisson_fit`: `o.len() == s.len()`, `c` finite and > 0, optional
170    /// `active_mask` length agrees, all `o[i]` / `s[i]` finite and >= 0, and
171    /// the caller-supplied transmission length agrees with `o.len()`.  The
172    /// `debug_assert!`s in the per-bin helpers are no-ops in release builds —
173    /// without this guard a length mismatch in `s` would silently truncate
174    /// via `.zip()` and a non-positive / NaN `c` would produce finite
175    /// garbage.
176    ///
177    /// **Error orientation.**  `FittingError::LengthMismatch` displays as
178    /// `"{field} length ({actual}) must match expected length ({expected})"`.
179    /// The objective's own invariants (`s.len()` vs `o.len()`, `mask.len()`
180    /// vs `o.len()`) are checked first, with `expected = o.len()` so the
181    /// message accurately names the offending field.  The caller-supplied
182    /// `t` length is then checked against `o.len()` with `field =
183    /// "transmission"` — pre-fix this branch reported `field =
184    /// "open_beam_counts"` with `expected = t_len`, which read as "the
185    /// open-beam array is wrong" when the actual fault was the caller's
186    /// transmission slice.
187    fn validate_inputs(&self, t_len: usize) -> Result<(), FittingError> {
188        // Internal invariants of the objective itself — these must hold
189        // regardless of what the caller passes for `t`.
190        if self.s.len() != self.o.len() {
191            return Err(FittingError::LengthMismatch {
192                expected: self.o.len(),
193                actual: self.s.len(),
194                field: "sample_counts",
195            });
196        }
197        if let Some(m) = self.active_mask
198            && m.len() != self.o.len()
199        {
200            return Err(FittingError::LengthMismatch {
201                expected: self.o.len(),
202                actual: m.len(),
203                field: "active_mask",
204            });
205        }
206        if !self.c.is_finite() || self.c <= 0.0 {
207            return Err(FittingError::InvalidConfig(format!(
208                "proton-charge ratio c = Q_s/Q_ob must be finite and > 0, got {}",
209                self.c
210            )));
211        }
212        // Caller-supplied length: the transmission slice must match the
213        // objective's bin count.
214        if t_len != self.o.len() {
215            return Err(FittingError::LengthMismatch {
216                expected: self.o.len(),
217                actual: t_len,
218                field: "transmission",
219            });
220        }
221        // Per-element count validation.  The entry point `joint_poisson_fit`
222        // also calls `validate_counts` up-front so the user gets the error
223        // before any LM work, but every public method that bypasses the
224        // entry point (`deviance_from_transmission`, `fisher_information`,
225        // `profile_lambda_per_bin`, …) must still reject non-finite /
226        // negative counts — the inner `binomial_deviance_term` /
227        // `xlogy_ratio` would otherwise propagate NaN past the zero-clamp
228        // (`NaN <= 0.0` is `false`) or silently swallow a negative as zero.
229        validate_counts(self.o, "open_beam_counts")?;
230        validate_counts(self.s, "sample_counts")?;
231        for (name, background) in [
232            ("open_background", self.open_background),
233            ("sample_background", self.sample_background),
234        ] {
235            if let Some(b) = background {
236                if b.len() != self.o.len() {
237                    return Err(FittingError::LengthMismatch {
238                        expected: self.o.len(),
239                        actual: b.len(),
240                        field: name,
241                    });
242                }
243                validate_counts(b, name)?;
244            }
245        }
246        Ok(())
247    }
248
249    /// Closed-form profile MLE for the per-bin flux: `λ̂ = c·(O+S) / (1+c·T)`.
250    ///
251    /// Guards: when `1 + c·T ≤ ε`, returns 0 to avoid division blow-up.
252    #[inline]
253    pub fn profile_lambda(&self, t_i: f64, o_i: f64, s_i: f64) -> f64 {
254        let denom = 1.0 + self.c * t_i;
255        if denom <= POISSON_EPSILON {
256            0.0
257        } else {
258            self.c * (o_i + s_i) / denom
259        }
260    }
261
262    /// Background counts for one bin, `(open, sample)`. Zero when absent.
263    #[inline]
264    fn backgrounds(&self, i: usize) -> (f64, f64) {
265        (
266            self.open_background.map_or(0.0, |b| b[i]),
267            self.sample_background.map_or(0.0, |b| b[i]),
268        )
269    }
270
271    /// Whether either arm carries a background, which decides whether the
272    /// binomial reduction applies.
273    #[inline]
274    fn has_background(&self) -> bool {
275        self.open_background.is_some() || self.sample_background.is_some()
276    }
277
278    /// Profile MLE for the per-bin flux WITH backgrounds present.
279    ///
280    /// With `E[O] = λ/c + b_o` and `E[S] = λ·T + b_s`, the score equation
281    ///
282    /// ```text
283    /// -1/c + O/(λ + c·b_o) - T + S·T/(λ·T + b_s) = 0
284    /// ```
285    ///
286    /// clears to a quadratic `a λ² + b λ + d = 0` with
287    ///
288    /// ```text
289    /// a = T·(1/c + T)
290    /// b = (1/c + T)·(b_s + c·b_o·T) - T·(O + S)
291    /// d = c·b_o·b_s·(1/c + T) - O·b_s - S·T·c·b_o
292    /// ```
293    ///
294    /// `a > 0` and `d <= 0` whenever the counts are non-negative, so there is
295    /// exactly one non-negative root and it is the `+` branch. Setting
296    /// `b_o = b_s = 0` gives `d = 0` and `λ̂ = -b/a = c(O+S)/(1+cT)`, the
297    /// background-free closed form — which is the check that this reduces
298    /// correctly, and is asserted by
299    /// `zero_backgrounds_reproduce_the_binomial_profile`.
300    ///
301    /// Solved with the numerically stable quadratic form: the `+` branch of
302    /// `(-b + sqrt(disc))/(2a)` cancels catastrophically when `b > 0`, so it
303    /// is evaluated as `-2d/(b + sqrt(disc))` there.
304    #[inline]
305    pub fn profile_lambda_with_background(
306        &self,
307        t_i: f64,
308        o_i: f64,
309        s_i: f64,
310        b_o: f64,
311        b_s: f64,
312    ) -> f64 {
313        let t = t_i.max(POISSON_EPSILON);
314        let inv_c = 1.0 / self.c;
315        let a = t * (inv_c + t);
316        if a <= POISSON_EPSILON {
317            return 0.0;
318        }
319        let b = (inv_c + t) * (b_s + self.c * b_o * t) - t * (o_i + s_i);
320        let d = self.c * b_o * b_s * (inv_c + t) - o_i * b_s - s_i * t * self.c * b_o;
321        let disc = (b * b - 4.0 * a * d).max(0.0);
322        let root = disc.sqrt();
323        let lambda = if b > 0.0 {
324            // -b + root cancels; use the algebraically equal form.
325            -2.0 * d / (b + root)
326        } else {
327            (-b + root) / (2.0 * a)
328        };
329        lambda.max(0.0)
330    }
331
332    /// Poisson deviance for one bin with backgrounds present.
333    ///
334    /// The binomial reduction needs the flux to cancel between the arms,
335    /// which it does not once either arm has a term the flux does not
336    /// multiply. So the deviance is the ordinary two-arm Poisson one against
337    /// the saturated model:
338    ///
339    /// ```text
340    /// D_i = 2·[ O·ln(O/μ_O) - (O - μ_O) + S·ln(S/μ_S) - (S - μ_S) ]
341    /// μ_O = λ̂/c + b_o     μ_S = λ̂·T + b_s
342    /// ```
343    #[inline]
344    fn poisson_deviance_term(&self, t_i: f64, o_i: f64, s_i: f64, b_o: f64, b_s: f64) -> f64 {
345        let t = t_i.max(POISSON_EPSILON);
346        let lambda = self.profile_lambda_with_background(t, o_i, s_i, b_o, b_s);
347        let mu_o = (lambda / self.c + b_o).max(POISSON_EPSILON);
348        let mu_s = (lambda * t + b_s).max(POISSON_EPSILON);
349        2.0 * (xlogy_ratio(o_i, mu_o) - (o_i - mu_o) + xlogy_ratio(s_i, mu_s) - (s_i - mu_s))
350    }
351
352    /// Vector form of [`profile_lambda`](Self::profile_lambda).
353    ///
354    /// Validates `t.len() == o.len() == s.len()` and `c > 0`; returns
355    /// `FittingError::LengthMismatch` / `InvalidConfig` rather than the
356    /// previous `.zip()` truncate-and-pretend behaviour (which would
357    /// silently shrink the output to `min(t.len(), o.len(), s.len())`).
358    pub fn profile_lambda_per_bin(&self, t: &[f64]) -> Result<Vec<f64>, FittingError> {
359        self.validate_inputs(t.len())?;
360        Ok(t.iter()
361            .zip(self.o.iter())
362            .zip(self.s.iter())
363            .enumerate()
364            .map(|(i, ((&ti, &oi), &si))| {
365                if self.has_background() {
366                    let (b_o, b_s) = self.backgrounds(i);
367                    self.profile_lambda_with_background(ti, oi, si, b_o, b_s)
368                } else {
369                    self.profile_lambda(ti, oi, si)
370                }
371            })
372            .collect())
373    }
374
375    /// Conditional binomial deviance at the given transmission vector.
376    ///
377    /// D = 2 · Σ [ S·ln(S/(Np)) + O·ln(O/(N(1−p))) ] with
378    /// `p = cT/(1+cT)`, `N = O+S`, and `x·ln(x/0) → 0`.
379    ///
380    /// Near invalid or numerically tiny transmission values, the per-bin
381    /// evaluation (`binomial_deviance_term`) uses `t.max(POISSON_EPSILON)`
382    /// to clamp T away from zero before entering the logarithms and the
383    /// `1/(1+cT)` factor.  This avoids singular logs and division-by-zero
384    /// but is a piecewise clamp, not a smooth quadratic extrapolation —
385    /// D(T) is C⁰ at the clamp boundary, not C¹.  In practice this is
386    /// adequate because the optimizer's transmission values come from a
387    /// `FitModel` that keeps T bounded well above `POISSON_EPSILON` for
388    /// physically plausible density / nuisance parameter values.
389    pub fn deviance_from_transmission(&self, t: &[f64]) -> Result<f64, FittingError> {
390        self.validate_inputs(t.len())?;
391        let mut d = 0.0;
392        // Total active counts — sets the scale of legitimate xlogy round-off
393        // (each term cancels quantities of magnitude ~(O+S), so the summed
394        // error is O(ε · Σ(O+S))).
395        let mut weight_scale = 0.0;
396        for (i, ((&t_i, &o_i), &s_i)) in t.iter().zip(self.o.iter()).zip(self.s.iter()).enumerate()
397        {
398            if !self.bin_active(i) {
399                continue;
400            }
401            d += if self.has_background() {
402                let (b_o, b_s) = self.backgrounds(i);
403                self.poisson_deviance_term(t_i, o_i, s_i, b_o, b_s)
404            } else {
405                binomial_deviance_term(s_i, o_i, t_i, self.c)
406            };
407            weight_scale += o_i + s_i;
408        }
409        // Each conditional-binomial term is nonnegative by Gibbs' inequality,
410        // so D ≥ 0 is a hard mathematical invariant — but the per-term xlogy
411        // cancellations carry O(ULP·count) round-off, and when the optimizer
412        // converges machine-exactly on noise-free (synthetic) data the sum
413        // can land at ~−1e-13.  Clamp WITHIN the round-off envelope only
414        // (review R2): an unbounded clamp would convert a genuine
415        // deviance-term defect producing a large negative / −∞ sum into a
416        // silent "perfect converged fit" (the D == 0 convergence shortcut in
417        // damped_fisher_stage).  Beyond the envelope, surface the defect as
418        // an Err — at the initial point that propagates to the caller, and
419        // mid-iteration it rejects trials until the fit reports
420        // non-converged, both honest failure signals.  NaN from a
421        // non-finite model row passes through first: NaN.max(0.0) = 0.0
422        // would be wrong (f64::max returns the OTHER operand for NaN).
423        if d.is_nan() {
424            return Ok(d);
425        }
426        // 64× margin over the observed magnitude class (−1.5e-13 at
427        // Σ(O+S) ≈ 4e6, i.e. ~ε·Σ ≈ 1e-9 worst-case).
428        let roundoff_envelope = 64.0 * f64::EPSILON * weight_scale.max(1.0);
429        if d < -roundoff_envelope {
430            return Err(FittingError::EvaluationFailed(format!(
431                "conditional-binomial deviance D = {d} is negative beyond the \
432                 accumulation round-off envelope ({roundoff_envelope:.3e}) — \
433                 this indicates a deviance-term defect, not round-off; \
434                 refusing to clamp it to a perfect fit"
435            )));
436        }
437        Ok(d.max(0.0))
438    }
439
440    /// Evaluate the deviance at parameter vector θ by calling the model.
441    pub fn deviance(&self, params: &[f64]) -> Result<f64, FittingError> {
442        let t = self.model.evaluate(params)?;
443        if t.len() != self.o.len() {
444            return Err(FittingError::LengthMismatch {
445                expected: self.o.len(),
446                actual: t.len(),
447                field: "transmission",
448            });
449        }
450        self.deviance_from_transmission(&t)
451    }
452
453    /// Analytical gradient of the deviance w.r.t. the free parameters.
454    ///
455    /// Returns `None` if the transmission model does not provide an analytical
456    /// Jacobian — callers should fall back to `deviance_gradient_fd`.
457    ///
458    /// Gradient derivation: with `p_i = cT_i/(1+cT_i)` and N_i = O_i+S_i,
459    ///
460    ///   d D / d T_i = −2 · (S_i − O_i·c·T_i) / (T_i · (1 + c·T_i))
461    ///
462    /// then chain-rule with the transmission Jacobian J_{i,j} = ∂T_i / ∂θ_{f(j)}
463    /// where f(j) is the j-th free parameter index.
464    pub fn deviance_gradient_analytical(
465        &self,
466        params: &[f64],
467        free_param_indices: &[usize],
468    ) -> Result<Option<Vec<f64>>, FittingError> {
469        // The analytic gradient and Fisher below are derived from the
470        // BINOMIAL reduction, which needs the flux to cancel between the
471        // arms. A background in either arm breaks that, so the closed forms
472        // no longer describe this objective. Declining here sends the solver
473        // to its finite-difference fallback, which differentiates the
474        // deviance actually in use.
475        //
476        // Deriving the background-bearing analytic forms is a speed change,
477        // not a correctness one, and is deliberately not attempted here.
478        if self.has_background() {
479            return Ok(None);
480        }
481        let t = self.model.evaluate(params)?;
482        self.validate_inputs(t.len())?;
483        let jac = match self
484            .model
485            .analytical_jacobian(params, free_param_indices, &t)
486        {
487            Some(j) => j,
488            None => return Ok(None),
489        };
490        let n_free = free_param_indices.len();
491        let mut grad = vec![0.0f64; n_free];
492        for (i, (&t_i, (&o_i, &s_i))) in t.iter().zip(self.o.iter().zip(self.s.iter())).enumerate()
493        {
494            if !self.bin_active(i) {
495                continue;
496            }
497            let w = deviance_weight(s_i, o_i, t_i, self.c);
498            // `deviance_weight` returns 0 for non-finite `t_i`, so a NaN
499            // transmission row already contributes nothing — except that
500            // `0.0 * NaN = NaN`.  If the upstream Jacobian column has a
501            // NaN cell (common for FD-built Jacobians where the model
502            // returns NaN at some probe point), the bare `0.0 * jac.get(...)`
503            // would poison `grad[col]`.  Skip the row entirely when the
504            // weight is zero, and skip any individual Jacobian cell that
505            // is not finite.
506            if w == 0.0 {
507                continue;
508            }
509            for (g, col) in grad.iter_mut().zip(0..n_free) {
510                let j = jac.get(i, col);
511                if j.is_finite() {
512                    *g += w * j;
513                }
514            }
515        }
516        Ok(Some(grad))
517    }
518
519    /// Fisher information for free parameters (Gauss-Newton curvature of D).
520    ///
521    /// Uses the expected-info form
522    ///
523    ///   h_i ≡ ∂² D / ∂ T_i²  ≈  2 · (O_i + S_i) · c / (T_i · (1 + c·T_i)²)
524    ///
525    /// (derived from logit-link binomial Var(S|N) = N p (1−p) and
526    /// d logit(p) / dT = 1/T, scaled by 2 since D = −2 L).  Then
527    ///
528    ///   I(θ)_{j,k} = Σ_i h_i · J_{i,j} · J_{i,k}.
529    ///
530    /// Returns `None` if the transmission model does not provide an analytical
531    /// Jacobian.
532    pub fn fisher_information(
533        &self,
534        params: &[f64],
535        free_param_indices: &[usize],
536    ) -> Result<Option<FlatMatrix>, FittingError> {
537        // The analytic gradient and Fisher below are derived from the
538        // BINOMIAL reduction, which needs the flux to cancel between the
539        // arms. A background in either arm breaks that, so the closed forms
540        // no longer describe this objective. Declining here sends the solver
541        // to its finite-difference fallback, which differentiates the
542        // deviance actually in use.
543        //
544        // Deriving the background-bearing analytic forms is a speed change,
545        // not a correctness one, and is deliberately not attempted here.
546        if self.has_background() {
547            return Ok(None);
548        }
549        let t = self.model.evaluate(params)?;
550        self.validate_inputs(t.len())?;
551        let jac = match self
552            .model
553            .analytical_jacobian(params, free_param_indices, &t)
554        {
555            Some(j) => j,
556            None => return Ok(None),
557        };
558        let n_free = free_param_indices.len();
559        let mut info = FlatMatrix::zeros(n_free, n_free);
560        for (i, ((&t_i, &o_i), &s_i)) in t.iter().zip(self.o.iter()).zip(self.s.iter()).enumerate()
561        {
562            if !self.bin_active(i) {
563                continue;
564            }
565            let h = deviance_curvature(s_i, o_i, t_i, self.c);
566            // Mirror the gradient guard: `deviance_curvature` returns 0
567            // for non-finite `t_i`, but `0.0 * NaN = NaN` would still
568            // poison the Fisher matrix when an FD-built Jacobian has a
569            // NaN cell.  Skip the row at h == 0, and skip cells that are
570            // not finite.
571            if h == 0.0 {
572                continue;
573            }
574            for j in 0..n_free {
575                let jij = jac.get(i, j);
576                if !jij.is_finite() {
577                    continue;
578                }
579                for k in 0..n_free {
580                    let jik = jac.get(i, k);
581                    if jik.is_finite() {
582                        *info.get_mut(j, k) += h * jij * jik;
583                    }
584                }
585            }
586        }
587        Ok(Some(info))
588    }
589
590    /// Finite-difference Fisher information.
591    ///
592    /// Fallback for callers whose transmission model does not implement
593    /// [`FitModel::analytical_jacobian`] — i.e., when
594    /// [`Self::fisher_information`] would return `None`.  Builds the
595    /// transmission Jacobian column-by-column via central differences and
596    /// assembles
597    ///
598    ///   `I(θ)_{j,k} = Σ_i h_i · J_{i,j} · J_{i,k}`
599    ///
600    /// where `h_i = ∂² D / ∂ T_i²` is the per-bin deviance curvature
601    /// `2·(O_i + S_i)·c / (T_i·(1 + c·T_i)²)` (Fisher-scoring form derived
602    /// from binomial logit-link Var(S | N) = N·p·(1−p) with d logit p / dT
603    /// = 1/T — see the module-level docstring §Model).  Returns `Ok(None)`
604    /// only if the base model evaluation itself fails.
605    pub fn fisher_information_fd(
606        &self,
607        params: &mut ParameterSet,
608        fd_step: f64,
609    ) -> Result<Option<FlatMatrix>, FittingError> {
610        let free_idx = params.free_indices();
611        let base_values = params.all_values();
612        let t_base = self.model.evaluate(&base_values)?;
613        self.validate_inputs(t_base.len())?;
614        let n_e = t_base.len();
615        let n_free = free_idx.len();
616        if n_free == 0 {
617            return Ok(Some(FlatMatrix::zeros(0, 0)));
618        }
619        let mut jac = FlatMatrix::zeros(n_e, n_free);
620        for (col, &idx) in free_idx.iter().enumerate() {
621            let original = params.params[idx].value;
622            let step = fd_step * (1.0 + original.abs());
623            params.params[idx].value = original + step;
624            params.params[idx].clamp();
625            let forward_step = params.params[idx].value - original;
626            let t_plus = if forward_step.abs() >= PIVOT_FLOOR {
627                Some(self.model.evaluate(&params.all_values())?)
628            } else {
629                None
630            };
631            params.params[idx].value = original - step;
632            params.params[idx].clamp();
633            let backward_step = original - params.params[idx].value;
634            let t_minus = if backward_step.abs() >= PIVOT_FLOOR {
635                Some(self.model.evaluate(&params.all_values())?)
636            } else {
637                None
638            };
639            params.params[idx].value = original;
640            let (t_a, t_b, denom) = match (t_plus, t_minus) {
641                (Some(tp), Some(tm)) => (tp, tm, forward_step + backward_step),
642                (Some(tp), None) => (tp, t_base.clone(), forward_step),
643                (None, Some(tm)) => (t_base.clone(), tm, backward_step),
644                (None, None) => continue,
645            };
646            if denom.abs() < PIVOT_FLOOR {
647                continue;
648            }
649            // Per-cell finiteness check.  The matching guard in lm.rs
650            // `compute_jacobian` zeroes NaN entries instead of dropping
651            // the column; the same pattern applies here because the
652            // downstream Fisher accumulator below already skips inactive
653            // rows (`bin_active(i)`), so a NaN at a masked / inactive
654            // row must not block the column for active rows.  Active-row
655            // NaN is handled by [`deviance_curvature`], which returns 0
656            // on non-finite `t_i` so the assembly stays clean.
657            for i in 0..n_e {
658                let a = t_a[i];
659                let b = t_b[i];
660                if a.is_finite() && b.is_finite() {
661                    *jac.get_mut(i, col) = (a - b) / denom;
662                }
663                // else: leave at the zero-default; masked rows are never
664                // read by the active-bin filter in the Fisher loop.
665            }
666        }
667        let mut info = FlatMatrix::zeros(n_free, n_free);
668        for (i, ((&t_i, &o_i), &s_i)) in t_base
669            .iter()
670            .zip(self.o.iter())
671            .zip(self.s.iter())
672            .enumerate()
673        {
674            if !self.bin_active(i) {
675                continue;
676            }
677            let h = deviance_curvature(s_i, o_i, t_i, self.c);
678            // Same guard as the analytical `fisher_information`: avoid
679            // `0.0 * NaN = NaN` poisoning the matrix from NaN Jacobian
680            // cells (per-cell zero default from the FD loop above leaves
681            // most NaN entries as 0, but a stale value from a partial
682            // FD failure must still be defensively skipped).
683            if h == 0.0 {
684                continue;
685            }
686            for j in 0..n_free {
687                let jij = jac.get(i, j);
688                if !jij.is_finite() {
689                    continue;
690                }
691                for k in 0..n_free {
692                    let jik = jac.get(i, k);
693                    if jik.is_finite() {
694                        *info.get_mut(j, k) += h * jij * jik;
695                    }
696                }
697            }
698        }
699        Ok(Some(info))
700    }
701
702    /// Finite-difference gradient of the deviance.
703    ///
704    /// Central differences on each free parameter.  Used as a fallback when
705    /// the model has no analytical Jacobian.  `params` is a mutable
706    /// `ParameterSet` so we can respect bounds via `clamp()`.
707    pub fn deviance_gradient_fd(
708        &self,
709        params: &mut ParameterSet,
710        fd_step: f64,
711    ) -> Result<Vec<f64>, FittingError> {
712        let free_idx = params.free_indices();
713        let base_values = params.all_values();
714        let base_d = self.deviance(&base_values)?;
715
716        let mut grad = vec![0.0; free_idx.len()];
717        for (j, &idx) in free_idx.iter().enumerate() {
718            let original = params.params[idx].value;
719            let step = fd_step * (1.0 + original.abs());
720
721            params.params[idx].value = original + step;
722            params.params[idx].clamp();
723            let mut actual_step = params.params[idx].value - original;
724            if actual_step.abs() < PIVOT_FLOOR {
725                // Upper bound blocks forward step: try backward.
726                params.params[idx].value = original - step;
727                params.params[idx].clamp();
728                actual_step = params.params[idx].value - original;
729                if actual_step.abs() < PIVOT_FLOOR {
730                    params.params[idx].value = original;
731                    continue;
732                }
733            }
734            let perturbed_values = params.all_values();
735            // After the NaN-T contract in `binomial_deviance_term`,
736            // `self.deviance` can legitimately return `Ok(NaN)` when a
737            // probe lands in a region where the model produces a
738            // non-finite transmission.  A non-finite `perturbed_d`
739            // divided by `actual_step` would write NaN into `grad[j]`
740            // and poison every subsequent step that consumes the
741            // gradient — symmetric with the `Err` branch below.  Treat
742            // both as "this probe is invalid; leave the column at 0".
743            let perturbed_d = match self.deviance(&perturbed_values) {
744                Ok(v) if v.is_finite() => v,
745                _ => {
746                    params.params[idx].value = original;
747                    continue;
748                }
749            };
750            params.params[idx].value = original;
751            grad[j] = (perturbed_d - base_d) / actual_step;
752        }
753        Ok(grad)
754    }
755}
756
757/// Per-bin binomial deviance term with smooth guards.
758///
759/// Returns `2 · [S·ln(S/(Np)) + O·ln(O/(N(1−p)))]` with the zero-count
760/// convention `x · ln(x / ·) → 0` when `x = 0`.
761///
762/// NaN-T contract (see also [`deviance_weight`] / [`deviance_curvature`]):
763///
764/// - For `0 ≤ T ≤ POISSON_EPSILON` (finite but numerically tiny or zero):
765///   clamps `T` to `POISSON_EPSILON` in the denominator so the optimizer
766///   sees a finite (large) D and a continuous gradient.  This is the
767///   "smooth guard" path.
768/// - For **non-finite** `T` (NaN or ±∞): returns `NaN` so the deviance
769///   sum becomes `NaN` and the LM / damped-Fisher trial-step guards
770///   (`Ok(v) if v.is_finite()`) reject the step.  This deliberately does
771///   *not* clamp via `f64::max`, because `f64::max(NaN, ε)` returns `ε`
772///   — which would silently masquerade as a valid bin.
773#[inline]
774fn binomial_deviance_term(s: f64, o: f64, t: f64, c: f64) -> f64 {
775    debug_assert!(
776        s.is_finite() && s >= 0.0,
777        "binomial_deviance_term: S must be finite and >= 0, got {s}"
778    );
779    debug_assert!(
780        o.is_finite() && o >= 0.0,
781        "binomial_deviance_term: O must be finite and >= 0, got {o}"
782    );
783    debug_assert!(
784        c.is_finite() && c > 0.0,
785        "binomial_deviance_term: c must be finite and > 0, got {c}"
786    );
787    // `f64::max(NaN, ε)` returns `ε`, so a non-finite T would silently
788    // masquerade as a tiny positive transmission and the deviance would
789    // evaluate to a finite (but meaningless) value that the LM trial-step
790    // guard `Ok(v) if v.is_finite()` would accept.  Return NaN so the
791    // deviance sum becomes NaN and the trial step is rejected.  The
792    // matching `deviance_weight` / `deviance_curvature` guards return 0,
793    // which keeps the gradient / Fisher accumulators clean rather than
794    // poisoning them with NaN contributions.
795    if !t.is_finite() {
796        return f64::NAN;
797    }
798    let t_safe = t.max(POISSON_EPSILON);
799    let n = s + o;
800    if n <= 0.0 {
801        // Bin has zero counts in both arms — no information, no contribution.
802        return 0.0;
803    }
804    let ct = c * t_safe;
805    // Use a numerically stable form for p.  For small cT, p ≈ cT, 1−p ≈ 1.
806    let one_plus_ct = 1.0 + ct;
807    // Expected sample and open-beam counts under profile λ̂.
808    let exp_s = ct / one_plus_ct * n; // = N·p = c·N·T/(1+cT)
809    let exp_o = n / one_plus_ct; //         = N·(1−p) = N/(1+cT)
810
811    let term_s = xlogy_ratio(s, exp_s);
812    let term_o = xlogy_ratio(o, exp_o);
813    2.0 * (term_s + term_o)
814}
815
816/// Reject non-finite or negative count arrays at public entry points.
817///
818/// Two distinct failure modes motivate the up-front check:
819///
820/// - **Non-finite (NaN / ±∞).**  The per-bin `xlogy_ratio` helper treats
821///   `x <= 0.0` as the zero-count branch and returns 0, but `NaN <= 0.0`
822///   is `false`, so a NaN slips past the branch and propagates
823///   `NaN · ln(NaN / y) = NaN` straight into the deviance sum.  The LM
824///   trial-step guard then sees `Ok(NaN)` instead of a clean error.
825/// - **Negative.**  `x <= 0.0` *is* true for `x < 0.0`, so the zero-count
826///   branch silently swallows negatives and returns 0 — the deviance
827///   stays finite but the bin is treated as "no data", which is
828///   physically meaningless and conceals the upstream bug (subtraction
829///   artefact in TOF normalisation, signed-int overflow in the loader,
830///   etc.).  Negatives never produce NaN, but the "successful" fit
831///   silently discards real data.
832///
833/// Validate up-front so callers get a typed `InvalidConfig` error
834/// pointing at the offending bin instead of either failure mode.
835fn validate_counts(counts: &[f64], field: &'static str) -> Result<(), FittingError> {
836    for (i, &v) in counts.iter().enumerate() {
837        if !v.is_finite() || v < 0.0 {
838            return Err(FittingError::InvalidConfig(format!(
839                "{field}[{i}] must be finite and >= 0, got {v}"
840            )));
841        }
842    }
843    Ok(())
844}
845
846/// `x · ln(x / y)` with the `0 · ln(0 / 0) → 0`, `x · ln(x / 0) → +∞`
847/// conventions.  For `y > 0` and `x = 0` the term is 0.  For `y = 0` and
848/// `x > 0` we clamp `y` to `POISSON_EPSILON` so the objective stays
849/// finite and continuous.
850#[inline]
851fn xlogy_ratio(x: f64, y: f64) -> f64 {
852    if x <= 0.0 {
853        0.0
854    } else {
855        let y_safe = y.max(POISSON_EPSILON);
856        x * (x / y_safe).ln()
857    }
858}
859
860/// Per-bin ∂D/∂T.
861///
862///   ∂D/∂T = −2 · (S − O·c·T) / (T · (1 + c·T))
863///
864/// When `T ≤ ε`, uses a linear extrapolation from `T = ε` so the gradient
865/// stays finite and continuous across the boundary (matching the clamping
866/// done in [`binomial_deviance_term`]).
867#[inline]
868fn deviance_weight(s: f64, o: f64, t: f64, c: f64) -> f64 {
869    // A non-finite T must not be folded into the gradient accumulator.
870    // `f64::max(NaN, ε)` returns `ε`, which would turn a NaN bin into a
871    // finite gradient contribution scaled by the Jacobian and silently
872    // steer the optimizer.  Skip the bin (return 0) — the matching
873    // `binomial_deviance_term` returns NaN so the step is rejected by
874    // the trial-guard, but the gradient stays clean in case the caller
875    // is using it for diagnostics on a partially-bad grid.
876    if !t.is_finite() {
877        return 0.0;
878    }
879    let t_safe = t.max(POISSON_EPSILON);
880    let one_plus_ct = 1.0 + c * t_safe;
881    -2.0 * (s - o * c * t_safe) / (t_safe * one_plus_ct)
882}
883
884/// Per-bin ∂²D/∂T² using the expected-info (Fisher) form.
885///
886/// Under the model, Var(S | N) = N · p · (1 − p) = N · cT / (1+cT)².  With
887/// d logit(p) / dT = 1/T, the Fisher info on T is
888///
889///   I_TT = N · c / (T · (1 + c·T)²)
890///
891/// and ∂²D/∂T² = 2 · I_TT (since D = −2 · L_c).
892#[inline]
893fn deviance_curvature(s: f64, o: f64, t: f64, c: f64) -> f64 {
894    // See the matching guard in [`deviance_weight`].  A non-finite T
895    // would otherwise contribute a huge spurious curvature via
896    // `f64::max(NaN, ε) -> ε`, inflating the diagonal of the Fisher
897    // matrix and underestimating the corresponding parameter
898    // uncertainty (covariance = I⁻¹ entries shrink as I grows).
899    if !t.is_finite() {
900        return 0.0;
901    }
902    let t_safe = t.max(POISSON_EPSILON);
903    let n = s + o;
904    let one_plus_ct = 1.0 + c * t_safe;
905    2.0 * n * c / (t_safe * one_plus_ct * one_plus_ct)
906}
907
908// ======================================================================
909// joint_poisson_fit — two-stage solver (damped Fisher + Nelder-Mead polish)
910// ======================================================================
911
912use crate::lm::{invert_matrix, solve_damped_system};
913use crate::nelder_mead::{NelderMeadConfig, nelder_mead_minimize};
914
915/// Configuration for [`joint_poisson_fit`].
916#[derive(Debug, Clone)]
917pub struct JointPoissonFitConfig {
918    /// Maximum number of damped-Fisher iterations in stage 1.
919    pub max_iter: usize,
920    /// Initial damping factor (Marquardt λ) on the Fisher matrix diagonal.
921    pub lambda_init: f64,
922    /// Multiplicative factor to increase λ on a rejected step.
923    pub lambda_up: f64,
924    /// Multiplicative factor to decrease λ on an accepted step.
925    pub lambda_down: f64,
926    /// Armijo sufficient-decrease coefficient.
927    pub armijo_c: f64,
928    /// Backtracking factor during line search.
929    pub backtrack: f64,
930    /// Convergence tolerance on relative deviance change.
931    pub tol_d: f64,
932    /// Convergence tolerance on normalized parameter step.
933    pub tol_param: f64,
934    /// Finite-difference step for gradient fallback.
935    pub fd_step: f64,
936    /// Enable Nelder-Mead polish after stage 1.
937    ///
938    /// Default `false` as of #486.  The polish tolerances
939    /// (`xatol = 1e-9, fatol = 1e-10`) were originally matched to a
940    /// synthetic counts benchmark where D stays O(1), so `fatol` is
941    /// physically meaningful.  On real-data regimes where D saturates
942    /// at 10⁴–10⁵ (un-modelled upstream physics), `fatol / D` drops
943    /// below f64 ULP and polish
944    /// cannot self-terminate — it burns its full `max_iter = 5000`
945    /// every fit at 70–260× wall cost, and the three-scenario
946    /// ablation on real VENUS Hf 120-min data (issue #486) showed
947    /// the resulting parameter shift is ≤ 0.35 Fisher σ on every
948    /// parameter in every scenario — i.e. below the solver's own
949    /// reported uncertainty floor.
950    ///
951    /// The polish mechanism itself is sound (self-terminates cleanly
952    /// on synthetic D≈1 data per ablation S3); only the absolute
953    /// tolerance defaults are mis-calibrated for real counts data.
954    /// A future scale-aware rescale (`fatol_rel` vs `D_stage1`) can
955    /// re-enable polish as a useful opt-in refinement.
956    ///
957    /// Set this to `true` (via `with_counts_enable_polish(Some(true))`
958    /// at the pipeline level) when you specifically want the polish
959    /// stage on a synthetic / clean-data scenario where the absolute
960    /// tolerance defaults are physically meaningful.
961    pub enable_polish: bool,
962    /// Polish (Nelder-Mead) configuration.  Used only when
963    /// `enable_polish == true`.  Default `xatol = 1e-9`, `fatol = 1e-10`
964    /// match the synthetic counts-benchmark tolerances — physically
965    /// meaningful when `D ≈ 1` (clean data) but sub-f64-ULP on real
966    /// counts where `D ≈ 10⁴`–`10⁵`, which is why `enable_polish`
967    /// defaults to `false`.  See #486.
968    pub polish: NelderMeadConfig,
969    /// Compute and return the Fisher covariance and parameter uncertainties.
970    pub compute_covariance: bool,
971    /// Inflate the covariance-only uncertainties by the goodness-of-fit factor
972    /// at the converged point: `Cov → (D/dof)·Cov`, i.e. `σ → σ·√(D/dof)`, where
973    /// `D` is the final Poisson deviance and
974    /// `dof = n_informative − n_free` — the count of active bins carrying data
975    /// (`O_i + S_i > 0`) minus the free parameters. Zero-total bins are
976    /// degenerate under the conditional-binomial model (identically zero
977    /// deviance for any `T`) and are excluded so a wide detector window with
978    /// empty bins cannot deflate the factor; see
979    /// [`JointPoissonObjective::n_informative`].
980    ///
981    /// Off by default, so the reported σ stays the raw Cramér-Rao (inverse-Fisher)
982    /// lower bound, which omits baseline/model mis-specification noise and can
983    /// underestimate the true per-superpixel scatter by ~3–4× on real data. When
984    /// enabled, scaling is only applied when `D/dof` is finite and positive
985    /// (exactly-determined fits with `dof = 0` report `D/dof = NaN` and are left
986    /// unscaled). See issue #638; the LM transmission path is already χ²-scaled
987    /// (Numerical Recipes §15.6) and does not use this flag.
988    pub scale_by_chi2: bool,
989}
990
991impl Default for JointPoissonFitConfig {
992    fn default() -> Self {
993        Self {
994            max_iter: 200,
995            lambda_init: 1e-3,
996            lambda_up: 10.0,
997            lambda_down: 0.1,
998            armijo_c: 1e-4,
999            backtrack: 0.5,
1000            tol_d: 1e-8,
1001            tol_param: 1e-8,
1002            fd_step: 1e-6,
1003            // #486: flipped from `true` to `false` after a three-scenario
1004            // ablation on real VENUS data showed polish burning full
1005            // `max_iter = 5000` at 70-260× wall cost for ≤ 0.35 Fisher σ
1006            // parameter movement.  The absolute tolerances below are
1007            // physically meaningful for synthetic (D ≈ 1) benchmarks and
1008            // dead on real counts data (D ≈ 10⁵).  Opt in via
1009            // `UnifiedFitConfig::with_counts_enable_polish(Some(true))`
1010            // when you specifically want the polish stage.  See the
1011            // field doc on `enable_polish` for details.
1012            enable_polish: false,
1013            polish: NelderMeadConfig {
1014                // Tolerances tuned for the synthetic D ≈ 1 regime —
1015                // `fatol = 1e-10` vs D ≈ 1 is a physically
1016                // meaningful "deviance isn't budging" check.  On real
1017                // counts data where D ≈ 10⁵ the same absolute value is
1018                // sub-ULP; polish can't self-terminate and is disabled
1019                // by the default above.  A future scale-aware rescale
1020                // (`fatol_rel` vs D_stage1) is tracked as a follow-up.
1021                xatol: 1e-9,
1022                fatol: 1e-10,
1023                max_iter: 5000,
1024                initial_step_frac: 0.02,
1025                initial_step_abs: 1e-4,
1026            },
1027            compute_covariance: true,
1028            scale_by_chi2: false,
1029        }
1030    }
1031}
1032
1033/// Outcome of [`joint_poisson_fit`].
1034#[derive(Debug, Clone)]
1035pub struct JointPoissonResult {
1036    /// Final deviance D at the fitted parameters.
1037    pub deviance: f64,
1038    /// D / dof.  The primary goodness-of-fit statistic for the counts path.
1039    /// `dof` counts *informative* active bins (`O_i + S_i > 0`) minus the
1040    /// free parameters — zero-total bins are degenerate under the
1041    /// conditional-binomial model and are excluded so wide detector windows
1042    /// with empty bins do not deflate the ratio.
1043    pub deviance_per_dof: f64,
1044    /// Number of data bins on the configured grid (n).  This is the
1045    /// total bin count; when a fit-energy-range mask is in effect, the
1046    /// count of bins that actually contributed to the cost function is
1047    /// reported separately as [`Self::n_active`].
1048    pub n_data: usize,
1049    /// Number of *active* data bins — equal to `n_data` when no mask is
1050    /// set, or the count of `true` entries in the objective's
1051    /// `active_mask` otherwise (SAMMY EMIN/EMAX semantics, #514).  The
1052    /// deviance / dof ratio additionally drops zero-total active bins
1053    /// (see [`JointPoissonObjective::n_informative`]).
1054    pub n_active: usize,
1055    /// Number of free parameters (k).
1056    pub n_free: usize,
1057    /// Iterations performed in the damped-Fisher stage.
1058    pub gn_iterations: usize,
1059    /// Iterations performed by the Nelder-Mead polish stage (0 if disabled).
1060    pub polish_iterations: usize,
1061    /// `true` when the stage-1 (damped Fisher) optimizer met its `tol_d`
1062    /// and `tol_param` criteria before hitting `max_iter`.
1063    pub gn_converged: bool,
1064    /// `true` when the Nelder-Mead polish met `xatol` and `fatol` before
1065    /// `max_iter` (always `false` if `enable_polish == false`).
1066    pub polish_converged: bool,
1067    /// `true` when the polish step lowered the deviance below the stage-1
1068    /// best value.  Useful diagnostic — if polish improved D materially,
1069    /// stage 1 likely stalled.
1070    pub polish_improved: bool,
1071    /// Final parameter values (all parameters, including fixed).
1072    pub params: Vec<f64>,
1073    /// Inverse Fisher covariance of free parameters (n_free × n_free),
1074    /// computed at the final θ.  `None` if the Fisher matrix was singular
1075    /// or `compute_covariance == false`.
1076    pub covariance: Option<FlatMatrix>,
1077    /// `√diag(covariance)` for each free parameter, in free-index order.
1078    pub uncertainties: Option<Vec<f64>>,
1079}
1080
1081/// Two-stage joint-Poisson fit: damped Fisher stage followed by
1082/// Nelder-Mead polish.
1083///
1084/// **Counts-path contract** this function satisfies:
1085///
1086/// - Minimizes the **conditional binomial deviance** `D(θ)`
1087///   ([`JointPoissonObjective::deviance`]), not fixed-flux Poisson NLL.
1088/// - Reports `D / (n − k)` as the primary GOF.
1089/// - Honours an **explicit `c = Q_s/Q_ob`** stored in the objective.
1090/// - Runs Nelder-Mead **polish** after the gradient stage to escape the
1091///   initial-point stall seen on backgrounded fits.
1092/// - Exposes `gn_converged` and `polish_converged` separately so callers
1093///   do not rely on a single "success" flag — acceptance is meant to come
1094///   from the deviance value.
1095///
1096/// The damped-Fisher stage uses LM-style acceptance: a step is accepted if
1097/// it satisfies an Armijo condition on D; on rejection, λ is increased and
1098/// the step is recomputed.  Bounds are enforced via projection (clamp).
1099pub fn joint_poisson_fit(
1100    objective: &JointPoissonObjective<'_>,
1101    params: &mut ParameterSet,
1102    config: &JointPoissonFitConfig,
1103) -> Result<JointPoissonResult, FittingError> {
1104    let n_data = objective.n_data();
1105    if n_data == 0 {
1106        return Err(FittingError::EmptyData);
1107    }
1108
1109    // Validate `o` / `s` length and `c` up-front at the public entry
1110    // point.  The inner per-bin helpers (`binomial_deviance_term`,
1111    // `deviance_from_transmission`) use `debug_assert!` only, which is a
1112    // no-op in release builds.  Without these hard checks:
1113    //   - A length mismatch in `o` vs `s` silently truncates via `.zip()`,
1114    //     minimising deviance on a sub-range of bins.
1115    //   - A non-positive or non-finite `c` produces finite garbage
1116    //     (e.g. zero `cT`, NaN denominators) that the LM happily descends.
1117    //   - A NaN / negative `o[i]` or `s[i]` would slip past the inner
1118    //     `xlogy_ratio` zero-clamp (`x <= 0.0` swallows negatives, but
1119    //     `NaN <= 0.0` is `false` so a NaN count bleeds straight into the
1120    //     log and out into the deviance sum).
1121    // All surface as "the fit converged" with bogus parameter values —
1122    // exactly the failure mode the trial-step guard cannot catch because
1123    // the deviance value is finite.
1124    if objective.s.len() != n_data {
1125        return Err(FittingError::LengthMismatch {
1126            expected: n_data,
1127            actual: objective.s.len(),
1128            field: "sample_counts",
1129        });
1130    }
1131    if !objective.c.is_finite() || objective.c <= 0.0 {
1132        return Err(FittingError::InvalidConfig(format!(
1133            "proton-charge ratio c = Q_s/Q_ob must be finite and > 0, got {}",
1134            objective.c
1135        )));
1136    }
1137    validate_counts(objective.o, "open_beam_counts")?;
1138    validate_counts(objective.s, "sample_counts")?;
1139
1140    // Validate active-mask length up-front, mirroring the LM solver's
1141    // length-mismatch early-return (#514).  A debug-assert deep in the
1142    // deviance routines would silently pass through in release builds
1143    // with a length mismatch, causing out-of-bounds index reads when
1144    // the masked accumulator iterates `o`/`s`/`mask` together.
1145    if let Some(m) = objective.active_mask
1146        && m.len() != n_data
1147    {
1148        return Err(FittingError::LengthMismatch {
1149            expected: n_data,
1150            actual: m.len(),
1151            field: "active_mask",
1152        });
1153    }
1154
1155    // SAMMY EMIN/EMAX-equivalent fit-energy-range (#514): zero active
1156    // bins means the user's `[E_min, E_max]` does not overlap the
1157    // configured grid.  No data contributes to the deviance — return
1158    // non-converged with NaN before falling through.  Without this
1159    // the all-bins-masked path would compute deviance = 0 (sum over
1160    // zero rows) which combined with `n_free == 0` (all-fixed params)
1161    // would report `gn_converged: true, deviance: 0` from a fit that
1162    // saw no data.
1163    let n_free_initial = params.n_free();
1164    let n_active_initial = objective.n_active();
1165    if n_active_initial == 0 {
1166        return Ok(JointPoissonResult {
1167            deviance: f64::NAN,
1168            deviance_per_dof: f64::NAN,
1169            n_data,
1170            n_active: 0,
1171            n_free: n_free_initial,
1172            gn_iterations: 0,
1173            polish_iterations: 0,
1174            gn_converged: false,
1175            polish_converged: false,
1176            polish_improved: false,
1177            params: params.all_values(),
1178            covariance: None,
1179            uncertainties: None,
1180        });
1181    }
1182
1183    // Underdetermined-check: when too few bins CONSTRAIN the fit, the
1184    // problem is rank-deficient and any deviance / dof ratio would be
1185    // deceptive (the previous `.max(1)` divisor produced a finite-looking
1186    // deviance-per-dof for empty / too-narrow masks).  Mirror LM's
1187    // behaviour: return a non-converged result up-front, before wasting
1188    // cycles on the damped-Fisher stage.
1189    //
1190    // The count that matters is the INFORMATIVE one (`O_i + S_i > 0`),
1191    // matching the dof denominator below: a zero-total bin contributes
1192    // identically zero deviance AND zero Fisher curvature, so it carries no
1193    // rank. Keying this guard on `n_active` instead would let a wholly
1194    // empty acquisition — every bin zero, routine to hit by mis-specifying
1195    // the detector window on the exact route — run to completion and report
1196    // `gn_converged = true` at the untouched initial guess with a NaN
1197    // deviance, i.e. a success-shaped result from a fit that saw no data.
1198    let n_informative_initial = objective.n_informative();
1199    if n_informative_initial < n_free_initial.max(1) {
1200        return Ok(JointPoissonResult {
1201            deviance: f64::NAN,
1202            deviance_per_dof: f64::NAN,
1203            n_data,
1204            n_active: n_active_initial,
1205            n_free: n_free_initial,
1206            gn_iterations: 0,
1207            polish_iterations: 0,
1208            gn_converged: false,
1209            polish_converged: false,
1210            polish_improved: false,
1211            params: params.all_values(),
1212            covariance: None,
1213            uncertainties: None,
1214        });
1215    }
1216
1217    // Stage 1: damped Fisher with Armijo backtracking.
1218    let stage1 = damped_fisher_stage(objective, params, config)?;
1219
1220    // Capture stage-1 best.
1221    let best_d_stage1 = stage1.deviance;
1222    let gn_iterations = stage1.iterations;
1223    let gn_converged = stage1.converged;
1224
1225    // Stage 2: Nelder-Mead polish on free parameters, seeded from stage-1 θ.
1226    //
1227    // Guard against the all-fixed configuration: `nelder_mead_minimize`
1228    // requires a non-empty `x0` (asserts in `nelder_mead.rs`).  When every
1229    // parameter is fixed there is nothing to polish, so skip stage 2 and
1230    // leave the polish flags at their default `false` values.  This path
1231    // is reachable from pipeline callers that pin all params and set
1232    // `with_counts_enable_polish(Some(true))`.
1233    //
1234    // Also short-circuit polish when stage 1 ended on a non-finite
1235    // deviance: there is no meaningful starting deviance to refine, and
1236    // the acceptance test `nm.fun < best_d_stage1` would degrade to
1237    // `finite < NaN == false` (discarding the NM result) while
1238    // `nm.self_converged` could still be `true`, leaking a spurious
1239    // converged flag together with a NaN final deviance.  Mirrors the
1240    // LM `n_free == 0` early-return at `lm.rs:584-607`, which refuses to
1241    // report a converged fit when the model emits NaN at active bins.
1242    let mut polish_iterations = 0usize;
1243    let mut polish_converged = false;
1244    let mut polish_improved = false;
1245    let free_idx = params.free_indices();
1246    if config.enable_polish && !free_idx.is_empty() && best_d_stage1.is_finite() {
1247        let bounds: Vec<(f64, f64)> = free_idx
1248            .iter()
1249            .map(|&i| (params.params[i].lower, params.params[i].upper))
1250            .collect();
1251        let x0: Vec<f64> = free_idx.iter().map(|&i| params.params[i].value).collect();
1252
1253        // Snapshot fixed parameters so the closure can rebuild the full
1254        // parameter vector for each evaluation.
1255        let all_values_snapshot = params.all_values();
1256
1257        let obj_closure = |x: &[f64]| -> Result<f64, FittingError> {
1258            let mut all = all_values_snapshot.clone();
1259            for (j, &idx) in free_idx.iter().enumerate() {
1260                all[idx] = x[j];
1261            }
1262            objective.deviance(&all)
1263        };
1264        let nm = nelder_mead_minimize(obj_closure, &x0, Some(&bounds), &config.polish)?;
1265        polish_iterations = nm.iterations;
1266        polish_converged = nm.self_converged;
1267        if nm.fun < best_d_stage1 {
1268            polish_improved = true;
1269            // Commit polish result to the parameter set.
1270            for (j, &idx) in free_idx.iter().enumerate() {
1271                params.params[idx].value = nm.x[j];
1272                params.params[idx].clamp();
1273            }
1274        }
1275    }
1276
1277    let final_values = params.all_values();
1278    let final_deviance = objective.deviance(&final_values)?;
1279    let n_free = params.n_free();
1280    // Active-bin masking (SAMMY EMIN/EMAX): when a fit-energy-range mask
1281    // is in effect, dof must use the count of bins that contributed to
1282    // the deviance — otherwise deviance-per-dof is biased low by the
1283    // ratio (n_active / n_data).  Within the active set, zero-total bins
1284    // (`O_i + S_i == 0`) are additionally excluded: they are degenerate
1285    // under the conditional-binomial model (identically zero deviance for
1286    // any T), so counting them deflates D/dof and the opt-in
1287    // `scale_by_chi2` σ inflation by the empty-bin fraction — routine on
1288    // the exact detector-time route, whose acquisition windows legitimately
1289    // contain unoccupied bins.  Fits with no informative surplus
1290    // (`n_informative <= n_free`) report `deviance_per_dof = NaN` (0/0),
1291    // matching the zero-dof handling in `lm.rs`'s reduced-chi-squared
1292    // computation (cite the behaviour, not a line number that drifts).
1293    let n_active = objective.n_active();
1294    let dof = objective.n_informative().saturating_sub(n_free);
1295    let deviance_per_dof = if dof > 0 {
1296        final_deviance / dof as f64
1297    } else {
1298        f64::NAN
1299    };
1300
1301    // Covariance from inverse Fisher at the final θ.  Uses the analytical
1302    // Jacobian when the transmission model provides one; otherwise falls
1303    // back to finite-difference Jacobian assembled into the deviance-
1304    // Hessian form — so callers always get uncertainties for identifiable
1305    // parameters.
1306    //
1307    // **Scale note (covariance vs Newton step).**  `fisher_information`
1308    // assembles `H_D = Σ h_i · J·J^T` with `h_i = ∂² D / ∂ T_i² = 2 · I_TT_i`
1309    // (see [`deviance_curvature`]).  This `2·I` form is exactly what the
1310    // damped-Fisher Newton step needs, since stepping on D with
1311    // `Δθ = -H_D^{-1} · ∇D = -(2I)^{-1} · (-2 ∇L) = I^{-1} · ∇L`
1312    // recovers the Fisher-scoring direction on the log-likelihood L.
1313    //
1314    // For the asymptotic MLE covariance, however, the Cramér-Rao bound is
1315    // `Cov(θ̂) = I^{-1}`, NOT `H_D^{-1} = (2I)^{-1} = I^{-1}/2`.  Inverting
1316    // `H_D` and using it directly would under-report variance by 2× and
1317    // standard errors by √2 × — a real ½-scaling bug.  We rescale
1318    // the inverse here: `I^{-1} = 2 · H_D^{-1}`.
1319    // Optional goodness-of-fit inflation (issue #638): σ → σ·√(D/dof), i.e.
1320    // Cov → (D/dof)·Cov. Folded into the Cramér-Rao ×2 rescale below. Only
1321    // applied when `D/dof` is finite and positive; `dof = 0` fits report
1322    // `deviance_per_dof = NaN` and are left at the raw covariance-only bound.
1323    let var_scale =
1324        if config.scale_by_chi2 && deviance_per_dof.is_finite() && deviance_per_dof > 0.0 {
1325            deviance_per_dof
1326        } else {
1327            1.0
1328        };
1329    let (covariance, uncertainties) = if config.compute_covariance {
1330        let free_idx = params.free_indices();
1331        let info_opt = match objective.fisher_information(&final_values, &free_idx)? {
1332            Some(info) => Some(info),
1333            None => objective.fisher_information_fd(params, config.fd_step)?,
1334        };
1335        match info_opt {
1336            Some(info) => match invert_matrix(&info) {
1337                Some(mut cov) => {
1338                    // Rescale: invert_matrix returned (2I)^{-1}; multiply
1339                    // every entry by 2 to obtain I^{-1}, and by `var_scale`
1340                    // (= D/dof when scale_by_chi2, else 1.0) for the optional
1341                    // χ²-inflation. `u` picks up √(var_scale) automatically.
1342                    for v in cov.data.iter_mut() {
1343                        *v *= 2.0 * var_scale;
1344                    }
1345                    let u: Vec<f64> = (0..cov.nrows)
1346                        .map(|i| {
1347                            let v = cov.get(i, i);
1348                            if v > 0.0 { v.sqrt() } else { f64::NAN }
1349                        })
1350                        .collect();
1351                    (Some(cov), Some(u))
1352                }
1353                None => (None, None),
1354            },
1355            None => (None, None),
1356        }
1357    } else {
1358        (None, None)
1359    };
1360
1361    Ok(JointPoissonResult {
1362        deviance: final_deviance,
1363        deviance_per_dof,
1364        n_data,
1365        n_active,
1366        n_free,
1367        gn_iterations,
1368        polish_iterations,
1369        gn_converged,
1370        polish_converged,
1371        polish_improved,
1372        params: final_values,
1373        covariance,
1374        uncertainties,
1375    })
1376}
1377
1378/// Stage 1 output.
1379struct Stage1Output {
1380    deviance: f64,
1381    iterations: usize,
1382    converged: bool,
1383}
1384
1385/// Damped-Fisher stage (Gauss-Newton / Marquardt on the deviance).
1386///
1387/// Mirrors the structure of `lm.rs` but on the joint-Poisson objective.
1388/// Falls back to finite-difference gradient when the model has no
1389/// analytical Jacobian.
1390fn damped_fisher_stage(
1391    objective: &JointPoissonObjective<'_>,
1392    params: &mut ParameterSet,
1393    config: &JointPoissonFitConfig,
1394) -> Result<Stage1Output, FittingError> {
1395    let mut lambda = config.lambda_init;
1396    let mut iter = 0usize;
1397    let mut converged = false;
1398
1399    let mut all_vals = params.all_values();
1400    let mut d_current = objective.deviance(&all_vals)?;
1401
1402    while iter < config.max_iter {
1403        iter += 1;
1404
1405        // D is a sum of nonnegative conditional-binomial terms (clamped at
1406        // 0 in `deviance_from_transmission`), so D == 0 is the exact global
1407        // minimum — reachable on noise-free synthetic data once the model
1408        // converges machine-exactly.  Declare convergence here: the Armijo
1409        // test can never accept a step from D == 0 (it demands a strict
1410        // decrease), so without this check the stage inflates λ past its
1411        // ceiling and reports a PERFECT fit as non-converged.
1412        if d_current == 0.0 {
1413            converged = true;
1414            break;
1415        }
1416
1417        let free_idx = params.free_indices();
1418        let n_free = free_idx.len();
1419        if n_free == 0 {
1420            // All parameters fixed: we are not optimizing; convergence is
1421            // well-defined only if the already-computed deviance at the
1422            // current parameters is finite.  If the model returned
1423            // non-finite transmission, `binomial_deviance_term` propagates
1424            // that as NaN deviance (see the non-finite-T contract documented
1425            // on `binomial_deviance_term`), and a non-finite deviance cannot
1426            // be reported as a converged fit.  LM applies the same guard in
1427            // the `n_free == 0` branch of `levenberg_marquardt_with_mask`;
1428            // the matching LM regression is `test_all_fixed_params_nan_model`.
1429            converged = d_current.is_finite();
1430            break;
1431        }
1432
1433        // Gradient (analytical if available, FD otherwise).
1434        let grad = match objective.deviance_gradient_analytical(&all_vals, &free_idx)? {
1435            Some(g) => g,
1436            None => objective.deviance_gradient_fd(params, config.fd_step)?,
1437        };
1438        let info = match objective.fisher_information(&all_vals, &free_idx)? {
1439            Some(m) => m,
1440            None => {
1441                let mut ident = FlatMatrix::zeros(n_free, n_free);
1442                for i in 0..n_free {
1443                    *ident.get_mut(i, i) = 1.0;
1444                }
1445                ident
1446            }
1447        };
1448        // Solve (I + λ diag(I)) δ = -g.
1449        let neg_grad: Vec<f64> = grad.iter().map(|&g| -g).collect();
1450        let step = match solve_damped_system(&info, &neg_grad, lambda) {
1451            Some(s) => s,
1452            None => {
1453                // Singular Fisher at current θ.  Increase damping and retry
1454                // on the next iteration.
1455                lambda *= config.lambda_up;
1456                if lambda > 1e16 {
1457                    break;
1458                }
1459                continue;
1460            }
1461        };
1462
1463        // Armijo line search with projection.
1464        let grad_dot_step = grad
1465            .iter()
1466            .zip(step.iter())
1467            .map(|(&g, &s)| g * s)
1468            .sum::<f64>();
1469        // If the step isn't a descent direction w.r.t. D, flip sign (fallback
1470        // to negative gradient direction).
1471        let effective_step: Vec<f64> = if grad_dot_step >= 0.0 {
1472            grad.iter().map(|&g| -g).collect()
1473        } else {
1474            step
1475        };
1476
1477        let mut alpha = 1.0;
1478        let mut accepted = false;
1479        let d0 = d_current;
1480        let mut trial_vals = all_vals.clone();
1481        for _ in 0..50 {
1482            for (j, &idx) in free_idx.iter().enumerate() {
1483                trial_vals[idx] = all_vals[idx] + alpha * effective_step[j];
1484            }
1485            // Project onto bounds.
1486            for &idx in free_idx.iter() {
1487                let lo = params.params[idx].lower;
1488                let hi = params.params[idx].upper;
1489                if trial_vals[idx] < lo {
1490                    trial_vals[idx] = lo;
1491                }
1492                if trial_vals[idx] > hi {
1493                    trial_vals[idx] = hi;
1494                }
1495            }
1496            let d_trial = match objective.deviance(&trial_vals) {
1497                Ok(v) if v.is_finite() => v,
1498                _ => f64::INFINITY,
1499            };
1500            // Armijo condition: f(x+αp) ≤ f(x) + c·α·⟨g, p⟩ (descent).  When
1501            // we flipped to -grad above, ⟨g, p⟩ = -||g||² < 0.
1502            let gdotp = grad
1503                .iter()
1504                .zip(effective_step.iter())
1505                .map(|(&g, &s)| g * s)
1506                .sum::<f64>();
1507            if d_trial <= d0 + config.armijo_c * alpha * gdotp {
1508                accepted = true;
1509                break;
1510            }
1511            alpha *= config.backtrack;
1512            if alpha < 1e-16 {
1513                break;
1514            }
1515        }
1516
1517        if accepted {
1518            // Commit step.
1519            for &idx in free_idx.iter() {
1520                params.params[idx].value = trial_vals[idx];
1521                params.params[idx].clamp();
1522            }
1523            let rel_change =
1524                (d_current - objective.deviance(&trial_vals)?) / d_current.abs().max(1.0);
1525            all_vals = params.all_values();
1526            let new_d = objective.deviance(&all_vals)?;
1527            let step_norm_sq = effective_step
1528                .iter()
1529                .map(|&s| (alpha * s).powi(2))
1530                .sum::<f64>();
1531            let step_norm = step_norm_sq.sqrt();
1532            d_current = new_d;
1533            lambda = (lambda * config.lambda_down).max(1e-16);
1534
1535            if rel_change.abs() < config.tol_d && step_norm < config.tol_param {
1536                converged = true;
1537                break;
1538            }
1539        } else {
1540            // Rejected: increase damping and try again.
1541            lambda *= config.lambda_up;
1542            if lambda > 1e16 {
1543                break;
1544            }
1545        }
1546    }
1547
1548    Ok(Stage1Output {
1549        deviance: d_current,
1550        iterations: iter,
1551        converged,
1552    })
1553}
1554
1555#[cfg(test)]
1556mod tests {
1557    use super::*;
1558    use crate::parameters::FitParameter;
1559
1560    // ------------------------------------------------------------------
1561    // Backgrounds
1562    // ------------------------------------------------------------------
1563
1564    fn objective_with<'a>(
1565        model: &'a dyn FitModel,
1566        o: &'a [f64],
1567        s: &'a [f64],
1568        b_o: Option<&'a [f64]>,
1569        b_s: Option<&'a [f64]>,
1570    ) -> JointPoissonObjective<'a> {
1571        JointPoissonObjective {
1572            model,
1573            o,
1574            s,
1575            c: 1.0,
1576            active_mask: None,
1577            open_background: b_o,
1578            sample_background: b_s,
1579        }
1580    }
1581
1582    /// Zero backgrounds must reproduce the binomial profile exactly.
1583    ///
1584    /// This is the check that the quadratic derivation is right: setting
1585    /// `b_o = b_s = 0` kills its constant term, and the remaining root is
1586    /// the documented `λ̂ = c(O+S)/(1+cT)`.
1587    #[test]
1588    fn zero_backgrounds_reproduce_the_binomial_profile() {
1589        let model = ConstModel { n_e: 1 };
1590        let o = [500.0];
1591        let s = [180.0];
1592        let zeros = [0.0];
1593        let plain = objective_with(&model, &o, &s, None, None);
1594        let zeroed = objective_with(&model, &o, &s, Some(&zeros), Some(&zeros));
1595
1596        for &t in &[0.05_f64, 0.2, 0.5, 0.9, 1.0] {
1597            let closed = plain.profile_lambda(t, o[0], s[0]);
1598            let quadratic = zeroed.profile_lambda_with_background(t, o[0], s[0], 0.0, 0.0);
1599            let rel = (closed - quadratic).abs() / closed.abs().max(1e-30);
1600            assert!(
1601                rel < 1e-12,
1602                "T={t}: binomial profile {closed} vs quadratic {quadratic} (rel {rel:e})"
1603            );
1604        }
1605    }
1606
1607    /// The profile solves the score equation it was derived from.
1608    ///
1609    /// Independent of the algebra above: differentiate the two-arm Poisson
1610    /// log-likelihood numerically at the returned lambda and confirm it is
1611    /// stationary. A sign slip or a dropped term in the quadratic would move
1612    /// the root without this noticing otherwise.
1613    #[test]
1614    fn the_profile_is_stationary_for_the_two_arm_likelihood() {
1615        let model = ConstModel { n_e: 1 };
1616        let o = [500.0];
1617        let s = [180.0];
1618        let b_o = [12.0];
1619        let b_s = [37.0];
1620        let obj = objective_with(&model, &o, &s, Some(&b_o), Some(&b_s));
1621
1622        for &t in &[0.05_f64, 0.2, 0.5, 0.9] {
1623            let lambda = obj.profile_lambda_with_background(t, o[0], s[0], b_o[0], b_s[0]);
1624            assert!(lambda > 0.0, "T={t}: profile returned {lambda}");
1625            let log_likelihood = |l: f64| {
1626                let mu_o = l / obj.c + b_o[0];
1627                let mu_s = l * t + b_s[0];
1628                -mu_o + o[0] * mu_o.ln() - mu_s + s[0] * mu_s.ln()
1629            };
1630            let h = lambda * 1e-6;
1631            let slope = (log_likelihood(lambda + h) - log_likelihood(lambda - h)) / (2.0 * h);
1632            // Scale the stationarity test by the curvature, so it means "the
1633            // root is in the right place" rather than "the slope is small".
1634            let curvature = (log_likelihood(lambda + h) - 2.0 * log_likelihood(lambda)
1635                + log_likelihood(lambda - h))
1636                / (h * h);
1637            let offset = (slope / curvature).abs() / lambda;
1638            assert!(
1639                offset < 1e-6,
1640                "T={t}: stationary point is {offset:e} of lambda away from the returned root"
1641            );
1642        }
1643    }
1644
1645    /// A background in the sample arm alone changes the deviance, and the
1646    /// two arms are not interchangeable.
1647    #[test]
1648    fn the_two_arms_carry_their_backgrounds_separately() {
1649        let model = ConstModel { n_e: 1 };
1650        let o = [500.0];
1651        let s = [180.0];
1652        let none = [0.0];
1653        let some = [40.0];
1654        let t = [0.35_f64];
1655
1656        let plain = objective_with(&model, &o, &s, None, None)
1657            .deviance_from_transmission(&t)
1658            .unwrap();
1659        let sample_only = objective_with(&model, &o, &s, Some(&none), Some(&some))
1660            .deviance_from_transmission(&t)
1661            .unwrap();
1662        let open_only = objective_with(&model, &o, &s, Some(&some), Some(&none))
1663            .deviance_from_transmission(&t)
1664            .unwrap();
1665
1666        assert!(
1667            (sample_only - plain).abs() > 1e-6,
1668            "a sample-arm background left the deviance unchanged"
1669        );
1670        assert!(
1671            (sample_only - open_only).abs() > 1e-6,
1672            "putting the background in either arm gave the same deviance, so \
1673             the arms are not being distinguished"
1674        );
1675    }
1676
1677    /// Zero backgrounds must also reproduce the binomial DEVIANCE, not only
1678    /// the profile — the Poisson and binomial forms differ by terms that
1679    /// cancel only when the flux cancels.
1680    #[test]
1681    fn zero_backgrounds_reproduce_the_binomial_deviance() {
1682        let model = ConstModel { n_e: 3 };
1683        let o = [500.0, 420.0, 610.0];
1684        let s = [180.0, 205.0, 150.0];
1685        let zeros = [0.0, 0.0, 0.0];
1686        let t = [0.35_f64, 0.40, 0.25];
1687
1688        let binomial = objective_with(&model, &o, &s, None, None)
1689            .deviance_from_transmission(&t)
1690            .unwrap();
1691        let poisson = objective_with(&model, &o, &s, Some(&zeros), Some(&zeros))
1692            .deviance_from_transmission(&t)
1693            .unwrap();
1694        let rel = (binomial - poisson).abs() / binomial.abs().max(1e-30);
1695        assert!(
1696            rel < 1e-10,
1697            "binomial deviance {binomial} vs Poisson deviance {poisson} (rel {rel:e})"
1698        );
1699    }
1700
1701    // ------------------------------------------------------------------
1702    // Test fixtures
1703    // ------------------------------------------------------------------
1704
1705    /// A constant-transmission model: T_i = θ_0 for all i.  Useful for
1706    /// testing the profile λ̂ formula and deviance / gradient in isolation.
1707    struct ConstModel {
1708        n_e: usize,
1709    }
1710
1711    impl FitModel for ConstModel {
1712        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1713            Ok(vec![params[0]; self.n_e])
1714        }
1715
1716        fn analytical_jacobian(
1717            &self,
1718            _params: &[f64],
1719            free_param_indices: &[usize],
1720            y_current: &[f64],
1721        ) -> Option<FlatMatrix> {
1722            let n_e = y_current.len();
1723            let n_free = free_param_indices.len();
1724            let mut jac = FlatMatrix::zeros(n_e, n_free);
1725            // ∂T/∂θ_0 = 1 for all i, and 0 for any other parameter.
1726            for i in 0..n_e {
1727                for (j, &pi) in free_param_indices.iter().enumerate() {
1728                    *jac.get_mut(i, j) = if pi == 0 { 1.0 } else { 0.0 };
1729                }
1730            }
1731            Some(jac)
1732        }
1733    }
1734
1735    /// A linear-in-E model: T_i = θ_0 − θ_1 · e_i (Beer-Lambert surrogate).
1736    /// Used for the analytical-vs-FD gradient check and profile tests with
1737    /// non-trivial Jacobian.
1738    struct LinearModel<'a> {
1739        e: &'a [f64],
1740    }
1741
1742    impl<'a> FitModel for LinearModel<'a> {
1743        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1744            Ok(self
1745                .e
1746                .iter()
1747                .map(|&ei| (params[0] - params[1] * ei).max(POISSON_EPSILON))
1748                .collect())
1749        }
1750
1751        fn analytical_jacobian(
1752            &self,
1753            _params: &[f64],
1754            free_param_indices: &[usize],
1755            y_current: &[f64],
1756        ) -> Option<FlatMatrix> {
1757            let n_e = y_current.len();
1758            let n_free = free_param_indices.len();
1759            let mut jac = FlatMatrix::zeros(n_e, n_free);
1760            for i in 0..n_e {
1761                for (j, &pi) in free_param_indices.iter().enumerate() {
1762                    *jac.get_mut(i, j) = match pi {
1763                        0 => 1.0,
1764                        1 => -self.e[i],
1765                        _ => 0.0,
1766                    };
1767                }
1768            }
1769            Some(jac)
1770        }
1771    }
1772
1773    // ------------------------------------------------------------------
1774    // (a) Profile λ̂ closed form matches the score-equation bisection root.
1775    // ------------------------------------------------------------------
1776    #[test]
1777    fn test_profile_lambda_closed_form_matches_bisection() {
1778        // For each bin independently, score(λ) = (O+S)/λ − (1/c + T) = 0
1779        // has the unique positive root λ̂ = c(O+S)/(1+cT).  Bisect on
1780        // [1e-10, 1e12] and verify agreement to 1e-9.
1781        let cases = [
1782            (50.0_f64, 5.0_f64, 0.5_f64, 1.0_f64),
1783            (1000.0, 900.0, 0.9, 5.98),
1784            (10.0, 1.0, 0.1, 2.0),
1785            (0.0, 5.0, 0.25, 1.5), // O=0 edge
1786            (5.0, 0.0, 0.75, 3.0), // S=0 edge
1787        ];
1788        for (o, s, t, c) in cases {
1789            let model = ConstModel { n_e: 1 };
1790            let obj = JointPoissonObjective {
1791                model: &model,
1792                o: &[o],
1793                s: &[s],
1794                c,
1795                active_mask: None,
1796                open_background: None,
1797                sample_background: None,
1798            };
1799            let closed = obj.profile_lambda(t, o, s);
1800
1801            // Bisection root of score(λ) = (O+S)/λ − (1/c + T).
1802            let score = |lam: f64| (o + s) / lam - (1.0 / c + t);
1803            let (mut lo, mut hi) = (1e-10, 1e12);
1804            // score is monotonically decreasing in λ, score(lo) > 0, score(hi) < 0.
1805            assert!(score(lo) >= 0.0);
1806            assert!(score(hi) <= 0.0);
1807            for _ in 0..200 {
1808                let mid = 0.5 * (lo + hi);
1809                if score(mid) > 0.0 {
1810                    lo = mid;
1811                } else {
1812                    hi = mid;
1813                }
1814            }
1815            let bisect = 0.5 * (lo + hi);
1816            let rel_err = ((closed - bisect) / bisect).abs();
1817            assert!(
1818                rel_err < 1e-9,
1819                "profile λ̂ mismatch: closed={closed} bisect={bisect} rel_err={rel_err}"
1820            );
1821        }
1822    }
1823
1824    // ------------------------------------------------------------------
1825    // (b) D = 0 at exact match of expected counts.
1826    // ------------------------------------------------------------------
1827    #[test]
1828    fn test_deviance_zero_at_exact_match() {
1829        // Construct a model where S_i = λ·T_i, O_i = λ/c exactly for integer
1830        // choices, then verify D < 1e-8.  With T=0.5, c=2, λ=200: S=100,
1831        // O=100 per bin; p = 2*0.5/(1+1) = 0.5; Np = (O+S)/2 = 100 = S;
1832        // N(1-p) = 100 = O, so both logs are zero and D = 0.
1833        let t_val = 0.5;
1834        let c = 2.0;
1835        let n_bins = 5;
1836        let o = vec![100.0; n_bins];
1837        let s = vec![100.0; n_bins];
1838        let t = vec![t_val; n_bins];
1839        let model = ConstModel { n_e: n_bins };
1840        let obj = JointPoissonObjective {
1841            model: &model,
1842            o: &o,
1843            s: &s,
1844            c,
1845            active_mask: None,
1846            open_background: None,
1847            sample_background: None,
1848        };
1849        let d = obj.deviance_from_transmission(&t).unwrap();
1850        assert!(d.abs() < 1e-8, "D should be ≈ 0 at exact match, got {d}");
1851
1852        // Also verify via parameter evaluation (model returns constant T).
1853        let d_via_params = obj.deviance(&[t_val]).unwrap();
1854        assert!(d_via_params.abs() < 1e-8);
1855    }
1856
1857    // ------------------------------------------------------------------
1858    // (c) Analytical gradient matches finite-difference.
1859    // ------------------------------------------------------------------
1860    #[test]
1861    fn test_deviance_gradient_matches_fd() {
1862        // Use the linear model T = θ_0 − θ_1 · E with noise-free synthetic
1863        // counts.  Compute analytical gradient via chain rule and FD
1864        // gradient via re-evaluation; they must agree.
1865        let e: Vec<f64> = (0..20).map(|i| 0.1 + 0.05 * i as f64).collect();
1866        let theta_true = [0.95_f64, 0.1_f64];
1867        let c = 3.0;
1868        let lam = 500.0;
1869
1870        // Generate noise-free expected counts.
1871        let model = LinearModel { e: &e };
1872        let t_true = model.evaluate(&theta_true).unwrap();
1873        let o: Vec<f64> = t_true.iter().map(|_| lam / c).collect();
1874        let s: Vec<f64> = t_true.iter().map(|&ti| lam * ti).collect();
1875
1876        let obj = JointPoissonObjective {
1877            model: &model,
1878            o: &o,
1879            s: &s,
1880            c,
1881            active_mask: None,
1882            open_background: None,
1883            sample_background: None,
1884        };
1885
1886        // Evaluate gradient at a point slightly off truth so it is nonzero.
1887        let theta_eval = [0.80_f64, 0.15_f64];
1888        let free_idx = vec![0, 1];
1889
1890        let g_analytical = obj
1891            .deviance_gradient_analytical(&theta_eval, &free_idx)
1892            .unwrap()
1893            .expect("LinearModel provides analytical jacobian");
1894
1895        // Central-difference gradient.
1896        let eps = 1e-6;
1897        let mut g_fd = [0.0_f64; 2];
1898        for j in 0..2 {
1899            let mut tp = theta_eval;
1900            let mut tm = theta_eval;
1901            tp[j] += eps;
1902            tm[j] -= eps;
1903            let dp = obj.deviance(&tp).unwrap();
1904            let dm = obj.deviance(&tm).unwrap();
1905            g_fd[j] = (dp - dm) / (2.0 * eps);
1906        }
1907
1908        for (a, f) in g_analytical.iter().zip(g_fd.iter()) {
1909            let rel = ((a - f) / f.abs().max(1e-6)).abs();
1910            assert!(
1911                rel < 1e-4,
1912                "analytical vs FD gradient disagree: analytical={a} fd={f} rel={rel}"
1913            );
1914        }
1915    }
1916
1917    // ------------------------------------------------------------------
1918    // (d) D/(n-k) asymptote on synthetic joint-Poisson data at matched
1919    //     model — single free parameter θ_0 = T, use 1D grid search to
1920    //     recover it, verify D/(n-1) ≈ 1 and density bias < 1%.
1921    // ------------------------------------------------------------------
1922    #[test]
1923    fn test_deviance_per_dof_asymptote() {
1924        // Deterministic generator (xorshift) so the test is reproducible.
1925        // `Xorshift` is defined at the module level below — Rust item order
1926        // is not significant inside a module.
1927        let n_bins = 200;
1928        let t_true = 0.35_f64;
1929        let c = 2.0;
1930        let lam = 50.0;
1931        let n_reps = 30;
1932
1933        let mut d_per_dof_samples = Vec::with_capacity(n_reps);
1934        let mut bias_samples = Vec::with_capacity(n_reps);
1935        let mut rng = Xorshift(0xDEAD_BEEF_CAFE_BABE);
1936
1937        for _ in 0..n_reps {
1938            let o: Vec<f64> = (0..n_bins).map(|_| rng.poisson(lam / c)).collect();
1939            let s: Vec<f64> = (0..n_bins).map(|_| rng.poisson(lam * t_true)).collect();
1940            let model = ConstModel { n_e: n_bins };
1941            let obj = JointPoissonObjective {
1942                model: &model,
1943                o: &o,
1944                s: &s,
1945                c,
1946                active_mask: None,
1947                open_background: None,
1948                sample_background: None,
1949            };
1950
1951            // 1D grid search over T, then local refinement via Brent-like
1952            // bisection on the gradient sign.
1953            let grid: Vec<f64> = (0..200).map(|i| 0.01 + 0.99 * (i as f64) / 199.0).collect();
1954            let mut best = (grid[0], f64::INFINITY);
1955            for &t_try in &grid {
1956                let d_try = obj
1957                    .deviance_from_transmission(&vec![t_try; n_bins])
1958                    .unwrap();
1959                if d_try < best.1 {
1960                    best = (t_try, d_try);
1961                }
1962            }
1963            // Bisect on the gradient-sign neighbourhood.
1964            let dt = 0.01;
1965            let (mut lo, mut hi) = ((best.0 - dt).max(POISSON_EPSILON), (best.0 + dt).min(0.999));
1966            let grad_at = |t: f64| -> f64 {
1967                let tvec = vec![t; n_bins];
1968                let free_idx = [0_usize];
1969                let g = obj
1970                    .deviance_gradient_analytical(&[t], &free_idx)
1971                    .unwrap()
1972                    .unwrap();
1973                // gradient is w.r.t. θ_0 = T (ConstModel Jacobian is 1).
1974                let _ = tvec; // silence unused
1975                g[0]
1976            };
1977            let mut glo = grad_at(lo);
1978            let mut ghi = grad_at(hi);
1979            if glo * ghi < 0.0 {
1980                for _ in 0..80 {
1981                    let mid = 0.5 * (lo + hi);
1982                    let gmid = grad_at(mid);
1983                    if gmid * glo < 0.0 {
1984                        hi = mid;
1985                        ghi = gmid;
1986                    } else {
1987                        lo = mid;
1988                        glo = gmid;
1989                    }
1990                }
1991            }
1992            let t_hat = 0.5 * (lo + hi);
1993            let d_hat = obj
1994                .deviance_from_transmission(&vec![t_hat; n_bins])
1995                .unwrap();
1996            let dof = (n_bins - 1) as f64;
1997            d_per_dof_samples.push(d_hat / dof);
1998            bias_samples.push((t_hat - t_true) / t_true);
1999        }
2000
2001        let mean_dpd: f64 = d_per_dof_samples.iter().sum::<f64>() / d_per_dof_samples.len() as f64;
2002        let mean_bias: f64 = bias_samples.iter().sum::<f64>() / bias_samples.len() as f64;
2003
2004        // Under matched model, E[D]/(n-k) → 1.  Tolerate [0.85, 1.15]
2005        // with n_bins=200, n_reps=30, small λ (some low-count bins).
2006        assert!(
2007            (0.85..=1.15).contains(&mean_dpd),
2008            "D/(n-k) asymptote out of band: mean={mean_dpd}"
2009        );
2010        assert!(
2011            mean_bias.abs() < 0.02,
2012            "density bias > 2%: mean={mean_bias}"
2013        );
2014    }
2015
2016    // ------------------------------------------------------------------
2017    // Edge: zero-count bin contributes 0 deviance regardless of T.
2018    // ------------------------------------------------------------------
2019    #[test]
2020    fn test_zero_counts_contribute_zero() {
2021        let model = ConstModel { n_e: 3 };
2022        let obj = JointPoissonObjective {
2023            model: &model,
2024            o: &[0.0, 10.0, 5.0],
2025            s: &[0.0, 5.0, 2.0],
2026            c: 1.5,
2027            active_mask: None,
2028            open_background: None,
2029            sample_background: None,
2030        };
2031        let d_full = obj.deviance_from_transmission(&[0.6, 0.6, 0.6]).unwrap();
2032        // Drop the zero-N bin — result must be identical.
2033        let obj_reduced = JointPoissonObjective {
2034            model: &model, // same model, we just bypass the 1st bin via data
2035            o: &[10.0, 5.0],
2036            s: &[5.0, 2.0],
2037            c: 1.5,
2038            active_mask: None,
2039            open_background: None,
2040            sample_background: None,
2041        };
2042        let d_reduced = obj_reduced.deviance_from_transmission(&[0.6, 0.6]).unwrap();
2043        assert!((d_full - d_reduced).abs() < 1e-12);
2044    }
2045
2046    // ------------------------------------------------------------------
2047    // FD gradient fallback agrees with analytical form.
2048    // ------------------------------------------------------------------
2049    #[test]
2050    fn test_fd_gradient_matches_analytical() {
2051        let e: Vec<f64> = (0..15).map(|i| 0.2 + 0.1 * i as f64).collect();
2052        let theta = [0.9_f64, 0.05_f64];
2053        let c = 1.5;
2054        let lam = 300.0;
2055        let model = LinearModel { e: &e };
2056        let t_true = model.evaluate(&theta).unwrap();
2057        let o: Vec<f64> = t_true.iter().map(|_| lam / c).collect();
2058        let s: Vec<f64> = t_true.iter().map(|&ti| lam * ti).collect();
2059        let obj = JointPoissonObjective {
2060            model: &model,
2061            o: &o,
2062            s: &s,
2063            c,
2064            active_mask: None,
2065            open_background: None,
2066            sample_background: None,
2067        };
2068        let mut ps = ParameterSet::new(vec![
2069            FitParameter::non_negative("theta_0", 0.85),
2070            FitParameter::non_negative("theta_1", 0.06),
2071        ]);
2072        let g_fd = obj.deviance_gradient_fd(&mut ps, 1e-6).unwrap();
2073        let g_analytical = obj
2074            .deviance_gradient_analytical(&ps.all_values(), &ps.free_indices())
2075            .unwrap()
2076            .unwrap();
2077        for (f, a) in g_fd.iter().zip(g_analytical.iter()) {
2078            let rel = ((f - a) / a.abs().max(1e-6)).abs();
2079            assert!(rel < 5e-3, "fd={f} analytical={a} rel={rel}");
2080        }
2081    }
2082
2083    // ------------------------------------------------------------------
2084    // Fisher matrix is symmetric positive semi-definite at the fit.
2085    // ------------------------------------------------------------------
2086    #[test]
2087    fn test_fisher_matrix_symmetry_psd() {
2088        let e: Vec<f64> = (0..10).map(|i| 0.3 + 0.1 * i as f64).collect();
2089        let theta = [0.9_f64, 0.05_f64];
2090        let c = 2.0;
2091        let lam = 400.0;
2092        let model = LinearModel { e: &e };
2093        let t_true = model.evaluate(&theta).unwrap();
2094        let o: Vec<f64> = t_true.iter().map(|_| lam / c).collect();
2095        let s: Vec<f64> = t_true.iter().map(|&ti| lam * ti).collect();
2096        let obj = JointPoissonObjective {
2097            model: &model,
2098            o: &o,
2099            s: &s,
2100            c,
2101            active_mask: None,
2102            open_background: None,
2103            sample_background: None,
2104        };
2105        let info = obj
2106            .fisher_information(&theta, &[0, 1])
2107            .unwrap()
2108            .expect("LinearModel provides analytical jacobian");
2109        // Symmetry.
2110        let i01 = info.get(0, 1);
2111        let i10 = info.get(1, 0);
2112        assert!((i01 - i10).abs() < 1e-10);
2113        // PSD: diagonal entries > 0 (model is identifiable).
2114        assert!(info.get(0, 0) > 0.0);
2115        assert!(info.get(1, 1) > 0.0);
2116        // Determinant > 0 (rank-2 identifiable).
2117        let det = info.get(0, 0) * info.get(1, 1) - i01 * i10;
2118        assert!(det > 0.0, "Fisher matrix determinant = {det}");
2119    }
2120
2121    // ==================================================================
2122    // joint_poisson_fit — end-to-end integration tests
2123    // ==================================================================
2124
2125    /// A wrapped transmission model: T_out = A_n · T_inner + B_A + B_B/√E + B_C·√E.
2126    /// Models the full counts-path background structure (normalization
2127    /// plus the three-term energy-dependent background).
2128    struct BackgroundedTransmission<'a> {
2129        inner: &'a dyn FitModel,
2130        energies: &'a [f64],
2131        n_idx: usize,
2132        a_idx: usize,
2133        b_a_idx: usize,
2134        b_b_idx: usize,
2135        b_c_idx: usize,
2136        n_params: usize,
2137    }
2138
2139    impl<'a> FitModel for BackgroundedTransmission<'a> {
2140        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2141            // Pass the "density" parameter to the inner model as its param 0.
2142            let t_inner = self.inner.evaluate(&[params[self.n_idx]])?;
2143            let a_n = params[self.a_idx];
2144            let b_a = params[self.b_a_idx];
2145            let b_b = params[self.b_b_idx];
2146            let b_c = params[self.b_c_idx];
2147            Ok(t_inner
2148                .iter()
2149                .zip(self.energies.iter())
2150                .map(|(&t, &e)| {
2151                    let inv_sqrt_e = if e > 0.0 { 1.0 / e.sqrt() } else { 0.0 };
2152                    let sqrt_e = if e > 0.0 { e.sqrt() } else { 0.0 };
2153                    a_n * t + b_a + b_b * inv_sqrt_e + b_c * sqrt_e
2154                })
2155                .collect())
2156        }
2157        // No analytical jacobian — forces the fitter onto FD fallback, which
2158        // is the stress test (FD + over-parameterization is the
2159        // empirically established stall trigger).
2160    }
2161
2162    /// Exponential-in-E model: T_inner = exp(−n · σ(E)), σ(E) = 1.
2163    /// Effectively a single-parameter constant transmission when σ=1 flat.
2164    /// Uses an energy-dependent "cross section" so Jacobian is identifiable.
2165    struct ExpDecayModel<'a> {
2166        sigma: &'a [f64],
2167    }
2168    impl<'a> FitModel for ExpDecayModel<'a> {
2169        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2170            let n = params[0];
2171            Ok(self
2172                .sigma
2173                .iter()
2174                .map(|&s| (-n * s).exp().max(POISSON_EPSILON))
2175                .collect())
2176        }
2177        fn analytical_jacobian(
2178            &self,
2179            _params: &[f64],
2180            free_param_indices: &[usize],
2181            y_current: &[f64],
2182        ) -> Option<FlatMatrix> {
2183            // ∂T/∂n = -σ · T
2184            let n_e = y_current.len();
2185            let n_free = free_param_indices.len();
2186            let mut jac = FlatMatrix::zeros(n_e, n_free);
2187            for (i, &y_i) in y_current.iter().enumerate() {
2188                for (j, &pi) in free_param_indices.iter().enumerate() {
2189                    *jac.get_mut(i, j) = if pi == 0 { -self.sigma[i] * y_i } else { 0.0 };
2190                }
2191            }
2192            Some(jac)
2193        }
2194    }
2195
2196    /// Deterministic Poisson generator (Knuth for small λ, Gaussian for
2197    /// large).  Shared across the stochastic-asymptote and joint-Poisson
2198    /// fit tests in this module.
2199    struct Xorshift(u64);
2200    impl Xorshift {
2201        fn next_u64(&mut self) -> u64 {
2202            let mut x = self.0;
2203            x ^= x << 13;
2204            x ^= x >> 7;
2205            x ^= x << 17;
2206            self.0 = x;
2207            x
2208        }
2209        fn uniform(&mut self) -> f64 {
2210            (self.next_u64() as f64) / (u64::MAX as f64)
2211        }
2212        fn poisson(&mut self, lambda: f64) -> f64 {
2213            if lambda <= 0.0 {
2214                return 0.0;
2215            }
2216            if lambda > 30.0 {
2217                let u1 = self.uniform().max(1e-12);
2218                let u2 = self.uniform();
2219                let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
2220                return (lambda + z * lambda.sqrt()).round().max(0.0);
2221            }
2222            let l = (-lambda).exp();
2223            let mut k: f64 = 0.0;
2224            let mut p: f64 = 1.0;
2225            loop {
2226                k += 1.0;
2227                let u = self.uniform();
2228                p *= u;
2229                if p <= l {
2230                    return k - 1.0;
2231                }
2232                if k > 1000.0 {
2233                    return k - 1.0;
2234                }
2235            }
2236        }
2237    }
2238
2239    // ------------------------------------------------------------------
2240    // Matched-model single-parameter recovery at c = 5.98.
2241    // A miniature of the validated matched-model configuration — verify |bias| < 1%
2242    // and D / (n − k) ∈ [0.85, 1.15] without needing the polish.
2243    // ------------------------------------------------------------------
2244    #[test]
2245    fn test_joint_poisson_fit_matched_model_single_param() {
2246        // Energies 1..10, flat cross section σ = 1.  Truth n = 0.3.
2247        let n_bins = 200;
2248        let sigma = vec![1.0_f64; n_bins];
2249        let model = ExpDecayModel { sigma: &sigma };
2250        let n_true = 0.3_f64;
2251        let c = 5.98;
2252        let lam = 3000.0; // OB target ~500 counts/bin
2253        let t_true = model.evaluate(&[n_true]).unwrap();
2254
2255        let mut rng = Xorshift(0x1234_5678_9ABC_DEF0);
2256        let o: Vec<f64> = (0..n_bins).map(|_| rng.poisson(lam / c)).collect();
2257        let s: Vec<f64> = (0..n_bins).map(|i| rng.poisson(lam * t_true[i])).collect();
2258
2259        let obj = JointPoissonObjective {
2260            model: &model,
2261            o: &o,
2262            s: &s,
2263            c,
2264            active_mask: None,
2265            open_background: None,
2266            sample_background: None,
2267        };
2268        let mut params = ParameterSet::new(vec![FitParameter::non_negative("n", 0.1)]);
2269        let cfg = JointPoissonFitConfig {
2270            enable_polish: true,
2271            ..Default::default()
2272        };
2273        let result = joint_poisson_fit(&obj, &mut params, &cfg).unwrap();
2274
2275        let n_fit = result.params[0];
2276        let rel_bias = (n_fit - n_true) / n_true;
2277        assert!(
2278            rel_bias.abs() < 0.01,
2279            "density bias {rel_bias} exceeds 1% (n_fit={n_fit} n_true={n_true})"
2280        );
2281        assert!(
2282            (0.85..=1.15).contains(&result.deviance_per_dof),
2283            "D/(n-k) out of band: {}",
2284            result.deviance_per_dof
2285        );
2286    }
2287
2288    /// Degenerate (zero-total) bins must not dilute the deviance dof.
2289    ///
2290    /// A bin with `O_i + S_i == 0` contributes identically zero deviance for
2291    /// every parameter value, so counting it as a degree of freedom deflates
2292    /// `deviance_per_dof` (and the opt-in `scale_by_chi2` σ inflation) by the
2293    /// empty-bin fraction — routine on the exact detector-time route, whose
2294    /// acquisition windows legitimately contain unoccupied bins.
2295    ///
2296    /// This test is deliberately DISCRIMINATING: padding a fit with empty
2297    /// bins must leave `deviance` AND `deviance_per_dof` unchanged while
2298    /// `n_active` grows, and the reported ratio must equal
2299    /// `deviance / (n_occupied − n_free)` exactly.  Reverting the divisor to
2300    /// `n_active − n_free` fails every one of those assertions.
2301    #[test]
2302    fn empty_bins_do_not_dilute_deviance_dof() {
2303        let n_occupied = 40_usize;
2304        let n_empty = 20_usize;
2305        let c = 1.0_f64;
2306
2307        // Per-bin ratios vary, so a single constant T cannot match every bin
2308        // and the fitted deviance is strictly positive (non-vacuous ratio).
2309        let open: Vec<f64> = (0..n_occupied).map(|i| 400.0 + 3.0 * i as f64).collect();
2310        let sample: Vec<f64> = (0..n_occupied)
2311            .map(|i| {
2312                let t_i = 0.55 + 0.12 * ((i % 5) as f64 / 4.0 - 0.5);
2313                (open[i] * t_i).round()
2314            })
2315            .collect();
2316
2317        let fit = |o: &[f64], s: &[f64]| {
2318            let model = ConstModel { n_e: o.len() };
2319            let obj = JointPoissonObjective {
2320                model: &model,
2321                o,
2322                s,
2323                c,
2324                active_mask: None,
2325                open_background: None,
2326                sample_background: None,
2327            };
2328            let mut params = ParameterSet::new(vec![FitParameter::non_negative("t", 0.5)]);
2329            joint_poisson_fit(&obj, &mut params, &JointPoissonFitConfig::default()).unwrap()
2330        };
2331
2332        let base = fit(&open, &sample);
2333
2334        // Same data, padded with wholly unoccupied trailing bins.
2335        let mut open_padded = open.clone();
2336        let mut sample_padded = sample.clone();
2337        open_padded.extend(std::iter::repeat_n(0.0, n_empty));
2338        sample_padded.extend(std::iter::repeat_n(0.0, n_empty));
2339        let padded = fit(&open_padded, &sample_padded);
2340
2341        // Non-vacuity: the fit must actually misfit, or the ratio assertions
2342        // below would hold for any divisor.
2343        assert!(
2344            base.deviance > 1.0,
2345            "fixture must produce a positive deviance, got {}",
2346            base.deviance
2347        );
2348        assert_eq!(base.n_free, 1);
2349
2350        // The padding is visible in n_active (so the test cannot pass by the
2351        // padding being silently dropped upstream)...
2352        assert_eq!(base.n_active, n_occupied);
2353        assert_eq!(padded.n_active, n_occupied + n_empty);
2354
2355        // ...but invisible in the deviance and in the reported GOF.
2356        assert!(
2357            (padded.deviance - base.deviance).abs() < 1e-9,
2358            "empty bins changed the deviance: {} vs {}",
2359            padded.deviance,
2360            base.deviance
2361        );
2362        assert!(
2363            (padded.deviance_per_dof - base.deviance_per_dof).abs() < 1e-9,
2364            "empty bins diluted deviance_per_dof: {} vs {}",
2365            padded.deviance_per_dof,
2366            base.deviance_per_dof
2367        );
2368
2369        // Exact denominator: informative bins minus free parameters.
2370        let expected = padded.deviance / (n_occupied - 1) as f64;
2371        assert!(
2372            (padded.deviance_per_dof - expected).abs() < 1e-9,
2373            "deviance_per_dof {} != deviance/(n_occupied - n_free) {}",
2374            padded.deviance_per_dof,
2375            expected
2376        );
2377        // And the diluted value the pre-fix divisor would have produced is
2378        // measurably different — the assertions above have teeth.
2379        let diluted = padded.deviance / (n_occupied + n_empty - 1) as f64;
2380        assert!(
2381            (expected - diluted).abs() > 1e-3,
2382            "fixture too weak to distinguish the two divisors"
2383        );
2384    }
2385
2386    /// An acquisition with no observed counts anywhere must not be reported
2387    /// as a converged fit.
2388    ///
2389    /// Every bin is degenerate (zero deviance, zero Fisher curvature), so
2390    /// the deviance-based convergence test would otherwise see `D == 0` at
2391    /// the untouched initial guess and declare success — a success-shaped
2392    /// result from a fit that saw no data. Easy to hit on the exact
2393    /// detector-time route by mis-specifying the acquisition window.
2394    #[test]
2395    fn all_empty_acquisition_does_not_report_convergence() {
2396        let n_bins = 32_usize;
2397        let zeros = vec![0.0_f64; n_bins];
2398        let model = ConstModel { n_e: n_bins };
2399        let obj = JointPoissonObjective {
2400            model: &model,
2401            o: &zeros,
2402            s: &zeros,
2403            c: 1.0,
2404            active_mask: None,
2405            open_background: None,
2406            sample_background: None,
2407        };
2408        let seed = 0.42_f64;
2409        let mut params = ParameterSet::new(vec![FitParameter::non_negative("t", seed)]);
2410        let result =
2411            joint_poisson_fit(&obj, &mut params, &JointPoissonFitConfig::default()).unwrap();
2412
2413        assert!(
2414            !result.gn_converged,
2415            "a zero-count acquisition must not report convergence"
2416        );
2417        assert!(result.deviance.is_nan(), "deviance: {}", result.deviance);
2418        assert!(result.deviance_per_dof.is_nan());
2419        assert!(result.covariance.is_none() && result.uncertainties.is_none());
2420        // The bins are visible as active but none of them constrain anything.
2421        assert_eq!(result.n_active, n_bins);
2422        assert_eq!(obj.n_informative(), 0);
2423    }
2424
2425    /// The infallible public accessors must not panic on a caller-built
2426    /// objective whose mask is shorter than the data: `validate_inputs`
2427    /// rejects that shape on every optimizing path, and a `usize`-returning
2428    /// accessor cannot report the mismatch, so it degrades to inactive.
2429    #[test]
2430    fn short_active_mask_does_not_panic_public_accessors() {
2431        let model = ConstModel { n_e: 3 };
2432        let short_mask = [true];
2433        let obj = JointPoissonObjective {
2434            model: &model,
2435            o: &[10.0, 10.0, 10.0],
2436            s: &[5.0, 5.0, 5.0],
2437            c: 1.0,
2438            active_mask: Some(&short_mask),
2439            open_background: None,
2440            sample_background: None,
2441        };
2442        // Bin 0 is in-mask and occupied; bins 1-2 fall past the mask end.
2443        assert_eq!(obj.n_informative(), 1);
2444        // And the optimizing path still rejects the malformed shape loudly.
2445        let mut params = ParameterSet::new(vec![FitParameter::non_negative("t", 0.5)]);
2446        assert!(joint_poisson_fit(&obj, &mut params, &JointPoissonFitConfig::default()).is_err());
2447    }
2448
2449    // ------------------------------------------------------------------
2450    // Polish-never-worsens invariant on a backgrounded fit.  NM polish
2451    // is meant to reduce D materially when stage-1 stalls.  At the
2452    // unit-test scale we verify the testable invariant: enabling polish
2453    // never produces a larger final D than disabling it on the same data.
2454    //
2455    // Note: on this over-parameterized (5-free-param) synthetic with only
2456    // 150 bins, the deviance surface has multiple near-equal minima —
2457    // exactly the over-parameterization identifiability ambiguity the
2458    // B_A-pairing rule targets.  Density
2459    // recovery under over-parameterization is therefore *not* a unit-test
2460    // contract here; it is tested end-to-end with the single-parameter
2461    // matched-model test above.
2462    // ------------------------------------------------------------------
2463    #[test]
2464    fn test_joint_poisson_fit_polish_does_not_worsen_deviance() {
2465        let n_bins = 150;
2466        let energies: Vec<f64> = (0..n_bins).map(|i| 1.0 + 0.5 * i as f64).collect();
2467        let sigma: Vec<f64> = energies.iter().map(|&e| 1.0 / e).collect();
2468        let inner = ExpDecayModel { sigma: &sigma };
2469
2470        // Truth: n = 0.3, A_n = 0.9, no additive bg.
2471        let n_true = 0.3_f64;
2472        let a_n_true = 0.9_f64;
2473        let t_inner_true = inner.evaluate(&[n_true]).unwrap();
2474        let t_true: Vec<f64> = t_inner_true.iter().map(|&t| a_n_true * t).collect();
2475
2476        let c = 5.98_f64;
2477        let lam = 5000.0_f64;
2478        let mut rng = Xorshift(0xF00D_FACE_DEAD_BEEF);
2479        let o: Vec<f64> = (0..n_bins).map(|_| rng.poisson(lam / c)).collect();
2480        let s: Vec<f64> = (0..n_bins).map(|i| rng.poisson(lam * t_true[i])).collect();
2481
2482        let bg_model = BackgroundedTransmission {
2483            inner: &inner,
2484            energies: &energies,
2485            n_idx: 0,
2486            a_idx: 1,
2487            b_a_idx: 2,
2488            b_b_idx: 3,
2489            b_c_idx: 4,
2490            n_params: 5,
2491        };
2492        let _ = bg_model.n_params; // silence dead-code warning
2493
2494        let obj = JointPoissonObjective {
2495            model: &bg_model,
2496            o: &o,
2497            s: &s,
2498            c,
2499            active_mask: None,
2500            open_background: None,
2501            sample_background: None,
2502        };
2503
2504        // x0 analogous to the stall-prone backgrounded regime: n near truth, A_n = 1, all
2505        // additive bg at 0, bg bounds tight to curb degeneracy.
2506        let mk_params = || {
2507            ParameterSet::new(vec![
2508                FitParameter::non_negative("n", 0.25),
2509                FitParameter::non_negative("A_n", 1.0),
2510                FitParameter {
2511                    name: "B_A".into(),
2512                    value: 0.0,
2513                    lower: -0.05,
2514                    upper: 0.05,
2515                    fixed: false,
2516                },
2517                FitParameter {
2518                    name: "B_B".into(),
2519                    value: 0.0,
2520                    lower: -0.05,
2521                    upper: 0.05,
2522                    fixed: false,
2523                },
2524                FitParameter {
2525                    name: "B_C".into(),
2526                    value: 0.0,
2527                    lower: -0.05,
2528                    upper: 0.05,
2529                    fixed: false,
2530                },
2531            ])
2532        };
2533
2534        let mut params_no_polish = mk_params();
2535        let cfg_no_polish = JointPoissonFitConfig {
2536            enable_polish: false,
2537            ..Default::default()
2538        };
2539        let r_no_polish = joint_poisson_fit(&obj, &mut params_no_polish, &cfg_no_polish).unwrap();
2540
2541        let mut params_polish = mk_params();
2542        let cfg_polish = JointPoissonFitConfig {
2543            enable_polish: true,
2544            ..Default::default()
2545        };
2546        let r_polish = joint_poisson_fit(&obj, &mut params_polish, &cfg_polish).unwrap();
2547
2548        // Invariant: enabling polish must not increase final D.
2549        assert!(
2550            r_polish.deviance <= r_no_polish.deviance + 1e-6,
2551            "polish worsened D: D_polish={} D_no_polish={}",
2552            r_polish.deviance,
2553            r_no_polish.deviance
2554        );
2555
2556        // When polish_improved flag is set, polish D must be strictly
2557        // better than stage-1 D (consistency check on the flag semantics).
2558        if r_polish.polish_improved {
2559            assert!(
2560                r_polish.deviance < r_no_polish.deviance,
2561                "polish_improved=true but D_polish={} >= D_no_polish={}",
2562                r_polish.deviance,
2563                r_no_polish.deviance
2564            );
2565        }
2566
2567        // The fit should return a physically sensible density (positive,
2568        // finite, within an order of magnitude of truth — not a strict
2569        // recovery test, just a sanity check).
2570        let n_fit = r_polish.params[0];
2571        assert!(n_fit.is_finite() && n_fit > 0.0);
2572        assert!(
2573            n_fit > 0.1 && n_fit < 0.8,
2574            "density grossly off: n_fit={n_fit} (truth={n_true})"
2575        );
2576    }
2577
2578    // ------------------------------------------------------------------
2579    // Fit result carries gn_converged and polish_converged separately
2580    // (acceptance is judged from the deviance value, not one flag).
2581    // ------------------------------------------------------------------
2582    #[test]
2583    fn test_joint_poisson_fit_exposes_separate_converged_flags() {
2584        let n_bins = 50;
2585        let sigma = vec![0.5_f64; n_bins];
2586        let model = ExpDecayModel { sigma: &sigma };
2587        let n_true = 0.2;
2588        let c = 2.0;
2589        let lam = 500.0;
2590        let t_true = model.evaluate(&[n_true]).unwrap();
2591        let mut rng = Xorshift(0xABAD_CAFE_BABE_F00D);
2592        let o: Vec<f64> = (0..n_bins).map(|_| rng.poisson(lam / c)).collect();
2593        let s: Vec<f64> = (0..n_bins).map(|i| rng.poisson(lam * t_true[i])).collect();
2594
2595        let obj = JointPoissonObjective {
2596            model: &model,
2597            o: &o,
2598            s: &s,
2599            c,
2600            active_mask: None,
2601            open_background: None,
2602            sample_background: None,
2603        };
2604        let mut params = ParameterSet::new(vec![FitParameter::non_negative("n", 0.1)]);
2605        let cfg = JointPoissonFitConfig {
2606            enable_polish: true,
2607            ..Default::default()
2608        };
2609        let r = joint_poisson_fit(&obj, &mut params, &cfg).unwrap();
2610
2611        // Both flags exist; at least one should be true on this easy case.
2612        assert!(r.gn_converged || r.polish_converged);
2613        assert!(r.n_data == n_bins);
2614        assert!(r.n_free == 1);
2615        assert!(r.deviance > 0.0);
2616        assert!(r.deviance_per_dof.is_finite());
2617        // Uncertainty present (compute_covariance default true).
2618        assert!(r.uncertainties.is_some());
2619        let u = r.uncertainties.as_ref().unwrap();
2620        assert_eq!(u.len(), 1);
2621        assert!(u[0].is_finite() && u[0] > 0.0);
2622    }
2623
2624    // ------------------------------------------------------------------
2625    // Reported uncertainty matches the analytical Cramér-Rao bound
2626    // I^{-1} (NOT (2I)^{-1} — the Hessian-of-D inverse, which would
2627    // under-report σ by √2).  A real bug in the original
2628    // implementation; see `joint_poisson_fit` covariance-extraction
2629    // doc-comment for the rescaling rationale.
2630    // ------------------------------------------------------------------
2631    #[test]
2632    fn test_uncertainty_matches_analytical_fisher_inverse() {
2633        // Construct a single-parameter constant-T model on noise-free
2634        // expected counts: O_i = λ/c, S_i = λ·T (the module-doc model).
2635        // With ConstModel (J_i = ∂T/∂θ = 1), the analytical Fisher is
2636        //   I(T) = Σ_i (O_i + S_i)·c / (T·(1+cT)²)
2637        //        = N · λ · (1+cT)/c · c / (T·(1+cT)²)
2638        //        = N · λ / (T · (1+cT))
2639        // and σ_T = √(I^{-1}) = √( T·(1+cT) / (N·λ) ).
2640        let n_bins = 200;
2641        let t_true = 0.5_f64;
2642        let c = 2.0_f64;
2643        let lam = 100.0_f64;
2644        let o: Vec<f64> = vec![lam / c; n_bins];
2645        let s: Vec<f64> = vec![lam * t_true; n_bins];
2646        let model = ConstModel { n_e: n_bins };
2647        let obj = JointPoissonObjective {
2648            model: &model,
2649            o: &o,
2650            s: &s,
2651            c,
2652            active_mask: None,
2653            open_background: None,
2654            sample_background: None,
2655        };
2656        let mut params = ParameterSet::new(vec![FitParameter::non_negative("T", t_true)]);
2657        let cfg = JointPoissonFitConfig {
2658            // Disable polish for a clean Newton-only fit (avoids NM-tail
2659            // perturbations of the final θ that would shift σ slightly).
2660            enable_polish: false,
2661            ..Default::default()
2662        };
2663        let r = joint_poisson_fit(&obj, &mut params, &cfg).unwrap();
2664        let sigma_reported = r.uncertainties.as_ref().expect("σ available")[0];
2665
2666        // Analytical Cramér-Rao σ.
2667        let sigma_analytical = (t_true * (1.0 + c * t_true) / (n_bins as f64 * lam)).sqrt();
2668
2669        // The pre-fix (uncompensated) value would be σ_analytical / √2 —
2670        // tighten the tolerance below √2 so the regression is caught.
2671        let rel_err = (sigma_reported - sigma_analytical).abs() / sigma_analytical;
2672        assert!(
2673            rel_err < 0.05,
2674            "reported σ = {sigma_reported} vs analytical I^{{-1}}^(1/2) = \
2675             {sigma_analytical} (rel_err = {rel_err}); pre-fix code reported \
2676             σ_analytical / √2 ≈ {} which would give rel_err ≈ 0.293",
2677            sigma_analytical / 2.0_f64.sqrt(),
2678        );
2679    }
2680
2681    // ------------------------------------------------------------------
2682    // Active-bin mask (SAMMY EMIN/EMAX-equivalent fit-energy-range, #514).
2683    // ------------------------------------------------------------------
2684
2685    /// `deviance_from_transmission` with `active_mask` set must equal
2686    /// the same call computed only over the `true` bins (subset
2687    /// equivalence) — the masking is correct iff dropping out-of-mask
2688    /// bins from `o`, `s`, `t` produces the same value.
2689    #[test]
2690    fn test_jp_active_mask_subset_equivalence() {
2691        // 5-bin objective with an arbitrary mask — bins 1 and 3 active.
2692        let o_full = [10.0, 20.0, 5.0, 15.0, 25.0];
2693        let s_full = [4.0, 8.0, 1.0, 6.0, 12.0];
2694        let t_full = [0.4, 0.5, 0.7, 0.6, 0.45];
2695        let mask = [false, true, false, true, false];
2696        let c = 1.5;
2697        let model_full = ConstModel { n_e: 5 };
2698        let obj_full = JointPoissonObjective {
2699            model: &model_full,
2700            o: &o_full,
2701            s: &s_full,
2702            c,
2703            active_mask: Some(&mask),
2704            open_background: None,
2705            sample_background: None,
2706        };
2707        let d_masked = obj_full.deviance_from_transmission(&t_full).unwrap();
2708
2709        // Compare against an objective built directly on the active subset.
2710        let o_sub = [o_full[1], o_full[3]];
2711        let s_sub = [s_full[1], s_full[3]];
2712        let t_sub = [t_full[1], t_full[3]];
2713        let model_sub = ConstModel { n_e: 2 };
2714        let obj_sub = JointPoissonObjective {
2715            model: &model_sub,
2716            o: &o_sub,
2717            s: &s_sub,
2718            c,
2719            active_mask: None,
2720            open_background: None,
2721            sample_background: None,
2722        };
2723        let d_subset = obj_sub.deviance_from_transmission(&t_sub).unwrap();
2724
2725        assert!(
2726            (d_masked - d_subset).abs() < 1e-12,
2727            "masked deviance {d_masked} != subset deviance {d_subset}"
2728        );
2729
2730        // Active-bin count should be 2, not 5.
2731        assert_eq!(obj_full.n_active(), 2);
2732        assert_eq!(obj_full.n_data(), 5);
2733    }
2734
2735    /// Out-of-mask gradient contributions must drop to zero — verified
2736    /// by comparing against an unmasked subset gradient.
2737    #[test]
2738    fn test_jp_active_mask_gradient_subset_equivalence() {
2739        let e_full: Vec<f64> = (0..6).map(|i| 0.1 + 0.1 * i as f64).collect();
2740        let theta_true = [0.95_f64, 0.05_f64];
2741        let c = 2.0;
2742        let lam = 100.0;
2743        let model_full = LinearModel { e: &e_full };
2744        let t_full = model_full.evaluate(&theta_true).unwrap();
2745        let o_full: Vec<f64> = vec![lam / c; e_full.len()];
2746        let s_full: Vec<f64> = t_full.iter().map(|&ti| lam * ti).collect();
2747
2748        // Mask = bins 2..5 active.
2749        let mask = vec![false, false, true, true, true, false];
2750        let obj_full = JointPoissonObjective {
2751            model: &model_full,
2752            o: &o_full,
2753            s: &s_full,
2754            c,
2755            active_mask: Some(&mask),
2756            open_background: None,
2757            sample_background: None,
2758        };
2759
2760        let params_full = ParameterSet::new(vec![
2761            FitParameter::non_negative("a", theta_true[0]),
2762            FitParameter::non_negative("b", theta_true[1]),
2763        ]);
2764        let free_idx = params_full.free_indices();
2765        let theta_eval = [0.9_f64, 0.07_f64];
2766        let grad_masked = obj_full
2767            .deviance_gradient_analytical(&theta_eval, &free_idx)
2768            .unwrap()
2769            .expect("analytical gradient");
2770
2771        // Subset reference: only bins 2..5.
2772        let e_sub = e_full[2..5].to_vec();
2773        let o_sub = o_full[2..5].to_vec();
2774        let s_sub = s_full[2..5].to_vec();
2775        let model_sub = LinearModel { e: &e_sub };
2776        let obj_sub = JointPoissonObjective {
2777            model: &model_sub,
2778            o: &o_sub,
2779            s: &s_sub,
2780            c,
2781            active_mask: None,
2782            open_background: None,
2783            sample_background: None,
2784        };
2785        let grad_sub = obj_sub
2786            .deviance_gradient_analytical(&theta_eval, &free_idx)
2787            .unwrap()
2788            .expect("analytical gradient");
2789
2790        for (i, (&gm, &gs)) in grad_masked.iter().zip(grad_sub.iter()).enumerate() {
2791            assert!(
2792                (gm - gs).abs() < 1e-9,
2793                "grad component {i}: masked={gm} subset={gs}"
2794            );
2795        }
2796    }
2797
2798    /// `joint_poisson_fit` must reject an underdetermined (n_active <
2799    /// n_free) configuration with a non-converged result and NaN
2800    /// deviance / per-dof, mirroring the LM solver.  An all-`false`
2801    /// active mask is the extreme case (`n_active == 0 < n_free`);
2802    /// the prior `.max(1)` divisor produced a deceptive
2803    /// finite-looking deviance-per-dof for empty / too-narrow masks.
2804    /// Regression for #514.
2805    #[test]
2806    fn test_joint_poisson_rejects_zero_active_mask() {
2807        let n_bins = 10;
2808        let o: Vec<f64> = vec![50.0; n_bins];
2809        let s: Vec<f64> = vec![25.0; n_bins];
2810        let mask = vec![false; n_bins]; // n_active = 0
2811        let model = ConstModel { n_e: n_bins };
2812        let obj = JointPoissonObjective {
2813            model: &model,
2814            o: &o,
2815            s: &s,
2816            c: 1.0,
2817            active_mask: Some(&mask),
2818            open_background: None,
2819            sample_background: None,
2820        };
2821        let mut params = ParameterSet::new(vec![FitParameter::non_negative("T", 0.5)]);
2822        let cfg = JointPoissonFitConfig::default();
2823        let r = joint_poisson_fit(&obj, &mut params, &cfg).unwrap();
2824
2825        assert!(
2826            !r.gn_converged && !r.polish_converged,
2827            "underdetermined fit must report non-converged"
2828        );
2829        assert!(
2830            r.deviance.is_nan(),
2831            "underdetermined deviance must be NaN, got {}",
2832            r.deviance
2833        );
2834        assert!(
2835            r.deviance_per_dof.is_nan(),
2836            "underdetermined deviance-per-dof must be NaN, got {}",
2837            r.deviance_per_dof
2838        );
2839        assert_eq!(r.n_data, n_bins);
2840        assert_eq!(r.n_active, 0);
2841        assert_eq!(r.n_free, 1);
2842        assert!(r.covariance.is_none());
2843        assert!(r.uncertainties.is_none());
2844    }
2845
2846    /// Zero active bins with **all parameters fixed** (`n_free == 0`)
2847    /// must still return non-converged.  Without the
2848    /// `n_active == 0` early-return, the underdetermined check
2849    /// `n_active < n_free` is `0 < 0` → false, so the function would
2850    /// fall through to the main loop, compute `deviance = 0` from the
2851    /// empty sum, and `dof = 0` → `deviance_per_dof = NaN` — but
2852    /// `gn_converged` could still be `true`, masquerading as a
2853    /// successful fit on no data.  Regression for #517 (#514).
2854    #[test]
2855    fn test_joint_poisson_rejects_zero_active_with_no_free_params() {
2856        let n_bins = 5;
2857        let o: Vec<f64> = vec![10.0; n_bins];
2858        let s: Vec<f64> = vec![5.0; n_bins];
2859        let mask = vec![false; n_bins];
2860        let model = ConstModel { n_e: n_bins };
2861        let obj = JointPoissonObjective {
2862            model: &model,
2863            o: &o,
2864            s: &s,
2865            c: 1.0,
2866            active_mask: Some(&mask),
2867            open_background: None,
2868            sample_background: None,
2869        };
2870        let mut params = ParameterSet::new(vec![FitParameter::fixed("T", 0.5)]);
2871        let r = joint_poisson_fit(&obj, &mut params, &JointPoissonFitConfig::default()).unwrap();
2872        assert!(!r.gn_converged);
2873        assert!(!r.polish_converged);
2874        assert!(r.deviance.is_nan());
2875        assert!(r.deviance_per_dof.is_nan());
2876        assert_eq!(r.n_active, 0);
2877        assert_eq!(r.n_free, 0);
2878    }
2879
2880    /// `joint_poisson_fit` validates active-mask length up-front and
2881    /// returns `LengthMismatch` rather than relying on a debug-assert
2882    /// deep in the deviance routines (which silently passes through in
2883    /// release builds, then panics on out-of-bounds index reads).
2884    /// Regression for #514.
2885    #[test]
2886    fn test_joint_poisson_rejects_active_mask_length_mismatch() {
2887        let n_bins = 5;
2888        let o: Vec<f64> = vec![10.0; n_bins];
2889        let s: Vec<f64> = vec![5.0; n_bins];
2890        let mask_wrong = vec![true, true, true]; // wrong length
2891        let model = ConstModel { n_e: n_bins };
2892        let obj = JointPoissonObjective {
2893            model: &model,
2894            o: &o,
2895            s: &s,
2896            c: 1.0,
2897            active_mask: Some(&mask_wrong),
2898            open_background: None,
2899            sample_background: None,
2900        };
2901        let mut params = ParameterSet::new(vec![FitParameter::non_negative("T", 0.5)]);
2902        let cfg = JointPoissonFitConfig::default();
2903        let err = joint_poisson_fit(&obj, &mut params, &cfg).unwrap_err();
2904        assert!(
2905            matches!(
2906                err,
2907                FittingError::LengthMismatch {
2908                    field: "active_mask",
2909                    ..
2910                }
2911            ),
2912            "expected LengthMismatch on active_mask; got {err:?}"
2913        );
2914    }
2915
2916    // ==================================================================
2917    // Release-mode input validation at joint_poisson_fit.
2918    //
2919    // The inner `binomial_deviance_term` and `deviance_from_transmission`
2920    // protect themselves with `debug_assert!` only.  Release builds skip
2921    // those, so a length mismatch in `o` vs `s` silently truncates via
2922    // `.zip()` and a non-positive `c` produces finite garbage that the
2923    // optimizer happily minimises.  Validate at the public entry point.
2924    // ==================================================================
2925
2926    /// `joint_poisson_fit` rejects an `o`/`s` length mismatch with a
2927    /// `LengthMismatch` error rather than silently truncating via `.zip()`
2928    /// and minimising bogus deviance on a sub-range of bins.
2929    #[test]
2930    fn test_joint_poisson_rejects_o_s_length_mismatch() {
2931        let n_bins = 5;
2932        let o: Vec<f64> = vec![10.0; n_bins];
2933        // Deliberate mismatch: `s` has one fewer bin than `o`.
2934        let s: Vec<f64> = vec![5.0; n_bins - 1];
2935        let model = ConstModel { n_e: n_bins };
2936        let obj = JointPoissonObjective {
2937            model: &model,
2938            o: &o,
2939            s: &s,
2940            c: 1.0,
2941            active_mask: None,
2942            open_background: None,
2943            sample_background: None,
2944        };
2945        let mut params = ParameterSet::new(vec![FitParameter::non_negative("T", 0.5)]);
2946        let err =
2947            joint_poisson_fit(&obj, &mut params, &JointPoissonFitConfig::default()).unwrap_err();
2948        assert!(
2949            matches!(
2950                err,
2951                FittingError::LengthMismatch {
2952                    field: "sample_counts",
2953                    ..
2954                }
2955            ),
2956            "expected LengthMismatch on sample_counts; got {err:?}"
2957        );
2958    }
2959
2960    /// `joint_poisson_fit` rejects a non-positive proton-charge ratio `c`
2961    /// with `InvalidConfig` rather than falling through to the inner
2962    /// `debug_assert!` (which is a no-op in release builds and lets the
2963    /// optimizer minimise a garbage deviance landscape).
2964    #[test]
2965    fn test_joint_poisson_rejects_non_positive_c() {
2966        let n_bins = 5;
2967        let o: Vec<f64> = vec![10.0; n_bins];
2968        let s: Vec<f64> = vec![5.0; n_bins];
2969        let model = ConstModel { n_e: n_bins };
2970        let mut params = ParameterSet::new(vec![FitParameter::non_negative("T", 0.5)]);
2971        // c = 0 is the textbook degenerate case (no sample counts).
2972        let obj_zero = JointPoissonObjective {
2973            model: &model,
2974            o: &o,
2975            s: &s,
2976            c: 0.0,
2977            active_mask: None,
2978            open_background: None,
2979            sample_background: None,
2980        };
2981        let err = joint_poisson_fit(&obj_zero, &mut params, &JointPoissonFitConfig::default())
2982            .unwrap_err();
2983        assert!(
2984            matches!(err, FittingError::InvalidConfig(_)),
2985            "expected InvalidConfig on c=0; got {err:?}"
2986        );
2987
2988        // Negative c is unphysical.
2989        let mut params2 = ParameterSet::new(vec![FitParameter::non_negative("T", 0.5)]);
2990        let obj_neg = JointPoissonObjective {
2991            model: &model,
2992            o: &o,
2993            s: &s,
2994            c: -1.5,
2995            active_mask: None,
2996            open_background: None,
2997            sample_background: None,
2998        };
2999        let err = joint_poisson_fit(&obj_neg, &mut params2, &JointPoissonFitConfig::default())
3000            .unwrap_err();
3001        assert!(
3002            matches!(err, FittingError::InvalidConfig(_)),
3003            "expected InvalidConfig on c<0; got {err:?}"
3004        );
3005
3006        // NaN c — caught by the same finiteness check.
3007        let mut params3 = ParameterSet::new(vec![FitParameter::non_negative("T", 0.5)]);
3008        let obj_nan = JointPoissonObjective {
3009            model: &model,
3010            o: &o,
3011            s: &s,
3012            c: f64::NAN,
3013            active_mask: None,
3014            open_background: None,
3015            sample_background: None,
3016        };
3017        let err = joint_poisson_fit(&obj_nan, &mut params3, &JointPoissonFitConfig::default())
3018            .unwrap_err();
3019        assert!(
3020            matches!(err, FittingError::InvalidConfig(_)),
3021            "expected InvalidConfig on c=NaN; got {err:?}"
3022        );
3023    }
3024
3025    // ==================================================================
3026    // `f64::max(NaN, ε) == ε` swallows active NaN T.
3027    //
3028    // Rust stdlib's `f64::max` returns the non-NaN argument when one is
3029    // NaN, so `t.max(POISSON_EPSILON)` silently turns a NaN transmission
3030    // into ε.  The deviance term then evaluates to a finite (large)
3031    // number which passes the trial-step's `v.is_finite()` guard, so the
3032    // optimizer accepts steps into regions where the model is broken.
3033    //
3034    // `binomial_deviance_term` returns NaN when T is non-finite (so the
3035    // deviance sum becomes NaN and the trial guard rejects the step),
3036    // and `deviance_weight` / `deviance_curvature` return 0 (so the
3037    // gradient / Fisher accumulators are not poisoned by the bad bin).
3038    // ==================================================================
3039
3040    /// `binomial_deviance_term` returns NaN when `t` is non-finite — so
3041    /// the per-bin sum poisons the deviance and the trial-step guard
3042    /// (`Ok(v) if v.is_finite()`) rejects the step instead of silently
3043    /// accepting a bogus-but-finite value.
3044    #[test]
3045    fn test_binomial_deviance_term_nan_t_returns_nan() {
3046        // Pre-fix: `t.max(POISSON_EPSILON)` swallows NaN and returns a
3047        // finite (but meaningless) deviance.
3048        let d_nan_t = binomial_deviance_term(50.0, 10.0, f64::NAN, 2.0);
3049        assert!(
3050            d_nan_t.is_nan(),
3051            "non-finite T must produce NaN deviance, not a finite shim; got {d_nan_t}"
3052        );
3053
3054        // +inf / -inf likewise — they are not physical transmission values.
3055        let d_inf_t = binomial_deviance_term(50.0, 10.0, f64::INFINITY, 2.0);
3056        assert!(
3057            d_inf_t.is_nan(),
3058            "+inf T must produce NaN deviance; got {d_inf_t}"
3059        );
3060        let d_neg_inf_t = binomial_deviance_term(50.0, 10.0, f64::NEG_INFINITY, 2.0);
3061        assert!(
3062            d_neg_inf_t.is_nan(),
3063            "-inf T must produce NaN deviance; got {d_neg_inf_t}"
3064        );
3065    }
3066
3067    /// `deviance_weight` returns 0 for non-finite `t` so the gradient
3068    /// accumulator is not poisoned — bad bins drop out instead of
3069    /// becoming silent NaN contributions weighted by the Jacobian.
3070    #[test]
3071    fn test_deviance_weight_nan_t_returns_zero() {
3072        let w = deviance_weight(50.0, 10.0, f64::NAN, 2.0);
3073        assert_eq!(w, 0.0, "non-finite T must give zero weight; got {w}");
3074    }
3075
3076    /// `deviance_curvature` returns 0 for non-finite `t` so the Fisher
3077    /// info accumulator is not poisoned.
3078    #[test]
3079    fn test_deviance_curvature_nan_t_returns_zero() {
3080        let h = deviance_curvature(50.0, 10.0, f64::NAN, 2.0);
3081        assert_eq!(h, 0.0, "non-finite T must give zero curvature; got {h}");
3082    }
3083
3084    /// End-to-end: a model that returns NaN at some active bin makes the
3085    /// deviance non-finite, the trial-step guard rejects it (rather than
3086    /// accepting a bogus finite step), and the fit either bails out
3087    /// non-converged or recovers without committing the bad step.  Prior
3088    /// to the M14 fix the optimizer could silently accept the NaN step.
3089    #[test]
3090    fn test_joint_poisson_fit_rejects_nan_transmission() {
3091        // Model that returns NaN at θ < 0.1 and a constant 0.5 otherwise.
3092        struct NanAtSmallTheta;
3093        impl FitModel for NanAtSmallTheta {
3094            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
3095                let t = if params[0] < 0.1 { f64::NAN } else { 0.5 };
3096                Ok(vec![t; 4])
3097            }
3098            fn analytical_jacobian(
3099                &self,
3100                _params: &[f64],
3101                free_param_indices: &[usize],
3102                y_current: &[f64],
3103            ) -> Option<FlatMatrix> {
3104                let n_e = y_current.len();
3105                let n_free = free_param_indices.len();
3106                let mut jac = FlatMatrix::zeros(n_e, n_free);
3107                for i in 0..n_e {
3108                    for (j, &pi) in free_param_indices.iter().enumerate() {
3109                        *jac.get_mut(i, j) = if pi == 0 { 1.0 } else { 0.0 };
3110                    }
3111                }
3112                Some(jac)
3113            }
3114        }
3115
3116        let model = NanAtSmallTheta;
3117        let n = 4;
3118        let o = vec![10.0; n];
3119        let s = vec![5.0; n];
3120        let obj = JointPoissonObjective {
3121            model: &model,
3122            o: &o,
3123            s: &s,
3124            c: 1.0,
3125            active_mask: None,
3126            open_background: None,
3127            sample_background: None,
3128        };
3129        // Initial point lands in the NaN region.
3130        let mut params = ParameterSet::new(vec![FitParameter::non_negative("T", 0.05)]);
3131        let cfg = JointPoissonFitConfig::default();
3132        let result = joint_poisson_fit(&obj, &mut params, &cfg);
3133        match result {
3134            Ok(r) => {
3135                // The optimizer must NOT report a finite deviance from a
3136                // NaN-T initial point — pre-fix it would do so by silently
3137                // converting NaN to POISSON_EPSILON.  After the fix the
3138                // deviance is NaN (initial eval propagates), or the fit
3139                // never accepts a NaN step, or it ascends out of the NaN
3140                // region and lands at the finite plateau (params[0] >= 0.1).
3141                if r.params[0] < 0.1 {
3142                    assert!(
3143                        r.deviance.is_nan() && !r.gn_converged,
3144                        "stayed in NaN region but reported finite deviance: {r:?}"
3145                    );
3146                }
3147            }
3148            Err(_) => {
3149                // Acceptable: hard error from the initial evaluation.
3150            }
3151        }
3152    }
3153
3154    /// All-fixed parameters + NaN transmission must NOT be reported as
3155    /// `gn_converged = true`.
3156    ///
3157    /// The `n_free == 0` shortcut in `damped_fisher_stage` previously set
3158    /// `converged = true` unconditionally, so a fit with every parameter
3159    /// fixed and a model that returns NaN at active bins would return
3160    /// `deviance = NaN` together with `gn_converged = true`.  Downstream
3161    /// pipeline code (`pipeline.rs`'s `gn_converged || polish_converged`)
3162    /// would then surface that pixel as a "converged" fit in the spatial
3163    /// map.  The guard at the top of `damped_fisher_stage` now keys
3164    /// convergence off `d_current.is_finite()`.
3165    ///
3166    /// Mirrors `lm.rs::test_all_fixed_params_nan_model` (issue #125.1),
3167    /// which exercises the equivalent guard in
3168    /// `levenberg_marquardt_with_mask`.
3169    #[test]
3170    fn test_joint_poisson_all_fixed_nan_transmission_does_not_converge() {
3171        struct NanModel {
3172            n_e: usize,
3173        }
3174        impl FitModel for NanModel {
3175            fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
3176                Ok(vec![f64::NAN; self.n_e])
3177            }
3178        }
3179
3180        let n_bins = 5;
3181        let o = vec![10.0; n_bins];
3182        let s = vec![5.0; n_bins];
3183        let model = NanModel { n_e: n_bins };
3184        let obj = JointPoissonObjective {
3185            model: &model,
3186            o: &o,
3187            s: &s,
3188            c: 1.0,
3189            active_mask: None,
3190            open_background: None,
3191            sample_background: None,
3192        };
3193        let mut params = ParameterSet::new(vec![FitParameter::fixed("T", 0.5)]);
3194        let cfg = JointPoissonFitConfig::default();
3195
3196        let r = joint_poisson_fit(&obj, &mut params, &cfg).unwrap();
3197
3198        assert!(
3199            r.deviance.is_nan(),
3200            "expected NaN deviance from all-fixed NaN model; got {}",
3201            r.deviance
3202        );
3203        assert!(
3204            r.deviance_per_dof.is_nan(),
3205            "expected NaN deviance_per_dof; got {}",
3206            r.deviance_per_dof
3207        );
3208        assert!(
3209            !r.gn_converged,
3210            "all-fixed NaN deviance must not be reported as GN-converged",
3211        );
3212        assert_eq!(r.n_free, 0);
3213        assert_eq!(r.n_active, n_bins);
3214        // The damped-Fisher loop increments `iter` before the `n_free == 0`
3215        // branch hits `break`, so the all-fixed path always reports exactly
3216        // one iteration.  Lock that in so future loop refactors don't
3217        // silently drift the iteration count.
3218        assert_eq!(
3219            r.gn_iterations, 1,
3220            "all-fixed branch should report exactly one iteration",
3221        );
3222    }
3223
3224    /// Companion to [`test_joint_poisson_all_fixed_nan_transmission_does_not_converge`]
3225    /// covering the polish-enabled path.
3226    ///
3227    /// `nelder_mead_minimize` asserts that `x0` is non-empty (see
3228    /// `nelder_mead.rs`), which used to panic when stage 2 was invoked with
3229    /// every parameter fixed.  The polish entry-point now short-circuits on
3230    /// `free_indices().is_empty()`, so the call must return cleanly with
3231    /// `polish_converged == false` and the stage-1 NaN deviance preserved.
3232    /// Mirrors the pipeline configuration in `nereids-pipeline` where
3233    /// `with_counts_enable_polish(Some(true))` is set independently of
3234    /// whether the parameter set has any free entries.
3235    #[test]
3236    fn test_joint_poisson_all_fixed_nan_transmission_with_polish_does_not_panic() {
3237        struct NanModel {
3238            n_e: usize,
3239        }
3240        impl FitModel for NanModel {
3241            fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
3242                Ok(vec![f64::NAN; self.n_e])
3243            }
3244        }
3245
3246        let n_bins = 5;
3247        let o = vec![10.0; n_bins];
3248        let s = vec![5.0; n_bins];
3249        let model = NanModel { n_e: n_bins };
3250        let obj = JointPoissonObjective {
3251            model: &model,
3252            o: &o,
3253            s: &s,
3254            c: 1.0,
3255            active_mask: None,
3256            open_background: None,
3257            sample_background: None,
3258        };
3259        let mut params = ParameterSet::new(vec![FitParameter::fixed("T", 0.5)]);
3260        let cfg = JointPoissonFitConfig {
3261            enable_polish: true,
3262            ..JointPoissonFitConfig::default()
3263        };
3264
3265        // Must not panic — the empty-x0 guard short-circuits stage 2.
3266        let r = joint_poisson_fit(&obj, &mut params, &cfg).unwrap();
3267
3268        assert!(
3269            r.deviance.is_nan(),
3270            "expected NaN deviance from all-fixed NaN model; got {}",
3271            r.deviance
3272        );
3273        assert!(
3274            !r.gn_converged,
3275            "all-fixed NaN deviance must not be reported as GN-converged",
3276        );
3277        assert!(
3278            !r.polish_converged,
3279            "polish stage must report not-converged when skipped on all-fixed params",
3280        );
3281        assert!(
3282            !r.polish_improved,
3283            "polish stage cannot have improved the deviance when it was skipped",
3284        );
3285        assert_eq!(
3286            r.polish_iterations, 0,
3287            "polish stage must report zero iterations when skipped",
3288        );
3289        assert_eq!(r.n_free, 0);
3290        assert_eq!(r.n_active, n_bins);
3291        assert_eq!(
3292            r.gn_iterations, 1,
3293            "all-fixed branch should report exactly one iteration",
3294        );
3295    }
3296
3297    /// Polish path with at least one **free** parameter must not report
3298    /// `polish_converged = true` when stage 1 ended on a non-finite
3299    /// deviance.
3300    ///
3301    /// Without the `best_d_stage1.is_finite()` short-circuit in the polish
3302    /// guard, Nelder-Mead would still run and return a finite `nm.fun`
3303    /// (its infeasible-point handler maps NaN evaluations to `+∞` and
3304    /// contracts away from them).  The commit test `nm.fun < best_d_stage1`
3305    /// then reduces to `finite < NaN == false`, so the polish step is
3306    /// discarded — but `polish_converged` would inherit `nm.self_converged`
3307    /// regardless, leaking a spurious converged flag together with a NaN
3308    /// final deviance.  Downstream pipeline code (`pipeline.rs`'s
3309    /// `gn_converged || polish_converged`) would then surface that fit as
3310    /// converged in the spatial map.
3311    ///
3312    /// Symmetric to the all-fixed NaN guard above: stage 2 refuses to run
3313    /// when there is no finite stage-1 deviance to refine.
3314    #[test]
3315    fn test_joint_poisson_polish_does_not_report_converged_when_stage1_nan() {
3316        struct NanModel {
3317            n_e: usize,
3318        }
3319        impl FitModel for NanModel {
3320            fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
3321                Ok(vec![f64::NAN; self.n_e])
3322            }
3323        }
3324
3325        let n_bins = 5;
3326        let o = vec![10.0; n_bins];
3327        let s = vec![5.0; n_bins];
3328        let model = NanModel { n_e: n_bins };
3329        let obj = JointPoissonObjective {
3330            model: &model,
3331            o: &o,
3332            s: &s,
3333            c: 1.0,
3334            active_mask: None,
3335            open_background: None,
3336            sample_background: None,
3337        };
3338        // At least one FREE parameter so polish actually runs (unlike
3339        // `test_joint_poisson_all_fixed_nan_transmission_with_polish_does_not_panic`,
3340        // which exercises the empty-free-set short-circuit instead).
3341        let mut params = ParameterSet::new(vec![FitParameter::non_negative("T", 0.5)]);
3342        let cfg = JointPoissonFitConfig {
3343            enable_polish: true,
3344            ..JointPoissonFitConfig::default()
3345        };
3346
3347        let r = joint_poisson_fit(&obj, &mut params, &cfg).unwrap();
3348
3349        assert!(
3350            r.deviance.is_nan(),
3351            "expected NaN deviance from NaN model; got {}",
3352            r.deviance
3353        );
3354        assert!(!r.gn_converged, "stage 1 cannot converge on NaN deviance",);
3355        assert!(
3356            !r.polish_converged,
3357            "stage 2 must not report converged when stage 1 ended non-finite",
3358        );
3359        assert!(
3360            !r.polish_improved,
3361            "polish cannot have improved a NaN starting deviance",
3362        );
3363        assert_eq!(
3364            r.polish_iterations, 0,
3365            "polish must not run when stage 1 is non-finite",
3366        );
3367        assert_eq!(r.n_free, 1);
3368        assert_eq!(r.n_active, n_bins);
3369    }
3370
3371    // ==================================================================
3372    // NaN-in-Jacobian during FD probes (Fisher info).
3373    //
3374    // The post-convergence Fisher / covariance path builds a Jacobian
3375    // via FD when the model has no analytical form.  If the FD probe
3376    // straddles a region where the model returns NaN, the resulting
3377    // column is poisoned and the inverse Fisher inherits NaN entries.
3378    // The main LM loop's trial guard does not run here (it only checks
3379    // the trial step in the main optimisation loop).
3380    //
3381    // Per-cell skip: when the FD probe output is non-finite, leave the
3382    // entry at its zero default rather than dividing NaN by `actual_step`
3383    // (consistent with the "model-evaluation-failed" branch in
3384    // `compute_jacobian`).
3385    // ==================================================================
3386
3387    /// `fisher_information_fd` zeroes per-cell entries whose FD probe
3388    /// returned a non-finite model output, rather than baking NaN into
3389    /// the Fisher matrix (and from there into the inverse covariance).
3390    #[test]
3391    fn test_fisher_information_fd_skips_nan_probe() {
3392        // Model: T_i = θ_0 (constant).  Returns NaN whenever
3393        // |θ_0 - 0.6| > 1e-3 — i.e. a NaN ring around the FD probe,
3394        // but a finite value at the base point.
3395        struct NanFdProbe;
3396        impl FitModel for NanFdProbe {
3397            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
3398                let t = if (params[0] - 0.6).abs() > 1e-3 {
3399                    f64::NAN
3400                } else {
3401                    params[0]
3402                };
3403                Ok(vec![t; 3])
3404            }
3405            // No analytical_jacobian -> Fisher info must use FD fallback.
3406        }
3407        let model = NanFdProbe;
3408        let n = 3;
3409        let o = vec![10.0; n];
3410        let s = vec![5.0; n];
3411        let obj = JointPoissonObjective {
3412            model: &model,
3413            o: &o,
3414            s: &s,
3415            c: 1.0,
3416            active_mask: None,
3417            open_background: None,
3418            sample_background: None,
3419        };
3420        let mut params = ParameterSet::new(vec![FitParameter::non_negative("T", 0.6)]);
3421        let info = obj
3422            .fisher_information_fd(&mut params, 1e-2)
3423            .expect("fisher_information_fd should not return Err on a finite base")
3424            .expect("fisher_information_fd should return Some(matrix)");
3425        // Every entry must be finite — column was skipped on NaN probe.
3426        for v in info.data.iter() {
3427            assert!(
3428                v.is_finite(),
3429                "fisher_information_fd produced non-finite entry: {v}"
3430            );
3431        }
3432    }
3433
3434    // ==================================================================
3435    // Per-element count validation propagates through `validate_inputs`.
3436    //
3437    // An earlier version ran `validate_counts` only at the
3438    // `joint_poisson_fit` entry point.  Direct callers of
3439    // `deviance_from_transmission` / `fisher_information_fd` /
3440    // `profile_lambda_per_bin` (diagnostics paths) bypassed that check,
3441    // so a NaN in `o` would propagate straight into the deviance sum
3442    // via `NaN <= 0.0 == false` slipping past `xlogy_ratio`'s
3443    // zero-branch, and a negative count would be silently swallowed as
3444    // zero.  The per-element check therefore lives in
3445    // `validate_inputs`, which every public method already calls.
3446    // These tests run in release mode (no `debug_assert!`) and verify
3447    // the typed error reaches the caller.
3448    // ==================================================================
3449
3450    /// `deviance_from_transmission` must reject a NaN open-beam count
3451    /// with `InvalidConfig` rather than returning `Ok(NaN)` (or, worse,
3452    /// `Ok(finite)` if a future `xlogy_ratio` rewrite handled NaN by
3453    /// falling through to the zero branch).  The inner `debug_assert!`
3454    /// is a no-op in release builds, so the typed error is the only
3455    /// real guard.
3456    #[test]
3457    fn test_deviance_from_transmission_rejects_non_finite_counts() {
3458        let n_bins = 4;
3459        let mut o = vec![10.0; n_bins];
3460        o[2] = f64::NAN;
3461        let s = vec![5.0; n_bins];
3462        let model = ConstModel { n_e: n_bins };
3463        let obj = JointPoissonObjective {
3464            model: &model,
3465            o: &o,
3466            s: &s,
3467            c: 1.0,
3468            active_mask: None,
3469            open_background: None,
3470            sample_background: None,
3471        };
3472        let t = vec![0.5; n_bins];
3473        let err = obj.deviance_from_transmission(&t).unwrap_err();
3474        assert!(
3475            matches!(err, FittingError::InvalidConfig(ref msg) if msg.contains("open_beam_counts")),
3476            "expected InvalidConfig naming open_beam_counts; got {err:?}"
3477        );
3478
3479        // +inf likewise.
3480        let mut s_inf = vec![5.0; n_bins];
3481        s_inf[0] = f64::INFINITY;
3482        let obj_inf = JointPoissonObjective {
3483            model: &model,
3484            o: &vec![10.0; n_bins],
3485            s: &s_inf,
3486            c: 1.0,
3487            active_mask: None,
3488            open_background: None,
3489            sample_background: None,
3490        };
3491        let err = obj_inf.deviance_from_transmission(&t).unwrap_err();
3492        assert!(
3493            matches!(err, FittingError::InvalidConfig(ref msg) if msg.contains("sample_counts")),
3494            "expected InvalidConfig naming sample_counts; got {err:?}"
3495        );
3496    }
3497
3498    /// `deviance_from_transmission` must reject a negative count with
3499    /// `InvalidConfig` rather than silently treating it as a zero-count
3500    /// bin (which `xlogy_ratio`'s `x <= 0.0` branch would do).  Negatives
3501    /// indicate an upstream loader / TOF-subtraction bug; swallowing
3502    /// them as "no data" conceals the failure mode.
3503    #[test]
3504    fn test_deviance_from_transmission_rejects_negative_counts() {
3505        let n_bins = 3;
3506        let mut o = vec![10.0; n_bins];
3507        o[1] = -2.0;
3508        let s = vec![5.0; n_bins];
3509        let model = ConstModel { n_e: n_bins };
3510        let obj = JointPoissonObjective {
3511            model: &model,
3512            o: &o,
3513            s: &s,
3514            c: 1.0,
3515            active_mask: None,
3516            open_background: None,
3517            sample_background: None,
3518        };
3519        let t = vec![0.5; n_bins];
3520        let err = obj.deviance_from_transmission(&t).unwrap_err();
3521        assert!(
3522            matches!(err, FittingError::InvalidConfig(ref msg) if msg.contains("open_beam_counts")),
3523            "expected InvalidConfig naming open_beam_counts; got {err:?}"
3524        );
3525    }
3526
3527    /// The reorientation also reaches `profile_lambda_per_bin` and
3528    /// `fisher_information_fd`: every public method that calls
3529    /// `validate_inputs` now picks up the per-element check.
3530    #[test]
3531    fn test_other_public_methods_reject_non_finite_counts() {
3532        let n_bins = 4;
3533        let mut s = vec![5.0; n_bins];
3534        s[3] = f64::NAN;
3535        let o = vec![10.0; n_bins];
3536        let model = ConstModel { n_e: n_bins };
3537        let obj = JointPoissonObjective {
3538            model: &model,
3539            o: &o,
3540            s: &s,
3541            c: 1.0,
3542            active_mask: None,
3543            open_background: None,
3544            sample_background: None,
3545        };
3546        let t = vec![0.5; n_bins];
3547
3548        let err = obj.profile_lambda_per_bin(&t).unwrap_err();
3549        assert!(
3550            matches!(err, FittingError::InvalidConfig(_)),
3551            "profile_lambda_per_bin: expected InvalidConfig; got {err:?}"
3552        );
3553
3554        let params = vec![0.5];
3555        let free_idx = vec![0];
3556        let err = obj
3557            .deviance_gradient_analytical(&params, &free_idx)
3558            .unwrap_err();
3559        assert!(
3560            matches!(err, FittingError::InvalidConfig(_)),
3561            "deviance_gradient_analytical: expected InvalidConfig; got {err:?}"
3562        );
3563
3564        let err = obj.fisher_information(&params, &free_idx).unwrap_err();
3565        assert!(
3566            matches!(err, FittingError::InvalidConfig(_)),
3567            "fisher_information: expected InvalidConfig; got {err:?}"
3568        );
3569
3570        let mut ps = ParameterSet::new(vec![FitParameter::non_negative("T", 0.5)]);
3571        let err = obj.fisher_information_fd(&mut ps, 1e-2).unwrap_err();
3572        assert!(
3573            matches!(err, FittingError::InvalidConfig(_)),
3574            "fisher_information_fd: expected InvalidConfig; got {err:?}"
3575        );
3576    }
3577
3578    /// `validate_inputs` now reports caller-supplied transmission length
3579    /// mismatches with `field = "transmission"` and `expected = o.len()`.
3580    /// Pre-fix this used `field = "open_beam_counts"` with reversed
3581    /// expected/actual, which read as "the open-beam array is wrong"
3582    /// when the actual fault was the caller's `t` slice.
3583    #[test]
3584    fn test_validate_inputs_reports_transmission_length_mismatch_correctly() {
3585        let n_bins = 5;
3586        let o = vec![10.0; n_bins];
3587        let s = vec![5.0; n_bins];
3588        let model = ConstModel { n_e: n_bins };
3589        let obj = JointPoissonObjective {
3590            model: &model,
3591            o: &o,
3592            s: &s,
3593            c: 1.0,
3594            active_mask: None,
3595            open_background: None,
3596            sample_background: None,
3597        };
3598        // Caller passes `t` shorter than `o`/`s`.
3599        let t_short = vec![0.5; n_bins - 2];
3600        let err = obj.deviance_from_transmission(&t_short).unwrap_err();
3601        match err {
3602            FittingError::LengthMismatch {
3603                expected,
3604                actual,
3605                field,
3606            } => {
3607                assert_eq!(field, "transmission", "field must name `transmission`");
3608                assert_eq!(expected, n_bins, "expected must be o.len()");
3609                assert_eq!(actual, n_bins - 2, "actual must be t.len()");
3610            }
3611            other => panic!("expected LengthMismatch on transmission; got {other:?}"),
3612        }
3613    }
3614}