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