Skip to main content

nereids_fitting/
poisson.rs

1//! Poisson-likelihood optimizer for low-count neutron data (transmission
2//! path).
3//!
4//! Minimizes the single-arm Poisson negative log-likelihood
5//!
6//! ```text
7//! L(θ) = Σᵢ [y_model(θ)ᵢ − y_obs,ᵢ · ln(y_model(θ)ᵢ)]
8//! ```
9//!
10//! using a projected damped Gauss-Newton / Fisher optimizer with
11//! backtracking line search and finite-difference fallback.
12//!
13//! **Scope note.**  In the current pipeline this solver is only reached
14//! for the **transmission + PoissonKL** path (via
15//! `crate::transmission_model::TransmissionKLBackgroundModel`).  The
16//! **counts** path uses the joint-Poisson conditional-binomial-deviance
17//! solver in [`crate::joint_poisson`], which replaced the older
18//! fixed-flux counts NLL that lived here.  The helpers [`CountsModel`]
19//! and [`CountsBackgroundScaleModel`] exposed from this module are
20//! retained for the [`crate::lm`]-side `evaluate_jacobian_and_fisher`
21//! Fisher-information helper and for spatial-regularization research
22//! drivers; they are not part of the production fit path.
23//!
24//! ## TRINIDI Reference
25//! - `trinidi/reconstruct.py` — Poisson NLL and APGM optimizer
26
27use nereids_core::constants::{PIVOT_FLOOR, POISSON_EPSILON};
28
29use crate::error::FittingError;
30use crate::lm::{FitModel, FlatMatrix};
31use crate::parameters::{FitParameter, ParameterSet};
32
33/// Configuration for the Poisson optimizer.
34#[derive(Debug, Clone)]
35pub struct PoissonConfig {
36    /// Maximum number of iterations.
37    pub max_iter: usize,
38    /// Step size for finite-difference gradient.
39    pub fd_step: f64,
40    /// Initial step size for line search.
41    pub step_size: f64,
42    /// Convergence tolerance used for both parameter displacement (L2 norm of step)
43    /// and gradient-norm convergence checks in `poisson_fit`.
44    pub tol_param: f64,
45    /// Armijo line search parameter (sufficient decrease).
46    pub armijo_c: f64,
47    /// Line search backtracking factor.
48    pub backtrack: f64,
49    /// Relative diagonal damping for analytical Gauss-Newton / Fisher steps.
50    pub gauss_newton_lambda: f64,
51    /// History size for the finite-difference L-BFGS fallback.
52    pub lbfgs_history: usize,
53    /// Whether to compute the Fisher covariance matrix (and uncertainties)
54    /// after convergence.  Set to `false` for per-pixel spatial mapping
55    /// when only densities are needed, avoiding extra model evaluations.
56    pub compute_covariance: bool,
57}
58
59impl Default for PoissonConfig {
60    fn default() -> Self {
61        Self {
62            max_iter: 200,
63            fd_step: 1e-7,
64            step_size: 1.0,
65            tol_param: 1e-8,
66            armijo_c: 1e-4,
67            backtrack: 0.5,
68            gauss_newton_lambda: 1e-3,
69            lbfgs_history: 8,
70            compute_covariance: true,
71        }
72    }
73}
74
75/// Result of Poisson-likelihood optimization.
76#[derive(Debug, Clone)]
77pub struct PoissonResult {
78    /// Final negative log-likelihood.
79    pub nll: f64,
80    /// Number of iterations taken.
81    pub iterations: usize,
82    /// Whether the optimizer converged.
83    pub converged: bool,
84    /// Final parameter values (all parameters, including fixed).
85    pub params: Vec<f64>,
86    /// Local covariance estimate from the inverse Fisher information matrix
87    /// at the converged parameters: `F⁻¹ = (J^T H J)⁻¹` where
88    /// `H = diag(obs/model²)` is the Poisson Hessian.
89    ///
90    /// This is a local curvature estimate, NOT a Bayesian posterior.
91    /// When an analytical Jacobian is available, it is used directly.
92    /// Otherwise a finite-difference Jacobian is computed as fallback.
93    /// `None` when the fit did not converge, the Fisher matrix is
94    /// singular, or covariance computation is disabled via config.
95    pub covariance: Option<FlatMatrix>,
96    /// Standard errors of free parameters: `√diag(F⁻¹)`.
97    /// `None` when covariance is not available.
98    pub uncertainties: Option<Vec<f64>>,
99}
100
101/// Compute Poisson negative log-likelihood.
102///
103/// NLL = Σᵢ [y_model - y_obs · ln(y_model)]
104///
105/// #109.2: For y_model ≤ epsilon, use a smooth C¹ quadratic extrapolation
106/// instead of a hard 1e30 penalty.  This keeps the NLL and its gradient
107/// continuous, so gradient-based optimizers (projected gradient, L-BFGS)
108/// can smoothly steer back into the feasible region rather than hitting
109/// a discontinuous cliff that stalls the line search.
110fn poisson_nll(y_obs: &[f64], y_model: &[f64]) -> f64 {
111    y_obs
112        .iter()
113        .zip(y_model.iter())
114        .map(|(&obs, &mdl)| poisson_nll_term(obs, mdl))
115        .sum()
116}
117
118/// Single-bin Poisson NLL with smooth extrapolation for mdl <= epsilon.
119///
120/// For mdl > 0: NLL = mdl - obs * ln(mdl)
121/// For mdl <= epsilon: quadratic Taylor expansion about epsilon,
122///   NLL(ε) + NLL'(ε)·(mdl−ε) + ½·NLL''(ε)·(mdl−ε)²
123/// where NLL'(x) = 1 − obs/x and NLL''(x) = obs/x².
124///
125/// Since delta = ε − mdl ≥ 0, this becomes:
126///   NLL(ε) − NLL'(ε)·delta + ½·NLL''(ε)·delta²
127///
128/// When obs == 0, the exact Hessian obs/ε² vanishes, leaving only a linear
129/// term that decreases without bound as mdl → −∞.  This can cause the
130/// optimizer to diverge.  We impose a minimum curvature of 1/ε so the
131/// quadratic penalty still curves upward for negative predictions.
132#[inline]
133fn poisson_nll_term(obs: f64, mdl: f64) -> f64 {
134    // #125.3: Negative observed counts would produce wrong-signed NLL terms.
135    // Release builds skip this check; callers must ensure non-negative counts. See #125 item 3.
136    debug_assert!(
137        obs.is_finite() && obs >= 0.0,
138        "poisson_nll_term: obs must be finite and >= 0, got {obs}"
139    );
140    if mdl > POISSON_EPSILON {
141        mdl - obs * mdl.ln()
142    } else {
143        let eps = POISSON_EPSILON;
144        let nll_eps = eps - obs * eps.ln();
145        let grad_eps = 1.0 - obs / eps;
146        // Minimum curvature 1/eps ensures the penalty grows quadratically
147        // even when obs == 0 (where the exact Hessian obs/eps^2 vanishes).
148        let hess_eps = if obs > 0.0 {
149            obs / (eps * eps)
150        } else {
151            1.0 / eps
152        };
153        let delta = eps - mdl;
154        // Taylor expansion: f(eps) + f'(eps)*(mdl - eps) + 0.5*f''(eps)*(mdl - eps)^2
155        // Since (mdl - eps) = -delta, the linear term flips sign.
156        nll_eps - grad_eps * delta + 0.5 * hess_eps * delta * delta
157    }
158}
159
160/// Per-bin Poisson NLL weight: ∂f(obs, mdl)/∂mdl.
161///
162/// For mdl > ε: w = 1 - obs/mdl
163/// For mdl ≤ ε: derivative of the smooth quadratic extrapolation,
164///   w = grad_eps - hess_eps · (ε - mdl), continuous at boundary.
165#[inline]
166fn poisson_nll_weight(obs: f64, mdl: f64) -> f64 {
167    if mdl > POISSON_EPSILON {
168        1.0 - obs / mdl
169    } else {
170        let eps = POISSON_EPSILON;
171        let grad_eps = 1.0 - obs / eps;
172        let hess_eps = if obs > 0.0 {
173            obs / (eps * eps)
174        } else {
175            1.0 / eps
176        };
177        grad_eps - hess_eps * (eps - mdl)
178    }
179}
180
181/// Per-bin Poisson NLL curvature: ∂²f(obs, mdl)/∂mdl².
182///
183/// For mdl > ε: h = obs / mdl²
184/// For mdl ≤ ε: curvature of the smooth quadratic extrapolation.
185#[inline]
186fn poisson_nll_curvature(obs: f64, mdl: f64) -> f64 {
187    if mdl > POISSON_EPSILON {
188        obs / (mdl * mdl)
189    } else {
190        let eps = POISSON_EPSILON;
191        if obs > 0.0 {
192            obs / (eps * eps)
193        } else {
194            1.0 / eps
195        }
196    }
197}
198
199/// Analytical first/second-order information for the Poisson objective.
200#[derive(Debug)]
201struct AnalyticalStepData {
202    /// Gradient of the Poisson NLL: grad = J^T · w.
203    grad: Vec<f64>,
204    /// Full Gauss-Newton / Fisher curvature approximation: J^T H J.
205    fisher: FlatMatrix,
206}
207
208/// Compute gradient and Gauss-Newton / Fisher curvature of the Poisson NLL
209/// using the analytical Jacobian.
210///
211/// `grad_j = Σᵢ wᵢ · J_{i,j}` where `wᵢ = ∂NLL/∂y_model_i`
212/// and `J_{i,j} = ∂y_model_i/∂θⱼ` from `model.analytical_jacobian()`.
213///
214/// The curvature uses the Poisson Hessian with respect to the model output:
215/// `fisher_{j,k} = Σᵢ hᵢ · J_{i,j} · J_{i,k}` where
216/// `hᵢ = ∂²NLL/∂y_model_i²`.
217///
218/// Returns `Some(step_data)` if the model provides an analytical Jacobian,
219/// `None` otherwise (caller should fall back to finite differences).
220fn compute_analytical_step_data(
221    model: &dyn FitModel,
222    params: &ParameterSet,
223    y_obs: &[f64],
224    y_model: &[f64],
225    all_vals_buf: &mut Vec<f64>,
226    free_idx_buf: &mut Vec<usize>,
227) -> Option<AnalyticalStepData> {
228    params.all_values_into(all_vals_buf);
229    params.free_indices_into(free_idx_buf);
230    let jac = model.analytical_jacobian(all_vals_buf, free_idx_buf, y_model)?;
231    let n_e = y_obs.len();
232    let n_free = free_idx_buf.len();
233    let mut grad = vec![0.0f64; n_free];
234    let mut fisher = FlatMatrix::zeros(n_free, n_free);
235    for i in 0..n_e {
236        let w = poisson_nll_weight(y_obs[i], y_model[i]);
237        let h = poisson_nll_curvature(y_obs[i], y_model[i]);
238        for (g, j) in grad.iter_mut().zip(0..n_free) {
239            let jij = jac.get(i, j);
240            *g += w * jij;
241            for k in 0..n_free {
242                *fisher.get_mut(j, k) += h * jij * jac.get(i, k);
243            }
244        }
245    }
246    Some(AnalyticalStepData { grad, fisher })
247}
248
249/// Compute gradient of Poisson NLL by finite differences.
250///
251/// `all_vals_buf` is a reusable scratch buffer for `params.all_values_into()`,
252/// avoiding a fresh allocation on every `model.evaluate()` call inside the
253/// per-parameter FD loop (N_free+1 allocations saved per gradient call).
254///
255/// `free_idx_buf` is a scratch buffer for `params.free_indices_into()`, reused
256/// across iterations to avoid per-gradient allocation.
257fn compute_gradient(
258    model: &dyn FitModel,
259    params: &mut ParameterSet,
260    y_obs: &[f64],
261    fd_step: f64,
262    all_vals_buf: &mut Vec<f64>,
263    free_idx_buf: &mut Vec<usize>,
264) -> Result<Vec<f64>, FittingError> {
265    params.all_values_into(all_vals_buf);
266    let base_model = model.evaluate(all_vals_buf)?;
267    let base_nll = poisson_nll(y_obs, &base_model);
268
269    params.free_indices_into(free_idx_buf);
270    let mut grad = vec![0.0; free_idx_buf.len()];
271
272    for (j, &idx) in free_idx_buf.iter().enumerate() {
273        let original = params.params[idx].value;
274        let step = fd_step * (1.0 + original.abs());
275
276        params.params[idx].value = original + step;
277        params.params[idx].clamp();
278        let mut actual_step = params.params[idx].value - original;
279
280        // #112: If the forward step is blocked by an upper bound, try the
281        // backward step so the gradient component is not frozen at zero.
282        if actual_step.abs() < PIVOT_FLOOR {
283            params.params[idx].value = original - step;
284            params.params[idx].clamp();
285            actual_step = params.params[idx].value - original;
286            if actual_step.abs() < PIVOT_FLOOR {
287                // Truly stuck at a point constraint — skip this parameter.
288                params.params[idx].value = original;
289                continue;
290            }
291        }
292
293        params.all_values_into(all_vals_buf);
294        let perturbed_model = match model.evaluate(all_vals_buf) {
295            Ok(v) => v,
296            Err(_) => {
297                params.params[idx].value = original;
298                continue;
299            }
300        };
301        let perturbed_nll = poisson_nll(y_obs, &perturbed_model);
302        params.params[idx].value = original;
303
304        grad[j] = (perturbed_nll - base_nll) / actual_step;
305    }
306
307    Ok(grad)
308}
309
310fn normalized_step_norm(
311    old_free: &[f64],
312    new_free: &[f64],
313    params: &ParameterSet,
314    free_param_indices: &[usize],
315) -> f64 {
316    old_free
317        .iter()
318        .zip(new_free.iter())
319        .zip(free_param_indices.iter())
320        .map(|((&old, &new), &idx)| {
321            let range = params.params[idx].upper - params.params[idx].lower;
322            let scale = if range.is_finite() && range > 1e-10 {
323                range
324            } else {
325                old.abs().max(1e-3)
326            };
327            ((old - new) / scale).powi(2)
328        })
329        .sum::<f64>()
330        .sqrt()
331}
332
333fn is_bound_active(param: &FitParameter, grad: f64) -> bool {
334    let at_lower = param.lower.is_finite() && (param.value - param.lower).abs() <= PIVOT_FLOOR;
335    let at_upper = param.upper.is_finite() && (param.value - param.upper).abs() <= PIVOT_FLOOR;
336    (at_lower && grad > 0.0) || (at_upper && grad < 0.0)
337}
338
339fn inactive_free_positions(
340    params: &ParameterSet,
341    free_param_indices: &[usize],
342    grad: &[f64],
343) -> Vec<usize> {
344    free_param_indices
345        .iter()
346        .zip(grad.iter())
347        .enumerate()
348        .filter_map(|(pos, (&idx, &g))| (!is_bound_active(&params.params[idx], g)).then_some(pos))
349        .collect()
350}
351
352fn inactive_free_mask(
353    params: &ParameterSet,
354    free_param_indices: &[usize],
355    grad: &[f64],
356) -> Vec<bool> {
357    free_param_indices
358        .iter()
359        .zip(grad.iter())
360        .map(|(&idx, &g)| !is_bound_active(&params.params[idx], g))
361        .collect()
362}
363
364fn projected_gradient_norm(
365    params: &ParameterSet,
366    free_param_indices: &[usize],
367    grad: &[f64],
368) -> f64 {
369    free_param_indices
370        .iter()
371        .zip(grad.iter())
372        .map(|(&idx, &g)| {
373            if is_bound_active(&params.params[idx], g) {
374                0.0
375            } else {
376                g * g
377            }
378        })
379        .sum::<f64>()
380        .sqrt()
381}
382
383fn extract_submatrix(matrix: &FlatMatrix, positions: &[usize]) -> FlatMatrix {
384    let n = positions.len();
385    let mut sub = FlatMatrix::zeros(n, n);
386    for (row_out, &row_in) in positions.iter().enumerate() {
387        for (col_out, &col_in) in positions.iter().enumerate() {
388            *sub.get_mut(row_out, col_out) = matrix.get(row_in, col_in);
389        }
390    }
391    sub
392}
393
394#[derive(Debug, Clone)]
395struct LbfgsHistory {
396    s_list: Vec<Vec<f64>>,
397    y_list: Vec<Vec<f64>>,
398    max_pairs: usize,
399}
400
401impl LbfgsHistory {
402    fn new(max_pairs: usize) -> Self {
403        Self {
404            s_list: Vec::with_capacity(max_pairs),
405            y_list: Vec::with_capacity(max_pairs),
406            max_pairs,
407        }
408    }
409
410    fn clear(&mut self) {
411        self.s_list.clear();
412        self.y_list.clear();
413    }
414
415    fn update(&mut self, old_free: &[f64], new_free: &[f64], old_grad: &[f64], new_grad: &[f64]) {
416        if self.max_pairs == 0 {
417            return;
418        }
419        let s: Vec<f64> = new_free
420            .iter()
421            .zip(old_free.iter())
422            .map(|(&new, &old)| new - old)
423            .collect();
424        let y: Vec<f64> = new_grad
425            .iter()
426            .zip(old_grad.iter())
427            .map(|(&new, &old)| new - old)
428            .collect();
429        let sy = dot(&s, &y);
430        let s_norm = dot(&s, &s).sqrt();
431        let y_norm = dot(&y, &y).sqrt();
432        if sy <= 1e-12 * s_norm * y_norm.max(1.0) {
433            return;
434        }
435        if self.s_list.len() == self.max_pairs {
436            self.s_list.remove(0);
437            self.y_list.remove(0);
438        }
439        self.s_list.push(s);
440        self.y_list.push(y);
441    }
442
443    fn apply_on_positions(&self, grad: &[f64], positions: &[usize]) -> Option<Vec<f64>> {
444        if self.s_list.is_empty() || positions.is_empty() {
445            return None;
446        }
447
448        let mut q: Vec<f64> = positions.iter().map(|&pos| grad[pos]).collect();
449        let mut alpha = vec![0.0; self.s_list.len()];
450        let mut rho = vec![0.0; self.s_list.len()];
451        let mut used = vec![false; self.s_list.len()];
452
453        for i in (0..self.s_list.len()).rev() {
454            let s_sub = extract_positions(&self.s_list[i], positions);
455            let y_sub = extract_positions(&self.y_list[i], positions);
456            let sy = dot(&s_sub, &y_sub);
457            if sy <= 1e-12 {
458                continue;
459            }
460            rho[i] = 1.0 / sy;
461            used[i] = true;
462            alpha[i] = rho[i] * dot(&s_sub, &q);
463            axpy(&mut q, -alpha[i], &y_sub);
464        }
465
466        let gamma = used
467            .iter()
468            .enumerate()
469            .rev()
470            .find_map(|(i, &is_used)| {
471                if !is_used {
472                    return None;
473                }
474                let last_s = extract_positions(&self.s_list[i], positions);
475                let last_y = extract_positions(&self.y_list[i], positions);
476                let yy = dot(&last_y, &last_y);
477                (yy > 0.0).then_some(dot(&last_s, &last_y) / yy)
478            })
479            .unwrap_or(1.0);
480
481        let mut r: Vec<f64> = q.into_iter().map(|v| gamma * v).collect();
482        for i in 0..self.s_list.len() {
483            if !used[i] {
484                continue;
485            }
486            let s_sub = extract_positions(&self.s_list[i], positions);
487            let y_sub = extract_positions(&self.y_list[i], positions);
488            let beta = rho[i] * dot(&y_sub, &r);
489            axpy(&mut r, alpha[i] - beta, &s_sub);
490        }
491
492        let mut full = vec![0.0; grad.len()];
493        for (&pos, &value) in positions.iter().zip(r.iter()) {
494            full[pos] = value;
495        }
496        Some(full)
497    }
498}
499
500fn dot(a: &[f64], b: &[f64]) -> f64 {
501    a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum()
502}
503
504fn axpy(dst: &mut [f64], alpha: f64, src: &[f64]) {
505    for (d, &s) in dst.iter_mut().zip(src.iter()) {
506        *d += alpha * s;
507    }
508}
509
510fn extract_positions(values: &[f64], positions: &[usize]) -> Vec<f64> {
511    positions.iter().map(|&pos| values[pos]).collect()
512}
513
514fn parameter_scaled_gradient_direction(
515    params: &ParameterSet,
516    free_param_indices: &[usize],
517    grad: &[f64],
518) -> Vec<f64> {
519    grad.iter()
520        .zip(free_param_indices.iter())
521        .map(|(&g, &idx)| {
522            if is_bound_active(&params.params[idx], g) {
523                return 0.0;
524            }
525            let p = &params.params[idx];
526            let range = p.upper - p.lower;
527            if range.is_finite() && range > 1e-10 {
528                g * range * range
529            } else {
530                let scale = p.value.abs().max(1e-3);
531                g * scale * scale
532            }
533        })
534        .collect()
535}
536
537fn max_feasible_step(
538    params: &ParameterSet,
539    free_param_indices: &[usize],
540    old_free: &[f64],
541    search_dir: &[f64],
542) -> f64 {
543    let mut alpha_max = f64::INFINITY;
544    for ((&idx, &x), &d) in free_param_indices
545        .iter()
546        .zip(old_free.iter())
547        .zip(search_dir.iter())
548    {
549        if d.abs() <= PIVOT_FLOOR {
550            continue;
551        }
552        let p = &params.params[idx];
553        let candidate = if d > 0.0 && p.lower.is_finite() {
554            (x - p.lower) / d
555        } else if d < 0.0 && p.upper.is_finite() {
556            (p.upper - x) / (-d)
557        } else {
558            f64::INFINITY
559        };
560        alpha_max = alpha_max.min(candidate);
561    }
562    alpha_max.max(0.0)
563}
564
565enum LineSearchResult {
566    Accepted {
567        nll: f64,
568        y_model: Vec<f64>,
569        hit_boundary: bool,
570    },
571    Stagnated,
572    Failed,
573}
574
575const MAX_FACE_STEPS_PER_ITER: usize = 4;
576
577/// Backtracking line search with Armijo condition.
578///
579/// Backtracking line search with Armijo sufficient-decrease condition:
580/// try a step, reject NaN/Inf model outputs, check the Armijo sufficient-decrease
581/// condition, and backtrack if needed.
582///
583/// # Arguments
584/// * `model`        — Forward model (maps parameters -> predicted counts).
585/// * `params`       — Parameter set (modified in place on success).
586/// * `y_obs`        — Observed counts.
587/// * `old_free`     — Free parameter values before the step.
588/// * `search_dir`   — Search direction (gradient or preconditioned gradient).
589/// * `initial_alpha`— Initial step size.
590/// * `config`       — Optimizer configuration (backtrack factor, Armijo c).
591/// * `grad`         — Raw gradient (used for Armijo descent computation).
592/// * `nll`          — Current negative log-likelihood.
593/// * `all_vals_buf` — Scratch buffer for `params.all_values_into()`, reused
594///   across the up-to-50 backtracking iterations to avoid per-trial allocation.
595/// * `free_vals_buf`— Scratch buffer for `params.free_values_into()`.
596/// * `trial_free_buf` — Scratch buffer for the trial free-parameter vector,
597///   reused across backtracking iterations to avoid up to 50 allocations.
598///
599/// # Returns
600/// `Some((new_nll, y_model))` if a step was accepted, `None` if the line search
601/// exhausted all backtracking attempts. Returns the model output alongside NLL
602/// so the caller can cache it for the next analytical gradient computation.
603///
604/// # Failure contract
605///
606/// On `None` return (line search exhausted), `params` is restored to
607/// `old_free` before returning. Callers need not restore manually.
608// All 12 arguments are genuinely needed: 9 original + 3 scratch buffers that
609// avoid per-backtracking-iteration allocations inside the 50-trial loop.
610#[allow(clippy::too_many_arguments)]
611fn backtracking_line_search(
612    model: &dyn FitModel,
613    params: &mut ParameterSet,
614    y_obs: &[f64],
615    old_free: &[f64],
616    free_param_indices: &[usize],
617    search_dir: &[f64],
618    initial_alpha: f64,
619    config: &PoissonConfig,
620    grad: &[f64],
621    nll: f64,
622    all_vals_buf: &mut Vec<f64>,
623    free_vals_buf: &mut Vec<f64>,
624    trial_free_buf: &mut Vec<f64>,
625) -> LineSearchResult {
626    let alpha_max = max_feasible_step(params, free_param_indices, old_free, search_dir);
627    if alpha_max <= PIVOT_FLOOR {
628        params.set_free_values(old_free);
629        return LineSearchResult::Stagnated;
630    }
631    let mut alpha = initial_alpha.min(alpha_max);
632    for _ in 0..50 {
633        // Trial step along the feasible path: x_new = x - alpha * d, with
634        // alpha capped so inactive-subspace directions hit bounds exactly
635        // instead of relying on projection to distort the step.
636        trial_free_buf.clear();
637        for ((&idx, &v), &d) in free_param_indices
638            .iter()
639            .zip(old_free.iter())
640            .zip(search_dir.iter())
641        {
642            let p = &params.params[idx];
643            trial_free_buf.push((v - alpha * d).clamp(p.lower, p.upper));
644        }
645        params.set_free_values(trial_free_buf);
646
647        params.all_values_into(all_vals_buf);
648        let trial_model = match model.evaluate(all_vals_buf) {
649            Ok(v) => v,
650            Err(_) => {
651                alpha *= config.backtrack;
652                continue;
653            }
654        };
655
656        // #113: If the model produced NaN/Inf, reduce step size rather
657        // than accepting a garbage NLL.
658        if trial_model.iter().any(|v| !v.is_finite()) {
659            alpha *= config.backtrack;
660            continue;
661        }
662
663        let trial_nll = poisson_nll(y_obs, &trial_model);
664
665        // Armijo condition: f(x_new) <= f(x) - c * descent
666        params.free_values_into(free_vals_buf);
667        let step_norm = normalized_step_norm(old_free, free_vals_buf, params, free_param_indices);
668        let descent = grad
669            .iter()
670            .zip(old_free.iter())
671            .zip(free_vals_buf.iter())
672            .map(|((&g, &old), &new)| g * (old - new))
673            .sum::<f64>();
674
675        if trial_nll.is_finite() && trial_nll <= nll - config.armijo_c * descent {
676            return LineSearchResult::Accepted {
677                nll: trial_nll,
678                y_model: trial_model,
679                hit_boundary: alpha_max.is_finite()
680                    && (alpha_max - alpha).abs() <= 1e-12 * alpha_max.max(1.0),
681            };
682        }
683
684        let nll_delta = (trial_nll - nll).abs();
685        let nll_scale = trial_nll.abs().max(nll.abs()).max(1.0);
686        if trial_nll.is_finite()
687            && step_norm < config.tol_param
688            && nll_delta <= config.tol_param * nll_scale
689        {
690            params.set_free_values(old_free);
691            return LineSearchResult::Stagnated;
692        }
693
694        // Backtrack
695        alpha *= config.backtrack;
696        if alpha <= PIVOT_FLOOR {
697            break;
698        }
699    }
700    params.set_free_values(old_free);
701    LineSearchResult::Failed
702}
703
704/// Early return for all-fixed parameters: evaluate once and report.
705///
706/// Returns `Ok(Some(PoissonResult))` if all parameters are fixed (either a
707/// valid result with converged=true, or a non-finite NLL with converged=false).
708/// Returns `Ok(None)` if there are free parameters and optimization should
709/// proceed. Returns `Err(FittingError)` if model evaluation fails.
710fn try_early_return_fixed(
711    model: &dyn FitModel,
712    y_obs: &[f64],
713    params: &ParameterSet,
714) -> Result<Option<PoissonResult>, FittingError> {
715    if params.n_free() != 0 {
716        return Ok(None);
717    }
718    let y_model = model.evaluate(&params.all_values())?;
719    let nll = poisson_nll(y_obs, &y_model);
720    if !nll.is_finite() {
721        return Ok(Some(PoissonResult {
722            nll,
723            iterations: 0,
724            converged: false,
725            params: params.all_values(),
726            covariance: None,
727            uncertainties: None,
728        }));
729    }
730    Ok(Some(PoissonResult {
731        nll,
732        iterations: 0,
733        converged: true,
734        params: params.all_values(),
735        covariance: None,
736        uncertainties: None,
737    }))
738}
739
740/// Build the Poisson Fisher information matrix via finite-difference Jacobian.
741///
742/// Used as fallback when the model does not provide an analytical Jacobian
743/// (e.g., `TransmissionFitModel` without base_xs).  Returns `None` if any
744/// model evaluation fails during the FD perturbation.
745///
746/// Respects parameter bounds via `clamp()` and uses the actual clamped step
747/// size.  When a bound blocks the central-difference step in one direction,
748/// falls back to one-sided difference.
749fn compute_fd_fisher(
750    model: &dyn FitModel,
751    params: &mut ParameterSet,
752    y_obs: &[f64],
753    y_model: &[f64],
754    fd_step: f64,
755    all_vals_buf: &mut Vec<f64>,
756    free_idx_buf: &mut Vec<usize>,
757) -> Option<FlatMatrix> {
758    params.free_indices_into(free_idx_buf);
759    let n_free = free_idx_buf.len();
760    let n_e = y_obs.len();
761
762    let mut jac = FlatMatrix::zeros(n_e, n_free);
763    for (col, &fi) in free_idx_buf.iter().enumerate() {
764        let orig = params.params[fi].value;
765        let h = fd_step * (1.0 + orig.abs());
766
767        // Forward perturbation (clamped to bounds).
768        params.params[fi].value = orig + h;
769        params.params[fi].clamp();
770        let step_plus = params.params[fi].value - orig;
771
772        let y_plus = if step_plus.abs() > PIVOT_FLOOR {
773            params.all_values_into(all_vals_buf);
774            let y = match model.evaluate(all_vals_buf) {
775                Ok(v) => v,
776                Err(_) => {
777                    params.params[fi].value = orig;
778                    return None;
779                }
780            };
781            Some(y)
782        } else {
783            None
784        };
785
786        // Backward perturbation (clamped to bounds).
787        params.params[fi].value = orig - h;
788        params.params[fi].clamp();
789        let step_minus = params.params[fi].value - orig;
790
791        let y_minus = if step_minus.abs() > PIVOT_FLOOR {
792            params.all_values_into(all_vals_buf);
793            let y = match model.evaluate(all_vals_buf) {
794                Ok(v) => v,
795                Err(_) => {
796                    params.params[fi].value = orig;
797                    return None;
798                }
799            };
800            Some(y)
801        } else {
802            None
803        };
804
805        params.params[fi].value = orig;
806
807        // Central difference, or one-sided fallback at bounds.
808        match (&y_plus, &y_minus) {
809            (Some(yp), Some(ym)) => {
810                let denom = step_plus - step_minus;
811                for (i, (&vp, &vm)) in yp.iter().zip(ym.iter()).enumerate() {
812                    *jac.get_mut(i, col) = (vp - vm) / denom;
813                }
814            }
815            (Some(yp), None) => {
816                for (i, (&vp, &v0)) in yp.iter().zip(y_model.iter()).enumerate() {
817                    *jac.get_mut(i, col) = (vp - v0) / step_plus;
818                }
819            }
820            (None, Some(ym)) => {
821                for (i, (&v0, &vm)) in y_model.iter().zip(ym.iter()).enumerate() {
822                    *jac.get_mut(i, col) = (v0 - vm) / (-step_minus);
823                }
824            }
825            (None, None) => {
826                // Both directions blocked — Jacobian column stays zero.
827            }
828        }
829    }
830
831    let mut fisher = FlatMatrix::zeros(n_free, n_free);
832    for i in 0..n_e {
833        let h_i = poisson_nll_curvature(y_obs[i], y_model[i]);
834        for j in 0..n_free {
835            let jij = jac.get(i, j);
836            for k in 0..n_free {
837                *fisher.get_mut(j, k) += h_i * jij * jac.get(i, k);
838            }
839        }
840    }
841    Some(fisher)
842}
843
844/// Run Poisson-likelihood optimization using a projected KL optimizer.
845///
846/// Uses damped Gauss-Newton / Fisher steps when an analytical Jacobian is
847/// available, falling back to projected gradient descent otherwise. Both paths
848/// use backtracking line search with Armijo condition and projection onto
849/// parameter bounds after each step.
850///
851/// # Arguments
852/// * `model` — Forward model (maps parameters → predicted counts).
853/// * `y_obs` — Observed counts at each data point.
854/// * `params` — Parameter set (modified in place).
855/// * `config` — Optimizer configuration.
856///
857/// # Returns
858/// `Ok(PoissonResult)` with final NLL, parameters, and convergence status.
859/// `Err(FittingError)` if model evaluation fails at the initial point.
860/// Evaluation errors during line-search trials are treated as bad steps
861/// (backtrack), not fatal errors.
862pub fn poisson_fit(
863    model: &dyn FitModel,
864    y_obs: &[f64],
865    params: &mut ParameterSet,
866    config: &PoissonConfig,
867) -> Result<PoissonResult, FittingError> {
868    if let Some(result) = try_early_return_fixed(model, y_obs, params)? {
869        return Ok(result);
870    }
871
872    // Scratch buffers reused across the entire optimization loop to avoid
873    // per-iteration allocations inside compute_gradient (N_free+1 calls)
874    // and backtracking_line_search (up to 50 calls).
875    let mut all_vals_buf = Vec::with_capacity(params.params.len());
876    let mut free_vals_buf = Vec::with_capacity(params.n_free());
877    let mut old_free_buf: Vec<f64> = Vec::with_capacity(params.n_free());
878    let mut trial_free_buf: Vec<f64> = Vec::with_capacity(params.n_free());
879    let mut free_idx_buf: Vec<usize> = Vec::with_capacity(params.n_free());
880    let mut fd_history = LbfgsHistory::new(config.lbfgs_history);
881    let mut pending_fd_state: Option<(Vec<f64>, Vec<f64>, Vec<bool>)> = None;
882
883    params.all_values_into(&mut all_vals_buf);
884    let mut y_model = model.evaluate(&all_vals_buf)?;
885    let mut nll = poisson_nll(y_obs, &y_model);
886
887    // Guard: if the initial NLL is non-finite, bail out immediately rather
888    // than entering the optimization loop with garbage values.
889    if !nll.is_finite() {
890        return Ok(PoissonResult {
891            nll,
892            iterations: 0,
893            converged: false,
894            params: params.all_values(),
895            covariance: None,
896            uncertainties: None,
897        });
898    }
899
900    let mut converged = false;
901    let mut iter = 0;
902
903    'outer: for _ in 0..config.max_iter {
904        iter += 1;
905        let mut face_steps = 0usize;
906
907        loop {
908            // Compute gradient: try analytical (grad = J^T · w) first,
909            // fall back to finite differences if the model doesn't provide
910            // an analytical Jacobian.
911            let analytical_step = compute_analytical_step_data(
912                model,
913                params,
914                y_obs,
915                &y_model,
916                &mut all_vals_buf,
917                &mut free_idx_buf,
918            );
919            let grad = if let Some(ref analytical) = analytical_step {
920                analytical.grad.clone()
921            } else {
922                compute_gradient(
923                    model,
924                    params,
925                    y_obs,
926                    config.fd_step,
927                    &mut all_vals_buf,
928                    &mut free_idx_buf,
929                )?
930            };
931
932            params.free_indices_into(&mut free_idx_buf);
933
934            let using_fd = analytical_step.is_none();
935            if using_fd {
936                params.free_values_into(&mut free_vals_buf);
937                let current_mask = inactive_free_mask(params, &free_idx_buf, &grad);
938                if let Some((prev_free, prev_grad, prev_mask)) = pending_fd_state.take() {
939                    if prev_mask == current_mask {
940                        fd_history.update(&prev_free, &free_vals_buf, &prev_grad, &grad);
941                    } else {
942                        fd_history.clear();
943                    }
944                }
945                pending_fd_state = Some((free_vals_buf.clone(), grad.clone(), current_mask));
946            } else {
947                pending_fd_state.take();
948                fd_history.clear();
949            }
950
951            // Use projected-gradient optimality for bound-constrained problems.
952            let projected_grad_norm = projected_gradient_norm(params, &free_idx_buf, &grad);
953            if projected_grad_norm < config.tol_param {
954                converged = true;
955                break 'outer;
956            }
957
958            let (search_dir, initial_alpha): (Vec<f64>, f64) =
959                if let Some(ref analytical) = analytical_step {
960                    let inactive_positions = inactive_free_positions(params, &free_idx_buf, &grad);
961                    if inactive_positions.is_empty() {
962                        converged = true;
963                        break 'outer;
964                    }
965                    let reduced_fisher = extract_submatrix(&analytical.fisher, &inactive_positions);
966                    let reduced_grad: Vec<f64> =
967                        inactive_positions.iter().map(|&pos| grad[pos]).collect();
968                    let reduced_dir = crate::lm::solve_damped_system(
969                        &reduced_fisher,
970                        &reduced_grad,
971                        config.gauss_newton_lambda,
972                    )
973                    .unwrap_or_else(|| {
974                        reduced_grad
975                            .iter()
976                            .enumerate()
977                            .map(|(j, &g)| g / reduced_fisher.get(j, j).max(1e-12))
978                            .collect()
979                    });
980                    let mut dir = vec![0.0; grad.len()];
981                    for (&pos, &value) in inactive_positions.iter().zip(reduced_dir.iter()) {
982                        dir[pos] = value;
983                    }
984                    (dir, config.step_size)
985                } else {
986                    let inactive_positions = inactive_free_positions(params, &free_idx_buf, &grad);
987                    if inactive_positions.is_empty() {
988                        converged = true;
989                        break 'outer;
990                    }
991                    let used_history = !fd_history.s_list.is_empty();
992                    let mut dir = fd_history
993                        .apply_on_positions(&grad, &inactive_positions)
994                        .unwrap_or_else(|| {
995                            parameter_scaled_gradient_direction(params, &free_idx_buf, &grad)
996                        });
997                    let descent = dot(&grad, &dir);
998                    if !descent.is_finite() || descent <= 0.0 {
999                        dir = parameter_scaled_gradient_direction(params, &free_idx_buf, &grad);
1000                    }
1001                    if used_history && descent.is_finite() && descent > 0.0 {
1002                        (dir, config.step_size)
1003                    } else {
1004                        let search_norm: f64 = dir.iter().map(|d| d * d).sum::<f64>().sqrt();
1005                        (dir, config.step_size / search_norm.max(1.0))
1006                    }
1007                };
1008
1009            params.free_values_into(&mut free_vals_buf);
1010            old_free_buf.clear();
1011            old_free_buf.extend_from_slice(&free_vals_buf);
1012
1013            match backtracking_line_search(
1014                model,
1015                params,
1016                y_obs,
1017                &old_free_buf,
1018                &free_idx_buf,
1019                &search_dir,
1020                initial_alpha,
1021                config,
1022                &grad,
1023                nll,
1024                &mut all_vals_buf,
1025                &mut free_vals_buf,
1026                &mut trial_free_buf,
1027            ) {
1028                LineSearchResult::Accepted {
1029                    nll: new_nll,
1030                    y_model: new_y_model,
1031                    hit_boundary,
1032                } => {
1033                    if !using_fd {
1034                        pending_fd_state = None;
1035                    }
1036                    nll = new_nll;
1037                    y_model = new_y_model;
1038
1039                    if hit_boundary && face_steps < MAX_FACE_STEPS_PER_ITER {
1040                        face_steps += 1;
1041                        continue;
1042                    }
1043                }
1044                LineSearchResult::Stagnated => {
1045                    converged = true;
1046                    break 'outer;
1047                }
1048                LineSearchResult::Failed => {
1049                    break 'outer;
1050                }
1051            }
1052
1053            params.free_values_into(&mut free_vals_buf);
1054            let step_norm =
1055                normalized_step_norm(&old_free_buf, &free_vals_buf, params, &free_idx_buf);
1056            if step_norm < config.tol_param {
1057                converged = true;
1058                break 'outer;
1059            }
1060
1061            break;
1062        }
1063    }
1064
1065    // Compute local covariance from the inverse Fisher information matrix
1066    // at the converged parameters: cov = (J^T H J)^{-1}.
1067    // This is a local curvature estimate, not a Bayesian posterior.
1068    // Gated by config.compute_covariance to avoid extra evaluations when
1069    // the caller only needs densities (e.g., per-pixel spatial mapping).
1070    let (covariance, uncertainties) = if converged && config.compute_covariance {
1071        // Use the final y_model from the last accepted step rather than
1072        // re-evaluating (avoids extra model call and cannot turn a
1073        // successful fit into an error during post-processing).
1074
1075        // Build the Fisher information matrix J^T H J from the Jacobian at
1076        // the converged parameters.  Try the analytical Jacobian first; fall
1077        // back to finite differences if not available.
1078        let fisher_opt = if let Some(step_data) = compute_analytical_step_data(
1079            model,
1080            params,
1081            y_obs,
1082            &y_model,
1083            &mut all_vals_buf,
1084            &mut free_idx_buf,
1085        ) {
1086            Some(step_data.fisher)
1087        } else {
1088            // Build Fisher via FD Jacobian.
1089            compute_fd_fisher(
1090                model,
1091                params,
1092                y_obs,
1093                &y_model,
1094                config.fd_step,
1095                &mut all_vals_buf,
1096                &mut free_idx_buf,
1097            )
1098        };
1099
1100        if let Some(fisher) = fisher_opt {
1101            if let Some(cov) = crate::lm::invert_matrix(&fisher) {
1102                // Raw Cramér-Rao (inverse-Fisher) covariance-only bound. This
1103                // fitter deliberately does NOT apply the optional χ²-scaling
1104                // (`scale_by_chi2`, issue #638): the objective here is the
1105                // Poisson NLL on transmission fractions (~0..1), so a Poisson
1106                // deviance would be a pseudo-Poisson statistic, not a valid
1107                // reduced-χ². The pipeline extraction layer
1108                // (`pipeline::poisson_to_lm_result`) scales this σ by the
1109                // Gaussian reduced-χ² the result reports — the same GOF the LM
1110                // transmission path uses — when the caller opts in, keeping the
1111                // formalism-agnostic scaling out of this count-statistics
1112                // fitter.
1113                let n_free = cov.nrows;
1114                let unc: Vec<f64> = (0..n_free)
1115                    .map(|i| {
1116                        let d = cov.get(i, i);
1117                        if d.is_finite() && d > 0.0 {
1118                            d.sqrt()
1119                        } else {
1120                            f64::NAN
1121                        }
1122                    })
1123                    .collect();
1124                (Some(cov), Some(unc))
1125            } else {
1126                (None, None)
1127            }
1128        } else {
1129            (None, None)
1130        }
1131    } else {
1132        (None, None)
1133    };
1134
1135    Ok(PoissonResult {
1136        nll,
1137        iterations: iter,
1138        converged,
1139        params: params.all_values(),
1140        covariance,
1141        uncertainties,
1142    })
1143}
1144
1145/// Fixed-flux counts-domain forward model: `Y_model = flux × T_model(θ) + background`.
1146///
1147/// **Retained for the research Fisher helper, not for production fitting.**
1148/// The production counts-KL dispatch (`SolverConfig::PoissonKL` on
1149/// `InputData::Counts` / `InputData::CountsWithNuisance`) goes through
1150/// the joint-Poisson conditional-binomial-deviance path in
1151/// [`crate::joint_poisson`].  `CountsModel` and
1152/// [`CountsBackgroundScaleModel`] below are consumed only by
1153/// `nereids_pipeline::pipeline::evaluate_jacobian_and_fisher` (the
1154/// Fisher-info research helper used by the spatial-regularization
1155/// epic #394) and by this module's `#[cfg(test)]` tests.  They assume
1156/// the caller has pre-computed `flux = c · O` (i.e. `c` is baked into
1157/// `flux` — a convention that proved error-prone for
1158/// end users, which is precisely why the production path no longer
1159/// uses this struct).
1160///
1161/// The `flux` and `background` slices must have the same length as the
1162/// transmission vector returned by the inner model.  In debug builds,
1163/// `evaluate()` asserts this invariant.
1164pub struct CountsModel<'a> {
1165    /// Underlying transmission model.
1166    pub transmission_model: &'a dyn FitModel,
1167    /// Incident flux (counts per bin in open beam, after normalization).
1168    pub flux: &'a [f64],
1169    /// Background counts per bin.
1170    pub background: &'a [f64],
1171    /// Total parameter count in the wrapped model.
1172    pub n_params: usize,
1173}
1174
1175impl<'a> FitModel for CountsModel<'a> {
1176    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1177        let transmission = self.transmission_model.evaluate(params)?;
1178        debug_assert_eq!(
1179            transmission.len(),
1180            self.flux.len(),
1181            "CountsModel: transmission length ({}) != flux length ({})",
1182            transmission.len(),
1183            self.flux.len(),
1184        );
1185        debug_assert_eq!(
1186            self.flux.len(),
1187            self.background.len(),
1188            "CountsModel: flux length ({}) != background length ({})",
1189            self.flux.len(),
1190            self.background.len(),
1191        );
1192        Ok(transmission
1193            .iter()
1194            .zip(self.flux.iter())
1195            .zip(self.background.iter())
1196            .map(|((&t, &f), &b)| f * t + b)
1197            .collect())
1198    }
1199
1200    /// Analytical Jacobian: ∂Y/∂θ = flux · ∂T_inner/∂θ.
1201    ///
1202    /// Background is constant w.r.t. θ and drops out.
1203    fn analytical_jacobian(
1204        &self,
1205        params: &[f64],
1206        free_param_indices: &[usize],
1207        y_current: &[f64],
1208    ) -> Option<FlatMatrix> {
1209        let n_e = y_current.len();
1210        // Recover inner transmission: T = (Y - background) / flux
1211        let t_inner: Vec<f64> = y_current
1212            .iter()
1213            .zip(self.flux.iter())
1214            .zip(self.background.iter())
1215            .map(|((&y, &f), &b)| if f.abs() > 1e-30 { (y - b) / f } else { 0.0 })
1216            .collect();
1217        let inner_jac =
1218            self.transmission_model
1219                .analytical_jacobian(params, free_param_indices, &t_inner)?;
1220        let n_free = free_param_indices.len();
1221        let mut jac = FlatMatrix::zeros(n_e, n_free);
1222        for i in 0..n_e {
1223            for j in 0..n_free {
1224                *jac.get_mut(i, j) = self.flux[i] * inner_jac.get(i, j);
1225            }
1226        }
1227        Some(jac)
1228    }
1229}
1230
1231// ── ForwardModel implementation for CountsModel (Phase 1) ────────────────
1232
1233impl<'a> crate::forward_model::ForwardModel for CountsModel<'a> {
1234    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1235        self.evaluate(params)
1236    }
1237
1238    // No analytical jacobian — uses finite differences (same as FitModel).
1239
1240    fn n_data(&self) -> usize {
1241        self.flux.len()
1242    }
1243
1244    fn n_params(&self) -> usize {
1245        self.n_params
1246    }
1247}
1248
1249/// Fixed-flux counts model with optional α₁ / α₂ nuisance scaling of
1250/// signal and detector background.
1251///
1252/// **Retained for the research Fisher helper, not for production fitting.**
1253/// See [`CountsModel`] for the scope note — the production counts-KL
1254/// dispatch does not use this struct; it is reached only from
1255/// `evaluate_jacobian_and_fisher` (Epic #394 spatial-regularization
1256/// prototype) and from this module's `#[cfg(test)]` tests.
1257///
1258/// Given a transmission model `T(θ)`, predicts:
1259///
1260///   Y(E) = α₁ · [Φ(E) · T(θ)] + α₂ · B(E)
1261///
1262/// where `α₁` and `α₂` are parameter-vector entries.
1263///
1264/// ## Index invariant
1265///
1266/// `alpha1_index` / `alpha2_index` must NOT designate a parameter index
1267/// the transmission model reads. The wrapper cannot detect such a
1268/// collision through `dyn FitModel`, and the analytic Jacobian excludes
1269/// the scale indices from the inner free set — a collided parameter
1270/// would get only the scale contribution, silently omitting ∂T/∂p.
1271/// (Sharing ONE parameter between the two scale roles,
1272/// `alpha1_index == alpha2_index`, IS supported: the columns
1273/// accumulate.)
1274pub struct CountsBackgroundScaleModel<'a> {
1275    /// Underlying transmission model.
1276    pub transmission_model: &'a dyn FitModel,
1277    /// Incident flux spectrum.
1278    pub flux: &'a [f64],
1279    /// Detector background spectrum.
1280    pub background: &'a [f64],
1281    /// Index of α₁ in the parameter vector.
1282    /// Must not be a parameter the transmission model reads (see struct docs).
1283    pub alpha1_index: usize,
1284    /// Index of α₂ in the parameter vector.
1285    /// Must not be a parameter the transmission model reads (see struct docs).
1286    pub alpha2_index: usize,
1287    /// Total parameter count in the wrapped model.
1288    pub n_params: usize,
1289}
1290
1291impl<'a> FitModel for CountsBackgroundScaleModel<'a> {
1292    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1293        let transmission = self.transmission_model.evaluate(params)?;
1294        let alpha1 = params[self.alpha1_index];
1295        let alpha2 = params[self.alpha2_index];
1296        debug_assert_eq!(transmission.len(), self.flux.len());
1297        debug_assert_eq!(self.flux.len(), self.background.len());
1298        Ok(transmission
1299            .iter()
1300            .zip(self.flux.iter())
1301            .zip(self.background.iter())
1302            .map(|((&t, &f), &b)| alpha1 * f * t + alpha2 * b)
1303            .collect())
1304    }
1305
1306    fn analytical_jacobian(
1307        &self,
1308        params: &[f64],
1309        free_param_indices: &[usize],
1310        y_current: &[f64],
1311    ) -> Option<FlatMatrix> {
1312        let n_e = y_current.len();
1313        let n_free = free_param_indices.len();
1314        let alpha1 = params[self.alpha1_index];
1315        let alpha1_col = free_param_indices
1316            .iter()
1317            .position(|&i| i == self.alpha1_index);
1318        let alpha2_col = free_param_indices
1319            .iter()
1320            .position(|&i| i == self.alpha2_index);
1321        let inner_free: Vec<usize> = free_param_indices
1322            .iter()
1323            .copied()
1324            .filter(|&i| i != self.alpha1_index && i != self.alpha2_index)
1325            .collect();
1326
1327        // Evaluate the inner transmission model directly instead of
1328        // reconstructing from y_current — reconstruction via
1329        // (y - alpha2*b)/(alpha1*f) is undefined when alpha1 ≈ 0.
1330        let t_inner = match self.transmission_model.evaluate(params) {
1331            Ok(t) => t,
1332            Err(_) => return None,
1333        };
1334
1335        let inner_jac = if !inner_free.is_empty() {
1336            self.transmission_model
1337                .analytical_jacobian(params, &inner_free, &t_inner)
1338        } else {
1339            None
1340        };
1341
1342        let mut jacobian = FlatMatrix::zeros(n_e, n_free);
1343        if let Some(ref ij) = inner_jac {
1344            let mut inner_col = 0;
1345            for (col, &fp) in free_param_indices.iter().enumerate() {
1346                if fp == self.alpha1_index || fp == self.alpha2_index {
1347                    continue;
1348                }
1349                for row in 0..n_e {
1350                    *jacobian.get_mut(row, col) = alpha1 * self.flux[row] * ij.get(row, inner_col);
1351                }
1352                inner_col += 1;
1353            }
1354        } else if !inner_free.is_empty() {
1355            return None;
1356        }
1357
1358        // Accumulate (+=) rather than assign: the struct does not forbid
1359        // alpha1_index == alpha2_index, and evaluate() reads the aliased
1360        // parameter for both roles, so its derivative is the SUM of both
1361        // column contributions (f·t + b). With distinct indices each
1362        // column is touched once and += on the zeroed matrix is identical
1363        // to assignment.
1364        if let Some(col) = alpha1_col {
1365            for (row, (&f, &t)) in self.flux.iter().zip(t_inner.iter()).enumerate() {
1366                *jacobian.get_mut(row, col) += f * t;
1367            }
1368        }
1369        if let Some(col) = alpha2_col {
1370            for (row, &bg) in self.background.iter().enumerate() {
1371                *jacobian.get_mut(row, col) += bg;
1372            }
1373        }
1374
1375        Some(jacobian)
1376    }
1377}
1378
1379impl<'a> crate::forward_model::ForwardModel for CountsBackgroundScaleModel<'a> {
1380    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1381        self.evaluate(params)
1382    }
1383
1384    fn n_data(&self) -> usize {
1385        self.flux.len()
1386    }
1387
1388    fn n_params(&self) -> usize {
1389        self.n_params
1390    }
1391}
1392
1393/// KL-compatible background model for transmission data.
1394///
1395/// Given a transmission model T_inner(θ), predicts:
1396///
1397///   T_out(E) = T_inner(E) + b₀ + b₁/√E
1398///
1399/// where b₀ and b₁ are the additive background parameters at indices
1400/// `b0_index` and `b1_index` in the parameter vector.
1401///
1402/// Unlike `NormalizedTransmissionModel` (which uses `Anorm * T + BackA +
1403/// BackB/√E + BackC√E` with 4 free parameters), this model:
1404/// - Has only 2 background parameters (b₀, b₁), reducing overfitting risk
1405/// - Constrains b₀, b₁ ≥ 0 via parameter bounds (physical: background
1406///   adds counts, never subtracts), ensuring T_out > 0 for valid Poisson NLL
1407/// - Does NOT multiply T_inner by a normalization factor — normalization
1408///   is handled separately (nuisance estimation for counts, or pre-processing
1409///   for transmission data)
1410///
1411/// ## Gradient
1412///
1413/// - ∂T_out/∂nₖ = ∂T_inner/∂nₖ = -σₖ(E)·T_inner(E)  (same as bare model)
1414/// - ∂T_out/∂b₀ = 1
1415/// - ∂T_out/∂b₁ = 1/√E
1416///
1417/// ## Index invariant
1418///
1419/// `b0_index` / `b1_index` must NOT designate a parameter index the
1420/// inner model reads. The wrapper cannot detect such a collision
1421/// through `dyn FitModel`, and the analytic Jacobian excludes the
1422/// background indices from the inner free set — a collided parameter
1423/// would get only the background contribution, silently omitting
1424/// ∂T_inner/∂p. (Sharing ONE parameter between the two background
1425/// roles, `b0_index == b1_index`, IS supported: the columns
1426/// accumulate.)
1427pub struct TransmissionKLBackgroundModel<'a> {
1428    /// Underlying transmission model (density parameters only).
1429    pub inner: &'a dyn FitModel,
1430    /// Precomputed 1/√E for each energy bin.
1431    pub inv_sqrt_energies: Vec<f64>,
1432    /// Index of b₀ (constant background) in the parameter vector.
1433    /// Must not be a parameter the inner model reads (see struct docs).
1434    pub b0_index: usize,
1435    /// Index of b₁ (1/√E background) in the parameter vector.
1436    /// Must not be a parameter the inner model reads (see struct docs).
1437    pub b1_index: usize,
1438    /// Total parameter count in the wrapped model.
1439    pub n_params: usize,
1440}
1441
1442impl<'a> FitModel for TransmissionKLBackgroundModel<'a> {
1443    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1444        let t_inner = self.inner.evaluate(params)?;
1445        let b0 = params[self.b0_index];
1446        let b1 = params[self.b1_index];
1447        Ok(t_inner
1448            .iter()
1449            .zip(self.inv_sqrt_energies.iter())
1450            .map(|(&t, &inv_sqrt_e)| t + b0 + b1 * inv_sqrt_e)
1451            .collect())
1452    }
1453
1454    fn analytical_jacobian(
1455        &self,
1456        params: &[f64],
1457        free_param_indices: &[usize],
1458        y_current: &[f64],
1459    ) -> Option<FlatMatrix> {
1460        let n_e = y_current.len();
1461        let n_free = free_param_indices.len();
1462
1463        // Identify which free params are background vs inner model.
1464        let b0_col = free_param_indices.iter().position(|&i| i == self.b0_index);
1465        let b1_col = free_param_indices.iter().position(|&i| i == self.b1_index);
1466
1467        // Inner model free params (those not b0 or b1).
1468        let inner_free: Vec<usize> = free_param_indices
1469            .iter()
1470            .copied()
1471            .filter(|&i| i != self.b0_index && i != self.b1_index)
1472            .collect();
1473
1474        // Get inner model Jacobian for density columns.
1475        let inner_jac = if !inner_free.is_empty() {
1476            // Evaluate inner model at current params to get T_inner for y_current.
1477            let t_inner = self.inner.evaluate(params).ok()?;
1478            self.inner
1479                .analytical_jacobian(params, &inner_free, &t_inner)
1480        } else {
1481            None
1482        };
1483
1484        let mut jacobian = FlatMatrix::zeros(n_e, n_free);
1485
1486        // Fill inner model columns (density, temperature).
1487        // Inner Jacobian is the same as bare model — background doesn't
1488        // affect ∂T_inner/∂nₖ.
1489        if let Some(ij) = inner_jac.as_ref() {
1490            let mut inner_col = 0;
1491            for (col, &fp) in free_param_indices.iter().enumerate() {
1492                if fp == self.b0_index || fp == self.b1_index {
1493                    continue;
1494                }
1495                for row in 0..n_e {
1496                    *jacobian.get_mut(row, col) = ij.get(row, inner_col);
1497                }
1498                inner_col += 1;
1499            }
1500        } else if !inner_free.is_empty() {
1501            // Inner params are free but the inner model has no analytical
1502            // Jacobian — fall back to FD for the entire model.
1503            return None;
1504        }
1505
1506        // Background columns. Accumulate (+=) rather than assign: the
1507        // struct does not forbid b0_index == b1_index, and evaluate()
1508        // reads the aliased parameter for both roles, so its derivative
1509        // is the SUM of both column contributions (1 + 1/√E). With
1510        // distinct indices each column is touched once and += on the
1511        // zeroed matrix is identical to assignment.
1512        if let Some(col) = b0_col {
1513            for row in 0..n_e {
1514                *jacobian.get_mut(row, col) += 1.0; // ∂T_out/∂b₀ = 1
1515            }
1516        }
1517        if let Some(col) = b1_col {
1518            for row in 0..n_e {
1519                *jacobian.get_mut(row, col) += self.inv_sqrt_energies[row]; // ∂T_out/∂b₁ = 1/√E
1520            }
1521        }
1522
1523        Some(jacobian)
1524    }
1525}
1526
1527impl<'a> crate::forward_model::ForwardModel for TransmissionKLBackgroundModel<'a> {
1528    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1529        self.evaluate(params)
1530    }
1531
1532    fn n_data(&self) -> usize {
1533        self.inv_sqrt_energies.len()
1534    }
1535
1536    fn n_params(&self) -> usize {
1537        self.n_params
1538    }
1539}
1540
1541#[cfg(test)]
1542mod tests {
1543    use super::*;
1544    use crate::lm::FitModel;
1545    use crate::parameters::FitParameter;
1546
1547    /// Poisson deviance `D = 2·Σ [y_obs·ln(y_obs/y_model) − (y_obs − y_model)]`.
1548    ///
1549    /// Goodness-of-fit statistic for the Poisson likelihood. Each term is
1550    /// non-negative; the `y_obs = 0` term reduces to `2·y_model`. `y_model` is
1551    /// HARD-floored at `POISSON_EPSILON` before the logarithm — a simpler
1552    /// scheme than [`poisson_nll_term`]'s smooth quadratic sub-`POISSON_EPSILON`
1553    /// extrapolation, used here solely to keep the deviance finite for a
1554    /// degenerate zero prediction (the two schemes agree only for
1555    /// `y_model ≥ POISSON_EPSILON`).
1556    ///
1557    /// Reference formula kept under `#[cfg(test)]` as an independently
1558    /// hand-checked oracle (issue #638). The production σ-scaling on the
1559    /// transmission Poisson-KL path scales by the Gaussian reduced-χ² the
1560    /// result reports (see `nereids_pipeline::pipeline::poisson_to_lm_result`),
1561    /// NOT this Poisson deviance, which on transmission fractions would be a
1562    /// pseudo-Poisson statistic rather than a valid reduced-χ².
1563    fn poisson_deviance(y_obs: &[f64], y_model: &[f64]) -> f64 {
1564        // Poisson deviance is undefined for negative observations (the term
1565        // `obs * ln(obs/m)` has no meaning); guard the impossible state.
1566        debug_assert!(
1567            y_obs.iter().all(|&o| o >= 0.0),
1568            "poisson_deviance requires non-negative observations"
1569        );
1570        y_obs
1571            .iter()
1572            .zip(y_model.iter())
1573            .map(|(&obs, &mdl)| {
1574                let m = mdl.max(POISSON_EPSILON);
1575                if obs > 0.0 {
1576                    2.0 * (obs * (obs / m).ln() - (obs - m))
1577                } else {
1578                    2.0 * m
1579                }
1580            })
1581            .sum()
1582    }
1583
1584    /// F3 oracle: `poisson_deviance` against a per-bin HAND-COMPUTED value.
1585    ///
1586    /// Bins exercise every branch:
1587    ///  - `obs=4, mdl=2` (main branch): `2·(4·ln(4/2) − (4−2)) = 8·ln2 − 4`.
1588    ///  - `obs=1, mdl=1` (main branch, exact match): `2·(1·ln1 − 0) = 0`.
1589    ///  - `obs=0, mdl=5` (`obs=0` branch): `2·5 = 10`.
1590    ///  - `obs=3, mdl=0` (flooring path, `obs>0`, `mdl<ε`): `mdl` is clamped to
1591    ///    `POISSON_EPSILON`, so the term is `2·(3·ln(3/ε) − (3−ε))`. Verifies
1592    ///    the hard floor is applied (an unclamped `mdl=0` would give `ln(3/0)=∞`
1593    ///    and a non-finite deviance).
1594    #[test]
1595    fn test_poisson_deviance_hand_computed() {
1596        let y_obs = [4.0, 1.0, 0.0, 3.0];
1597        let y_model = [2.0, 1.0, 5.0, 0.0];
1598
1599        let eps = POISSON_EPSILON;
1600        let term_main = 8.0 * 2.0_f64.ln() - 4.0; // obs=4, mdl=2
1601        let term_match = 0.0; // obs=1, mdl=1
1602        let term_zero_obs = 10.0; // obs=0, mdl=5 → 2·5
1603        let term_floored = 2.0 * (3.0 * (3.0 / eps).ln() - (3.0 - eps)); // obs=3, mdl=0
1604        let expected = term_main + term_match + term_zero_obs + term_floored;
1605
1606        let got = poisson_deviance(&y_obs, &y_model);
1607        assert!(
1608            (got - expected).abs() < 1e-9,
1609            "poisson_deviance = {got}, hand-computed = {expected}"
1610        );
1611        // Each term is non-negative and the floored bin stayed finite.
1612        assert!(got.is_finite() && got > 0.0, "deviance = {got}");
1613    }
1614
1615    /// Simple model: y = a * exp(-b * x)
1616    /// This mimics transmission: counts = flux * exp(-density * sigma)
1617    struct ExponentialModel {
1618        x: Vec<f64>,
1619        flux: Vec<f64>,
1620    }
1621
1622    impl FitModel for ExponentialModel {
1623        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1624            let b = params[0]; // "density"
1625            Ok(self
1626                .x
1627                .iter()
1628                .zip(self.flux.iter())
1629                .map(|(&xi, &fi)| fi * (-b * xi).exp())
1630                .collect())
1631        }
1632    }
1633
1634    #[test]
1635    fn test_poisson_nll_perfect_match() {
1636        let y_obs = vec![10.0, 20.0, 30.0];
1637        let y_model = vec![10.0, 20.0, 30.0];
1638        let nll = poisson_nll(&y_obs, &y_model);
1639        // NLL = Σ(y_model - y_obs*ln(y_model))
1640        let expected: f64 = y_obs
1641            .iter()
1642            .zip(y_model.iter())
1643            .map(|(&o, &m)| m - o * m.ln())
1644            .sum();
1645        assert!((nll - expected).abs() < 1e-10);
1646    }
1647
1648    #[test]
1649    fn test_poisson_fit_exponential() {
1650        // Generate synthetic Poisson data from y = 1000 * exp(-0.5 * x)
1651        let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.5).collect();
1652        let true_b = 0.5;
1653        let flux: Vec<f64> = vec![1000.0; x.len()];
1654
1655        let model = ExponentialModel {
1656            x: x.clone(),
1657            flux: flux.clone(),
1658        };
1659
1660        // Use exact expected counts (no noise) for reproducibility
1661        let y_obs = model.evaluate(&[true_b]).unwrap();
1662
1663        let mut params = ParameterSet::new(vec![
1664            FitParameter::non_negative("b", 1.0), // Initial guess 2× off
1665        ]);
1666
1667        let result = poisson_fit(&model, &y_obs, &mut params, &PoissonConfig::default()).unwrap();
1668
1669        assert!(
1670            result.converged,
1671            "Poisson fit did not converge after {} iterations",
1672            result.iterations,
1673        );
1674        assert!(
1675            (result.params[0] - true_b).abs() / true_b < 0.05,
1676            "Fitted b = {}, true = {}, error = {:.1}%",
1677            result.params[0],
1678            true_b,
1679            (result.params[0] - true_b).abs() / true_b * 100.0,
1680        );
1681    }
1682
1683    #[test]
1684    fn test_poisson_fit_low_counts() {
1685        // Low-count regime: flux = 10 counts per bin
1686        let x: Vec<f64> = (0..30).map(|i| i as f64 * 0.2).collect();
1687        let true_b = 0.3;
1688        let flux: Vec<f64> = vec![10.0; x.len()];
1689
1690        let model = ExponentialModel {
1691            x: x.clone(),
1692            flux: flux.clone(),
1693        };
1694
1695        let y_obs = model.evaluate(&[true_b]).unwrap();
1696
1697        let mut params = ParameterSet::new(vec![FitParameter::non_negative("b", 0.1)]);
1698
1699        let result = poisson_fit(&model, &y_obs, &mut params, &PoissonConfig::default()).unwrap();
1700
1701        assert!(result.converged);
1702        assert!(
1703            (result.params[0] - true_b).abs() / true_b < 0.1,
1704            "Low-count: fitted b = {}, true = {}",
1705            result.params[0],
1706            true_b,
1707        );
1708    }
1709
1710    #[test]
1711    fn test_poisson_non_negativity() {
1712        // Data that would drive parameter negative without constraint
1713        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1714        let flux: Vec<f64> = vec![100.0; x.len()];
1715
1716        let model = ExponentialModel {
1717            x: x.clone(),
1718            flux: flux.clone(),
1719        };
1720
1721        // Generate data with b=0 (constant), but start with b=1
1722        let y_obs: Vec<f64> = vec![100.0; x.len()];
1723
1724        let mut params = ParameterSet::new(vec![FitParameter::non_negative("b", 1.0)]);
1725
1726        let result = poisson_fit(&model, &y_obs, &mut params, &PoissonConfig::default()).unwrap();
1727
1728        assert!(
1729            result.params[0] >= 0.0,
1730            "b should be non-negative, got {}",
1731            result.params[0],
1732        );
1733        assert!(
1734            result.params[0] < 0.1,
1735            "b should be ~0, got {}",
1736            result.params[0],
1737        );
1738    }
1739
1740    #[test]
1741    fn test_counts_model() {
1742        struct ConstTransmission;
1743        impl FitModel for ConstTransmission {
1744            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1745                Ok(vec![params[0]; 3])
1746            }
1747        }
1748
1749        let t_model = ConstTransmission;
1750        let flux = [100.0, 200.0, 300.0];
1751        let background = [5.0, 10.0, 15.0];
1752        let counts_model = CountsModel {
1753            transmission_model: &t_model,
1754            flux: &flux,
1755            background: &background,
1756            n_params: 1,
1757        };
1758
1759        // T = 0.5 → counts = flux*0.5 + background
1760        let result = counts_model.evaluate(&[0.5]).unwrap();
1761        assert!((result[0] - 55.0).abs() < 1e-10);
1762        assert!((result[1] - 110.0).abs() < 1e-10);
1763        assert!((result[2] - 165.0).abs() < 1e-10);
1764        assert_eq!(
1765            crate::forward_model::ForwardModel::n_params(&counts_model),
1766            1
1767        );
1768    }
1769
1770    #[test]
1771    fn test_counts_background_scale_model() {
1772        struct ConstTransmission;
1773        impl FitModel for ConstTransmission {
1774            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1775                Ok(vec![params[0]; 3])
1776            }
1777
1778            fn analytical_jacobian(
1779                &self,
1780                _params: &[f64],
1781                free_param_indices: &[usize],
1782                _y_current: &[f64],
1783            ) -> Option<FlatMatrix> {
1784                let mut jac = FlatMatrix::zeros(3, free_param_indices.len());
1785                for (col, &fp) in free_param_indices.iter().enumerate() {
1786                    if fp == 0 {
1787                        for row in 0..3 {
1788                            *jac.get_mut(row, col) = 1.0;
1789                        }
1790                    }
1791                }
1792                Some(jac)
1793            }
1794        }
1795
1796        let t_model = ConstTransmission;
1797        let flux = [100.0, 200.0, 300.0];
1798        let background = [5.0, 10.0, 15.0];
1799        let counts_model = CountsBackgroundScaleModel {
1800            transmission_model: &t_model,
1801            flux: &flux,
1802            background: &background,
1803            alpha1_index: 1,
1804            alpha2_index: 2,
1805            n_params: 3,
1806        };
1807
1808        let params = [0.5, 0.8, 1.5];
1809        let result = counts_model.evaluate(&params).unwrap();
1810        assert!((result[0] - 47.5).abs() < 1e-10);
1811        assert!((result[1] - 95.0).abs() < 1e-10);
1812        assert!((result[2] - 142.5).abs() < 1e-10);
1813        assert_eq!(
1814            crate::forward_model::ForwardModel::n_params(&counts_model),
1815            3
1816        );
1817    }
1818
1819    #[test]
1820    fn test_poisson_fit_multi_density_temperature_converges() {
1821        struct MultiDensityCountsModel {
1822            energies: Vec<f64>,
1823            flux: Vec<f64>,
1824            density_count: usize,
1825            temp_index: usize,
1826        }
1827
1828        impl MultiDensityCountsModel {
1829            fn sigma(&self, iso: usize, energy: f64, temp_k: f64) -> f64 {
1830                let center = 6.0 + iso as f64 * 4.5;
1831                let amp = 150.0 + 25.0 * iso as f64;
1832                let base_width = 0.8 + 0.12 * iso as f64;
1833                let width_coeff = 0.05 + 0.01 * iso as f64;
1834                let width = (base_width * (1.0 + width_coeff * (temp_k - 300.0) / 300.0)).max(0.1);
1835                let delta = energy - center;
1836                let gauss = (-(delta * delta) / (2.0 * width * width)).exp();
1837                amp * gauss
1838            }
1839
1840            fn dsigma_dt(&self, iso: usize, energy: f64, temp_k: f64) -> f64 {
1841                let center = 6.0 + iso as f64 * 4.5;
1842                let amp = 150.0 + 25.0 * iso as f64;
1843                let base_width = 0.8 + 0.12 * iso as f64;
1844                let width_coeff = 0.05 + 0.01 * iso as f64;
1845                let width = (base_width * (1.0 + width_coeff * (temp_k - 300.0) / 300.0)).max(0.1);
1846                let delta = energy - center;
1847                let gauss = (-(delta * delta) / (2.0 * width * width)).exp();
1848                let dwidth_dt = base_width * width_coeff / 300.0;
1849                amp * gauss * (delta * delta) * dwidth_dt / width.powi(3)
1850            }
1851        }
1852
1853        impl FitModel for MultiDensityCountsModel {
1854            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1855                let temp_k = params[self.temp_index];
1856                let mut out = Vec::with_capacity(self.energies.len());
1857                for (i, &energy) in self.energies.iter().enumerate() {
1858                    let optical_depth = (0..self.density_count)
1859                        .map(|iso| params[iso] * self.sigma(iso, energy, temp_k))
1860                        .sum::<f64>();
1861                    out.push(self.flux[i] * (-optical_depth).exp());
1862                }
1863                Ok(out)
1864            }
1865
1866            fn analytical_jacobian(
1867                &self,
1868                params: &[f64],
1869                free_param_indices: &[usize],
1870                y_current: &[f64],
1871            ) -> Option<crate::lm::FlatMatrix> {
1872                let temp_k = params[self.temp_index];
1873                let mut jac =
1874                    crate::lm::FlatMatrix::zeros(self.energies.len(), free_param_indices.len());
1875                for (row, &energy) in self.energies.iter().enumerate() {
1876                    let y = y_current[row];
1877                    let mut sum_n_dsigma_dt = 0.0;
1878                    for (iso, &density) in params[..self.density_count].iter().enumerate() {
1879                        sum_n_dsigma_dt += density * self.dsigma_dt(iso, energy, temp_k);
1880                    }
1881                    for (col, &fp) in free_param_indices.iter().enumerate() {
1882                        let val = if fp == self.temp_index {
1883                            -y * sum_n_dsigma_dt
1884                        } else {
1885                            -y * self.sigma(fp, energy, temp_k)
1886                        };
1887                        *jac.get_mut(row, col) = val;
1888                    }
1889                }
1890                Some(jac)
1891            }
1892        }
1893
1894        let energies: Vec<f64> = (0..220).map(|i| 1.0 + 0.18 * i as f64).collect();
1895        let flux: Vec<f64> = energies
1896            .iter()
1897            .map(|&e| 1500.0 * (1.0 + 0.15 * (e / 8.0).sin()).max(0.2))
1898            .collect();
1899        let density_count = 6usize;
1900        let temp_index = density_count;
1901        let model = MultiDensityCountsModel {
1902            energies,
1903            flux,
1904            density_count,
1905            temp_index,
1906        };
1907
1908        let true_params = vec![3.2e-4, 2.4e-4, 1.7e-4, 1.1e-4, 7.5e-5, 4.2e-5, 360.0];
1909        let y_obs = model.evaluate(&true_params).unwrap();
1910
1911        let mut params = ParameterSet::new(vec![
1912            FitParameter::non_negative("n0", 6.0e-4),
1913            FitParameter::non_negative("n1", 4.0e-4),
1914            FitParameter::non_negative("n2", 2.5e-4),
1915            FitParameter::non_negative("n3", 1.8e-4),
1916            FitParameter::non_negative("n4", 1.0e-4),
1917            FitParameter::non_negative("n5", 8.0e-5),
1918            FitParameter {
1919                name: "temperature_k".into(),
1920                value: 300.0,
1921                lower: 1.0,
1922                upper: 5000.0,
1923                fixed: false,
1924            },
1925        ]);
1926
1927        let config = PoissonConfig {
1928            max_iter: 200,
1929            gauss_newton_lambda: 1e-4,
1930            ..PoissonConfig::default()
1931        };
1932        let result = poisson_fit(&model, &y_obs, &mut params, &config).unwrap();
1933
1934        assert!(
1935            result.converged,
1936            "multi-density+temperature Poisson fit did not converge after {} iterations",
1937            result.iterations,
1938        );
1939        assert!(
1940            result.iterations < config.max_iter,
1941            "fit hit max_iter={}, params={:?}",
1942            config.max_iter,
1943            result.params,
1944        );
1945
1946        for (i, (&fit, &truth)) in result.params[..density_count]
1947            .iter()
1948            .zip(true_params[..density_count].iter())
1949            .enumerate()
1950        {
1951            let rel_err = (fit - truth).abs() / truth;
1952            assert!(
1953                rel_err < 0.10,
1954                "density[{i}] fit={fit} truth={truth} rel_err={rel_err:.3}",
1955            );
1956        }
1957
1958        let fitted_temp = result.params[temp_index];
1959        assert!(
1960            (fitted_temp - true_params[temp_index]).abs() < 10.0,
1961            "temperature fit={fitted_temp} truth={}",
1962            true_params[temp_index],
1963        );
1964        assert!(
1965            result.iterations <= 80,
1966            "expected analytical KL path to converge well before max_iter; got {}",
1967            result.iterations,
1968        );
1969    }
1970
1971    #[test]
1972    fn test_poisson_fit_exact_optimum_without_analytical_jacobian_converges() {
1973        let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.5).collect();
1974        let true_b = 0.5;
1975        let flux: Vec<f64> = vec![1000.0; x.len()];
1976
1977        let model = ExponentialModel { x, flux };
1978        let y_obs = model.evaluate(&[true_b]).unwrap();
1979        let mut params = ParameterSet::new(vec![FitParameter::non_negative("b", true_b)]);
1980        let config = PoissonConfig {
1981            fd_step: 1e-4,
1982            tol_param: 1e-12,
1983            max_iter: 50,
1984            ..PoissonConfig::default()
1985        };
1986
1987        let result = poisson_fit(&model, &y_obs, &mut params, &config).unwrap();
1988
1989        assert!(
1990            result.converged,
1991            "exact-optimum FD fit should converge instead of exhausting line search"
1992        );
1993        assert!(
1994            result.iterations < config.max_iter,
1995            "fit should stop by convergence, not hit max_iter"
1996        );
1997        assert!(
1998            (result.params[0] - true_b).abs() < 1e-6,
1999            "parameter drifted away from optimum: {}",
2000            result.params[0]
2001        );
2002    }
2003
2004    #[test]
2005    fn test_projected_gradient_ignores_lower_bound_blocked_direction() {
2006        let params = ParameterSet::new(vec![
2007            FitParameter::non_negative("density", 0.0),
2008            FitParameter::unbounded("temp", 300.0),
2009        ]);
2010        let free_idx = vec![0, 1];
2011        let grad = vec![0.25, -0.5];
2012
2013        let inactive = inactive_free_positions(&params, &free_idx, &grad);
2014        assert_eq!(
2015            inactive,
2016            vec![1],
2017            "lower-bound blocked density should be active"
2018        );
2019
2020        let pg_norm = projected_gradient_norm(&params, &free_idx, &grad);
2021        assert!(
2022            (pg_norm - 0.5).abs() < 1e-12,
2023            "projected gradient should ignore blocked lower-bound component"
2024        );
2025    }
2026
2027    #[test]
2028    fn test_max_feasible_step_hits_lower_bound() {
2029        let params = ParameterSet::new(vec![
2030            FitParameter::non_negative("density", 0.2),
2031            FitParameter::unbounded("temp", 300.0),
2032        ]);
2033        let alpha = max_feasible_step(&params, &[0, 1], &[0.2, 300.0], &[0.5, 0.0]);
2034        assert!(
2035            (alpha - 0.4).abs() < 1e-12,
2036            "feasible step should stop exactly at lower bound"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_max_feasible_step_hits_upper_bound() {
2042        let params = ParameterSet::new(vec![FitParameter {
2043            name: "temp".into(),
2044            value: 300.0,
2045            lower: 1.0,
2046            upper: 500.0,
2047            fixed: false,
2048        }]);
2049        let alpha = max_feasible_step(&params, &[0], &[300.0], &[-50.0]);
2050        assert!(
2051            (alpha - 4.0).abs() < 1e-12,
2052            "feasible step should stop exactly at upper bound"
2053        );
2054    }
2055
2056    #[test]
2057    fn test_inactive_mask_changes_when_bound_activity_changes() {
2058        let free_idx = vec![0, 1];
2059        let params_at_bound = ParameterSet::new(vec![
2060            FitParameter::non_negative("density", 0.0),
2061            FitParameter::non_negative("temp", 1.0),
2062        ]);
2063        let params_free = ParameterSet::new(vec![
2064            FitParameter::non_negative("density", 0.2),
2065            FitParameter::non_negative("temp", 1.0),
2066        ]);
2067
2068        let mask_at_bound = inactive_free_mask(&params_at_bound, &free_idx, &[0.3, -0.2]);
2069        let mask_free = inactive_free_mask(&params_free, &free_idx, &[0.3, -0.2]);
2070
2071        assert_eq!(mask_at_bound, vec![false, true]);
2072        assert_eq!(mask_free, vec![true, true]);
2073        assert_ne!(
2074            mask_at_bound, mask_free,
2075            "active-set changes should invalidate FD quasi-Newton history"
2076        );
2077    }
2078
2079    #[test]
2080    fn test_lbfgs_history_two_loop_matches_secant_direction() {
2081        let mut history = LbfgsHistory::new(4);
2082        history.update(&[0.0], &[1.0], &[0.0], &[2.0]);
2083        let dir = history
2084            .apply_on_positions(&[4.0], &[0])
2085            .expect("history should produce a direction");
2086        assert!(
2087            (dir[0] - 2.0).abs() < 1e-12,
2088            "1D secant pair should scale gradient by inverse curvature"
2089        );
2090    }
2091
2092    #[test]
2093    fn test_lbfgs_subspace_ignores_active_components() {
2094        let mut history = LbfgsHistory::new(4);
2095        history.update(&[0.0, 0.0], &[1.0, 100.0], &[0.0, 0.0], &[2.0, 100.0]);
2096        let dir = history
2097            .apply_on_positions(&[4.0, 50.0], &[0])
2098            .expect("subspace history should produce a direction");
2099        assert!(
2100            (dir[0] - 2.0).abs() < 1e-12,
2101            "inactive-subspace L-BFGS should match 1D secant scaling on the free variable"
2102        );
2103        assert!(
2104            dir[1].abs() < 1e-12,
2105            "inactive-subspace L-BFGS should not leak blocked-variable history into the direction"
2106        );
2107    }
2108
2109    #[test]
2110    fn test_poisson_fit_converges_at_bound_active_optimum() {
2111        struct OffsetModel {
2112            base: Vec<f64>,
2113        }
2114
2115        impl FitModel for OffsetModel {
2116            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2117                Ok(self.base.iter().map(|&b| b + params[0]).collect())
2118            }
2119
2120            fn analytical_jacobian(
2121                &self,
2122                _params: &[f64],
2123                free_param_indices: &[usize],
2124                y_current: &[f64],
2125            ) -> Option<FlatMatrix> {
2126                let mut jac = FlatMatrix::zeros(y_current.len(), free_param_indices.len());
2127                for (col, &fp) in free_param_indices.iter().enumerate() {
2128                    assert_eq!(fp, 0);
2129                    for row in 0..y_current.len() {
2130                        *jac.get_mut(row, col) = 1.0;
2131                    }
2132                }
2133                Some(jac)
2134            }
2135        }
2136
2137        let model = OffsetModel {
2138            base: vec![10.0; 12],
2139        };
2140        let y_obs = vec![8.0; 12];
2141        let mut params = ParameterSet::new(vec![FitParameter::non_negative("offset", 0.0)]);
2142
2143        let result = poisson_fit(&model, &y_obs, &mut params, &PoissonConfig::default()).unwrap();
2144
2145        assert!(
2146            result.converged,
2147            "bound-active optimum should satisfy projected optimality"
2148        );
2149        assert_eq!(
2150            result.iterations, 1,
2151            "should stop on projected-gradient check"
2152        );
2153        assert!(
2154            result.params[0].abs() < 1e-12,
2155            "offset should stay pinned at lower bound, got {}",
2156            result.params[0]
2157        );
2158    }
2159
2160    #[test]
2161    fn test_poisson_fit_fd_lbfgs_handles_coupled_two_parameter_model() {
2162        struct CoupledExponentialModel {
2163            x: Vec<f64>,
2164        }
2165
2166        impl FitModel for CoupledExponentialModel {
2167            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2168                let amp = params[0];
2169                let decay = params[1];
2170                Ok(self
2171                    .x
2172                    .iter()
2173                    .map(|&x| amp * (-decay * x).exp() + 1.0)
2174                    .collect())
2175            }
2176        }
2177
2178        let model = CoupledExponentialModel {
2179            x: (0..60).map(|i| i as f64 * 0.08).collect(),
2180        };
2181        let true_params = [120.0, 0.45];
2182        let y_obs = model.evaluate(&true_params).unwrap();
2183        let mut params = ParameterSet::new(vec![
2184            FitParameter::non_negative("amp", 30.0),
2185            FitParameter::non_negative("decay", 1.2),
2186        ]);
2187        let config = PoissonConfig {
2188            max_iter: 120,
2189            lbfgs_history: 8,
2190            ..PoissonConfig::default()
2191        };
2192        let result = poisson_fit(&model, &y_obs, &mut params, &config).unwrap();
2193        let mut baseline_params = ParameterSet::new(vec![
2194            FitParameter::non_negative("amp", 30.0),
2195            FitParameter::non_negative("decay", 1.2),
2196        ]);
2197        let baseline = poisson_fit(
2198            &model,
2199            &y_obs,
2200            &mut baseline_params,
2201            &PoissonConfig {
2202                lbfgs_history: 0,
2203                ..config.clone()
2204            },
2205        )
2206        .unwrap();
2207
2208        assert!(
2209            result.converged,
2210            "FD L-BFGS fit did not converge: {result:?}"
2211        );
2212        assert!(
2213            baseline.converged,
2214            "baseline FD fit should still converge: {baseline:?}"
2215        );
2216        assert!(
2217            result.iterations <= 60,
2218            "expected FD quasi-Newton path to converge well before max_iter; got {}",
2219            result.iterations,
2220        );
2221        assert!(
2222            result.iterations < baseline.iterations,
2223            "L-BFGS fallback should beat no-history gradient scaling: lbfgs={} baseline={}",
2224            result.iterations,
2225            baseline.iterations,
2226        );
2227        assert!(
2228            (result.params[0] - true_params[0]).abs() / true_params[0] < 0.02,
2229            "amplitude fit={}, true={}",
2230            result.params[0],
2231            true_params[0],
2232        );
2233        assert!(
2234            (result.params[1] - true_params[1]).abs() / true_params[1] < 0.02,
2235            "decay fit={}, true={}",
2236            result.params[1],
2237            true_params[1],
2238        );
2239    }
2240
2241    #[test]
2242    fn test_poisson_fit_fd_lbfgs_with_bound_active_offset_uses_subspace() {
2243        struct OffsetDecayModel {
2244            x: Vec<f64>,
2245        }
2246
2247        impl FitModel for OffsetDecayModel {
2248            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2249                let offset = params[0];
2250                let decay = params[1];
2251                Ok(self
2252                    .x
2253                    .iter()
2254                    .map(|&x| offset + (-decay * x).exp())
2255                    .collect())
2256            }
2257        }
2258
2259        let model = OffsetDecayModel {
2260            x: (0..60).map(|i| i as f64 * 0.08).collect(),
2261        };
2262        let true_params = [0.0, 0.35];
2263        let y_obs = model.evaluate(&true_params).unwrap();
2264
2265        let config = PoissonConfig {
2266            max_iter: 120,
2267            lbfgs_history: 8,
2268            ..PoissonConfig::default()
2269        };
2270        let mut params = ParameterSet::new(vec![
2271            FitParameter::non_negative("offset", 0.0),
2272            FitParameter::non_negative("decay", 1.1),
2273        ]);
2274        let result = poisson_fit(&model, &y_obs, &mut params, &config).unwrap();
2275        assert!(
2276            result.converged,
2277            "subspace FD L-BFGS fit did not converge: {result:?}"
2278        );
2279        assert!(
2280            result.iterations <= 20,
2281            "bound-active subspace FD fit should converge comfortably before max_iter; got {}",
2282            result.iterations,
2283        );
2284        assert!(
2285            result.params[0].abs() < 1e-8,
2286            "offset should remain at the lower bound, got {}",
2287            result.params[0]
2288        );
2289        assert!(
2290            (result.params[1] - true_params[1]).abs() / true_params[1] < 0.02,
2291            "decay fit={}, true={}",
2292            result.params[1],
2293            true_params[1],
2294        );
2295    }
2296
2297    #[test]
2298    fn test_poisson_fit_temperature_and_background_converges() {
2299        struct TempTransmissionModel {
2300            energies: Vec<f64>,
2301        }
2302
2303        impl TempTransmissionModel {
2304            fn sigma(&self, energy: f64, temp_k: f64) -> f64 {
2305                let center = 6.0;
2306                let amp = 110.0;
2307                let base_width = 0.55;
2308                let width = (base_width * (temp_k / 300.0).sqrt()).max(0.08);
2309                let delta = energy - center;
2310                amp * (-(delta * delta) / (2.0 * width * width)).exp()
2311            }
2312
2313            fn dsigma_dt(&self, energy: f64, temp_k: f64) -> f64 {
2314                let center = 6.0;
2315                let amp = 110.0;
2316                let base_width = 0.55;
2317                let width = (base_width * (temp_k / 300.0).sqrt()).max(0.08);
2318                let dwidth_dt = if temp_k > 0.0 {
2319                    base_width / (2.0 * (300.0 * temp_k).sqrt())
2320                } else {
2321                    0.0
2322                };
2323                let delta = energy - center;
2324                let gauss = (-(delta * delta) / (2.0 * width * width)).exp();
2325                amp * gauss * (delta * delta) * dwidth_dt / width.powi(3)
2326            }
2327        }
2328
2329        impl FitModel for TempTransmissionModel {
2330            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2331                let density = params[0];
2332                let temp_k = params[1];
2333                Ok(self
2334                    .energies
2335                    .iter()
2336                    .map(|&energy| (-density * self.sigma(energy, temp_k)).exp())
2337                    .collect())
2338            }
2339
2340            fn analytical_jacobian(
2341                &self,
2342                params: &[f64],
2343                free_param_indices: &[usize],
2344                y_current: &[f64],
2345            ) -> Option<FlatMatrix> {
2346                let density = params[0];
2347                let temp_k = params[1];
2348                let mut jac = FlatMatrix::zeros(self.energies.len(), free_param_indices.len());
2349                for (row, &energy) in self.energies.iter().enumerate() {
2350                    let y = y_current[row];
2351                    let sigma = self.sigma(energy, temp_k);
2352                    let dsigma_dt = self.dsigma_dt(energy, temp_k);
2353                    for (col, &fp) in free_param_indices.iter().enumerate() {
2354                        let deriv = match fp {
2355                            0 => -sigma * y,
2356                            1 => -density * dsigma_dt * y,
2357                            _ => unreachable!("unexpected parameter index {fp}"),
2358                        };
2359                        *jac.get_mut(row, col) = deriv;
2360                    }
2361                }
2362                Some(jac)
2363            }
2364        }
2365
2366        let energies: Vec<f64> = (0..180).map(|i| 1.0 + 0.06 * i as f64).collect();
2367        let inner = TempTransmissionModel {
2368            energies: energies.clone(),
2369        };
2370        let inv_sqrt_energies: Vec<f64> = energies.iter().map(|&e| 1.0 / e.sqrt()).collect();
2371        let wrapped = TransmissionKLBackgroundModel {
2372            inner: &inner,
2373            inv_sqrt_energies,
2374            b0_index: 2,
2375            b1_index: 3,
2376            n_params: 4,
2377        };
2378
2379        let true_params = vec![4.5e-4, 345.0, 0.012, 0.008];
2380        let y_obs = wrapped.evaluate(&true_params).unwrap();
2381
2382        let mut params = ParameterSet::new(vec![
2383            FitParameter::non_negative("density", 8.0e-4),
2384            FitParameter {
2385                name: "temperature_k".into(),
2386                value: 290.0,
2387                lower: 1.0,
2388                upper: 5000.0,
2389                fixed: false,
2390            },
2391            FitParameter {
2392                name: "kl_b0".into(),
2393                value: 0.0,
2394                lower: 0.0,
2395                upper: 0.5,
2396                fixed: false,
2397            },
2398            FitParameter {
2399                name: "kl_b1".into(),
2400                value: 0.0,
2401                lower: 0.0,
2402                upper: 0.5,
2403                fixed: false,
2404            },
2405        ]);
2406
2407        let config = PoissonConfig {
2408            max_iter: 120,
2409            gauss_newton_lambda: 1e-4,
2410            ..PoissonConfig::default()
2411        };
2412        let result = poisson_fit(&wrapped, &y_obs, &mut params, &config).unwrap();
2413
2414        assert!(result.converged, "fit did not converge: {result:?}");
2415        assert!(
2416            result.iterations <= 80,
2417            "expected convergence well before max_iter; got {}",
2418            result.iterations,
2419        );
2420        assert!(
2421            (result.params[0] - true_params[0]).abs() / true_params[0] < 0.05,
2422            "density fit={}, true={}",
2423            result.params[0],
2424            true_params[0],
2425        );
2426        assert!(
2427            (result.params[1] - true_params[1]).abs() < 8.0,
2428            "temperature fit={}, true={}",
2429            result.params[1],
2430            true_params[1],
2431        );
2432        assert!(
2433            (result.params[2] - true_params[2]).abs() < 5e-3,
2434            "b0 fit={}, true={}",
2435            result.params[2],
2436            true_params[2],
2437        );
2438        assert!(
2439            (result.params[3] - true_params[3]).abs() < 5e-3,
2440            "b1 fit={}, true={}",
2441            result.params[3],
2442            true_params[3],
2443        );
2444    }
2445
2446    /// Inner params FREE + inner model WITHOUT `analytical_jacobian` →
2447    /// the wrapper must return `None` (FD fallback for the entire model)
2448    /// and the fit must still converge on the FD path.
2449    #[test]
2450    fn test_transmission_kl_background_fd_fallback_when_inner_lacks_jacobian() {
2451        // ExponentialModel has no analytical_jacobian (trait default None).
2452        // Counts-scale data (flux 1000, backgrounds 20/10) keeps the
2453        // Poisson NLL well-conditioned for parameter recovery.
2454        let x: Vec<f64> = (0..40).map(|i| 1.0 + 0.25 * i as f64).collect();
2455        let inner = ExponentialModel {
2456            x: x.clone(),
2457            flux: vec![1000.0; x.len()],
2458        };
2459        let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
2460        let wrapped = TransmissionKLBackgroundModel {
2461            inner: &inner,
2462            inv_sqrt_energies,
2463            b0_index: 1,
2464            b1_index: 2,
2465            n_params: 3,
2466        };
2467
2468        let true_params = vec![0.4, 20.0, 10.0];
2469        let y_obs = wrapped.evaluate(&true_params).unwrap();
2470
2471        // Inner param 0 free (alone and together with b0/b1): no analytic
2472        // inner Jacobian → wrapper falls back to FD.
2473        assert!(
2474            wrapped
2475                .analytical_jacobian(&true_params, &[0, 1, 2], &y_obs)
2476                .is_none(),
2477            "inner param free without inner analytical_jacobian must give None"
2478        );
2479        assert!(
2480            wrapped
2481                .analytical_jacobian(&true_params, &[0], &y_obs)
2482                .is_none(),
2483            "inner-only free set without inner analytical_jacobian must give None"
2484        );
2485
2486        let mut params = ParameterSet::new(vec![
2487            FitParameter::non_negative("density", 0.8),
2488            FitParameter {
2489                name: "kl_b0".into(),
2490                value: 5.0,
2491                lower: 0.0,
2492                upper: 500.0,
2493                fixed: false,
2494            },
2495            FitParameter {
2496                name: "kl_b1".into(),
2497                value: 5.0,
2498                lower: 0.0,
2499                upper: 500.0,
2500                fixed: false,
2501            },
2502        ]);
2503        let config = PoissonConfig {
2504            max_iter: 300,
2505            ..PoissonConfig::default()
2506        };
2507        let result = poisson_fit(&wrapped, &y_obs, &mut params, &config).unwrap();
2508        assert!(
2509            result.converged,
2510            "FD-fallback fit did not converge: {result:?}"
2511        );
2512        assert!(
2513            (result.params[0] - true_params[0]).abs() / true_params[0] < 0.05,
2514            "density fit={}, true={}",
2515            result.params[0],
2516            true_params[0],
2517        );
2518    }
2519
2520    /// Inner params FIXED (only b0/b1 free) → the wrapper stays on the
2521    /// analytic path: `analytical_jacobian` returns `Some` with the exact
2522    /// background columns ∂T/∂b₀ = 1 and ∂T/∂b₁ = 1/√E, and the
2523    /// background-only fit converges.
2524    #[test]
2525    fn test_transmission_kl_background_background_only_analytic_jacobian() {
2526        // Counts-scale data — see the FD-fallback test above.
2527        let x: Vec<f64> = (0..40).map(|i| 1.0 + 0.25 * i as f64).collect();
2528        let inner = ExponentialModel {
2529            x: x.clone(),
2530            flux: vec![1000.0; x.len()],
2531        };
2532        let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
2533        let wrapped = TransmissionKLBackgroundModel {
2534            inner: &inner,
2535            inv_sqrt_energies: inv_sqrt_energies.clone(),
2536            b0_index: 1,
2537            b1_index: 2,
2538            n_params: 3,
2539        };
2540
2541        let true_params = vec![0.4, 20.0, 10.0];
2542        let y_obs = wrapped.evaluate(&true_params).unwrap();
2543
2544        let jac = wrapped
2545            .analytical_jacobian(&true_params, &[1, 2], &y_obs)
2546            .expect("background-only free set must stay on the analytic path");
2547        for (row, &inv_sqrt_e) in inv_sqrt_energies.iter().enumerate() {
2548            assert!(
2549                (jac.get(row, 0) - 1.0).abs() < 1e-15,
2550                "∂T/∂b₀ at row {row} = {}, expected 1.0",
2551                jac.get(row, 0),
2552            );
2553            assert!(
2554                (jac.get(row, 1) - inv_sqrt_e).abs() < 1e-15,
2555                "∂T/∂b₁ at row {row} = {}, expected {inv_sqrt_e}",
2556                jac.get(row, 1),
2557            );
2558        }
2559
2560        // Background-only fit (density fixed at truth) converges on the
2561        // analytic path.
2562        let mut params = ParameterSet::new(vec![
2563            FitParameter::fixed("density", true_params[0]),
2564            FitParameter {
2565                name: "kl_b0".into(),
2566                value: 5.0,
2567                lower: 0.0,
2568                upper: 500.0,
2569                fixed: false,
2570            },
2571            FitParameter {
2572                name: "kl_b1".into(),
2573                value: 5.0,
2574                lower: 0.0,
2575                upper: 500.0,
2576                fixed: false,
2577            },
2578        ]);
2579        let config = PoissonConfig {
2580            max_iter: 300,
2581            ..PoissonConfig::default()
2582        };
2583        let result = poisson_fit(&wrapped, &y_obs, &mut params, &config).unwrap();
2584        assert!(
2585            result.converged,
2586            "background-only analytic fit did not converge: {result:?}"
2587        );
2588        assert!(
2589            (result.params[1] - true_params[1]).abs() < 0.1,
2590            "b0 fit={}, true={}",
2591            result.params[1],
2592            true_params[1],
2593        );
2594        assert!(
2595            (result.params[2] - true_params[2]).abs() < 0.1,
2596            "b1 fit={}, true={}",
2597            result.params[2],
2598            true_params[2],
2599        );
2600    }
2601
2602    /// Central finite-difference column for one parameter, computed
2603    /// straight from `evaluate` — an oracle independent of the model's
2604    /// analytic Jacobian path.
2605    fn fd_column(model: &dyn FitModel, params: &[f64], param_index: usize, h: f64) -> Vec<f64> {
2606        let mut plus = params.to_vec();
2607        plus[param_index] += h;
2608        let mut minus = params.to_vec();
2609        minus[param_index] -= h;
2610        let y_plus = model.evaluate(&plus).unwrap();
2611        let y_minus = model.evaluate(&minus).unwrap();
2612        y_plus
2613            .iter()
2614            .zip(y_minus.iter())
2615            .map(|(&p, &m)| (p - m) / (2.0 * h))
2616            .collect()
2617    }
2618
2619    /// Aliased background indices (b0_index == b1_index): the analytic
2620    /// Jacobian must ACCUMULATE both roles' contributions (1 + 1/√E),
2621    /// matching finite differences — not overwrite one with the other.
2622    #[test]
2623    fn test_transmission_kl_background_aliased_indices_jacobian_matches_fd() {
2624        let x: Vec<f64> = (0..10).map(|i| 1.0 + 0.5 * i as f64).collect();
2625        let inner = ExponentialModel {
2626            x: x.clone(),
2627            flux: vec![1000.0; x.len()],
2628        };
2629        let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
2630        let wrapped = TransmissionKLBackgroundModel {
2631            inner: &inner,
2632            inv_sqrt_energies: inv_sqrt_energies.clone(),
2633            b0_index: 1,
2634            b1_index: 1, // deliberately aliased with b0
2635            n_params: 2,
2636        };
2637
2638        let params = vec![0.4, 15.0];
2639        let y = wrapped.evaluate(&params).unwrap();
2640        // Inner param fixed, only the aliased background param free →
2641        // analytic path.
2642        let jac = wrapped
2643            .analytical_jacobian(&params, &[1], &y)
2644            .expect("background-only free set must stay on the analytic path");
2645
2646        let fd = fd_column(&wrapped, &params, 1, 1e-6);
2647        for (row, (&fd_val, &inv_sqrt_e)) in fd.iter().zip(inv_sqrt_energies.iter()).enumerate() {
2648            let expected = 1.0 + inv_sqrt_e;
2649            assert!(
2650                (jac.get(row, 0) - expected).abs() < 1e-12,
2651                "aliased ∂/∂b at row {row}: analytic {}, expected {expected}",
2652                jac.get(row, 0),
2653            );
2654            assert!(
2655                (jac.get(row, 0) - fd_val).abs() < 1e-5,
2656                "aliased ∂/∂b at row {row}: analytic {}, FD {fd_val}",
2657                jac.get(row, 0),
2658            );
2659        }
2660    }
2661
2662    /// Aliased scale indices (alpha1_index == alpha2_index) in the
2663    /// sibling counts model: same accumulate-not-overwrite requirement,
2664    /// derivative f·T + B against the finite-difference oracle.
2665    #[test]
2666    fn test_counts_background_scale_aliased_indices_jacobian_matches_fd() {
2667        let x: Vec<f64> = (0..10).map(|i| 1.0 + 0.5 * i as f64).collect();
2668        let inner = ExponentialModel {
2669            x: x.clone(),
2670            flux: vec![1.0; x.len()], // inner transmission in [0,1]
2671        };
2672        let flux: Vec<f64> = vec![1000.0; x.len()];
2673        let background: Vec<f64> = x.iter().map(|&e| 5.0 + e).collect();
2674        let wrapped = CountsBackgroundScaleModel {
2675            transmission_model: &inner,
2676            flux: &flux,
2677            background: &background,
2678            alpha1_index: 1,
2679            alpha2_index: 1, // deliberately aliased with alpha1
2680            n_params: 2,
2681        };
2682
2683        let params = vec![0.4, 1.2];
2684        let y = wrapped.evaluate(&params).unwrap();
2685        let t_inner = inner.evaluate(&params).unwrap();
2686        // Inner param fixed, only the aliased scale param free →
2687        // analytic path.
2688        let jac = wrapped
2689            .analytical_jacobian(&params, &[1], &y)
2690            .expect("scale-only free set must stay on the analytic path");
2691
2692        let fd = fd_column(&wrapped, &params, 1, 1e-6);
2693        for (row, &fd_val) in fd.iter().enumerate() {
2694            let expected = flux[row] * t_inner[row] + background[row];
2695            assert!(
2696                (jac.get(row, 0) - expected).abs() < 1e-9,
2697                "aliased ∂/∂α at row {row}: analytic {}, expected {expected}",
2698                jac.get(row, 0),
2699            );
2700            assert!(
2701                (jac.get(row, 0) - fd_val).abs() < 1e-3,
2702                "aliased ∂/∂α at row {row}: analytic {}, FD {fd_val}",
2703                jac.get(row, 0),
2704            );
2705        }
2706    }
2707}