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