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