Skip to main content

nereids_fitting/
count_background.rs

1//! Non-negative additive background templates for two-arm count measurements.
2//!
3//! The neutron signal is evaluated before entering this module. A background
4//! template is an expected detector-bin shape from an independent source such
5//! as a blocked-beam or detector-only measurement. Its fitted amplitude is
6//! non-negative, and the background is added after the neutron response.
7//!
8//! This module deliberately provides no smooth-curve generator. Fitting a
9//! flexible curve to the same residual it is meant to explain would not
10//! identify a physical background. The SAMMY transmission-level background is
11//! also a separate model with different placement and meaning.
12
13use std::collections::HashSet;
14
15use nereids_physics::counts_response::{
16    TwoArmCountPrediction, TwoArmCounts, add_count_backgrounds,
17};
18
19use crate::error::FittingError;
20use crate::lm::{FlatMatrix, invert_matrix};
21use crate::poisson::PoissonConfig;
22
23/// One independently supplied detector-bin background shape.
24///
25/// A single non-negative amplitude multiplies both arms. For a component that
26/// exists in only one acquisition, supply zeros for the other arm. Arrays are
27/// counts per unit amplitude, already normalized to the corresponding run.
28#[derive(Debug, Clone, PartialEq)]
29pub struct TwoArmBackgroundTemplate {
30    /// Stable component name retained in fit output.
31    pub name: String,
32    /// Open-beam background counts per unit amplitude.
33    pub open_beam: Vec<f64>,
34    /// Sample background counts per unit amplitude.
35    pub sample: Vec<f64>,
36}
37
38/// Result of fitting fixed neutron signals plus background templates.
39#[derive(Debug, Clone)]
40pub struct TwoArmBackgroundFitResult {
41    /// Component names in the same order as `amplitudes`.
42    pub names: Vec<String>,
43    /// Fitted non-negative template amplitudes.
44    pub amplitudes: Vec<f64>,
45    /// Local one-sigma amplitude uncertainties from the expected (Fisher)
46    /// information of the constrained objective at the solution.
47    ///
48    /// Free amplitudes are conditioned on the constraint: the information is
49    /// restricted to the free set before inversion, so an amplitude whose
50    /// correlated partner is held on its bound is not reported with the
51    /// marginal over a direction the constraint removed. An amplitude on its
52    /// zero bound (see `amplitude_at_bound`) reports `1 / sqrt(I_jj)`, the
53    /// one-sided curvature scale of the objective at the boundary, not a
54    /// symmetric interval.
55    ///
56    /// `None` when the fit did not converge, when the amplitudes are not
57    /// identifiable, when the free block of the information matrix is
58    /// singular, or when the caller's `PoissonConfig` has
59    /// `compute_covariance == false`. An individual entry is `NaN` when its variance is
60    /// non-positive, or when its template is sensitive on a bin with zero
61    /// expectation — the boundary of the Poisson support, where the expected
62    /// information diverges and no regular estimate exists. A reported number
63    /// is therefore never zero.
64    pub amplitude_uncertainties: Option<Vec<f64>>,
65    /// Whether each fitted amplitude sits on its non-negativity bound with the
66    /// gradient still pushing it negative — a one-sided limit rather than an
67    /// interior estimate.
68    pub amplitude_at_bound: Vec<bool>,
69    /// Whether every named template amplitude is separately determined.
70    ///
71    /// `false` means that at least two supplied shapes are linearly dependent:
72    /// the total fitted background can be valid, but the individual amplitudes
73    /// are not physically interpretable. In that case uncertainties are not
74    /// reported.
75    pub amplitudes_identifiable: bool,
76    /// Neutron signal, fitted background, and total for both arms.
77    pub prediction: TwoArmCountPrediction,
78    /// Poisson deviance for the concatenated open and sample arrays.
79    pub poisson_deviance: f64,
80    /// Deviance divided by `n_informative - template_rank`.
81    pub deviance_per_dof: f64,
82    /// Concatenated bins that contribute to the deviance: the observation,
83    /// the neutron signal, or at least one template is nonzero there.
84    ///
85    /// This is not simply `2 * n_bins`: a bin where all three are exactly
86    /// zero yields identically zero deviance for any amplitude vector, so it
87    /// cannot be a degree of freedom.
88    pub n_informative: usize,
89    /// Whether the bounded optimizer converged.
90    pub converged: bool,
91    /// Joint iterations taken, including the post-convergence active-set
92    /// polish; never exceeds the caller's `max_iter`.
93    pub iterations: usize,
94}
95
96/// Fit amplitudes of independently supplied detector-bin templates.
97///
98/// The neutron signal and every template shape remain fixed. This tests
99/// whether an independently chosen shape explains the counts, but cannot prove
100/// its provenance; callers must retain the independent measurement record.
101///
102/// `observed_open_counts` and `observed_sample_counts` are measured counts, so
103/// they are plain slices rather than a [`TwoArmCounts`]: that type carries an
104/// acquisition-window loss, which is a property of a prediction and has no
105/// meaning for data read off a detector.
106#[allow(clippy::too_many_arguments)]
107pub fn fit_two_arm_background_templates(
108    observed_open_counts: &[f64],
109    observed_sample_counts: &[f64],
110    neutron_signal: TwoArmCounts,
111    open_exposure_scale: f64,
112    sample_exposure_scale: f64,
113    templates: &[TwoArmBackgroundTemplate],
114    initial_amplitudes: &[f64],
115    config: &PoissonConfig,
116) -> Result<TwoArmBackgroundFitResult, FittingError> {
117    validate_fit_inputs(
118        observed_open_counts,
119        observed_sample_counts,
120        &neutron_signal,
121        open_exposure_scale,
122        sample_exposure_scale,
123        templates,
124        initial_amplitudes,
125    )?;
126    validate_config(config)?;
127    let neutron_signal =
128        scale_neutron_signal(neutron_signal, open_exposure_scale, sample_exposure_scale)?;
129    let n_bins = observed_open_counts.len();
130    let mut observed_joined = Vec::with_capacity(2 * n_bins);
131    observed_joined.extend_from_slice(observed_open_counts);
132    observed_joined.extend_from_slice(observed_sample_counts);
133
134    // Fit a contribution measured in counts, rather than the caller's
135    // arbitrary template units.  Without this normalization, multiplying a
136    // template by (say) 1e-8 and its amplitude by 1e8 changes the optimizer's
137    // stopping test even though the physical prediction is unchanged.
138    let (normalized_templates, template_scales) = normalize_templates(templates);
139    let normalized_initial_amplitudes: Vec<f64> = initial_amplitudes
140        .iter()
141        .zip(&template_scales)
142        .map(|(&amplitude, &scale)| amplitude * scale)
143        .collect();
144    if normalized_initial_amplitudes
145        .iter()
146        .any(|value| !value.is_finite())
147    {
148        return Err(FittingError::InvalidConfig(
149            "initial amplitude times template scale must be finite".into(),
150        ));
151    }
152
153    let mut neutron_joined = Vec::with_capacity(2 * n_bins);
154    neutron_joined.extend_from_slice(&neutron_signal.open_beam);
155    neutron_joined.extend_from_slice(&neutron_signal.sample);
156    let normalized_basis: Vec<Vec<f64>> = normalized_templates
157        .iter()
158        .map(|template| {
159            let mut joined = Vec::with_capacity(2 * n_bins);
160            joined.extend_from_slice(&template.open_beam);
161            joined.extend_from_slice(&template.sample);
162            joined
163        })
164        .collect();
165
166    let n_informative =
167        count_informative_bins(&observed_joined, &neutron_joined, &normalized_basis);
168    let template_rank = background_template_rank(&normalized_basis);
169    let amplitudes_identifiable = template_rank == normalized_basis.len();
170    // Degrees of freedom are `n_informative - template_rank`, so the guard
171    // keys on the rank the same way: a dependent template set spans fewer
172    // directions than it has names and is still fittable (its total is
173    // determined; its individual amplitudes are reported as unidentifiable).
174    if n_informative <= template_rank {
175        return Err(FittingError::InvalidConfig(format!(
176            "{} background amplitudes spanning {template_rank} independent direction(s) cannot \
177             be fitted from {n_informative} informative count value(s) with positive degrees of \
178             freedom",
179            templates.len(),
180        )));
181    }
182
183    let fit = fit_non_negative_poisson_linear(
184        &observed_joined,
185        &neutron_joined,
186        &normalized_basis,
187        &normalized_initial_amplitudes,
188        config,
189    )?;
190
191    let amplitudes: Vec<f64> = fit
192        .amplitudes
193        .iter()
194        .zip(&template_scales)
195        .map(|(&normalized_amplitude, &scale)| normalized_amplitude / scale)
196        .collect();
197    // Both overflow and underflow are unrepresentable: an amplitude that
198    // underflows to zero could not rebuild the nonzero background the fit
199    // actually found, so the pair would contradict each other.
200    let unrepresentable =
201        amplitudes
202            .iter()
203            .zip(&fit.amplitudes)
204            .any(|(&amplitude, &normalized)| {
205                !amplitude.is_finite() || (amplitude == 0.0 && normalized != 0.0)
206            });
207    if unrepresentable {
208        return Err(FittingError::EvaluationFailed(
209            "a fitted amplitude cannot be represented in the supplied template units; rescale the template counts"
210                .into(),
211        ));
212    }
213    let amplitude_uncertainties = amplitudes_identifiable
214        .then(|| {
215            fit.uncertainties.map(|uncertainties| {
216                uncertainties
217                    .iter()
218                    .zip(&template_scales)
219                    .map(|(&uncertainty, &scale)| {
220                        let caller_units = uncertainty / scale;
221                        if caller_units.is_finite() && caller_units > 0.0 {
222                            caller_units
223                        } else {
224                            f64::NAN
225                        }
226                    })
227                    .collect()
228            })
229        })
230        .flatten();
231
232    let mut open_background = vec![0.0; n_bins];
233    let mut sample_background = vec![0.0; n_bins];
234    for (&amplitude, template) in fit.amplitudes.iter().zip(&normalized_templates) {
235        add_scaled(&mut open_background, &template.open_beam, amplitude);
236        add_scaled(&mut sample_background, &template.sample, amplitude);
237    }
238    let prediction = add_count_backgrounds(neutron_signal, &open_background, &sample_background)
239        .map_err(|error| FittingError::EvaluationFailed(error.to_string()))?;
240    let mut predicted_joined = Vec::with_capacity(2 * n_bins);
241    predicted_joined.extend_from_slice(&prediction.open_beam.total);
242    predicted_joined.extend_from_slice(&prediction.sample.total);
243    let poisson_deviance = poisson_deviance(&observed_joined, &predicted_joined);
244    // Dependent shapes add only `rank` independent fitted directions, and only
245    // informative bins can constrain them. Counting every named row, or every
246    // bin, would overstate the degrees of freedom and deflate the reported
247    // goodness of fit.
248    let dof = n_informative - template_rank;
249
250    Ok(TwoArmBackgroundFitResult {
251        names: templates
252            .iter()
253            .map(|template| template.name.clone())
254            .collect(),
255        amplitudes,
256        amplitude_uncertainties,
257        amplitude_at_bound: fit.at_bound,
258        amplitudes_identifiable,
259        prediction,
260        poisson_deviance,
261        deviance_per_dof: poisson_deviance / dof as f64,
262        n_informative,
263        converged: fit.converged,
264        iterations: fit.iterations,
265    })
266}
267
268/// Count concatenated bins that contribute to the deviance.
269///
270/// Under this Poisson objective a bin contributes identically zero deviance
271/// for *every* amplitude vector when its observation, its neutron signal, and
272/// every template are all exactly zero there: the expectation is pinned at
273/// zero whatever the amplitudes do. Such a bin carries no information and must
274/// not inflate the degrees of freedom, exactly as dead bins are excluded from
275/// the joint-Poisson dof in [`crate::joint_poisson`].
276///
277/// A bin with a positive observation but zero capacity is *not* degenerate —
278/// it makes the deviance infinite, which is the correct verdict on a model
279/// that cannot produce an observed count — so it is counted.
280fn count_informative_bins(observed: &[f64], neutron_signal: &[f64], basis: &[Vec<f64>]) -> usize {
281    (0..observed.len())
282        .filter(|&bin| {
283            observed[bin] > 0.0
284                || neutron_signal[bin] > 0.0
285                || basis.iter().any(|template| template[bin] > 0.0)
286        })
287        .count()
288}
289
290fn scale_neutron_signal(
291    mut signal: TwoArmCounts,
292    open_exposure_scale: f64,
293    sample_exposure_scale: f64,
294) -> Result<TwoArmCounts, FittingError> {
295    for value in &mut signal.open_beam {
296        *value *= open_exposure_scale;
297    }
298    for value in &mut signal.sample {
299        *value *= sample_exposure_scale;
300    }
301    // The window-loss report is in the same units as the arm it belongs to, so
302    // it must follow the same exposure conversion or the disclosed loss would
303    // silently describe a different acquisition than the counts beside it.
304    signal.open_beam_window_loss *= open_exposure_scale;
305    signal.sample_window_loss *= sample_exposure_scale;
306    if signal
307        .open_beam
308        .iter()
309        .chain(&signal.sample)
310        .chain(std::iter::once(&signal.open_beam_window_loss))
311        .chain(std::iter::once(&signal.sample_window_loss))
312        .any(|value| !value.is_finite())
313    {
314        return Err(FittingError::InvalidConfig(
315            "exposure-scaled neutron signal must remain finite; rescale the reference signal"
316                .into(),
317        ));
318    }
319    Ok(signal)
320}
321
322struct LinearPoissonFit {
323    amplitudes: Vec<f64>,
324    uncertainties: Option<Vec<f64>>,
325    at_bound: Vec<bool>,
326    converged: bool,
327    iterations: usize,
328}
329
330/// Minimize the exact count likelihood for a non-negative linear background.
331///
332/// With fixed neutron counts `s`, fixed non-negative templates `B`, and
333/// non-negative amplitudes `a`, the expectation is `mu = s + B a` and the
334/// Poisson objective is convex in `a`. Each iteration takes one joint
335/// Fisher-scoring step on the free amplitudes — those off the zero bound, or
336/// on it with the gradient pulling inward — projected back onto `a >= 0`
337/// with backtracking on the objective.
338///
339/// The joint step matters for the ordinary inputs: a flat detector-dark
340/// reference beside a slowly varying blocked-beam one are highly correlated,
341/// and a coordinate-at-a-time sweep zig-zags between them for tens of
342/// thousands of passes while the joint step resolves them in a handful. The
343/// safeguarded per-coordinate solve remains as the fallback whenever the
344/// reduced information matrix cannot be inverted (dependent templates, or
345/// counts so large that `1/mu` underflows the pivot floor) or the joint step
346/// fails to decrease the objective, so those cases still reach their
347/// constrained minimum. Convergence is the scale-free KKT test on the
348/// normalized gradient in every case.
349fn fit_non_negative_poisson_linear(
350    observed: &[f64],
351    neutron_signal: &[f64],
352    basis: &[Vec<f64>],
353    initial_amplitudes: &[f64],
354    config: &PoissonConfig,
355) -> Result<LinearPoissonFit, FittingError> {
356    let mut amplitudes = initial_amplitudes.to_vec();
357    for (amplitude, template) in amplitudes.iter_mut().zip(basis) {
358        let upper = amplitude_upper_bound(observed, template)?;
359        *amplitude = amplitude.min(upper);
360    }
361    let mut converged = false;
362    let mut iterations = 0;
363    let mut gradient = vec![0.0; basis.len()];
364
365    for _ in 0..config.max_iter {
366        joint_iteration(
367            observed,
368            neutron_signal,
369            basis,
370            &mut amplitudes,
371            &mut gradient,
372        )?;
373        iterations += 1;
374        if kkt_violation(observed, neutron_signal, basis, &amplitudes, &mut gradient)?
375            <= config.tol_param
376        {
377            converged = true;
378            break;
379        }
380    }
381    if converged {
382        iterations += polish_active_set(
383            observed,
384            neutron_signal,
385            basis,
386            &mut amplitudes,
387            &mut gradient,
388            config.tol_param,
389            config.max_iter - iterations,
390        )?;
391    }
392
393    let prediction = linear_prediction(neutron_signal, basis, &amplitudes)?;
394    poisson_gradient_into(observed, &prediction, basis, &mut gradient);
395    // An amplitude on its zero bound with the gradient still positive is held
396    // there by the constraint, not by the data: report it as a one-sided
397    // limit rather than an interior estimate.
398    let at_bound: Vec<bool> = amplitudes
399        .iter()
400        .zip(&gradient)
401        .map(|(&amplitude, &slope)| amplitude == 0.0 && slope > 0.0)
402        .collect();
403    let uncertainties = if converged && config.compute_covariance {
404        poisson_linear_uncertainties(&prediction, basis, &at_bound)
405    } else {
406        None
407    };
408    Ok(LinearPoissonFit {
409        amplitudes,
410        uncertainties,
411        at_bound,
412        converged,
413        iterations,
414    })
415}
416
417fn poisson_gradient_into(
418    observed: &[f64],
419    prediction: &[f64],
420    basis: &[Vec<f64>],
421    gradient: &mut [f64],
422) {
423    for (slot, template) in gradient.iter_mut().zip(basis) {
424        *slot = poisson_coordinate_gradient(observed, prediction, template);
425    }
426}
427
428/// One joint step: Fisher scoring on the free amplitudes with projected
429/// backtracking, or a coordinate sweep when that step is unavailable or
430/// fails to decrease the objective.
431fn joint_iteration(
432    observed: &[f64],
433    neutron_signal: &[f64],
434    basis: &[Vec<f64>],
435    amplitudes: &mut Vec<f64>,
436    gradient: &mut [f64],
437) -> Result<(), FittingError> {
438    let prediction = linear_prediction(neutron_signal, basis, amplitudes)?;
439    poisson_gradient_into(observed, &prediction, basis, gradient);
440    let free: Vec<usize> = (0..basis.len())
441        .filter(|&component| amplitudes[component] > 0.0 || gradient[component] < 0.0)
442        .collect();
443
444    let joint_step =
445        fisher_scoring_direction(&prediction, basis, gradient, &free).and_then(|direction| {
446            projected_backtracking_step(
447                observed,
448                neutron_signal,
449                basis,
450                amplitudes,
451                &prediction,
452                &direction,
453            )
454        });
455    match joint_step {
456        Some(next) => *amplitudes = next,
457        None => {
458            for component in 0..basis.len() {
459                let base = linear_prediction_without_component(
460                    neutron_signal,
461                    basis,
462                    amplitudes,
463                    component,
464                )?;
465                amplitudes[component] =
466                    coordinate_minimum(observed, &base, &basis[component], amplitudes[component])?;
467            }
468        }
469    }
470    Ok(())
471}
472
473/// Scale-free KKT residual at the current amplitudes: the largest normalized
474/// gradient violation, refreshing `gradient` on the way.
475fn kkt_violation(
476    observed: &[f64],
477    neutron_signal: &[f64],
478    basis: &[Vec<f64>],
479    amplitudes: &[f64],
480    gradient: &mut [f64],
481) -> Result<f64, FittingError> {
482    let prediction = linear_prediction(neutron_signal, basis, amplitudes)?;
483    poisson_gradient_into(observed, &prediction, basis, gradient);
484    Ok(basis
485        .iter()
486        .enumerate()
487        .map(|(component, template)| {
488            let violation = if amplitudes[component] == 0.0 {
489                (-gradient[component]).max(0.0)
490            } else {
491                gradient[component].abs()
492            };
493            violation / template.iter().sum::<f64>()
494        })
495        .fold(0.0_f64, f64::max))
496}
497
498/// Resolve the active set at the converged point.
499///
500/// The stopping rule accepts a KKT residual up to `tol_param`, but along a
501/// nearly degenerate direction of a near-collinear template set the true
502/// multiplier of an active bound can be smaller than that slack. The
503/// iteration can then stop with an amplitude a hair above zero and its bound
504/// read as inactive — or the reverse — depending on the start and the
505/// tolerance, and the conditioned sigma flips regime with it. A few extra
506/// joint iterations at a much tighter tolerance sharpen the point (the
507/// Fisher-scoring step converges quadratically, so this is cheap), and an
508/// exact one-dimensional test then settles each amplitude: if its objective
509/// with the others held fixed is minimized at zero, it is snapped there and
510/// its bound is active. Returns the number of extra iterations taken.
511fn polish_active_set(
512    observed: &[f64],
513    neutron_signal: &[f64],
514    basis: &[Vec<f64>],
515    amplitudes: &mut Vec<f64>,
516    gradient: &mut [f64],
517    tol_param: f64,
518    budget: usize,
519) -> Result<usize, FittingError> {
520    const POLISH_ROUNDS: usize = 4;
521    const POLISH_ITERATIONS: usize = 8;
522    // The polish spends only what the caller's `max_iter` has left, so the
523    // reported iteration count never exceeds the contract. With no budget
524    // remaining the active set is read as converged; that is the documented
525    // price of an exhausted budget, not a hidden extra cost.
526    if budget == 0 {
527        return Ok(0);
528    }
529    let polish_tol = (tol_param * 1.0e-6).max(1.0e-15);
530    let mut extra = 0;
531    for _ in 0..POLISH_ROUNDS {
532        for _ in 0..POLISH_ITERATIONS {
533            if extra == budget
534                || kkt_violation(observed, neutron_signal, basis, amplitudes, gradient)?
535                    <= polish_tol
536            {
537                break;
538            }
539            joint_iteration(observed, neutron_signal, basis, amplitudes, gradient)?;
540            extra += 1;
541        }
542        if extra == budget {
543            break;
544        }
545        let mut snapped = false;
546        for component in 0..basis.len() {
547            if amplitudes[component] > 0.0 {
548                let base = linear_prediction_without_component(
549                    neutron_signal,
550                    basis,
551                    amplitudes,
552                    component,
553                )?;
554                if poisson_coordinate_gradient_at(observed, &base, &basis[component], 0.0) >= 0.0 {
555                    amplitudes[component] = 0.0;
556                    snapped = true;
557                }
558            }
559        }
560        if !snapped {
561            break;
562        }
563    }
564    Ok(extra)
565}
566
567/// Expected (Fisher) information between two templates at the current
568/// expectation: `sum_i B_ij B_ik / mu_i` over bins with `mu_i > 0`.
569///
570/// Unlike the observed information `sum_i B_ij B_ik y_i / mu_i^2`, this does
571/// not vanish on zero-count bins, so the background-consistent-with-zero
572/// case keeps a finite, meaningful bound. It is the same convention as the
573/// joint-Poisson curvature elsewhere in this crate. Bins with `mu_i == 0`
574/// carry either no counts (no information) or an infinite deviance already.
575fn expected_information(prediction: &[f64], left: &[f64], right: &[f64]) -> f64 {
576    prediction
577        .iter()
578        .zip(left)
579        .zip(right)
580        .filter(|&((&expected, &l), &r)| expected > 0.0 && l != 0.0 && r != 0.0)
581        .map(|((&expected, &l), &r)| {
582            // Split the expectation symmetrically between the two weights.
583            // `(l / expected) * r` overflows for a subnormal expectation while
584            // the reversed order does not, which would make the nominally
585            // symmetric information matrix asymmetric. Normalized weights
586            // are at most one, so `w / sqrt(expected)` stays finite down to
587            // the smallest subnormal, and the term is identical in both
588            // orders by construction.
589            let scale = expected.sqrt();
590            (l / scale) * (r / scale)
591        })
592        .sum()
593}
594
595/// Fisher-scoring direction on the free amplitudes, zero elsewhere.
596///
597/// `None` when the reduced information matrix cannot be inverted or the
598/// direction is not finite; the caller then falls back to a coordinate
599/// sweep, which is always well posed one amplitude at a time.
600fn fisher_scoring_direction(
601    prediction: &[f64],
602    basis: &[Vec<f64>],
603    gradient: &[f64],
604    free: &[usize],
605) -> Option<Vec<f64>> {
606    if free.is_empty() {
607        return None;
608    }
609    let mut information = FlatMatrix::zeros(free.len(), free.len());
610    for (row, &j) in free.iter().enumerate() {
611        for (column, &k) in free.iter().enumerate() {
612            *information.get_mut(row, column) =
613                expected_information(prediction, &basis[j], &basis[k]);
614        }
615    }
616    let inverse = invert_matrix(&information)?;
617    let mut direction = vec![0.0; basis.len()];
618    for (row, &j) in free.iter().enumerate() {
619        direction[j] = -(0..free.len())
620            .map(|column| inverse.get(row, column) * gradient[free[column]])
621            .sum::<f64>();
622    }
623    direction
624        .iter()
625        .all(|value| value.is_finite())
626        .then_some(direction)
627}
628
629/// Move along `direction`, projecting onto `a >= 0`, halving the step until
630/// the Poisson deviance strictly decreases. `None` when no step length does —
631/// including at the solution itself, where the fallback sweep then confirms
632/// the KKT conditions at negligible cost.
633fn projected_backtracking_step(
634    observed: &[f64],
635    neutron_signal: &[f64],
636    basis: &[Vec<f64>],
637    amplitudes: &[f64],
638    prediction: &[f64],
639    direction: &[f64],
640) -> Option<Vec<f64>> {
641    let current = poisson_deviance(observed, prediction);
642    let mut step = 1.0;
643    for _ in 0..40 {
644        let trial: Vec<f64> = amplitudes
645            .iter()
646            .zip(direction)
647            .map(|(&amplitude, &delta)| (amplitude + step * delta).max(0.0))
648            .collect();
649        if let Ok(trial_prediction) = linear_prediction(neutron_signal, basis, &trial)
650            && poisson_deviance(observed, &trial_prediction) < current
651        {
652            return Some(trial);
653        }
654        step *= 0.5;
655    }
656    None
657}
658
659fn validate_config(config: &PoissonConfig) -> Result<(), FittingError> {
660    // A non-finite tolerance would accept the first iteration unconditionally
661    // and report convergence for an arbitrary amplitude vector.
662    if !config.tol_param.is_finite() || config.tol_param <= 0.0 {
663        return Err(FittingError::InvalidConfig(format!(
664            "tol_param must be finite and > 0, got {}",
665            config.tol_param
666        )));
667    }
668    // Zero iterations would return the clamped initial guess as an
669    // unconverged result with no error, which is a request that cannot be
670    // meant.
671    if config.max_iter == 0 {
672        return Err(FittingError::InvalidConfig(
673            "max_iter must be at least 1".into(),
674        ));
675    }
676    Ok(())
677}
678
679fn coordinate_minimum(
680    observed: &[f64],
681    base: &[f64],
682    template: &[f64],
683    initial: f64,
684) -> Result<f64, FittingError> {
685    let gradient_at_zero = poisson_coordinate_gradient_at(observed, base, template, 0.0);
686    if gradient_at_zero >= 0.0 {
687        return Ok(0.0);
688    }
689
690    // For base >= 0, this is a mathematical upper bound on the root:
691    // b*y/(base+x*b) <= y/x for every bin with b>0.
692    let mut upper = amplitude_upper_bound(observed, template)?;
693    if upper <= 0.0 {
694        return Err(FittingError::EvaluationFailed(
695            "could not form a finite upper bound for a background amplitude".into(),
696        ));
697    }
698    let mut lower = 0.0;
699    let mut value = initial.clamp(lower, upper);
700
701    // Safeguarded Newton solves the monotone analytical derivative. The
702    // bracket makes the result deterministic at a zero boundary and across
703    // large changes in curvature.
704    for _ in 0..80 {
705        let (gradient, curvature) =
706            poisson_coordinate_gradient_and_curvature_at(observed, base, template, value);
707        if gradient == 0.0 {
708            return Ok(value);
709        }
710        if gradient < 0.0 {
711            lower = value;
712        } else {
713            upper = value;
714        }
715        let newton = value - gradient / curvature;
716        let next = if curvature.is_finite()
717            && curvature > 0.0
718            && newton.is_finite()
719            && newton > lower
720            && newton < upper
721        {
722            newton
723        } else {
724            lower + 0.5 * (upper - lower)
725        };
726        if next == value {
727            return Ok(next);
728        }
729        value = next;
730    }
731    Ok(value)
732}
733
734/// A finite upper bound for one normalized template amplitude.
735///
736/// Dividing two direct sums can overflow even when their ratio is finite
737/// (for example, two observations near `f64::MAX`). Scaling the numerator by
738/// its largest supported observation computes the same ratio without that
739/// intermediate overflow.
740fn amplitude_upper_bound(observed: &[f64], template: &[f64]) -> Result<f64, FittingError> {
741    let maximum_observed = observed
742        .iter()
743        .zip(template)
744        .filter_map(|(&count, &weight)| (weight > 0.0).then_some(count))
745        .fold(0.0_f64, f64::max);
746    if maximum_observed == 0.0 {
747        return Ok(0.0);
748    }
749    let scaled_observed_sum: f64 = observed
750        .iter()
751        .zip(template)
752        .filter_map(|(&count, &weight)| (weight > 0.0).then_some(count / maximum_observed))
753        .sum();
754    let template_sum: f64 = template.iter().sum();
755    let upper = maximum_observed * (scaled_observed_sum / template_sum);
756    if upper.is_nan() {
757        return Err(FittingError::EvaluationFailed(
758            "could not form a finite upper bound for a background amplitude".into(),
759        ));
760    }
761    // This is a loose bracket, not the solution. When it exceeds the largest
762    // representable value the root is still bracketed by `f64::MAX`, so an
763    // overflow here must not abort a fit whose optimum is representable — a
764    // zero-background optimum in particular fits any observation.
765    Ok(upper.min(f64::MAX))
766}
767
768fn linear_prediction_without_component(
769    neutron_signal: &[f64],
770    basis: &[Vec<f64>],
771    amplitudes: &[f64],
772    excluded: usize,
773) -> Result<Vec<f64>, FittingError> {
774    let mut prediction = neutron_signal.to_vec();
775    for (component, (&amplitude, template)) in amplitudes.iter().zip(basis).enumerate() {
776        if component != excluded {
777            add_scaled(&mut prediction, template, amplitude);
778        }
779    }
780    validate_linear_prediction(&prediction)?;
781    Ok(prediction)
782}
783
784fn linear_prediction(
785    neutron_signal: &[f64],
786    basis: &[Vec<f64>],
787    amplitudes: &[f64],
788) -> Result<Vec<f64>, FittingError> {
789    let mut prediction = neutron_signal.to_vec();
790    for (&amplitude, template) in amplitudes.iter().zip(basis) {
791        add_scaled(&mut prediction, template, amplitude);
792    }
793    validate_linear_prediction(&prediction)?;
794    Ok(prediction)
795}
796
797fn validate_linear_prediction(prediction: &[f64]) -> Result<(), FittingError> {
798    if prediction.iter().any(|value| !value.is_finite()) {
799        return Err(FittingError::EvaluationFailed(
800            "background fit produced non-finite expected counts".into(),
801        ));
802    }
803    Ok(())
804}
805
806fn poisson_coordinate_gradient(observed: &[f64], prediction: &[f64], template: &[f64]) -> f64 {
807    observed
808        .iter()
809        .zip(prediction)
810        .zip(template)
811        .filter(|&((_, _), &weight)| weight > 0.0)
812        .map(|((&count, &expected), &weight)| {
813            weight - weighted_count_ratio(weight, count, expected)
814        })
815        .sum()
816}
817
818fn poisson_coordinate_gradient_at(
819    observed: &[f64],
820    base: &[f64],
821    template: &[f64],
822    amplitude: f64,
823) -> f64 {
824    poisson_coordinate_gradient_and_curvature_at(observed, base, template, amplitude).0
825}
826
827fn poisson_coordinate_gradient_and_curvature_at(
828    observed: &[f64],
829    base: &[f64],
830    template: &[f64],
831    amplitude: f64,
832) -> (f64, f64) {
833    observed.iter().zip(base).zip(template).fold(
834        (0.0, 0.0),
835        |(gradient, curvature), ((&count, &base_count), &weight)| {
836            if weight == 0.0 {
837                return (gradient, curvature);
838            }
839            let expected = base_count + amplitude * weight;
840            let weighted_ratio = weighted_count_ratio(weight, count, expected);
841            let curvature_term = if count == 0.0 {
842                0.0
843            } else {
844                (weight / expected) * weighted_ratio
845            };
846            (
847                gradient + weight - weighted_ratio,
848                curvature + curvature_term,
849            )
850        },
851    )
852}
853
854/// Compute `weight * count / expected` without avoidable intermediate
855/// overflow or precision loss. Exact zero observations contribute zero; a
856/// positive observation with zero expectation contributes infinity, as
857/// required by Poisson counts.
858///
859/// `count / expected` is the accurate form whenever it is representable:
860/// the alternative `weight / expected` goes subnormal once `expected`
861/// approaches `f64::MAX`, and the rounding it suffers then turns an exact
862/// zero gradient at the bound into a spurious sign that drives the
863/// one-dimensional solve up to the sub-ULP plateau of the counts.
864fn weighted_count_ratio(weight: f64, count: f64, expected: f64) -> f64 {
865    if weight == 0.0 || count == 0.0 {
866        return 0.0;
867    }
868    let ratio = count / expected;
869    if ratio.is_finite() {
870        weight * ratio
871    } else {
872        count * (weight / expected)
873    }
874}
875
876/// One-sigma amplitude uncertainties at the constrained solution.
877///
878/// The local curvature of the constrained objective is the expected
879/// information restricted to the free amplitudes, so the free block is
880/// inverted on its own. A partner held on its bound has been removed as a
881/// direction; marginalizing over it would report the flat direction of a
882/// near-collinear pair as the uncertainty of an amplitude the data in fact
883/// pin to a fraction of a percent. An amplitude on its bound reports
884/// `1 / sqrt(I_jj)`, the direct curvature scale of the objective at the
885/// boundary.
886///
887/// A template that is sensitive on a bin with zero expectation sits on the
888/// boundary of the Poisson support, where its expected information diverges
889/// and the Fisher approximation is not a regular estimate. Such entries are
890/// `NaN` rather than a number that would jump discontinuously as the
891/// expectation reaches zero. `None` only when the free block is singular.
892fn poisson_linear_uncertainties(
893    prediction: &[f64],
894    basis: &[Vec<f64>],
895    at_bound: &[bool],
896) -> Option<Vec<f64>> {
897    let support_boundary: Vec<bool> = basis
898        .iter()
899        .map(|template| {
900            prediction
901                .iter()
902                .zip(template)
903                .any(|(&expected, &weight)| expected == 0.0 && weight != 0.0)
904        })
905        .collect();
906    let free: Vec<usize> = (0..basis.len())
907        .filter(|&j| !at_bound[j] && !support_boundary[j])
908        .collect();
909
910    let mut sigma = vec![f64::NAN; basis.len()];
911    if !free.is_empty() {
912        let mut information = FlatMatrix::zeros(free.len(), free.len());
913        for (row, &j) in free.iter().enumerate() {
914            for (column, &k) in free.iter().enumerate() {
915                *information.get_mut(row, column) =
916                    expected_information(prediction, &basis[j], &basis[k]);
917            }
918        }
919        let covariance = invert_matrix(&information)?;
920        for (row, &j) in free.iter().enumerate() {
921            sigma[j] = positive_sqrt_or_nan(covariance.get(row, row));
922        }
923    }
924    for (j, template) in basis.iter().enumerate() {
925        if at_bound[j] && !support_boundary[j] {
926            sigma[j] =
927                positive_sqrt_or_nan(1.0 / expected_information(prediction, template, template));
928        }
929    }
930    Some(sigma)
931}
932
933fn positive_sqrt_or_nan(variance: f64) -> f64 {
934    if variance.is_finite() && variance > 0.0 {
935        variance.sqrt()
936    } else {
937        f64::NAN
938    }
939}
940
941fn normalize_templates(
942    templates: &[TwoArmBackgroundTemplate],
943) -> (Vec<TwoArmBackgroundTemplate>, Vec<f64>) {
944    let scales: Vec<f64> = templates
945        .iter()
946        .map(|template| {
947            template
948                .open_beam
949                .iter()
950                .chain(&template.sample)
951                .copied()
952                .fold(0.0_f64, f64::max)
953        })
954        .collect();
955    let normalized = templates
956        .iter()
957        .zip(&scales)
958        .map(|(template, &scale)| TwoArmBackgroundTemplate {
959            name: template.name.clone(),
960            open_beam: template
961                .open_beam
962                .iter()
963                .map(|value| value / scale)
964                .collect(),
965            sample: template.sample.iter().map(|value| value / scale).collect(),
966        })
967        .collect();
968    (normalized, scales)
969}
970
971/// Number of linearly independent concatenated open/sample shapes.
972///
973/// Templates are first normalized to a maximum entry of one, so this test is
974/// independent of the caller's amplitude units. The tolerance identifies only
975/// dependence at floating-point resolution; it does not claim that two nearly
976/// similar shapes can be separated in noisy data. That practical question is
977/// handled by the later parameter-separation analysis.
978fn background_template_rank(basis: &[Vec<f64>]) -> usize {
979    let n_rows = basis.first().map_or(0, Vec::len);
980    let dimension = n_rows.max(basis.len()) as f64;
981    let tolerance = f64::EPSILON * dimension * (n_rows as f64).sqrt();
982    let mut orthonormal: Vec<Vec<f64>> = Vec::with_capacity(basis.len());
983
984    for column in basis {
985        let mut residual = column.clone();
986        // A second pass makes the result stable when several earlier columns
987        // are close to one another.
988        for _ in 0..2 {
989            for direction in &orthonormal {
990                let projection: f64 = residual.iter().zip(direction).map(|(a, b)| a * b).sum();
991                for (value, &unit_value) in residual.iter_mut().zip(direction) {
992                    *value -= projection * unit_value;
993                }
994            }
995        }
996        let norm = residual
997            .iter()
998            .map(|value| value * value)
999            .sum::<f64>()
1000            .sqrt();
1001        if norm > tolerance {
1002            for value in &mut residual {
1003                *value /= norm;
1004            }
1005            orthonormal.push(residual);
1006        }
1007    }
1008    orthonormal.len()
1009}
1010
1011fn add_scaled(output: &mut [f64], template: &[f64], amplitude: f64) {
1012    for (value, &basis) in output.iter_mut().zip(template) {
1013        *value += amplitude * basis;
1014    }
1015}
1016
1017#[allow(clippy::too_many_arguments)]
1018fn validate_fit_inputs(
1019    observed_open_counts: &[f64],
1020    observed_sample_counts: &[f64],
1021    neutron_signal: &TwoArmCounts,
1022    open_exposure_scale: f64,
1023    sample_exposure_scale: f64,
1024    templates: &[TwoArmBackgroundTemplate],
1025    initial_amplitudes: &[f64],
1026) -> Result<(), FittingError> {
1027    let n_bins = observed_open_counts.len();
1028    if n_bins == 0 {
1029        return Err(FittingError::EmptyData);
1030    }
1031    for (field, actual) in [
1032        ("observed_sample_counts", observed_sample_counts.len()),
1033        ("open_neutron_signal", neutron_signal.open_beam.len()),
1034        ("sample_neutron_signal", neutron_signal.sample.len()),
1035    ] {
1036        if actual != n_bins {
1037            return Err(FittingError::LengthMismatch {
1038                expected: n_bins,
1039                actual,
1040                field,
1041            });
1042        }
1043    }
1044    if templates.is_empty() {
1045        return Err(FittingError::InvalidConfig(
1046            "at least one independently supplied background template is required".into(),
1047        ));
1048    }
1049    if initial_amplitudes.len() != templates.len() {
1050        return Err(FittingError::LengthMismatch {
1051            expected: templates.len(),
1052            actual: initial_amplitudes.len(),
1053            field: "initial_amplitudes",
1054        });
1055    }
1056
1057    validate_non_negative("observed_open_counts", observed_open_counts)?;
1058    validate_non_negative("observed_sample_counts", observed_sample_counts)?;
1059    validate_non_negative("open_neutron_signal", &neutron_signal.open_beam)?;
1060    validate_non_negative("sample_neutron_signal", &neutron_signal.sample)?;
1061    for (name, scale) in [
1062        ("open_exposure_scale", open_exposure_scale),
1063        ("sample_exposure_scale", sample_exposure_scale),
1064    ] {
1065        if !scale.is_finite() || scale <= 0.0 {
1066            return Err(FittingError::InvalidConfig(format!(
1067                "{name} must be finite and > 0, got {scale}"
1068            )));
1069        }
1070    }
1071    // Validated here, before any optimization, so a bad value is reported as
1072    // malformed input (InvalidConfig) rather than surfacing from the physics
1073    // layer after a full fit as an evaluation failure.
1074    for (name, value) in [
1075        (
1076            "open_beam_window_loss",
1077            neutron_signal.open_beam_window_loss,
1078        ),
1079        ("sample_window_loss", neutron_signal.sample_window_loss),
1080    ] {
1081        if !value.is_finite() || value < 0.0 {
1082            return Err(FittingError::InvalidConfig(format!(
1083                "{name} must be finite and >= 0 expected counts, got {value}"
1084            )));
1085        }
1086    }
1087
1088    let mut names = HashSet::with_capacity(templates.len());
1089    for (index, (template, &initial)) in templates.iter().zip(initial_amplitudes).enumerate() {
1090        if template.name.trim().is_empty() {
1091            return Err(FittingError::InvalidConfig(format!(
1092                "background template {index} has an empty name"
1093            )));
1094        }
1095        if !names.insert(template.name.as_str()) {
1096            return Err(FittingError::InvalidConfig(format!(
1097                "background template name '{}' is duplicated",
1098                template.name
1099            )));
1100        }
1101        for (field, actual) in [
1102            ("open_background_template", template.open_beam.len()),
1103            ("sample_background_template", template.sample.len()),
1104        ] {
1105            if actual != n_bins {
1106                return Err(FittingError::LengthMismatch {
1107                    expected: n_bins,
1108                    actual,
1109                    field,
1110                });
1111            }
1112        }
1113        validate_non_negative("open_background_template", &template.open_beam)?;
1114        validate_non_negative("sample_background_template", &template.sample)?;
1115        if !template
1116            .open_beam
1117            .iter()
1118            .chain(&template.sample)
1119            .any(|&value| value > 0.0)
1120        {
1121            return Err(FittingError::InvalidConfig(format!(
1122                "background template '{}' is zero in both arms",
1123                template.name
1124            )));
1125        }
1126        if !initial.is_finite() || initial < 0.0 {
1127            return Err(FittingError::InvalidConfig(format!(
1128                "initial_amplitudes[{index}] must be finite and >= 0, got {initial}"
1129            )));
1130        }
1131    }
1132    Ok(())
1133}
1134
1135fn validate_non_negative(field: &'static str, values: &[f64]) -> Result<(), FittingError> {
1136    for (index, &value) in values.iter().enumerate() {
1137        if !value.is_finite() || value < 0.0 {
1138            return Err(FittingError::InvalidConfig(format!(
1139                "{field}[{index}] must be finite and >= 0 expected counts, got {value}"
1140            )));
1141        }
1142    }
1143    Ok(())
1144}
1145
1146fn poisson_deviance(observed: &[f64], predicted: &[f64]) -> f64 {
1147    observed
1148        .iter()
1149        .zip(predicted)
1150        .map(|(&obs, &model)| {
1151            if obs > 0.0 {
1152                if model == 0.0 {
1153                    return f64::INFINITY;
1154                }
1155                // h(r) = (1+r) ln(1+r) - r, where r=(obs-model)/model.
1156                // A short series avoids subtracting nearly equal, very large
1157                // numbers when the fitted and observed counts almost match.
1158                let r = (obs - model) / model;
1159                let deviance = if r.abs() < 1.0e-3 {
1160                    let h = r
1161                        * r
1162                        * (0.5
1163                            + r * (-1.0 / 6.0
1164                                + r * (1.0 / 12.0 + r * (-1.0 / 20.0 + r * (1.0 / 30.0)))));
1165                    // The small factor is applied first: `(2 * model) * h`
1166                    // overflows for `model > MAX / 2` even when the exact
1167                    // term is representable.
1168                    2.0 * (model * h)
1169                } else {
1170                    // Away from equality the direct form does not suffer
1171                    // cancellation.  Subtracting logarithms also avoids an
1172                    // intermediate obs/model overflow or underflow.
1173                    2.0 * (obs * (obs.ln() - model.ln()) - (obs - model))
1174                };
1175                // Each exact deviance term is non-negative.  Guard only
1176                // against a final sub-ulp negative caused by floating point.
1177                deviance.max(0.0)
1178            } else {
1179                2.0 * model
1180            }
1181        })
1182        .sum()
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::{count_informative_bins, expected_information, poisson_deviance};
1188
1189    #[test]
1190    fn exact_zero_observation_and_prediction_have_zero_deviance() {
1191        assert_eq!(poisson_deviance(&[0.0], &[0.0]), 0.0);
1192        assert!(poisson_deviance(&[1.0], &[0.0]).is_infinite());
1193    }
1194
1195    #[test]
1196    fn nearly_equal_large_counts_have_non_negative_deviance() {
1197        let observed = [31_415_926_535_897.0, 27_182_818_284_590.0];
1198        let predicted = [31_415_926_535_896.0, 27_182_818_284_592.0];
1199        let deviance = poisson_deviance(&observed, &predicted);
1200        assert!(deviance.is_finite());
1201        assert!(deviance >= 0.0, "deviance = {deviance}");
1202    }
1203
1204    /// `(2 * model) * h` overflows for `model > MAX / 2` although the exact
1205    /// term `2 * model * h` is representable; the factor order matters.
1206    /// `(l / expected) * r` overflows for a subnormal expectation while the
1207    /// reversed order does not, which would make the information matrix
1208    /// asymmetric. The symmetric split must give the same finite value in
1209    /// both orders.
1210    #[test]
1211    fn expected_information_is_symmetric_and_finite_at_subnormal_expectation() {
1212        let forward = expected_information(&[1.0e-320], &[1.0], &[1.0e-320]);
1213        let reversed = expected_information(&[1.0e-320], &[1.0e-320], &[1.0]);
1214        assert!(forward.is_finite(), "forward = {forward}");
1215        assert_eq!(forward, reversed);
1216        assert!((forward - 1.0).abs() < 1.0e-9, "forward = {forward}");
1217    }
1218
1219    #[test]
1220    fn near_equality_extreme_counts_keep_finite_deviance() {
1221        let deviance = poisson_deviance(&[1.0001e308], &[1.0e308]);
1222        assert!(
1223            deviance.is_finite() && deviance > 0.0,
1224            "deviance = {deviance}"
1225        );
1226    }
1227
1228    #[test]
1229    fn extreme_finite_count_ratios_keep_finite_deviance() {
1230        let low_observation = poisson_deviance(&[1.0e-200], &[1.0e200]);
1231        let high_observation = poisson_deviance(&[1.0e200], &[1.0e-200]);
1232        assert!(low_observation.is_finite() && low_observation >= 0.0);
1233        assert!(high_observation.is_finite() && high_observation >= 0.0);
1234    }
1235
1236    /// A bin whose observation, signal, and every template are exactly zero
1237    /// cannot discriminate between amplitude vectors, so it is not a degree of
1238    /// freedom. A positive observation against zero capacity is informative:
1239    /// it refutes the model outright.
1240    #[test]
1241    fn only_bins_that_can_discriminate_are_informative() {
1242        let basis = vec![vec![0.0, 1.0, 0.0, 0.0]];
1243        let observed = [0.0, 5.0, 0.0, 3.0];
1244        let neutron = [0.0, 2.0, 0.0, 0.0];
1245        assert_eq!(count_informative_bins(&observed, &neutron, &basis), 2);
1246
1247        let all_dead = [0.0, 0.0];
1248        assert_eq!(
1249            count_informative_bins(&all_dead, &all_dead, &[vec![0.0, 0.0]]),
1250            0
1251        );
1252    }
1253}