Skip to main content

nereids_fitting/
poisson.rs

1//! Poisson-likelihood fitting of counts.
2//!
3//! Minimizes half the Poisson deviance
4//!
5//! ```text
6//! D(θ) = Σᵢ [μᵢ(θ) − yᵢ + yᵢ ln(yᵢ / μᵢ(θ))]
7//! ```
8//!
9//! within box bounds, by projected Levenberg–Marquardt steps in the Fisher
10//! metric, for models with an analytical Jacobian.
11//!
12//! **Scope note.** The production pipeline does not apply this single-arm
13//! objective to normalized transmission. Raw open/sample counts use the
14//! joint-Poisson conditional-binomial-deviance solver in
15//! [`crate::joint_poisson`]. This module remains available to the
16//! `evaluate_jacobian_and_fisher` Fisher-information helper (via
17//! [`CountsModel`], [`CountsBackgroundScaleModel`] and
18//! [`TransmissionKLBackgroundModel`], all three of which that helper still
19//! constructs) and to spatial-regularization research drivers; it is not a
20//! public transmission fitting route.
21
22use faer::dyn_stack::{MemBuffer, MemStack};
23use faer::linalg::svd::{ComputeSvdVectors, svd, svd_scratch};
24use faer::{Mat, Par};
25
26use crate::error::FittingError;
27use crate::lm::{FitModel, FlatMatrix};
28use crate::parameters::{FitParameter, ParameterSet};
29
30/// Configuration for the Poisson solvers.
31#[derive(Debug, Clone)]
32pub struct PoissonConfig {
33    /// Maximum number of steps.
34    pub max_iter: usize,
35    /// Convergence tolerance of the count-background solver; [`poisson_fit`]
36    /// stops on the Newton decrement instead.
37    pub tol_param: f64,
38    /// Whether to compute the covariance and error bars after convergence.
39    pub compute_covariance: bool,
40}
41
42impl Default for PoissonConfig {
43    fn default() -> Self {
44        Self {
45            max_iter: 200,
46            tol_param: 1e-8,
47            compute_covariance: true,
48        }
49    }
50}
51
52/// Result of [`poisson_fit`].
53#[derive(Debug, Clone)]
54pub struct PoissonResult {
55    /// Half the Poisson deviance at `params`.
56    pub deviance: f64,
57    /// Number of steps taken.
58    pub iterations: usize,
59    /// Whether the expected-information Newton decrement fell below 1e-6
60    /// inside the bounds: within about 0.0014 standard errors of a minimum
61    /// where the deviance is quadratic there; see [`poisson_fit`].
62    pub converged: bool,
63    /// Final parameter values (all parameters, including fixed).
64    pub params: Vec<f64>,
65    /// Covariance of the free parameters; the rows and columns of a
66    /// parameter without an error bar are NaN.  `None` when the fit did not
67    /// converge or covariance computation is disabled.
68    pub covariance: Option<FlatMatrix>,
69    /// Standard error of each free parameter; `None` for a parameter on a
70    /// bound or with a component along a direction the data do not
71    /// determine, larger than the SVD's rounding for that parameter.  `None`
72    /// overall when `covariance` is.
73    pub uncertainties: Option<Vec<Option<f64>>>,
74    /// Whether each free parameter ended on one of its bounds.
75    pub on_bound: Vec<bool>,
76}
77
78const NEWTON_DECREMENT_TOL: f64 = 1e-6;
79
80const DEGENERATE_EIGENVALUE: f64 = 1e-12;
81
82const MAX_REJECTIONS: usize = 60;
83
84const INITIAL_DAMPING: f64 = 1e-3;
85
86const DAMPING_FACTOR: f64 = 10.0;
87
88/// `obs·ln(obs/mean) + mean − obs`, by C. Loader's `bd0` ("Fast and
89/// accurate computation of binomial probabilities", 2000): a series in
90/// `v = (obs − mean)/(obs + mean)` when `|obs − mean| < 0.1 (obs + mean)`.
91fn half_deviance(obs: f64, mean: f64) -> f64 {
92    let half_sum = obs / 2.0 + mean / 2.0;
93    let difference = obs - mean;
94    if difference.abs() < 0.2 * half_sum {
95        let v = difference / 2.0 / half_sum;
96        let mut sum = difference * v;
97        let mut term = obs * (2.0 * v);
98        let mut j = 1.0;
99        loop {
100            term *= v * v;
101            let next = sum + term / (2.0 * j + 1.0);
102            if next == sum {
103                return sum;
104            }
105            sum = next;
106            j += 1.0;
107        }
108    } else if obs == 0.0 {
109        mean
110    } else {
111        obs * (obs.ln() - mean.ln()) + mean - obs
112    }
113}
114
115fn deviance(y_obs: &[f64], y_model: &[f64]) -> f64 {
116    y_obs
117        .iter()
118        .zip(y_model)
119        .map(|(&obs, &mean)| {
120            if mean > 0.0 || (mean == 0.0 && obs == 0.0) {
121                half_deviance(obs, mean)
122            } else {
123                f64::INFINITY
124            }
125        })
126        .sum()
127}
128
129struct Linearization {
130    weighted: FlatMatrix,
131    residual: Vec<f64>,
132    zero_slope: Vec<f64>,
133    gradient: Vec<f64>,
134}
135
136fn linearize(
137    model: &dyn FitModel,
138    params: &ParameterSet,
139    free: &[usize],
140    y_obs: &[f64],
141    y_model: &[f64],
142) -> Result<Linearization, FittingError> {
143    let mut weighted = model
144        .analytical_jacobian(&params.all_values(), free, y_model)
145        .ok_or_else(|| {
146            FittingError::InvalidConfig(
147                "poisson_fit needs a model with an analytical Jacobian".into(),
148            )
149        })?;
150    for (expected, actual, field) in [
151        (y_obs.len(), weighted.nrows, "analytical Jacobian rows"),
152        (free.len(), weighted.ncols, "analytical Jacobian columns"),
153    ] {
154        if expected != actual {
155            return Err(FittingError::LengthMismatch {
156                expected,
157                actual,
158                field,
159            });
160        }
161    }
162    let root: Vec<f64> = y_model.iter().map(|mean| mean.sqrt()).collect();
163    let mut zero_slope = vec![0.0; weighted.ncols];
164    for (i, &r) in root.iter().enumerate() {
165        for (j, z) in zero_slope.iter_mut().enumerate() {
166            let slope = weighted.get(i, j);
167            if r > 0.0 {
168                *weighted.get_mut(i, j) = slope / r;
169            } else {
170                *z += slope;
171                *weighted.get_mut(i, j) = 0.0;
172            }
173        }
174    }
175    let residual: Vec<f64> = y_obs
176        .iter()
177        .zip(y_model)
178        .zip(&root)
179        .map(|((&obs, &mean), &r)| if r > 0.0 { (mean - obs) / r } else { 0.0 })
180        .collect();
181    let gradient = zero_slope
182        .iter()
183        .enumerate()
184        .map(|(j, z)| {
185            z + (0..weighted.nrows)
186                .map(|i| weighted.get(i, j) * residual[i])
187                .sum::<f64>()
188        })
189        .collect();
190    Ok(Linearization {
191        weighted,
192        residual,
193        zero_slope,
194        gradient,
195    })
196}
197
198fn column_scale(weighted: &FlatMatrix, col: usize) -> (f64, f64) {
199    let peak = (0..weighted.nrows).fold(0.0_f64, |m, i| m.max(weighted.get(i, col).abs()));
200    let length = (0..weighted.nrows)
201        .map(|i| (weighted.get(i, col) / peak).powi(2))
202        .sum::<f64>()
203        .sqrt();
204    (peak, length)
205}
206
207struct Decomposition {
208    columns: Vec<usize>,
209    largest: Vec<f64>,
210    length: Vec<f64>,
211    rows: usize,
212    singular: Vec<f64>,
213    left: Mat<f64>,
214    right: Vec<Vec<f64>>,
215}
216
217impl Decomposition {
218    fn new(weighted: &FlatMatrix, columns: &[usize]) -> Option<Self> {
219        let (mut kept, mut largest, mut length) = (vec![], vec![], vec![]);
220        for &col in columns {
221            let (peak, norm) = column_scale(weighted, col);
222            if peak > 0.0 {
223                kept.push(col);
224                largest.push(peak);
225                length.push(norm);
226            }
227        }
228        let (rows, k) = (weighted.nrows, kept.len());
229        let scaled = Mat::from_fn(rows, k, |row, i| {
230            weighted.get(row, kept[i]) / largest[i] / length[i]
231        });
232        let mut values = Mat::<f64>::zeros(rows.min(k), rows.min(k));
233        let mut left = Mat::<f64>::zeros(rows, rows.min(k));
234        let mut v = Mat::<f64>::zeros(k, k);
235        let params = Default::default();
236        svd(
237            scaled.as_ref(),
238            values.diagonal_mut(),
239            Some(left.as_mut()),
240            Some(v.as_mut()),
241            Par::Seq,
242            MemStack::new(&mut MemBuffer::new(svd_scratch::<f64>(
243                rows,
244                k,
245                ComputeSvdVectors::Thin,
246                ComputeSvdVectors::Full,
247                Par::Seq,
248                params,
249            ))),
250            params,
251        )
252        .ok()?;
253        Some(Self {
254            columns: kept,
255            largest,
256            length,
257            rows,
258            singular: (0..k)
259                .map(|j| if j < rows { values[(j, j)] } else { 0.0 })
260                .collect(),
261            left,
262            right: (0..k)
263                .map(|j| (0..k).map(|i| v[(i, j)]).collect())
264                .collect(),
265        })
266    }
267
268    fn unscale(&self, i: usize, value: f64) -> f64 {
269        value / self.length[i] / self.largest[i]
270    }
271
272    fn spanned(&self) -> impl Iterator<Item = usize> + '_ {
273        let largest = self.singular.iter().fold(0.0_f64, |m, &s| m.max(s));
274        let rank_floor = f64::EPSILON * self.rows.max(self.singular.len()) as f64 * largest;
275        (0..self.singular.len()).filter(move |&k| self.singular[k] > rank_floor)
276    }
277
278    fn along(&self, k: usize, linear: &Linearization) -> f64 {
279        let zero_part: f64 = self
280            .columns
281            .iter()
282            .enumerate()
283            .map(|(i, &col)| self.right[k][i] * self.unscale(i, linear.zero_slope[col]))
284            .sum();
285        let residual_part: f64 = (0..self.rows)
286            .map(|row| self.left[(row, k)] * linear.residual[row])
287            .sum();
288        residual_part + zero_part / self.singular[k]
289    }
290
291    fn newton_decrement(&self, linear: &Linearization) -> f64 {
292        0.5 * self
293            .spanned()
294            .map(|k| self.along(k, linear).powi(2))
295            .sum::<f64>()
296    }
297
298    fn step(&self, linear: &Linearization, n_free: usize, damping: f64) -> Vec<f64> {
299        let mut direction = vec![0.0; n_free];
300        for k in self.spanned() {
301            let s = self.singular[k];
302            let coefficient = self.along(k, linear) * s / (s * s + damping);
303            for (i, &col) in self.columns.iter().enumerate() {
304                direction[col] += self.unscale(i, self.right[k][i] * coefficient);
305            }
306        }
307        direction
308    }
309
310    fn error_bars(&self, n_free: usize) -> (FlatMatrix, Vec<Option<f64>>) {
311        let n = self.columns.len();
312        let determined: Vec<bool> = self
313            .singular
314            .iter()
315            .map(|s| s * s >= DEGENERATE_EIGENVALUE)
316            .collect();
317        let largest = self.singular.iter().fold(0.0_f64, |m, &s| m.max(s));
318        let resolved: Vec<bool> = (0..n)
319            .map(|i| {
320                let sensitivity: f64 = (0..n)
321                    .filter(|&k| determined[k])
322                    .map(|k| self.right[k][i].abs() / self.singular[k])
323                    .sum();
324                let rounding = f64::EPSILON * self.rows.max(n) as f64 * largest * sensitivity;
325                (0..n)
326                    .filter(|&k| !determined[k])
327                    .map(|k| self.right[k][i].powi(2))
328                    .sum::<f64>()
329                    <= rounding.powi(2)
330            })
331            .collect();
332        let variance = |i: usize, j: usize| -> f64 {
333            (0..n)
334                .filter(|&k| determined[k])
335                .map(|k| self.right[k][i] * self.right[k][j] / self.singular[k].powi(2))
336                .sum()
337        };
338        let (mut covariance, mut errors) = withheld(n_free);
339        for i in (0..n).filter(|&i| resolved[i]) {
340            for j in (0..n).filter(|&j| resolved[j]) {
341                *covariance.get_mut(self.columns[i], self.columns[j]) =
342                    self.unscale(j, self.unscale(i, variance(i, j)));
343            }
344            errors[self.columns[i]] = Some(self.unscale(i, variance(i, i).sqrt()));
345        }
346        (covariance, errors)
347    }
348}
349
350fn withheld(n_free: usize) -> (FlatMatrix, Vec<Option<f64>>) {
351    let mut covariance = FlatMatrix::zeros(n_free, n_free);
352    covariance.data.fill(f64::NAN);
353    (covariance, vec![None; n_free])
354}
355
356fn on_bound(param: &FitParameter) -> bool {
357    param.value == param.lower || param.value == param.upper
358}
359
360fn held_by_bound(param: &FitParameter, gradient: f64) -> bool {
361    (param.value == param.lower && gradient > 0.0) || (param.value == param.upper && gradient < 0.0)
362}
363
364/// Fit `params` to the counts `y_obs` by minimizing half the Poisson
365/// deviance within the parameter bounds.
366///
367/// The model must provide an analytical Jacobian at every point the fit
368/// visits.  A trial predicting a negative count, or zero where something was
369/// counted, is rejected; a bin predicted zero with no counts adds its slope
370/// to the gradient and nothing to the information.  Each step is the
371/// Levenberg–Marquardt step `(F + λI)⁻¹g` in coordinates scaled to unit
372/// Fisher information, projected onto the box;
373/// `λ` is divided by 10 after a step that lowers the deviance, and
374/// multiplied by 10, to at least its starting 1e-3, before retrying one that
375/// does not (D. W. Marquardt, J. Soc. Indust. Appl. Math. 11, 431–441,
376/// 1963).  A free parameter on its bound whose gradient points out of the
377/// box is held there for the step and left out of the convergence test.
378///
379/// The fit has converged when the Newton decrement `½ gᵀF⁺g` over the
380/// parameters not held by a bound (on it, gradient pointing out),
381/// `F = Jᵀ diag(1/μ) J` the expected information, is below 1e-6: the
382/// quadratic model built from `F` predicts less than 1e-6 of further
383/// decrease.  Where the deviance is quadratic near the minimum this puts the
384/// fit within about 0.0014 standard errors of it, and within 0.01 where the
385/// expected information overstates the curvature, as at one count per bin.
386/// Where it is not quadratic — weakly determined parameters on a curved or
387/// flat likelihood ridge, a minimum at infinity, or a parameter that adds
388/// counts to a bin predicted near zero — the fit can stop short of the
389/// minimum, or at another local minimum, and the error bars are not standard
390/// errors.  A minimum where a bin's prediction reaches zero inside the box is
391/// reported unconverged.
392///
393/// It stops unconverged when no step lowers the deviance, when the Jacobian
394/// is not finite, when the start predicts a negative count or zero where
395/// something was counted, or after `config.max_iter` steps.  A trial whose
396/// prediction has a different length from `y_obs` is rejected.
397///
398/// # Errors
399/// `FittingError::EmptyData` if `y_obs` is empty;
400/// `FittingError::InvalidConfig` if an observation is negative or not
401/// finite, a parameter value is not finite, a free parameter's bounds are
402/// inverted, NaN or admit no finite value, or the model returns no
403/// analytical Jacobian;
404/// `FittingError::LengthMismatch` if the prediction or the Jacobian does not
405/// match `y_obs` and the free parameters; the model's error if it fails at
406/// the start.
407pub fn poisson_fit(
408    model: &dyn FitModel,
409    y_obs: &[f64],
410    params: &mut ParameterSet,
411    config: &PoissonConfig,
412) -> Result<PoissonResult, FittingError> {
413    if let Some((bin, &obs)) = y_obs
414        .iter()
415        .enumerate()
416        .find(|(_, obs)| !(obs.is_finite() && **obs >= 0.0))
417    {
418        return Err(FittingError::InvalidConfig(format!(
419            "observed counts must be finite and non-negative, got {obs} in bin {bin}"
420        )));
421    }
422    if y_obs.is_empty() {
423        return Err(FittingError::EmptyData);
424    }
425    if let Some(p) = params.params.iter().find(|p| {
426        !p.value.is_finite()
427            || (!p.fixed
428                && (p.lower.is_nan()
429                    || p.upper.is_nan()
430                    || p.lower > p.upper
431                    || p.lower == f64::INFINITY
432                    || p.upper == f64::NEG_INFINITY))
433    }) {
434        return Err(FittingError::InvalidConfig(format!(
435            "parameter {} = {} with bounds [{}, {}]",
436            p.name, p.value, p.lower, p.upper
437        )));
438    }
439    params.set_free_values(&params.free_values());
440    let mut y_model = model.evaluate(&params.all_values())?;
441    if y_model.len() != y_obs.len() {
442        return Err(FittingError::LengthMismatch {
443            expected: y_model.len(),
444            actual: y_obs.len(),
445            field: "y_obs",
446        });
447    }
448    let free = params.free_indices();
449    let mut value = deviance(y_obs, &y_model);
450    let mut iterations = 0;
451    let mut at_minimum = None;
452    let mut damping = INITIAL_DAMPING;
453    while value.is_finite() {
454        let linear = linearize(model, params, &free, y_obs, &y_model)?;
455        if !linear
456            .weighted
457            .data
458            .iter()
459            .chain(&linear.zero_slope)
460            .all(|v| v.is_finite())
461        {
462            break;
463        }
464        let movable: Vec<usize> = (0..free.len())
465            .filter(|&j| !held_by_bound(&params.params[free[j]], linear.gradient[j]))
466            .collect();
467        let Some(decomposition) = Decomposition::new(&linear.weighted, &movable) else {
468            break;
469        };
470        if decomposition.newton_decrement(&linear) < NEWTON_DECREMENT_TOL {
471            at_minimum = Some(linear);
472            break;
473        }
474        if iterations == config.max_iter {
475            break;
476        }
477        let start = params.free_values();
478        let mut accepted = None;
479        for _ in 0..MAX_REJECTIONS {
480            let direction = decomposition.step(&linear, free.len(), damping);
481            let unprojected: Vec<f64> = start.iter().zip(&direction).map(|(x, d)| x - d).collect();
482            params.set_free_values(&unprojected);
483            if let Some(trial_model) = model
484                .evaluate(&params.all_values())
485                .ok()
486                .filter(|trial| trial.len() == y_obs.len())
487            {
488                let trial_value = deviance(y_obs, &trial_model);
489                if trial_value < value {
490                    damping /= DAMPING_FACTOR;
491                    accepted = Some((trial_model, trial_value));
492                    break;
493                }
494            }
495            params.set_free_values(&start);
496            damping = (damping * DAMPING_FACTOR).max(INITIAL_DAMPING);
497        }
498        let Some((trial_model, trial_value)) = accepted else {
499            break;
500        };
501        y_model = trial_model;
502        value = trial_value;
503        iterations += 1;
504    }
505
506    let bounded: Vec<bool> = free
507        .iter()
508        .map(|&idx| on_bound(&params.params[idx]))
509        .collect();
510    let (covariance, uncertainties) = match &at_minimum {
511        Some(linear) if config.compute_covariance => {
512            let interior: Vec<usize> = (0..free.len()).filter(|&j| !bounded[j]).collect();
513            let (covariance, errors) = Decomposition::new(&linear.weighted, &interior).map_or_else(
514                || withheld(free.len()),
515                |decomposition| decomposition.error_bars(free.len()),
516            );
517            (Some(covariance), Some(errors))
518        }
519        _ => (None, None),
520    };
521    Ok(PoissonResult {
522        deviance: value,
523        iterations,
524        converged: at_minimum.is_some(),
525        params: params.all_values(),
526        covariance,
527        uncertainties,
528        on_bound: bounded,
529    })
530}
531
532/// Fixed-flux counts-domain forward model: `Y_model = flux × T_model(θ) + background`.
533///
534/// **Retained for the research Fisher helper, not for production fitting.**
535/// The production counts-KL dispatch (`SolverConfig::PoissonKL` on
536/// `InputData::Counts` / `InputData::CountsWithNuisance`) goes through
537/// the joint-Poisson conditional-binomial-deviance path in
538/// [`crate::joint_poisson`].  `CountsModel` and
539/// [`CountsBackgroundScaleModel`] below are consumed only by
540/// `nereids_pipeline::pipeline::evaluate_jacobian_and_fisher` (the
541/// Fisher-info research helper used by the spatial-regularization
542/// epic #394) and by this module's `#[cfg(test)]` tests.  They assume
543/// the caller has pre-computed `flux = c · O` (i.e. `c` is baked into
544/// `flux` — a convention that proved error-prone for
545/// end users, which is precisely why the production path no longer
546/// uses this struct).
547///
548/// ## Physical count-response contract
549///
550/// This low-level wrapper cannot inspect `transmission_model` to determine how
551/// instrument resolution was applied.  Its output must already be the
552/// physically valid effective sample/open count response.  With response
553/// operator `R` and incident spectrum `Φ`, the required ratio is
554///
555/// ```text
556/// T_eff = R[Φ · T] / R[Φ].
557/// ```
558///
559/// A post-hoc broadened transmission `R[T]` is not a valid substitute and
560/// must never be wrapped here as though it were.  In particular, do not
561/// directly wrap a resolution-bearing
562/// [`TransmissionFitModel`](crate::transmission_model::TransmissionFitModel),
563/// because that model returns `R[T]`.  An ordinary transmission is valid when
564/// resolution is disabled, and a custom inner model that already returns the
565/// exact effective ratio is also valid.  Otherwise, model the open and sample
566/// response arms separately or call the guarded pipeline, which rejects
567/// unsupported counts-plus-resolution combinations.  When resolution is
568/// active, `flux` must be the matching resolved open-arm response `R[Φ]`.
569///
570/// The `flux` and `background` slices must have the same length as the
571/// transmission vector returned by the inner model.  In debug builds,
572/// `evaluate()` asserts this invariant.
573pub struct CountsModel<'a> {
574    /// Underlying effective count-ratio model.
575    ///
576    /// See the struct-level physical count-response contract.  In particular,
577    /// this must not be a post-hoc broadened transmission `R[T]`.
578    pub transmission_model: &'a dyn FitModel,
579    /// Open-arm flux response (counts per bin, after normalization).
580    pub flux: &'a [f64],
581    /// Background counts per bin.
582    pub background: &'a [f64],
583    /// Total parameter count in the wrapped model.
584    pub n_params: usize,
585}
586
587impl<'a> FitModel for CountsModel<'a> {
588    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
589        let transmission = self.transmission_model.evaluate(params)?;
590        debug_assert_eq!(
591            transmission.len(),
592            self.flux.len(),
593            "CountsModel: transmission length ({}) != flux length ({})",
594            transmission.len(),
595            self.flux.len(),
596        );
597        debug_assert_eq!(
598            self.flux.len(),
599            self.background.len(),
600            "CountsModel: flux length ({}) != background length ({})",
601            self.flux.len(),
602            self.background.len(),
603        );
604        Ok(transmission
605            .iter()
606            .zip(self.flux.iter())
607            .zip(self.background.iter())
608            .map(|((&t, &f), &b)| f * t + b)
609            .collect())
610    }
611
612    /// Analytical Jacobian: ∂Y/∂θ = flux · ∂T_inner/∂θ.
613    ///
614    /// Background is constant w.r.t. θ and drops out.
615    fn analytical_jacobian(
616        &self,
617        params: &[f64],
618        free_param_indices: &[usize],
619        y_current: &[f64],
620    ) -> Option<FlatMatrix> {
621        let n_e = y_current.len();
622        // Recover inner transmission: T = (Y - background) / flux
623        let t_inner: Vec<f64> = y_current
624            .iter()
625            .zip(self.flux.iter())
626            .zip(self.background.iter())
627            .map(|((&y, &f), &b)| if f.abs() > 1e-30 { (y - b) / f } else { 0.0 })
628            .collect();
629        let inner_jac =
630            self.transmission_model
631                .analytical_jacobian(params, free_param_indices, &t_inner)?;
632        let n_free = free_param_indices.len();
633        let mut jac = FlatMatrix::zeros(n_e, n_free);
634        for i in 0..n_e {
635            for j in 0..n_free {
636                *jac.get_mut(i, j) = self.flux[i] * inner_jac.get(i, j);
637            }
638        }
639        Some(jac)
640    }
641}
642
643// ── ForwardModel implementation for CountsModel (Phase 1) ────────────────
644
645impl<'a> crate::forward_model::ForwardModel for CountsModel<'a> {
646    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
647        self.evaluate(params)
648    }
649
650    // No analytical jacobian — uses finite differences (same as FitModel).
651
652    fn n_data(&self) -> usize {
653        self.flux.len()
654    }
655
656    fn n_params(&self) -> usize {
657        self.n_params
658    }
659}
660
661/// Fixed-flux counts model with optional α₁ / α₂ nuisance scaling of
662/// signal and detector background.
663///
664/// **Retained for the research Fisher helper, not for production fitting.**
665/// See [`CountsModel`] for the scope note — the production counts-KL
666/// dispatch does not use this struct; it is reached only from
667/// `evaluate_jacobian_and_fisher` (Epic #394 spatial-regularization
668/// prototype) and from this module's `#[cfg(test)]` tests.
669///
670/// Given an effective count-ratio model `T_eff(θ)`, predicts:
671///
672///   Y(E) = α₁ · [F_open(E) · T_eff(θ)] + α₂ · B(E)
673///
674/// where `F_open` is the observed open-arm count response and `α₁` and
675/// `α₂` are parameter-vector entries.
676///
677/// ## Physical count-response contract
678///
679/// This low-level wrapper cannot inspect `transmission_model` to determine how
680/// instrument resolution was applied.  The inner model must already return
681/// the physically valid effective sample/open count response
682/// `R[Φ · T] / R[Φ]`.  It must never return a post-hoc broadened
683/// transmission `R[T]` as a substitute.  In particular, do not directly wrap
684/// a resolution-bearing
685/// [`TransmissionFitModel`](crate::transmission_model::TransmissionFitModel),
686/// because that model returns `R[T]`.  No-resolution models and custom models
687/// that already compute the exact effective ratio remain valid.  Otherwise,
688/// model the two response arms separately or call the guarded pipeline, which
689/// rejects unsupported counts-plus-resolution combinations.  When resolution
690/// is active, `flux` must be the matching resolved open-arm response `R[Φ]`.
691///
692/// ## Index invariant
693///
694/// `alpha1_index` / `alpha2_index` must NOT designate a parameter index
695/// the transmission model reads. The wrapper cannot detect such a
696/// collision through `dyn FitModel`, and the analytic Jacobian excludes
697/// the scale indices from the inner free set — a collided parameter
698/// would get only the scale contribution, silently omitting ∂T/∂p.
699/// (Sharing ONE parameter between the two scale roles,
700/// `alpha1_index == alpha2_index`, IS supported: the columns
701/// accumulate.)
702pub struct CountsBackgroundScaleModel<'a> {
703    /// Underlying effective count-ratio model.
704    ///
705    /// See the struct-level physical count-response contract.  In particular,
706    /// this must not be a post-hoc broadened transmission `R[T]`.
707    pub transmission_model: &'a dyn FitModel,
708    /// Open-arm flux response.
709    pub flux: &'a [f64],
710    /// Detector background spectrum.
711    pub background: &'a [f64],
712    /// Index of α₁ in the parameter vector.
713    /// Must not be a parameter the transmission model reads (see struct docs).
714    pub alpha1_index: usize,
715    /// Index of α₂ in the parameter vector.
716    /// Must not be a parameter the transmission model reads (see struct docs).
717    pub alpha2_index: usize,
718    /// Total parameter count in the wrapped model.
719    pub n_params: usize,
720}
721
722impl<'a> FitModel for CountsBackgroundScaleModel<'a> {
723    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
724        let transmission = self.transmission_model.evaluate(params)?;
725        let alpha1 = params[self.alpha1_index];
726        let alpha2 = params[self.alpha2_index];
727        debug_assert_eq!(transmission.len(), self.flux.len());
728        debug_assert_eq!(self.flux.len(), self.background.len());
729        Ok(transmission
730            .iter()
731            .zip(self.flux.iter())
732            .zip(self.background.iter())
733            .map(|((&t, &f), &b)| alpha1 * f * t + alpha2 * b)
734            .collect())
735    }
736
737    fn analytical_jacobian(
738        &self,
739        params: &[f64],
740        free_param_indices: &[usize],
741        y_current: &[f64],
742    ) -> Option<FlatMatrix> {
743        let n_e = y_current.len();
744        let n_free = free_param_indices.len();
745        let alpha1 = params[self.alpha1_index];
746        let alpha1_col = free_param_indices
747            .iter()
748            .position(|&i| i == self.alpha1_index);
749        let alpha2_col = free_param_indices
750            .iter()
751            .position(|&i| i == self.alpha2_index);
752        let inner_free: Vec<usize> = free_param_indices
753            .iter()
754            .copied()
755            .filter(|&i| i != self.alpha1_index && i != self.alpha2_index)
756            .collect();
757
758        // Evaluate the inner transmission model directly instead of
759        // reconstructing from y_current — reconstruction via
760        // (y - alpha2*b)/(alpha1*f) is undefined when alpha1 ≈ 0.
761        let t_inner = match self.transmission_model.evaluate(params) {
762            Ok(t) => t,
763            Err(_) => return None,
764        };
765
766        let inner_jac = if !inner_free.is_empty() {
767            self.transmission_model
768                .analytical_jacobian(params, &inner_free, &t_inner)
769        } else {
770            None
771        };
772
773        let mut jacobian = FlatMatrix::zeros(n_e, n_free);
774        if let Some(ref ij) = inner_jac {
775            let mut inner_col = 0;
776            for (col, &fp) in free_param_indices.iter().enumerate() {
777                if fp == self.alpha1_index || fp == self.alpha2_index {
778                    continue;
779                }
780                for row in 0..n_e {
781                    *jacobian.get_mut(row, col) = alpha1 * self.flux[row] * ij.get(row, inner_col);
782                }
783                inner_col += 1;
784            }
785        } else if !inner_free.is_empty() {
786            return None;
787        }
788
789        // Accumulate (+=) rather than assign: the struct does not forbid
790        // alpha1_index == alpha2_index, and evaluate() reads the aliased
791        // parameter for both roles, so its derivative is the SUM of both
792        // column contributions (f·t + b). With distinct indices each
793        // column is touched once and += on the zeroed matrix is identical
794        // to assignment.
795        if let Some(col) = alpha1_col {
796            for (row, (&f, &t)) in self.flux.iter().zip(t_inner.iter()).enumerate() {
797                *jacobian.get_mut(row, col) += f * t;
798            }
799        }
800        if let Some(col) = alpha2_col {
801            for (row, &bg) in self.background.iter().enumerate() {
802                *jacobian.get_mut(row, col) += bg;
803            }
804        }
805
806        Some(jacobian)
807    }
808}
809
810impl<'a> crate::forward_model::ForwardModel for CountsBackgroundScaleModel<'a> {
811    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
812        self.evaluate(params)
813    }
814
815    fn n_data(&self) -> usize {
816        self.flux.len()
817    }
818
819    fn n_params(&self) -> usize {
820        self.n_params
821    }
822}
823
824/// KL-compatible background model for transmission data.
825///
826/// Given a transmission model T_inner(θ), predicts:
827///
828///   T_out(E) = T_inner(E) + b₀ + b₁/√E
829///
830/// where b₀ and b₁ are the additive background parameters at indices
831/// `b0_index` and `b1_index` in the parameter vector.
832///
833/// Unlike `NormalizedTransmissionModel` (which uses `Anorm * T + BackA +
834/// BackB/√E + BackC√E` with 4 free parameters), this model:
835/// - Has only 2 background parameters (b₀, b₁), reducing overfitting risk
836/// - Constrains b₀, b₁ ≥ 0 via parameter bounds (physical: background
837///   adds counts, never subtracts), ensuring T_out > 0 for valid Poisson NLL
838/// - Does NOT multiply T_inner by a normalization factor — normalization
839///   is handled separately (nuisance estimation for counts, or pre-processing
840///   for transmission data)
841///
842/// ## Gradient
843///
844/// - ∂T_out/∂nₖ = ∂T_inner/∂nₖ = -σₖ(E)·T_inner(E)  (same as bare model)
845/// - ∂T_out/∂b₀ = 1
846/// - ∂T_out/∂b₁ = 1/√E
847///
848/// ## Index invariant
849///
850/// `b0_index` / `b1_index` must NOT designate a parameter index the
851/// inner model reads. The wrapper cannot detect such a collision
852/// through `dyn FitModel`, and the analytic Jacobian excludes the
853/// background indices from the inner free set — a collided parameter
854/// would get only the background contribution, silently omitting
855/// ∂T_inner/∂p. (Sharing ONE parameter between the two background
856/// roles, `b0_index == b1_index`, IS supported: the columns
857/// accumulate.)
858pub struct TransmissionKLBackgroundModel<'a> {
859    /// Underlying transmission model (density parameters only).
860    pub inner: &'a dyn FitModel,
861    /// Precomputed 1/√E for each energy bin.
862    pub inv_sqrt_energies: Vec<f64>,
863    /// Index of b₀ (constant background) in the parameter vector.
864    /// Must not be a parameter the inner model reads (see struct docs).
865    pub b0_index: usize,
866    /// Index of b₁ (1/√E background) in the parameter vector.
867    /// Must not be a parameter the inner model reads (see struct docs).
868    pub b1_index: usize,
869    /// Total parameter count in the wrapped model.
870    pub n_params: usize,
871}
872
873impl<'a> FitModel for TransmissionKLBackgroundModel<'a> {
874    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
875        let t_inner = self.inner.evaluate(params)?;
876        let b0 = params[self.b0_index];
877        let b1 = params[self.b1_index];
878        Ok(t_inner
879            .iter()
880            .zip(self.inv_sqrt_energies.iter())
881            .map(|(&t, &inv_sqrt_e)| t + b0 + b1 * inv_sqrt_e)
882            .collect())
883    }
884
885    fn analytical_jacobian(
886        &self,
887        params: &[f64],
888        free_param_indices: &[usize],
889        y_current: &[f64],
890    ) -> Option<FlatMatrix> {
891        let n_e = y_current.len();
892        let n_free = free_param_indices.len();
893
894        // Identify which free params are background vs inner model.
895        let b0_col = free_param_indices.iter().position(|&i| i == self.b0_index);
896        let b1_col = free_param_indices.iter().position(|&i| i == self.b1_index);
897
898        // Inner model free params (those not b0 or b1).
899        let inner_free: Vec<usize> = free_param_indices
900            .iter()
901            .copied()
902            .filter(|&i| i != self.b0_index && i != self.b1_index)
903            .collect();
904
905        // Get inner model Jacobian for density columns.
906        let inner_jac = if !inner_free.is_empty() {
907            // Evaluate inner model at current params to get T_inner for y_current.
908            let t_inner = self.inner.evaluate(params).ok()?;
909            self.inner
910                .analytical_jacobian(params, &inner_free, &t_inner)
911        } else {
912            None
913        };
914
915        let mut jacobian = FlatMatrix::zeros(n_e, n_free);
916
917        // Fill inner model columns (density, temperature).
918        // Inner Jacobian is the same as bare model — background doesn't
919        // affect ∂T_inner/∂nₖ.
920        if let Some(ij) = inner_jac.as_ref() {
921            let mut inner_col = 0;
922            for (col, &fp) in free_param_indices.iter().enumerate() {
923                if fp == self.b0_index || fp == self.b1_index {
924                    continue;
925                }
926                for row in 0..n_e {
927                    *jacobian.get_mut(row, col) = ij.get(row, inner_col);
928                }
929                inner_col += 1;
930            }
931        } else if !inner_free.is_empty() {
932            // Inner params are free but the inner model has no analytical
933            // Jacobian — fall back to FD for the entire model.
934            return None;
935        }
936
937        // Background columns. Accumulate (+=) rather than assign: the
938        // struct does not forbid b0_index == b1_index, and evaluate()
939        // reads the aliased parameter for both roles, so its derivative
940        // is the SUM of both column contributions (1 + 1/√E). With
941        // distinct indices each column is touched once and += on the
942        // zeroed matrix is identical to assignment.
943        if let Some(col) = b0_col {
944            for row in 0..n_e {
945                *jacobian.get_mut(row, col) += 1.0; // ∂T_out/∂b₀ = 1
946            }
947        }
948        if let Some(col) = b1_col {
949            for row in 0..n_e {
950                *jacobian.get_mut(row, col) += self.inv_sqrt_energies[row]; // ∂T_out/∂b₁ = 1/√E
951            }
952        }
953
954        Some(jacobian)
955    }
956}
957
958impl<'a> crate::forward_model::ForwardModel for TransmissionKLBackgroundModel<'a> {
959    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
960        self.evaluate(params)
961    }
962
963    fn n_data(&self) -> usize {
964        self.inv_sqrt_energies.len()
965    }
966
967    fn n_params(&self) -> usize {
968        self.n_params
969    }
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975
976    /// Simple model: y = a * exp(-b * x)
977    /// This mimics transmission: counts = flux * exp(-density * sigma)
978    struct ExponentialModel {
979        x: Vec<f64>,
980        flux: Vec<f64>,
981    }
982
983    impl FitModel for ExponentialModel {
984        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
985            let b = params[0]; // "density"
986            Ok(self
987                .x
988                .iter()
989                .zip(self.flux.iter())
990                .map(|(&xi, &fi)| fi * (-b * xi).exp())
991                .collect())
992        }
993    }
994
995    #[test]
996    fn test_counts_model() {
997        struct ConstTransmission;
998        impl FitModel for ConstTransmission {
999            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1000                Ok(vec![params[0]; 3])
1001            }
1002        }
1003
1004        let t_model = ConstTransmission;
1005        let flux = [100.0, 200.0, 300.0];
1006        let background = [5.0, 10.0, 15.0];
1007        let counts_model = CountsModel {
1008            transmission_model: &t_model,
1009            flux: &flux,
1010            background: &background,
1011            n_params: 1,
1012        };
1013
1014        // T = 0.5 → counts = flux*0.5 + background
1015        let result = counts_model.evaluate(&[0.5]).unwrap();
1016        assert!((result[0] - 55.0).abs() < 1e-10);
1017        assert!((result[1] - 110.0).abs() < 1e-10);
1018        assert!((result[2] - 165.0).abs() < 1e-10);
1019        assert_eq!(
1020            crate::forward_model::ForwardModel::n_params(&counts_model),
1021            1
1022        );
1023    }
1024
1025    #[test]
1026    fn test_counts_background_scale_model() {
1027        struct ConstTransmission;
1028        impl FitModel for ConstTransmission {
1029            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1030                Ok(vec![params[0]; 3])
1031            }
1032
1033            fn analytical_jacobian(
1034                &self,
1035                _params: &[f64],
1036                free_param_indices: &[usize],
1037                _y_current: &[f64],
1038            ) -> Option<FlatMatrix> {
1039                let mut jac = FlatMatrix::zeros(3, free_param_indices.len());
1040                for (col, &fp) in free_param_indices.iter().enumerate() {
1041                    if fp == 0 {
1042                        for row in 0..3 {
1043                            *jac.get_mut(row, col) = 1.0;
1044                        }
1045                    }
1046                }
1047                Some(jac)
1048            }
1049        }
1050
1051        let t_model = ConstTransmission;
1052        let flux = [100.0, 200.0, 300.0];
1053        let background = [5.0, 10.0, 15.0];
1054        let counts_model = CountsBackgroundScaleModel {
1055            transmission_model: &t_model,
1056            flux: &flux,
1057            background: &background,
1058            alpha1_index: 1,
1059            alpha2_index: 2,
1060            n_params: 3,
1061        };
1062
1063        let params = [0.5, 0.8, 1.5];
1064        let result = counts_model.evaluate(&params).unwrap();
1065        assert!((result[0] - 47.5).abs() < 1e-10);
1066        assert!((result[1] - 95.0).abs() < 1e-10);
1067        assert!((result[2] - 142.5).abs() < 1e-10);
1068        assert_eq!(
1069            crate::forward_model::ForwardModel::n_params(&counts_model),
1070            3
1071        );
1072    }
1073
1074    #[test]
1075    fn test_transmission_kl_background_has_no_jacobian_when_inner_lacks_one() {
1076        let x: Vec<f64> = (0..40).map(|i| 1.0 + 0.25 * i as f64).collect();
1077        let inner = ExponentialModel {
1078            x: x.clone(),
1079            flux: vec![1000.0; x.len()],
1080        };
1081        let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
1082        let wrapped = TransmissionKLBackgroundModel {
1083            inner: &inner,
1084            inv_sqrt_energies,
1085            b0_index: 1,
1086            b1_index: 2,
1087            n_params: 3,
1088        };
1089
1090        let true_params = vec![0.4, 20.0, 10.0];
1091        let y_obs = wrapped.evaluate(&true_params).unwrap();
1092
1093        assert!(
1094            wrapped
1095                .analytical_jacobian(&true_params, &[0, 1, 2], &y_obs)
1096                .is_none(),
1097            "inner param free without inner analytical_jacobian must give None"
1098        );
1099        assert!(
1100            wrapped
1101                .analytical_jacobian(&true_params, &[0], &y_obs)
1102                .is_none(),
1103            "inner-only free set without inner analytical_jacobian must give None"
1104        );
1105    }
1106
1107    #[test]
1108    fn test_transmission_kl_background_background_only_analytic_jacobian() {
1109        let x: Vec<f64> = (0..40).map(|i| 1.0 + 0.25 * i as f64).collect();
1110        let inner = ExponentialModel {
1111            x: x.clone(),
1112            flux: vec![1000.0; x.len()],
1113        };
1114        let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
1115        let wrapped = TransmissionKLBackgroundModel {
1116            inner: &inner,
1117            inv_sqrt_energies: inv_sqrt_energies.clone(),
1118            b0_index: 1,
1119            b1_index: 2,
1120            n_params: 3,
1121        };
1122
1123        let true_params = vec![0.4, 20.0, 10.0];
1124        let y_obs = wrapped.evaluate(&true_params).unwrap();
1125
1126        let jac = wrapped
1127            .analytical_jacobian(&true_params, &[1, 2], &y_obs)
1128            .expect("background-only free set must stay on the analytic path");
1129        for (row, &inv_sqrt_e) in inv_sqrt_energies.iter().enumerate() {
1130            assert!(
1131                (jac.get(row, 0) - 1.0).abs() < 1e-15,
1132                "∂T/∂b₀ at row {row} = {}, expected 1.0",
1133                jac.get(row, 0),
1134            );
1135            assert!(
1136                (jac.get(row, 1) - inv_sqrt_e).abs() < 1e-15,
1137                "∂T/∂b₁ at row {row} = {}, expected {inv_sqrt_e}",
1138                jac.get(row, 1),
1139            );
1140        }
1141    }
1142
1143    /// Central finite-difference column for one parameter, computed
1144    /// straight from `evaluate` — an oracle independent of the model's
1145    /// analytic Jacobian path.
1146    fn fd_column(model: &dyn FitModel, params: &[f64], param_index: usize, h: f64) -> Vec<f64> {
1147        let mut plus = params.to_vec();
1148        plus[param_index] += h;
1149        let mut minus = params.to_vec();
1150        minus[param_index] -= h;
1151        let y_plus = model.evaluate(&plus).unwrap();
1152        let y_minus = model.evaluate(&minus).unwrap();
1153        y_plus
1154            .iter()
1155            .zip(y_minus.iter())
1156            .map(|(&p, &m)| (p - m) / (2.0 * h))
1157            .collect()
1158    }
1159
1160    /// Aliased background indices (b0_index == b1_index): the analytic
1161    /// Jacobian must ACCUMULATE both roles' contributions (1 + 1/√E),
1162    /// matching finite differences — not overwrite one with the other.
1163    #[test]
1164    fn test_transmission_kl_background_aliased_indices_jacobian_matches_fd() {
1165        let x: Vec<f64> = (0..10).map(|i| 1.0 + 0.5 * i as f64).collect();
1166        let inner = ExponentialModel {
1167            x: x.clone(),
1168            flux: vec![1000.0; x.len()],
1169        };
1170        let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
1171        let wrapped = TransmissionKLBackgroundModel {
1172            inner: &inner,
1173            inv_sqrt_energies: inv_sqrt_energies.clone(),
1174            b0_index: 1,
1175            b1_index: 1, // deliberately aliased with b0
1176            n_params: 2,
1177        };
1178
1179        let params = vec![0.4, 15.0];
1180        let y = wrapped.evaluate(&params).unwrap();
1181        // Inner param fixed, only the aliased background param free →
1182        // analytic path.
1183        let jac = wrapped
1184            .analytical_jacobian(&params, &[1], &y)
1185            .expect("background-only free set must stay on the analytic path");
1186
1187        let fd = fd_column(&wrapped, &params, 1, 1e-6);
1188        for (row, (&fd_val, &inv_sqrt_e)) in fd.iter().zip(inv_sqrt_energies.iter()).enumerate() {
1189            let expected = 1.0 + inv_sqrt_e;
1190            assert!(
1191                (jac.get(row, 0) - expected).abs() < 1e-12,
1192                "aliased ∂/∂b at row {row}: analytic {}, expected {expected}",
1193                jac.get(row, 0),
1194            );
1195            assert!(
1196                (jac.get(row, 0) - fd_val).abs() < 1e-5,
1197                "aliased ∂/∂b at row {row}: analytic {}, FD {fd_val}",
1198                jac.get(row, 0),
1199            );
1200        }
1201    }
1202
1203    /// Aliased scale indices (alpha1_index == alpha2_index) in the
1204    /// sibling counts model: same accumulate-not-overwrite requirement,
1205    /// derivative f·T + B against the finite-difference oracle.
1206    #[test]
1207    fn test_counts_background_scale_aliased_indices_jacobian_matches_fd() {
1208        let x: Vec<f64> = (0..10).map(|i| 1.0 + 0.5 * i as f64).collect();
1209        let inner = ExponentialModel {
1210            x: x.clone(),
1211            flux: vec![1.0; x.len()], // inner transmission in [0,1]
1212        };
1213        let flux: Vec<f64> = vec![1000.0; x.len()];
1214        let background: Vec<f64> = x.iter().map(|&e| 5.0 + e).collect();
1215        let wrapped = CountsBackgroundScaleModel {
1216            transmission_model: &inner,
1217            flux: &flux,
1218            background: &background,
1219            alpha1_index: 1,
1220            alpha2_index: 1, // deliberately aliased with alpha1
1221            n_params: 2,
1222        };
1223
1224        let params = vec![0.4, 1.2];
1225        let y = wrapped.evaluate(&params).unwrap();
1226        let t_inner = inner.evaluate(&params).unwrap();
1227        // Inner param fixed, only the aliased scale param free →
1228        // analytic path.
1229        let jac = wrapped
1230            .analytical_jacobian(&params, &[1], &y)
1231            .expect("scale-only free set must stay on the analytic path");
1232
1233        let fd = fd_column(&wrapped, &params, 1, 1e-6);
1234        for (row, &fd_val) in fd.iter().enumerate() {
1235            let expected = flux[row] * t_inner[row] + background[row];
1236            assert!(
1237                (jac.get(row, 0) - expected).abs() < 1e-9,
1238                "aliased ∂/∂α at row {row}: analytic {}, expected {expected}",
1239                jac.get(row, 0),
1240            );
1241            assert!(
1242                (jac.get(row, 0) - fd_val).abs() < 1e-3,
1243                "aliased ∂/∂α at row {row}: analytic {}, FD {fd_val}",
1244                jac.get(row, 0),
1245            );
1246        }
1247    }
1248
1249    struct Decay {
1250        t: Vec<f64>,
1251        jacobian_factor: f64,
1252    }
1253
1254    impl FitModel for Decay {
1255        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1256            Ok(self
1257                .t
1258                .iter()
1259                .map(|&t| params[0] * (-params[1] * t).exp())
1260                .collect())
1261        }
1262
1263        fn analytical_jacobian(
1264            &self,
1265            params: &[f64],
1266            free_param_indices: &[usize],
1267            _y_current: &[f64],
1268        ) -> Option<FlatMatrix> {
1269            let mut jacobian = FlatMatrix::zeros(self.t.len(), free_param_indices.len());
1270            for (row, &t) in self.t.iter().enumerate() {
1271                let e = (-params[1] * t).exp();
1272                for (col, &index) in free_param_indices.iter().enumerate() {
1273                    let slope = [e, -params[0] * t * e][index];
1274                    *jacobian.get_mut(row, col) = slope * self.jacobian_factor;
1275                }
1276            }
1277            Some(jacobian)
1278        }
1279    }
1280
1281    fn decay(jacobian_factor: f64) -> Decay {
1282        Decay {
1283            t: (0..25).map(|i| 0.2 * f64::from(i)).collect(),
1284            jacobian_factor,
1285        }
1286    }
1287
1288    fn decay_params(a: f64, b: f64, a_upper: f64) -> ParameterSet {
1289        ParameterSet::new(vec![
1290            FitParameter {
1291                name: "a".into(),
1292                value: a,
1293                lower: 0.0,
1294                upper: a_upper,
1295                fixed: false,
1296            },
1297            FitParameter::non_negative("b", b),
1298        ])
1299    }
1300
1301    fn fit_decay(
1302        model: &Decay,
1303        observed: &[f64],
1304        start: (f64, f64),
1305        max_iter: usize,
1306    ) -> PoissonResult {
1307        let mut params = decay_params(start.0, start.1, f64::INFINITY);
1308        let config = PoissonConfig {
1309            max_iter,
1310            ..PoissonConfig::default()
1311        };
1312        poisson_fit(model, observed, &mut params, &config).unwrap()
1313    }
1314
1315    #[test]
1316    fn half_deviance_matches_high_precision_values() {
1317        for (obs, mean, exact) in [
1318            (1_000_001.0, 1_000_000.0, 4.999_998_333_334_167e-7),
1319            (3.0, 2.5, 0.046_964_670_381_863_88),
1320            (0.0, 2.5, 2.5),
1321            (1.0e308, 9.0e307, 5.360_515_657_826_301e305),
1322            (1.7e308, 1.65e308, 7.500_373_544_579_622e304),
1323            (1.0, 1.0e-310, 712.801_378_828_154_2),
1324        ] {
1325            let got = half_deviance(obs, mean);
1326            assert!(
1327                ((got - exact) / exact).abs() < 1e-12,
1328                "{obs}, {mean}: {got}"
1329            );
1330        }
1331    }
1332
1333    #[test]
1334    fn inputs_that_are_not_counts_for_this_model_are_refused() {
1335        let model = decay(1.0);
1336        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1337        let fit = |model: &dyn FitModel, observed: &[f64]| {
1338            poisson_fit(
1339                model,
1340                observed,
1341                &mut decay_params(50.0, 1.0, f64::INFINITY),
1342                &PoissonConfig::default(),
1343            )
1344        };
1345        let mut bad = observed.clone();
1346        bad[3] = f64::NAN;
1347        assert!(fit(&model, &bad).is_err());
1348        bad[3] = -1.0;
1349        assert!(fit(&model, &bad).is_err());
1350        assert!(fit(&model, &observed[1..]).is_err());
1351        let empty = Decay {
1352            t: vec![],
1353            jacobian_factor: 1.0,
1354        };
1355        assert!(fit(&empty, &[]).is_err());
1356        let no_jacobian = ExponentialModel {
1357            x: model.t.clone(),
1358            flux: vec![100.0; model.t.len()],
1359        };
1360        assert!(fit(&no_jacobian, &observed).is_err());
1361    }
1362
1363    #[test]
1364    fn a_start_with_a_zero_prediction_is_not_converged() {
1365        let model = decay(1.0);
1366        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1367        let result = fit_decay(&model, &observed, (0.0, 1.0), 200);
1368        assert!(!result.converged && result.iterations == 0, "{result:?}");
1369    }
1370
1371    #[test]
1372    fn a_non_finite_slope_ends_the_fit_unconverged() {
1373        let model = decay(f64::NAN);
1374        let observed = decay(1.0).evaluate(&[100.0, 1.5]).unwrap();
1375        let result = fit_decay(&model, &observed, (50.0, 1.0), 200);
1376        assert!(
1377            !result.converged && result.uncertainties.is_none(),
1378            "{result:?}"
1379        );
1380    }
1381
1382    #[test]
1383    fn a_fit_whose_step_cannot_lower_the_deviance_is_not_converged() {
1384        let model = decay(-1.0);
1385        let observed = decay(1.0).evaluate(&[100.0, 1.5]).unwrap();
1386        let result = fit_decay(&model, &observed, (50.0, 1.0), 200);
1387        assert!(
1388            !result.converged && result.iterations == 0 && result.uncertainties.is_none(),
1389            "{result:?}"
1390        );
1391    }
1392
1393    #[test]
1394    fn a_start_outside_the_bounds_ends_inside_them() {
1395        let model = decay(1.0);
1396        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1397        let mut params = decay_params(100.0, 1.5, 80.0);
1398        let result =
1399            poisson_fit(&model, &observed, &mut params, &PoissonConfig::default()).unwrap();
1400        assert!(result.converged && result.params[0] == 80.0, "{result:?}");
1401        assert_eq!(result.on_bound, vec![true, false]);
1402    }
1403
1404    #[test]
1405    fn a_fit_that_reaches_the_minimum_on_its_last_allowed_step_has_converged() {
1406        let model = decay(1.0);
1407        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1408        let steps = fit_decay(&model, &observed, (20.0, 0.3), 200).iterations;
1409        assert!(steps > 1);
1410        assert!(fit_decay(&model, &observed, (20.0, 0.3), steps).converged);
1411        assert!(!fit_decay(&model, &observed, (20.0, 0.3), steps - 1).converged);
1412    }
1413
1414    struct Scaled {
1415        x: Vec<f64>,
1416        slope: f64,
1417        tiny: f64,
1418    }
1419
1420    impl FitModel for Scaled {
1421        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1422            Ok(self
1423                .x
1424                .iter()
1425                .map(|&x| 100.0 * (self.slope * params[0] * x).exp() + self.tiny * params[1] * x)
1426                .collect())
1427        }
1428
1429        fn analytical_jacobian(
1430            &self,
1431            params: &[f64],
1432            free_param_indices: &[usize],
1433            _y_current: &[f64],
1434        ) -> Option<FlatMatrix> {
1435            let mut jacobian = FlatMatrix::zeros(self.x.len(), free_param_indices.len());
1436            for (row, &x) in self.x.iter().enumerate() {
1437                let slopes = [
1438                    100.0 * self.slope * x * (self.slope * params[0] * x).exp(),
1439                    self.tiny * x,
1440                ];
1441                for (col, &index) in free_param_indices.iter().enumerate() {
1442                    *jacobian.get_mut(row, col) = slopes[index];
1443                }
1444            }
1445            Some(jacobian)
1446        }
1447    }
1448
1449    fn fit_scaled(slope: f64, tiny: f64) -> PoissonResult {
1450        let model = Scaled {
1451            x: vec![1.0, 2.0, 3.0],
1452            slope,
1453            tiny,
1454        };
1455        let observed = model.evaluate(&[0.3 / slope, 0.0]).unwrap();
1456        let mut params = ParameterSet::new(vec![
1457            FitParameter::unbounded("theta", 0.0),
1458            FitParameter::unbounded("weak", 0.0),
1459        ]);
1460        poisson_fit(&model, &observed, &mut params, &PoissonConfig::default()).unwrap()
1461    }
1462
1463    #[test]
1464    fn a_slope_too_large_to_square_is_fitted() {
1465        let result = fit_scaled(1.0e160, 1.0);
1466        assert!(
1467            result.converged && result.deviance < NEWTON_DECREMENT_TOL,
1468            "{result:?}"
1469        );
1470    }
1471
1472    #[test]
1473    fn a_slope_too_small_to_square_does_not_fake_convergence() {
1474        let result = fit_scaled(1.0, 1.0e-310);
1475        assert!(
1476            !result.converged || result.deviance < NEWTON_DECREMENT_TOL,
1477            "{result:?}"
1478        );
1479    }
1480
1481    struct Line {
1482        x: Vec<f64>,
1483    }
1484
1485    impl FitModel for Line {
1486        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1487            Ok(self.x.iter().map(|&x| 1.0 + params[0] * x).collect())
1488        }
1489
1490        fn analytical_jacobian(
1491            &self,
1492            _params: &[f64],
1493            free_param_indices: &[usize],
1494            _y_current: &[f64],
1495        ) -> Option<FlatMatrix> {
1496            let mut jacobian = FlatMatrix::zeros(self.x.len(), free_param_indices.len());
1497            jacobian.data.copy_from_slice(&self.x);
1498            Some(jacobian)
1499        }
1500    }
1501
1502    #[test]
1503    fn predictions_stay_positive_where_nothing_was_counted() {
1504        let model = Line {
1505            x: vec![1.0, 2.0, 3.0],
1506        };
1507        let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 0.0)]);
1508        let result =
1509            poisson_fit(&model, &[0.0; 3], &mut params, &PoissonConfig::default()).unwrap();
1510        let predicted = model.evaluate(&result.params).unwrap();
1511        assert!(predicted.iter().all(|&mean| mean > 0.0), "{result:?}");
1512    }
1513
1514    struct WrongShape;
1515
1516    impl FitModel for WrongShape {
1517        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1518            Ok(vec![params[0]; 3])
1519        }
1520
1521        fn analytical_jacobian(
1522            &self,
1523            _params: &[f64],
1524            free_param_indices: &[usize],
1525            _y_current: &[f64],
1526        ) -> Option<FlatMatrix> {
1527            Some(FlatMatrix::zeros(2, free_param_indices.len()))
1528        }
1529    }
1530
1531    #[test]
1532    fn a_jacobian_of_the_wrong_shape_is_refused() {
1533        let mut params = ParameterSet::new(vec![FitParameter::non_negative("a", 1.0)]);
1534        let result = poisson_fit(
1535            &WrongShape,
1536            &[1.0; 3],
1537            &mut params,
1538            &PoissonConfig::default(),
1539        );
1540        assert!(
1541            matches!(result, Err(FittingError::LengthMismatch { .. })),
1542            "{result:?}"
1543        );
1544    }
1545
1546    #[test]
1547    fn inverted_or_nan_bounds_are_refused() {
1548        let model = decay(1.0);
1549        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1550        for (lower, upper) in [
1551            (1.0, 0.0),
1552            (f64::NAN, 1.0),
1553            (0.0, f64::NAN),
1554            (f64::INFINITY, f64::INFINITY),
1555            (f64::NEG_INFINITY, f64::NEG_INFINITY),
1556        ] {
1557            let mut params = ParameterSet::new(vec![
1558                FitParameter {
1559                    name: "a".into(),
1560                    value: 50.0,
1561                    lower,
1562                    upper,
1563                    fixed: false,
1564                },
1565                FitParameter::non_negative("b", 1.0),
1566            ]);
1567            let result = poisson_fit(&model, &observed, &mut params, &PoissonConfig::default());
1568            assert!(
1569                matches!(result, Err(FittingError::InvalidConfig(_))),
1570                "{lower}, {upper}"
1571            );
1572        }
1573    }
1574
1575    struct Additive;
1576
1577    impl FitModel for Additive {
1578        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1579            Ok(vec![2.0 * params[0], 1.0 - params[0]])
1580        }
1581
1582        fn analytical_jacobian(
1583            &self,
1584            _params: &[f64],
1585            free_param_indices: &[usize],
1586            _y_current: &[f64],
1587        ) -> Option<FlatMatrix> {
1588            let mut jacobian = FlatMatrix::zeros(2, free_param_indices.len());
1589            jacobian.data.copy_from_slice(&[2.0, -1.0]);
1590            Some(jacobian)
1591        }
1592    }
1593
1594    #[test]
1595    fn a_bin_predicted_zero_keeps_its_slope_in_the_gradient() {
1596        let mut params = ParameterSet::new(vec![FitParameter {
1597            name: "theta".into(),
1598            value: 0.0,
1599            lower: 0.0,
1600            upper: 1.0,
1601            fixed: false,
1602        }]);
1603        let result = poisson_fit(
1604            &Additive,
1605            &[0.0, 0.0],
1606            &mut params,
1607            &PoissonConfig::default(),
1608        )
1609        .unwrap();
1610        assert!(
1611            result.converged && result.params[0] == 0.0 && result.on_bound == vec![true],
1612            "{result:?}"
1613        );
1614    }
1615
1616    struct Line2 {
1617        offset: [f64; 2],
1618        slope: [f64; 2],
1619    }
1620
1621    impl FitModel for Line2 {
1622        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1623            Ok((0..2)
1624                .map(|i| self.offset[i] + self.slope[i] * params[0])
1625                .collect())
1626        }
1627
1628        fn analytical_jacobian(
1629            &self,
1630            _params: &[f64],
1631            free_param_indices: &[usize],
1632            _y_current: &[f64],
1633        ) -> Option<FlatMatrix> {
1634            let mut jacobian = FlatMatrix::zeros(2, free_param_indices.len());
1635            jacobian.data.copy_from_slice(&self.slope);
1636            Some(jacobian)
1637        }
1638    }
1639
1640    fn bounded(value: f64, upper: f64) -> ParameterSet {
1641        ParameterSet::new(vec![FitParameter {
1642            name: "theta".into(),
1643            value,
1644            lower: 0.0,
1645            upper,
1646            fixed: false,
1647        }])
1648    }
1649
1650    #[test]
1651    fn a_zero_bin_slope_enters_the_convergence_test() {
1652        let model = Line2 {
1653            offset: [0.0, 4.0],
1654            slope: [1.0, -4.0],
1655        };
1656        let result = poisson_fit(
1657            &model,
1658            &[0.0, 3.0],
1659            &mut bounded(0.0, 1.5),
1660            &PoissonConfig::default(),
1661        )
1662        .unwrap();
1663        assert!(
1664            result.converged && result.iterations == 0 && result.params[0] == 0.0,
1665            "{result:?}"
1666        );
1667    }
1668
1669    struct Affine {
1670        offset: Vec<f64>,
1671        jacobian: Vec<Vec<f64>>,
1672    }
1673
1674    impl FitModel for Affine {
1675        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1676            Ok(self
1677                .offset
1678                .iter()
1679                .zip(&self.jacobian)
1680                .map(|(offset, row)| {
1681                    offset + row.iter().zip(params).map(|(j, p)| j * p).sum::<f64>()
1682                })
1683                .collect())
1684        }
1685
1686        fn analytical_jacobian(
1687            &self,
1688            _params: &[f64],
1689            free_param_indices: &[usize],
1690            _y_current: &[f64],
1691        ) -> Option<FlatMatrix> {
1692            let mut jacobian = FlatMatrix::zeros(self.offset.len(), free_param_indices.len());
1693            jacobian.data.copy_from_slice(&self.jacobian.concat());
1694            Some(jacobian)
1695        }
1696    }
1697
1698    fn two_free() -> ParameterSet {
1699        ParameterSet::new(vec![
1700            FitParameter::unbounded("a", 0.0),
1701            FitParameter::unbounded("b", 0.0),
1702        ])
1703    }
1704
1705    fn at_the_start() -> PoissonConfig {
1706        PoissonConfig {
1707            max_iter: 0,
1708            ..PoissonConfig::default()
1709        }
1710    }
1711
1712    #[test]
1713    fn a_weak_direction_keeps_its_share_of_the_newton_decrement() {
1714        let model = Affine {
1715            offset: vec![1.0; 2],
1716            jacobian: vec![vec![1.0, 1.0], vec![1.0e-14, -1.0e-14]],
1717        };
1718        for observed in [[0.999, 0.998999], [0.999, 0.999001]] {
1719            let decrement = 0.5
1720                * observed
1721                    .iter()
1722                    .map(|y: &f64| (1.0 - y).powi(2))
1723                    .sum::<f64>();
1724            let result = poisson_fit(&model, &observed, &mut two_free(), &at_the_start()).unwrap();
1725            assert_eq!(
1726                result.converged,
1727                decrement < NEWTON_DECREMENT_TOL,
1728                "{decrement:e}: {result:?}"
1729            );
1730        }
1731    }
1732
1733    #[test]
1734    fn a_zero_bin_slope_enters_every_direction_of_the_decrement() {
1735        let jacobian = vec![vec![1.0, 2.0], vec![1.0, 3.0], vec![2.0, -1.0]];
1736        let offset: Vec<f64> = vec![0.0, 4.0, 9.0];
1737        let root = [offset[1].sqrt(), offset[2].sqrt()];
1738        let w = [
1739            [jacobian[1][0] / root[0], jacobian[1][1] / root[0]],
1740            [jacobian[2][0] / root[1], jacobian[2][1] / root[1]],
1741        ];
1742        let information = |a: usize, b: usize| w[0][a] * w[0][b] + w[1][a] * w[1][b];
1743        let inverse_00 =
1744            information(1, 1) / (information(0, 0) * information(1, 1) - information(0, 1).powi(2));
1745        let transpose_det = w[0][0] * w[1][1] - w[1][0] * w[0][1];
1746        for decrement in [0.99e-6_f64, 1.01e-6] {
1747            let gradient = [(2.0 * decrement / inverse_00).sqrt(), 0.0];
1748            let rhs = [gradient[0] - jacobian[0][0], gradient[1] - jacobian[0][1]];
1749            let residual = [
1750                (rhs[0] * w[1][1] - w[1][0] * rhs[1]) / transpose_det,
1751                (w[0][0] * rhs[1] - w[0][1] * rhs[0]) / transpose_det,
1752            ];
1753            let observed = [
1754                0.0,
1755                offset[1] - residual[0] * root[0],
1756                offset[2] - residual[1] * root[1],
1757            ];
1758            let model = Affine {
1759                offset: offset.clone(),
1760                jacobian: jacobian.clone(),
1761            };
1762            let result = poisson_fit(&model, &observed, &mut two_free(), &at_the_start()).unwrap();
1763            assert_eq!(
1764                result.converged,
1765                decrement < NEWTON_DECREMENT_TOL,
1766                "{decrement:e}: {result:?}"
1767            );
1768        }
1769    }
1770
1771    #[test]
1772    fn a_fit_with_hundreds_of_parameters_has_the_error_bars_of_its_inverse_information() {
1773        use rand::SeedableRng;
1774        use rand_chacha::ChaCha12Rng;
1775        use rand_distr::{Distribution, StandardNormal};
1776        let (bins, count, level) = (300, 150, 1.0e4);
1777        let mut rng = ChaCha12Rng::seed_from_u64(806);
1778        let jacobian: Vec<Vec<f64>> = (0..bins)
1779            .map(|_| {
1780                (0..count)
1781                    .map(|_| StandardNormal.sample(&mut rng))
1782                    .collect()
1783            })
1784            .collect();
1785        let model = Affine {
1786            offset: vec![level; bins],
1787            jacobian: jacobian.clone(),
1788        };
1789        let mut params = ParameterSet::new(
1790            (0..count)
1791                .map(|j| FitParameter::unbounded(format!("c{j}"), 0.0))
1792                .collect(),
1793        );
1794        let result = poisson_fit(
1795            &model,
1796            &vec![level; bins],
1797            &mut params,
1798            &PoissonConfig::default(),
1799        )
1800        .unwrap();
1801        assert!(result.converged && result.iterations == 0, "{result:?}");
1802        let mut cholesky = vec![vec![0.0; count]; count];
1803        for a in 0..count {
1804            for b in 0..=a {
1805                let information: f64 = jacobian.iter().map(|row| row[a] * row[b] / level).sum();
1806                let rest =
1807                    information - (0..b).map(|c| cholesky[a][c] * cholesky[b][c]).sum::<f64>();
1808                cholesky[a][b] = if a == b {
1809                    rest.sqrt()
1810                } else {
1811                    rest / cholesky[b][b]
1812                };
1813            }
1814        }
1815        for (j, error) in result.uncertainties.unwrap().into_iter().enumerate() {
1816            let mut solved = vec![0.0; count];
1817            for a in 0..count {
1818                let unit = if a == j { 1.0 } else { 0.0 };
1819                solved[a] = (unit - (0..a).map(|c| cholesky[a][c] * solved[c]).sum::<f64>())
1820                    / cholesky[a][a];
1821            }
1822            let expected = solved.iter().map(|v| v * v).sum::<f64>().sqrt();
1823            let error = error.unwrap();
1824            assert!(
1825                (error / expected - 1.0).abs() < 1e-10,
1826                "{j}: {error} vs {expected}"
1827            );
1828        }
1829    }
1830
1831    struct SilentZeroBin;
1832
1833    impl FitModel for SilentZeroBin {
1834        fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
1835            Ok(vec![0.0])
1836        }
1837
1838        fn analytical_jacobian(
1839            &self,
1840            _params: &[f64],
1841            free_param_indices: &[usize],
1842            _y_current: &[f64],
1843        ) -> Option<FlatMatrix> {
1844            let mut jacobian = FlatMatrix::zeros(1, free_param_indices.len());
1845            jacobian.data[0] = f64::NAN;
1846            Some(jacobian)
1847        }
1848    }
1849
1850    #[test]
1851    fn a_non_finite_slope_in_a_zero_bin_ends_the_fit_unconverged() {
1852        let mut params = ParameterSet::new(vec![FitParameter::unbounded("theta", 0.0)]);
1853        let result = poisson_fit(
1854            &SilentZeroBin,
1855            &[0.0],
1856            &mut params,
1857            &PoissonConfig::default(),
1858        )
1859        .unwrap();
1860        assert!(!result.converged, "{result:?}");
1861    }
1862
1863    struct Wall;
1864
1865    impl FitModel for Wall {
1866        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1867            let x = params[0];
1868            Ok(vec![(100.0 - x + (x - 80.0).max(0.0).powi(2)).exp()])
1869        }
1870
1871        fn analytical_jacobian(
1872            &self,
1873            params: &[f64],
1874            free_param_indices: &[usize],
1875            _y_current: &[f64],
1876        ) -> Option<FlatMatrix> {
1877            let x = params[0];
1878            let mean = (100.0 - x + (x - 80.0).max(0.0).powi(2)).exp();
1879            let mut jacobian = FlatMatrix::zeros(1, free_param_indices.len());
1880            jacobian.data[0] = mean * (2.0 * (x - 80.0).max(0.0) - 1.0);
1881            Some(jacobian)
1882        }
1883    }
1884
1885    #[test]
1886    fn damping_recovers_after_many_accepted_steps_to_reach_the_minimum() {
1887        let mut params = ParameterSet::new(vec![FitParameter::unbounded("x", 0.0)]);
1888        let result = poisson_fit(&Wall, &[0.0], &mut params, &PoissonConfig::default()).unwrap();
1889        assert!((result.params[0] - 80.5).abs() < 1e-3, "{result:?}");
1890    }
1891
1892    #[test]
1893    fn non_finite_parameter_values_are_refused() {
1894        let model = decay(1.0);
1895        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1896        for (a, b) in [(f64::NAN, 1.0), (50.0, f64::INFINITY)] {
1897            let mut params = ParameterSet::new(vec![
1898                FitParameter::non_negative("a", a),
1899                FitParameter::fixed("b", b),
1900            ]);
1901            let result = poisson_fit(&model, &observed, &mut params, &PoissonConfig::default());
1902            assert!(
1903                matches!(result, Err(FittingError::InvalidConfig(_))),
1904                "{a}, {b}"
1905            );
1906        }
1907    }
1908
1909    struct Shrinking;
1910
1911    impl FitModel for Shrinking {
1912        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1913            let bins = if params[0] < 0.5 { 3 } else { 2 };
1914            Ok(vec![1.0 + params[0]; bins])
1915        }
1916
1917        fn analytical_jacobian(
1918            &self,
1919            params: &[f64],
1920            free_param_indices: &[usize],
1921            _y_current: &[f64],
1922        ) -> Option<FlatMatrix> {
1923            let bins = if params[0] < 0.5 { 3 } else { 2 };
1924            let mut jacobian = FlatMatrix::zeros(bins, free_param_indices.len());
1925            jacobian.data.fill(1.0);
1926            Some(jacobian)
1927        }
1928    }
1929
1930    #[test]
1931    fn a_trial_prediction_of_the_wrong_length_is_rejected() {
1932        let mut params = ParameterSet::new(vec![FitParameter::non_negative("theta", 0.0)]);
1933        let result = poisson_fit(
1934            &Shrinking,
1935            &[3.0; 3],
1936            &mut params,
1937            &PoissonConfig::default(),
1938        )
1939        .unwrap();
1940        assert_eq!(
1941            Shrinking.evaluate(&result.params).unwrap().len(),
1942            3,
1943            "{result:?}"
1944        );
1945    }
1946}