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