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