Skip to main content

nereids_fitting/
lm.rs

1//! Levenberg-Marquardt least-squares optimizer.
2//!
3//! Minimizes χ² = Σᵢ [(y_obs - y_model)² / σᵢ²] by iteratively solving:
4//!
5//!   (JᵀWJ + λ·diag(JᵀWJ)) · δ = JᵀW·r
6//!
7//! where J is the Jacobian, W = diag(1/σ²), r = y_obs - y_model,
8//! and λ is the damping parameter.
9//!
10//! ## SAMMY Reference
11//! - `fit/` module, manual Sec. IV (Bayes equations / generalized least-squares)
12
13use nereids_core::constants::{LM_DIAGONAL_FLOOR, PIVOT_FLOOR};
14
15use crate::error::FittingError;
16use crate::parameters::ParameterSet;
17
18/// Row-major flat matrix for cache-friendly storage.
19///
20/// Replaces `Vec<Vec<f64>>` to collapse ~N separate heap allocations into 1
21/// and improve cache locality for JtWJ assembly.  Access: `data[i * ncols + j]`.
22#[derive(Debug, Clone)]
23pub struct FlatMatrix {
24    /// Flat row-major storage: `data[i * ncols + j]` = element at row i, col j.
25    pub data: Vec<f64>,
26    /// Number of rows.
27    pub nrows: usize,
28    /// Number of columns.
29    pub ncols: usize,
30}
31
32impl FlatMatrix {
33    /// Create a new zero-filled matrix with the given dimensions.
34    pub fn zeros(nrows: usize, ncols: usize) -> Self {
35        let len = nrows
36            .checked_mul(ncols)
37            .expect("FlatMatrix dimensions overflow usize");
38        Self {
39            data: vec![0.0; len],
40            nrows,
41            ncols,
42        }
43    }
44
45    /// Access element at (row, col) immutably.
46    #[inline(always)]
47    pub fn get(&self, row: usize, col: usize) -> f64 {
48        debug_assert!(row < self.nrows && col < self.ncols);
49        self.data[row * self.ncols + col]
50    }
51
52    /// Access element at (row, col) mutably.
53    #[inline(always)]
54    pub fn get_mut(&mut self, row: usize, col: usize) -> &mut f64 {
55        debug_assert!(row < self.nrows && col < self.ncols);
56        &mut self.data[row * self.ncols + col]
57    }
58}
59
60/// #125.4: Maximum damping parameter before the optimizer gives up.
61///
62/// When λ exceeds this threshold, the optimizer is stuck in a region where no
63/// step improves chi-squared.  Breaking out avoids wasting iterations.
64const LAMBDA_BREAKOUT: f64 = 1e16;
65
66/// Configuration for the LM optimizer.
67#[derive(Debug, Clone)]
68pub struct LmConfig {
69    /// Maximum number of iterations.
70    pub max_iter: usize,
71    /// Initial damping parameter λ.
72    pub lambda_init: f64,
73    /// Factor to increase λ on rejected step.
74    pub lambda_up: f64,
75    /// Factor to decrease λ on accepted step.
76    pub lambda_down: f64,
77    /// Convergence tolerance on relative χ² change.
78    pub tol_chi2: f64,
79    /// Convergence tolerance on relative parameter change.
80    pub tol_param: f64,
81    /// Step size for finite-difference Jacobian.
82    pub fd_step: f64,
83    /// Whether to compute the covariance matrix (and uncertainties) after
84    /// convergence.  This requires an extra Jacobian evaluation + matrix
85    /// inversion at the final parameters.  Set to `false` for per-pixel
86    /// spatial mapping where only densities are needed.
87    ///
88    /// Default: `true`.
89    pub compute_covariance: bool,
90}
91
92impl Default for LmConfig {
93    fn default() -> Self {
94        Self {
95            max_iter: 200,
96            lambda_init: 1e-3,
97            lambda_up: 10.0,
98            lambda_down: 0.1,
99            tol_chi2: 1e-8,
100            tol_param: 1e-8,
101            fd_step: 1e-6,
102            compute_covariance: true,
103        }
104    }
105}
106
107/// Result of a Levenberg-Marquardt fit.
108#[derive(Debug, Clone)]
109pub struct LmResult {
110    /// Final chi-squared value.
111    pub chi_squared: f64,
112    /// Reduced chi-squared (χ²/ν where ν = n_data - n_params).
113    pub reduced_chi_squared: f64,
114    /// Number of iterations taken.
115    pub iterations: usize,
116    /// Whether the fit converged.
117    pub converged: bool,
118    /// Final parameter values (all parameters, including fixed).
119    pub params: Vec<f64>,
120    /// Covariance matrix of free parameters (n_free × n_free), if available.
121    pub covariance: Option<FlatMatrix>,
122    /// Standard errors of free parameters (diagonal of covariance).
123    pub uncertainties: Option<Vec<f64>>,
124}
125
126/// A model function that can be fitted.
127///
128/// Given parameter values (all params including fixed), computes
129/// the model prediction at each data point.
130pub trait FitModel {
131    /// Evaluate the model for the given parameters.
132    ///
133    /// On success, returns a vector of model predictions with the same
134    /// length as the data being fitted. On failure, returns a
135    /// [`FittingError`] indicating that the model could not be evaluated
136    /// (e.g. a broadening or physics error).
137    ///
138    /// **Optimizer semantics:** during Levenberg-Marquardt trial steps,
139    /// an `Err` is treated as a failed step (increase λ / backtrack).
140    /// At the initial point or post-convergence, `Err` is propagated.
141    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError>;
142
143    /// Optionally provide an analytical Jacobian.
144    ///
145    /// `free_param_indices`: indices (into `params`) of the free parameters,
146    /// in the same order as the Jacobian columns.
147    ///
148    /// `y_current`: current model output, i.e. `self.evaluate(params)`.
149    /// Provided so implementations can compute J analytically from T without
150    /// an extra `evaluate` call.
151    ///
152    /// Returns `Some(J)` where `J.get(i, j) = ∂model[i]/∂params[free_param_indices[j]]`.
153    /// The matrix has `y_current.len()` rows and `free_param_indices.len()` columns.
154    /// Return `None` to fall back to finite-difference Jacobian (the default).
155    fn analytical_jacobian(
156        &self,
157        _params: &[f64],
158        _free_param_indices: &[usize],
159        _y_current: &[f64],
160    ) -> Option<FlatMatrix> {
161        None
162    }
163}
164
165/// Blanket implementation: shared references to any `FitModel` also implement
166/// `FitModel`, forwarding all calls to the underlying implementation.
167///
168/// This enables `NormalizedTransmissionModel<&dyn FitModel>` to work when the
169/// inner model is borrowed (e.g. in `fit_spectrum` where the inner model is a
170/// local variable wrapped conditionally).
171impl<M: FitModel + ?Sized> FitModel for &M {
172    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
173        (**self).evaluate(params)
174    }
175
176    fn analytical_jacobian(
177        &self,
178        params: &[f64],
179        free_param_indices: &[usize],
180        y_current: &[f64],
181    ) -> Option<FlatMatrix> {
182        (**self).analytical_jacobian(params, free_param_indices, y_current)
183    }
184}
185
186/// Blanket implementation for boxed models, forwarding to the underlying
187/// implementation.  Lets the pipeline stack optional wrapper models linearly
188/// (`model = Box::new(Wrapper::new(model, …))`) instead of enumerating every
189/// wrapper combination in nested match arms (issue #635).
190impl<M: FitModel + ?Sized> FitModel for Box<M> {
191    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
192        (**self).evaluate(params)
193    }
194
195    fn analytical_jacobian(
196        &self,
197        params: &[f64],
198        free_param_indices: &[usize],
199        y_current: &[f64],
200    ) -> Option<FlatMatrix> {
201        (**self).analytical_jacobian(params, free_param_indices, y_current)
202    }
203}
204
205/// Compute weighted chi-squared: Σ [(y_obs - y_model)² / σ²].
206///
207/// When `active_mask` is `Some(m)`, bins where `m[i]` is `false` are
208/// **skipped entirely** rather than zero-weighted.  Explicit row-skip
209/// matters when masked bins may contain non-finite residuals (e.g. NaN
210/// outside the user's fit-energy range): `0.0 * NaN = NaN`, so a
211/// zero-weight strategy would propagate NaN through the χ² accumulator
212/// even though the bin contributes no information.  See SAMMY EMIN/EMAX
213/// semantics (#514) and lm.rs Fix 4.
214fn chi_squared(residuals: &[f64], weights: &[f64], active_mask: Option<&[bool]>) -> f64 {
215    let mut sum = 0.0;
216    for (i, (&r, &w)) in residuals.iter().zip(weights.iter()).enumerate() {
217        if active_mask.is_some_and(|m| !m[i]) {
218            continue;
219        }
220        sum += r * r * w;
221    }
222    sum
223}
224
225/// Infinity norm of the gradient, scaled by local curvature and residual size.
226///
227/// This is a dimensionless first-order optimality measure for least squares:
228/// small values indicate that the current point is stationary even when χ² is
229/// nonzero because the data are noisy or the model is imperfect.
230fn scaled_gradient_inf_norm(jtw_j: &FlatMatrix, jtw_r: &[f64], chi2: f64) -> f64 {
231    let residual_scale = chi2.sqrt().max(1.0);
232    let mut max_scaled: f64 = 0.0;
233    for (j, &grad_j) in jtw_r.iter().enumerate() {
234        let curvature = jtw_j.get(j, j).abs().sqrt();
235        let scale = curvature * residual_scale + PIVOT_FLOOR;
236        max_scaled = max_scaled.max(grad_j.abs() / scale);
237    }
238    max_scaled
239}
240
241/// Whether the local model has meaningful curvature in at least one free direction.
242///
243/// Purely flat models (J = 0 everywhere) should still report failure rather than
244/// "converged", even though their gradient is numerically zero.
245fn has_informative_curvature(jtw_j: &FlatMatrix) -> bool {
246    (0..jtw_j.nrows).any(|j| jtw_j.get(j, j) > LM_DIAGONAL_FLOOR)
247}
248
249/// Compute the Jacobian, preferring an analytical formula over finite differences.
250///
251/// `y_current` must equal `model.evaluate(&params.all_values())` at the
252/// current parameter values — it is passed in to avoid a redundant evaluate
253/// call (the LM loop already has this vector from the previous accepted step).
254///
255/// `all_vals_buf` is a scratch buffer reused across the per-parameter FD loop
256/// to avoid allocating a fresh `Vec<f64>` on every `model.evaluate()` call.
257///
258/// `free_idx_buf` is a scratch buffer for `params.free_indices_into()`, reused
259/// across iterations to avoid per-Jacobian allocation.
260///
261/// J.get(i, j) = ∂model[i] / ∂free_param[j]
262fn compute_jacobian(
263    model: &dyn FitModel,
264    params: &mut ParameterSet,
265    y_current: &[f64],
266    fd_step: f64,
267    all_vals_buf: &mut Vec<f64>,
268    free_idx_buf: &mut Vec<usize>,
269) -> Result<FlatMatrix, FittingError> {
270    params.free_indices_into(free_idx_buf);
271    let n_free = free_idx_buf.len();
272    let n_data = y_current.len();
273
274    // Try analytical Jacobian first (no extra evaluate calls).
275    params.all_values_into(all_vals_buf);
276    if let Some(j) = model.analytical_jacobian(all_vals_buf, free_idx_buf, y_current) {
277        debug_assert!(
278            j.nrows == n_data && j.ncols == n_free && j.data.len() == n_data * n_free,
279            "analytical_jacobian shape mismatch: got ({}x{}, len={}), expected ({}x{}, len={})",
280            j.nrows,
281            j.ncols,
282            j.data.len(),
283            n_data,
284            n_free,
285            n_data * n_free,
286        );
287        return Ok(j);
288    }
289
290    // Fallback: forward finite differences, reusing y_current as the base.
291    let mut jacobian = FlatMatrix::zeros(n_data, n_free);
292
293    for (j, &idx) in free_idx_buf.iter().enumerate() {
294        let original = params.params[idx].value;
295        let step = fd_step * (1.0 + original.abs());
296
297        params.params[idx].value = original + step;
298        params.params[idx].clamp();
299        let mut actual_step = params.params[idx].value - original;
300
301        // #112: If the forward step is blocked by an upper bound, try the
302        // backward step so the Jacobian column is not frozen at zero.
303        if actual_step.abs() < PIVOT_FLOOR {
304            params.params[idx].value = original - step;
305            params.params[idx].clamp();
306            actual_step = params.params[idx].value - original;
307            if actual_step.abs() < PIVOT_FLOOR {
308                // Truly stuck at a point constraint — skip this parameter.
309                params.params[idx].value = original;
310                continue;
311            }
312        }
313
314        params.all_values_into(all_vals_buf);
315        let perturbed = match model.evaluate(all_vals_buf) {
316            Ok(v) => v,
317            Err(_) => {
318                // Restore original before skipping — leaving the column as
319                // zero makes this parameter unresponsive for one LM step,
320                // which is safe (matches Poisson compute_gradient pattern).
321                params.params[idx].value = original;
322                continue;
323            }
324        };
325        params.params[idx].value = original;
326
327        // The main LM loop checks the trial step via
328        // `trial_has_active_nonfinite`, but `compute_jacobian` is also
329        // called from the post-convergence covariance path, where no
330        // such guard runs.  A NaN row in the perturbed output divided
331        // by `actual_step` would yield NaN in the Jacobian, which then
332        // poisons JᵀWJ and the inverse covariance.
333        //
334        // Per-cell skip (zero the entry) rather than whole-column skip:
335        // a NaN at a masked / inactive row is benign (the JᵀWJ assembly
336        // skips masked rows entirely via `active_mask.is_some_and(|m| !m[i])`),
337        // so a finite Jacobian row produced from a bad-but-masked probe
338        // must still be allowed to land in the column — see
339        // `test_lm_active_mask_tolerates_model_nan_outside_range`.  The
340        // active-row NaNs that would otherwise propagate into JᵀWJ are
341        // zeroed here, which is the same numerical outcome as if the
342        // model had reported `Err` at that probe (skipped column, but
343        // per-row).
344        for i in 0..n_data {
345            let p = perturbed[i];
346            let y = y_current[i];
347            if p.is_finite() && y.is_finite() {
348                *jacobian.get_mut(i, j) = (p - y) / actual_step;
349            }
350            // else: leave at the zero-default; for active rows this is
351            // safe (matches the whole-column-skip outcome on Err), for
352            // masked rows it never gets read.
353        }
354    }
355
356    Ok(jacobian)
357}
358
359/// Solve (A + λ·diag(A)) · x = b using Gaussian elimination.
360///
361/// A is a flat n×n symmetric positive definite matrix (approximately).
362/// Returns the solution vector x.
363pub(crate) fn solve_damped_system(a: &FlatMatrix, b: &[f64], lambda: f64) -> Option<Vec<f64>> {
364    let n = b.len();
365    if n == 0 {
366        return Some(vec![]);
367    }
368
369    // Build the augmented matrix [A + λ·diag(A) | b] as flat (n × (n+1)).
370    let ncols = n + 1;
371    let mut aug = FlatMatrix::zeros(n, ncols);
372    for (i, &bi) in b.iter().enumerate() {
373        for j in 0..n {
374            *aug.get_mut(i, j) = a.get(i, j);
375        }
376        *aug.get_mut(i, i) += lambda * a.get(i, i).max(LM_DIAGONAL_FLOOR); // Ensure non-zero diagonal
377        *aug.get_mut(i, n) = bi;
378    }
379
380    // Gaussian elimination with partial pivoting
381    for col in 0..n {
382        // Find pivot
383        let mut max_val = aug.get(col, col).abs();
384        let mut max_row = col;
385        for row in (col + 1)..n {
386            if aug.get(row, col).abs() > max_val {
387                max_val = aug.get(row, col).abs();
388                max_row = row;
389            }
390        }
391
392        if max_val < PIVOT_FLOOR {
393            return None; // Singular
394        }
395
396        // Swap rows col and max_row in the flat buffer.
397        if col != max_row {
398            let (row_a, row_b) = (col * ncols, max_row * ncols);
399            let (first, second) = aug.data.split_at_mut(row_b);
400            first[row_a..row_a + ncols].swap_with_slice(&mut second[..ncols]);
401        }
402
403        let pivot = aug.get(col, col);
404        for row in (col + 1)..n {
405            let factor = aug.get(row, col) / pivot;
406            for j in col..=n {
407                let val = aug.get(col, j);
408                *aug.get_mut(row, j) -= factor * val;
409            }
410        }
411    }
412
413    // Back substitution
414    let mut x = vec![0.0; n];
415    for i in (0..n).rev() {
416        let mut sum = aug.get(i, n);
417        for (j, &xj) in x.iter().enumerate().skip(i + 1) {
418            sum -= aug.get(i, j) * xj;
419        }
420        x[i] = sum / aug.get(i, i);
421    }
422
423    Some(x)
424}
425
426/// Invert a symmetric positive definite matrix (for covariance).
427///
428/// Input: flat n×n matrix. Output: flat n×n inverse, or None if singular.
429pub(crate) fn invert_matrix(a: &FlatMatrix) -> Option<FlatMatrix> {
430    let n = a.nrows;
431    if n == 0 {
432        return Some(FlatMatrix::zeros(0, 0));
433    }
434
435    // Build [A | I] as flat (n × 2n).
436    let ncols = 2 * n;
437    let mut aug = FlatMatrix::zeros(n, ncols);
438    for i in 0..n {
439        for j in 0..n {
440            *aug.get_mut(i, j) = a.get(i, j);
441        }
442        *aug.get_mut(i, n + i) = 1.0;
443    }
444
445    // Forward elimination with partial pivoting
446    for col in 0..n {
447        let mut max_val = aug.get(col, col).abs();
448        let mut max_row = col;
449        for row in (col + 1)..n {
450            if aug.get(row, col).abs() > max_val {
451                max_val = aug.get(row, col).abs();
452                max_row = row;
453            }
454        }
455
456        if max_val < PIVOT_FLOOR {
457            return None;
458        }
459
460        // Swap rows col and max_row.
461        if col != max_row {
462            let (row_a, row_b) = (col * ncols, max_row * ncols);
463            let (first, second) = aug.data.split_at_mut(row_b);
464            first[row_a..row_a + ncols].swap_with_slice(&mut second[..ncols]);
465        }
466
467        let pivot = aug.get(col, col);
468        for j in 0..ncols {
469            *aug.get_mut(col, j) /= pivot;
470        }
471
472        for row in 0..n {
473            if row != col {
474                let factor = aug.get(row, col);
475                for j in 0..ncols {
476                    let val = aug.get(col, j);
477                    *aug.get_mut(row, j) -= factor * val;
478                }
479            }
480        }
481    }
482
483    // Extract the right half [I|A⁻¹] → A⁻¹
484    let mut inv = FlatMatrix::zeros(n, n);
485    for i in 0..n {
486        for j in 0..n {
487            *inv.get_mut(i, j) = aug.get(i, n + j);
488        }
489    }
490
491    Some(inv)
492}
493
494/// Run the Levenberg-Marquardt optimizer.
495///
496/// # Arguments
497/// * `model` — Forward model implementing `FitModel`.
498/// * `y_obs` — Observed data values.
499/// * `sigma` — Uncertainties on observed data (standard deviations).
500/// * `params` — Initial parameter set (modified in place on convergence).
501/// * `config` — LM configuration.
502///
503/// # Returns
504/// Fit result including final parameters, chi-squared, and uncertainties.
505pub fn levenberg_marquardt(
506    model: &dyn FitModel,
507    y_obs: &[f64],
508    sigma: &[f64],
509    params: &mut ParameterSet,
510    config: &LmConfig,
511) -> Result<LmResult, FittingError> {
512    levenberg_marquardt_with_mask(model, y_obs, sigma, params, config, None)
513}
514
515/// Run the Levenberg-Marquardt optimizer with an optional per-bin
516/// active mask (SAMMY EMIN/EMAX-equivalent fit-energy-range restriction).
517///
518/// When `active_mask` is `Some(m)`:
519/// - bins where `m[i]` is `false` are excluded from χ² and the normal
520///   equations (weight zeroed before assembly), so they contribute
521///   nothing to the gradient or Hessian;
522/// - the model is still evaluated on the full grid so resolution
523///   broadening at the boundaries of the active region is correct;
524/// - `dof = n_active − n_free` where `n_active` is the count of
525///   active bins.
526///
527/// When `active_mask` is `None`, behaviour is identical to
528/// [`levenberg_marquardt`] (all bins active).
529pub fn levenberg_marquardt_with_mask(
530    model: &dyn FitModel,
531    y_obs: &[f64],
532    sigma: &[f64],
533    params: &mut ParameterSet,
534    config: &LmConfig,
535    active_mask: Option<&[bool]>,
536) -> Result<LmResult, FittingError> {
537    let n_data = y_obs.len();
538    if n_data == 0 {
539        return Err(FittingError::EmptyData);
540    }
541    if sigma.len() != n_data {
542        return Err(FittingError::LengthMismatch {
543            expected: n_data,
544            actual: sigma.len(),
545            field: "sigma",
546        });
547    }
548    if let Some(m) = active_mask
549        && m.len() != n_data
550    {
551        return Err(FittingError::LengthMismatch {
552            expected: n_data,
553            actual: m.len(),
554            field: "active_mask",
555        });
556    }
557    let n_active = crate::active_mask::active_count(active_mask, n_data);
558
559    // SAMMY EMIN/EMAX-equivalent fit-energy-range (#514): a mask with
560    // zero active bins means the user's `[E_min, E_max]` does not
561    // overlap the energy grid.  No data contributes to the cost
562    // function — return non-converged with NaN χ² rather than
563    // falling through to the all-fixed fast-return path (which would
564    // report `converged: true, chi_squared: 0` from a zero-row sum)
565    // or to the main optimisation loop (where `n_active < n_free`
566    // would catch it but only after wasted setup work).
567    if n_active == 0 {
568        return Ok(LmResult {
569            chi_squared: f64::NAN,
570            reduced_chi_squared: f64::NAN,
571            iterations: 0,
572            converged: false,
573            params: params.all_values(),
574            covariance: None,
575            uncertainties: None,
576        });
577    }
578
579    let n_free = params.n_free();
580
581    // Early return when all parameters are fixed: evaluate once and report the
582    // model's chi-squared.  There is nothing to optimize, so iterating would
583    // waste cycles.
584    if n_free == 0 {
585        let weights: Vec<f64> = sigma
586            .iter()
587            .enumerate()
588            .map(|(i, &s)| {
589                // Active-bin masking (SAMMY EMIN/EMAX): bins outside the
590                // user range contribute zero to χ².
591                if active_mask.is_some_and(|m| !m[i]) {
592                    return 0.0;
593                }
594                if !s.is_finite() || s <= 0.0 {
595                    1.0 / 1e30
596                } else {
597                    1.0 / (s * s)
598                }
599            })
600            .collect();
601        let y_model = model.evaluate(&params.all_values())?;
602
603        // #P1: If the model produces NaN/Inf with all-fixed parameters,
604        // return converged=false rather than silently propagating NaN chi².
605        // Covariance/uncertainties are None because the fit did not converge —
606        // an unconverged result has no meaningful covariance to report.
607        //
608        // SAMMY EMIN/EMAX-equivalent fit-energy-range (#514): masked rows are
609        // skipped throughout the cost / normal-equation accumulators, so a
610        // non-finite model output at a masked bin should not abort the fit
611        // — only check finiteness on active rows.
612        let model_has_active_nonfinite = y_model
613            .iter()
614            .enumerate()
615            .any(|(i, v)| active_mask.is_none_or(|m| m[i]) && !v.is_finite());
616        if model_has_active_nonfinite {
617            return Ok(LmResult {
618                chi_squared: f64::NAN,
619                reduced_chi_squared: f64::NAN,
620                iterations: 0,
621                converged: false,
622                params: params.all_values(),
623                covariance: None,
624                uncertainties: None,
625            });
626        }
627
628        let residuals: Vec<f64> = y_obs
629            .iter()
630            .zip(y_model.iter())
631            .map(|(&obs, &mdl)| obs - mdl)
632            .collect();
633        let chi2 = chi_squared(&residuals, &weights, active_mask);
634        // #125.5: Compute dof via `n_active - n_free` (with `n_active = n_data`
635        // when no mask is set) to mirror the main path and keep a single
636        // visible formula.  When a fit-energy-range mask is in effect,
637        // `n_active` reflects the count of bins that actually contributed
638        // to χ² (SAMMY EMIN/EMAX semantics).
639        let dof = n_active.saturating_sub(n_free);
640        let reduced = if dof > 0 { chi2 / dof as f64 } else { f64::NAN };
641        return Ok(LmResult {
642            chi_squared: chi2,
643            reduced_chi_squared: reduced,
644            iterations: 0,
645            converged: true,
646            params: params.all_values(),
647            covariance: Some(FlatMatrix::zeros(0, 0)),
648            uncertainties: Some(vec![]),
649        });
650    }
651
652    // #108.3: Underdetermined systems — when n_active < n_free, the problem is
653    // underdetermined and the Jacobian cannot be full rank.  Return early
654    // with converged=false so callers can detect the problem.
655    // n_active == n_free is exactly determined (dof=0) and still solvable;
656    // we allow it and report reduced_chi_squared = NaN (0/0).
657    //
658    // When a fit-energy-range mask is in effect, the underdetermined
659    // check uses the active-bin count (SAMMY EMIN/EMAX semantics) — masked
660    // bins do not contribute information to the fit.
661    if n_active < n_free {
662        return Ok(LmResult {
663            chi_squared: f64::NAN,
664            reduced_chi_squared: f64::NAN,
665            iterations: 0,
666            converged: false,
667            params: params.all_values(),
668            covariance: None,
669            uncertainties: None,
670        });
671    }
672    let dof = n_active - n_free;
673
674    // #104: Validate sigma — division by zero or non-finite sigma would produce
675    // NaN/Inf weights and silently corrupt the entire fit.  Clamp to a small
676    // floor instead of rejecting outright, so callers with a few zero-sigma
677    // bins still get a usable fit.
678    //
679    // Active-bin masking (SAMMY EMIN/EMAX): bins outside the user range
680    // get weight 0 here, which propagates through χ² and the JᵀWJ /
681    // JᵀWr normal-equation assembly to contribute exactly zero —
682    // covering both residual and gradient masking with no further
683    // changes downstream.
684    let weights: Vec<f64> = sigma
685        .iter()
686        .enumerate()
687        .map(|(i, &s)| {
688            if active_mask.is_some_and(|m| !m[i]) {
689                return 0.0;
690            }
691            if !s.is_finite() || s <= 0.0 {
692                // Treat as negligible weight (huge sigma) rather than panicking.
693                1.0 / 1e30
694            } else {
695                1.0 / (s * s)
696            }
697        })
698        .collect();
699
700    // Scratch buffers reused across the optimization loop for
701    // params.all_values_into() calls in compute_jacobian (1 + N_free calls
702    // per Jacobian computation) and the trial-step evaluation,
703    // params.free_values_into() calls for snapshotting free parameters
704    // before trial steps, and params.free_indices_into() calls inside
705    // compute_jacobian.
706    let mut all_vals_buf = Vec::with_capacity(params.params.len());
707    let mut free_vals_buf = Vec::with_capacity(n_free);
708    let mut free_idx_buf = Vec::with_capacity(n_free);
709
710    // Initial model output, residuals, and chi².
711    // y_current is kept up-to-date after accepted steps so that the next
712    // Jacobian call can reuse it without an extra evaluate() call.
713    params.all_values_into(&mut all_vals_buf);
714    let mut y_current = model.evaluate(&all_vals_buf)?;
715    let mut residuals: Vec<f64> = y_obs
716        .iter()
717        .zip(y_current.iter())
718        .map(|(&obs, &mdl)| obs - mdl)
719        .collect();
720    let mut chi2 = chi_squared(&residuals, &weights, active_mask);
721
722    let mut lambda = config.lambda_init;
723    let mut converged = false;
724    let mut iter = 0;
725
726    for _ in 0..config.max_iter {
727        iter += 1;
728
729        // Compute Jacobian — uses y_current to avoid a redundant evaluate().
730        // Analytical Jacobian (if provided by the model) costs 0 extra evaluates;
731        // finite-difference fallback costs N_free extra evaluates.
732        let jacobian = compute_jacobian(
733            model,
734            params,
735            &y_current,
736            config.fd_step,
737            &mut all_vals_buf,
738            &mut free_idx_buf,
739        )?;
740
741        // Build normal equations: JᵀWJ and JᵀWr.
742        //
743        // Active-bin masking (SAMMY EMIN/EMAX, #514): explicitly skip
744        // masked rows rather than relying on weights[i] == 0 — the
745        // model or the residual at a masked margin bin may be NaN
746        // (e.g. when y_obs is NaN outside the user's fit-energy range),
747        // and `0.0 * NaN = NaN` would poison both accumulators.
748        let mut jtw_j = FlatMatrix::zeros(n_free, n_free);
749        let mut jtw_r = vec![0.0; n_free];
750
751        for (i, (&wi, &ri)) in weights.iter().zip(residuals.iter()).enumerate() {
752            if active_mask.is_some_and(|m| !m[i]) {
753                continue;
754            }
755            for (j, jtw_r_j) in jtw_r.iter_mut().enumerate() {
756                let jij = jacobian.get(i, j);
757                *jtw_r_j += jij * wi * ri;
758                for k in 0..n_free {
759                    *jtw_j.get_mut(j, k) += jij * wi * jacobian.get(i, k);
760                }
761            }
762        }
763        let scaled_grad_inf = scaled_gradient_inf_norm(&jtw_j, &jtw_r, chi2);
764        let informative_curvature = has_informative_curvature(&jtw_j);
765
766        // Solve (JᵀWJ + λ·diag(JᵀWJ)) · δ = JᵀWr
767        let delta = match solve_damped_system(&jtw_j, &jtw_r, lambda) {
768            Some(d) => d,
769            None => break, // Singular system
770        };
771
772        // Trial step — snapshot free values into a reusable buffer to avoid
773        // per-iteration allocation.
774        params.free_values_into(&mut free_vals_buf);
775        let trial_free: Vec<f64> = free_vals_buf
776            .iter()
777            .zip(delta.iter())
778            .map(|(&v, &d)| v + d)
779            .collect();
780        let param_change: f64 = delta
781            .iter()
782            .zip(free_vals_buf.iter())
783            .map(|(&d, &v)| (d / (v.abs() + PIVOT_FLOOR)).powi(2))
784            .sum::<f64>()
785            .sqrt();
786        params.set_free_values(&trial_free);
787
788        params.all_values_into(&mut all_vals_buf);
789        let y_trial = match model.evaluate(&all_vals_buf) {
790            Ok(y) => y,
791            Err(_) => {
792                // Treat evaluation error as a bad step (same as NaN).
793                params.set_free_values(&free_vals_buf);
794                lambda *= config.lambda_up;
795                if lambda > LAMBDA_BREAKOUT {
796                    converged = false;
797                    break;
798                }
799                continue;
800            }
801        };
802
803        // #113: If the model produced NaN/Inf at any *active* bin, treat as
804        // a bad step (same as chi2 increase) — increase lambda and try again.
805        // SAMMY EMIN/EMAX-equivalent fit-energy-range (#514): masked rows are
806        // skipped from χ² / JᵀWJ / JᵀWr / covariance, so a non-finite model
807        // output at a masked margin bin must not penalise the trial step.
808        let trial_has_active_nonfinite = y_trial
809            .iter()
810            .enumerate()
811            .any(|(i, v)| active_mask.is_none_or(|m| m[i]) && !v.is_finite());
812        if trial_has_active_nonfinite {
813            params.set_free_values(&free_vals_buf);
814            lambda *= config.lambda_up;
815            if lambda > LAMBDA_BREAKOUT {
816                converged = false;
817                break;
818            }
819            continue;
820        }
821
822        let trial_residuals: Vec<f64> = y_obs
823            .iter()
824            .zip(y_trial.iter())
825            .map(|(&obs, &mdl)| obs - mdl)
826            .collect();
827        let trial_chi2 = chi_squared(&trial_residuals, &weights, active_mask);
828        let chi2_delta = (trial_chi2 - chi2).abs();
829        let chi2_scale = chi2.abs().max(trial_chi2.abs()).max(1.0);
830        let chi2_stagnated = chi2_delta <= config.tol_chi2 * chi2_scale;
831
832        if trial_chi2 < chi2 {
833            // Accept step — cache y_trial so the next iteration can skip
834            // the base evaluate() inside compute_jacobian.
835            let rel_change = (chi2 - trial_chi2) / (chi2 + PIVOT_FLOOR);
836            chi2 = trial_chi2;
837            residuals = trial_residuals;
838            y_current = y_trial;
839            lambda *= config.lambda_down;
840
841            // Check convergence: relative chi2 change is tiny or parameters
842            // stopped moving.  The old third condition
843            // `chi2 < tol_chi2 * n_data` was scale-dependent and could cause
844            // premature convergence on data with small residuals.  (#108.2)
845            if rel_change < config.tol_chi2 || param_change < config.tol_param {
846                converged = true;
847                break;
848            }
849        } else {
850            // Numerical stagnation: the strict LM acceptance test keeps
851            // `trial_chi2 == chi2` in the reject path, but when both the
852            // objective change and parameter step are tiny, the optimizer may
853            // already be at a nonzero-χ² stationary point (noisy data,
854            // correlated parameters, imperfect model). Report convergence if
855            // the gradient is also tiny and the local model has real
856            // curvature, rather than inflating lambda until breakout.
857            let grad_tol = config.tol_chi2.sqrt().max(config.tol_param.sqrt());
858            if chi2_stagnated
859                && param_change < config.tol_param
860                && scaled_grad_inf < grad_tol
861                && informative_curvature
862            {
863                params.set_free_values(&free_vals_buf);
864                converged = true;
865                break;
866            }
867
868            // Reject step, restore parameters.
869            // y_current stays valid (parameters reverted to free_vals_buf snapshot).
870            params.set_free_values(&free_vals_buf);
871            lambda *= config.lambda_up;
872
873            // #108.4: If lambda is astronomically large, the optimizer is stuck
874            // in a region where no step improves chi2.  Break out rather than
875            // wasting iterations.
876            if lambda > LAMBDA_BREAKOUT {
877                converged = false;
878                break;
879            }
880        }
881    }
882
883    let reduced_chi2 = if dof > 0 { chi2 / dof as f64 } else { f64::NAN };
884
885    // Compute covariance matrix: (JᵀWJ)⁻¹ at the final parameters.
886    //
887    // This block requires an extra Jacobian evaluation + O(n_free³) matrix
888    // inversion.  When `compute_covariance` is false (e.g. per-pixel spatial
889    // mapping), we skip it entirely — the caller only needs densities and
890    // chi-squared, not uncertainties.
891    let (covariance, uncertainties) = if converged && config.compute_covariance {
892        let jacobian = compute_jacobian(
893            model,
894            params,
895            &y_current,
896            config.fd_step,
897            &mut all_vals_buf,
898            &mut free_idx_buf,
899        )?;
900        // Same active-mask row-skip semantics as the main assembly
901        // loop above — keeps the covariance free of NaN contributions
902        // from masked margin bins (#514).
903        let mut jtw_j = FlatMatrix::zeros(n_free, n_free);
904        for (i, &wi) in weights.iter().enumerate() {
905            if active_mask.is_some_and(|m| !m[i]) {
906                continue;
907            }
908            for j in 0..n_free {
909                let jij = jacobian.get(i, j);
910                for k in 0..n_free {
911                    *jtw_j.get_mut(j, k) += jij * wi * jacobian.get(i, k);
912                }
913            }
914        }
915
916        // #108.1: Scale covariance by reduced chi-squared.
917        //
918        // The raw (JᵀWJ)⁻¹ gives the covariance only when the model is a perfect
919        // description and the weights are exact.  Multiplying by χ²/ν accounts for
920        // misfit (model inadequacy or underestimated errors).  This is the standard
921        // statistical prescription (see e.g. Numerical Recipes §15.6).
922        //
923        // When dof == 0 (exactly determined system), reduced chi-squared is
924        // undefined (0/0).  We report NaN and skip covariance scaling entirely,
925        // returning None for covariance and uncertainties.
926        if dof > 0 {
927            if let Some(mut cov) = invert_matrix(&jtw_j) {
928                for elem in cov.data.iter_mut() {
929                    *elem *= reduced_chi2;
930                }
931                let unc: Vec<f64> = (0..n_free)
932                    .map(|i| {
933                        let diag = cov.get(i, i);
934                        if diag.is_finite() && diag > 0.0 {
935                            diag.sqrt()
936                        } else {
937                            f64::NAN
938                        }
939                    })
940                    .collect();
941                (Some(cov), Some(unc))
942            } else {
943                (None, None)
944            }
945        } else {
946            // dof == 0: covariance scaling is undefined; report None.
947            (None, None)
948        }
949    } else {
950        // Covariance computation skipped (compute_covariance == false).
951        (None, None)
952    };
953
954    Ok(LmResult {
955        chi_squared: chi2,
956        reduced_chi_squared: reduced_chi2,
957        iterations: iter,
958        converged,
959        params: params.all_values(),
960        covariance,
961        uncertainties,
962    })
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968    use crate::parameters::{FitParameter, ParameterSet};
969
970    /// Simple linear model: y = a*x + b
971    struct LinearModel {
972        x: Vec<f64>,
973    }
974
975    impl FitModel for LinearModel {
976        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
977            let a = params[0];
978            let b = params[1];
979            Ok(self.x.iter().map(|&x| a * x + b).collect())
980        }
981    }
982
983    #[test]
984    fn test_fit_linear_exact() {
985        // Fit y = 2x + 3 with exact data (no noise)
986        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
987        let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
988        let sigma = vec![1.0; 10];
989
990        let model = LinearModel { x };
991        let mut params = ParameterSet::new(vec![
992            FitParameter::unbounded("a", 1.0), // initial guess
993            FitParameter::unbounded("b", 1.0),
994        ]);
995
996        let result =
997            levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
998
999        assert!(result.converged, "Fit did not converge");
1000        assert!(
1001            (result.params[0] - 2.0).abs() < 1e-4,
1002            "a = {}, expected 2.0",
1003            result.params[0]
1004        );
1005        assert!(
1006            (result.params[1] - 3.0).abs() < 1e-4,
1007            "b = {}, expected 3.0",
1008            result.params[1]
1009        );
1010        assert!(result.chi_squared < 1e-6);
1011    }
1012
1013    #[test]
1014    fn test_converges_on_exact_flat_bottom_without_lambda_breakout() {
1015        // Exact data with zero initial damping reaches the optimum in one
1016        // Newton step. The next iteration sits on a flat χ² floor where the
1017        // strict `trial_chi2 < chi2` check must not force a false non-
1018        // convergence.
1019        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1020        let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1021        let sigma = vec![1.0; 10];
1022
1023        let model = LinearModel { x };
1024        let mut params = ParameterSet::new(vec![
1025            FitParameter::unbounded("a", 0.0),
1026            FitParameter::unbounded("b", 0.0),
1027        ]);
1028        let config = LmConfig {
1029            lambda_init: 0.0,
1030            ..LmConfig::default()
1031        };
1032
1033        let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1034
1035        assert!(
1036            result.converged,
1037            "Fit should converge on an exact flat bottom"
1038        );
1039        assert!(result.chi_squared < 1e-20, "chi2 = {}", result.chi_squared);
1040        assert!(
1041            result.iterations < config.max_iter,
1042            "LM should stop by convergence, not by iteration exhaustion"
1043        );
1044    }
1045
1046    #[test]
1047    fn test_converges_on_nonzero_chi2_stationary_point() {
1048        // Noisy overdetermined data have a nonzero-chi2 optimum. With very
1049        // strict tolerances, the accepted step to the optimum does not trip
1050        // the accept-branch convergence checks, so the next iteration reaches
1051        // a reject-path stationary point with trial_chi2 == chi2.
1052        struct AffineModel {
1053            x: Vec<f64>,
1054        }
1055        impl FitModel for AffineModel {
1056            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1057                let a = params[0];
1058                let b = params[1];
1059                Ok(self.x.iter().map(|&x| a * x + b).collect())
1060            }
1061        }
1062
1063        let model = AffineModel {
1064            x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
1065        };
1066        let y_obs = vec![0.1, 0.9, 2.2, 2.8, 4.1];
1067        let sigma = vec![1.0; y_obs.len()];
1068        let mut params = ParameterSet::new(vec![
1069            FitParameter::unbounded("a", 0.0),
1070            FitParameter::unbounded("b", 0.0),
1071        ]);
1072        let config = LmConfig {
1073            max_iter: 200,
1074            tol_chi2: 1e-16,
1075            tol_param: 1e-16,
1076            ..LmConfig::default()
1077        };
1078
1079        let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1080
1081        assert!(
1082            result.converged,
1083            "stationary nonzero-chi2 optimum should converge instead of lambda breakout"
1084        );
1085        assert!(
1086            result.reduced_chi_squared.is_finite() && result.reduced_chi_squared > 0.0,
1087            "expected nonzero reduced chi2 at noisy optimum, got {}",
1088            result.reduced_chi_squared
1089        );
1090    }
1091
1092    #[test]
1093    fn test_fit_linear_with_fixed_param() {
1094        // Fit y = a*x + 3 with b fixed at 3.0
1095        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1096        let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1097        let sigma = vec![1.0; 10];
1098
1099        let model = LinearModel { x };
1100        let mut params = ParameterSet::new(vec![
1101            FitParameter::unbounded("a", 1.0),
1102            FitParameter::fixed("b", 3.0),
1103        ]);
1104
1105        let result =
1106            levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1107
1108        assert!(result.converged);
1109        assert!(
1110            (result.params[0] - 2.0).abs() < 1e-6,
1111            "a = {}",
1112            result.params[0]
1113        );
1114        assert_eq!(result.params[1], 3.0); // fixed
1115    }
1116
1117    #[test]
1118    fn test_fit_quadratic() {
1119        // Fit y = a*x² + b*x + c to quadratic data
1120        struct QuadModel {
1121            x: Vec<f64>,
1122        }
1123        impl FitModel for QuadModel {
1124            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1125                let (a, b, c) = (params[0], params[1], params[2]);
1126                Ok(self.x.iter().map(|&x| a * x * x + b * x + c).collect())
1127            }
1128        }
1129
1130        let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.5).collect();
1131        let y_obs: Vec<f64> = x.iter().map(|&xi| 0.5 * xi * xi - 2.0 * xi + 1.0).collect();
1132        let sigma = vec![1.0; 20];
1133
1134        let model = QuadModel { x };
1135        let mut params = ParameterSet::new(vec![
1136            FitParameter::unbounded("a", 1.0),
1137            FitParameter::unbounded("b", 0.0),
1138            FitParameter::unbounded("c", 0.0),
1139        ]);
1140
1141        let result =
1142            levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1143
1144        assert!(result.converged);
1145        assert!(
1146            (result.params[0] - 0.5).abs() < 1e-5,
1147            "a = {}",
1148            result.params[0]
1149        );
1150        assert!(
1151            (result.params[1] - (-2.0)).abs() < 1e-5,
1152            "b = {}",
1153            result.params[1]
1154        );
1155        assert!(
1156            (result.params[2] - 1.0).abs() < 1e-5,
1157            "c = {}",
1158            result.params[2]
1159        );
1160    }
1161
1162    #[test]
1163    fn test_non_negative_constraint() {
1164        // Fit y = a*x with data that has negative slope,
1165        // but parameter a is constrained to be non-negative.
1166        // Should converge to a ≈ 0.
1167        struct SlopeModel {
1168            x: Vec<f64>,
1169        }
1170        impl FitModel for SlopeModel {
1171            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1172                let a = params[0];
1173                Ok(self.x.iter().map(|&x| a * x).collect())
1174            }
1175        }
1176
1177        let x: Vec<f64> = (1..10).map(|i| i as f64).collect();
1178        let y_obs: Vec<f64> = x.iter().map(|&xi| -2.0 * xi).collect();
1179        let sigma = vec![1.0; 9];
1180
1181        let model = SlopeModel { x };
1182        let mut params = ParameterSet::new(vec![FitParameter::non_negative("a", 1.0)]);
1183
1184        let result =
1185            levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1186
1187        // Should be clamped at 0
1188        assert!(
1189            result.params[0] >= 0.0 && result.params[0] < 0.1,
1190            "a = {}, expected ~0",
1191            result.params[0]
1192        );
1193    }
1194
1195    #[test]
1196    fn test_uncertainty_estimation() {
1197        // Fit linear model; uncertainties should be reasonable
1198        let x: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
1199        let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1200        let sigma = vec![0.1; 100]; // Small uncertainty
1201
1202        let model = LinearModel { x };
1203        let mut params = ParameterSet::new(vec![
1204            FitParameter::unbounded("a", 1.0),
1205            FitParameter::unbounded("b", 1.0),
1206        ]);
1207
1208        let result =
1209            levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1210
1211        assert!(result.converged);
1212        assert!(result.uncertainties.is_some());
1213        let unc = result.uncertainties.unwrap();
1214        // Uncertainties should be positive and small
1215        assert!(unc[0] > 0.0 && unc[0] < 0.01, "σ_a = {}", unc[0]);
1216        assert!(unc[1] > 0.0 && unc[1] < 0.1, "σ_b = {}", unc[1]);
1217    }
1218
1219    #[test]
1220    fn test_solve_damped_system_identity() {
1221        // (I + λ·I)x = b → x = b/(1+λ)
1222        let a = FlatMatrix {
1223            data: vec![1.0, 0.0, 0.0, 1.0],
1224            nrows: 2,
1225            ncols: 2,
1226        };
1227        let b = vec![2.0, 4.0];
1228        let lambda = 1.0;
1229        let x = solve_damped_system(&a, &b, lambda).unwrap();
1230        assert!((x[0] - 1.0).abs() < 1e-10);
1231        assert!((x[1] - 2.0).abs() < 1e-10);
1232    }
1233
1234    #[test]
1235    fn test_invert_matrix_2x2() {
1236        let a = FlatMatrix {
1237            data: vec![4.0, 7.0, 2.0, 6.0],
1238            nrows: 2,
1239            ncols: 2,
1240        };
1241        let inv = invert_matrix(&a).unwrap();
1242        // A⁻¹ = 1/10 × [6 -7; -2 4]
1243        assert!((inv.get(0, 0) - 0.6).abs() < 1e-10);
1244        assert!((inv.get(0, 1) - (-0.7)).abs() < 1e-10);
1245        assert!((inv.get(1, 0) - (-0.2)).abs() < 1e-10);
1246        assert!((inv.get(1, 1) - 0.4).abs() < 1e-10);
1247    }
1248
1249    // ---- Edge-case tests for issue #125 ----
1250
1251    #[test]
1252    fn test_all_fixed_params_nan_model() {
1253        // #125.1: When all parameters are fixed and the model produces NaN,
1254        // the result must report converged=false (not converged=true with NaN chi2).
1255        struct NanModel;
1256        impl FitModel for NanModel {
1257            fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
1258                Ok(vec![f64::NAN; 5])
1259            }
1260        }
1261
1262        let y_obs = vec![1.0; 5];
1263        let sigma = vec![1.0; 5];
1264        let mut params = ParameterSet::new(vec![FitParameter::fixed("a", 1.0)]);
1265
1266        let result =
1267            levenberg_marquardt(&NanModel, &y_obs, &sigma, &mut params, &LmConfig::default())
1268                .unwrap();
1269
1270        assert!(!result.converged, "All-fixed NaN model should not converge");
1271        assert!(result.chi_squared.is_nan(), "chi2 should be NaN");
1272        assert_eq!(result.iterations, 0);
1273    }
1274
1275    #[test]
1276    fn test_underdetermined_system() {
1277        // #125.6: More free parameters than data points → underdetermined.
1278        // Should return converged=false immediately.
1279        let y_obs = vec![1.0, 2.0]; // 2 data points
1280        let sigma = vec![1.0, 1.0];
1281
1282        // 2 free params for 2 data points is exactly determined (ok),
1283        // but 3 free params for 2 data points is underdetermined.
1284        struct ThreeParamModel;
1285        impl FitModel for ThreeParamModel {
1286            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1287                Ok(vec![params[0] + params[1] + params[2]; 2])
1288            }
1289        }
1290
1291        let mut params = ParameterSet::new(vec![
1292            FitParameter::unbounded("a", 1.0),
1293            FitParameter::unbounded("b", 1.0),
1294            FitParameter::unbounded("c", 1.0),
1295        ]);
1296
1297        let result = levenberg_marquardt(
1298            &ThreeParamModel,
1299            &y_obs,
1300            &sigma,
1301            &mut params,
1302            &LmConfig::default(),
1303        )
1304        .unwrap();
1305
1306        assert!(
1307            !result.converged,
1308            "Underdetermined system should not converge"
1309        );
1310        assert!(result.chi_squared.is_nan());
1311        assert_eq!(result.iterations, 0);
1312    }
1313
1314    #[test]
1315    fn test_exactly_determined_dof_zero() {
1316        // #125.6: n_data == n_free → dof=0, exactly determined.
1317        // Should still converge but reduced_chi_squared is NaN (0/0).
1318        let y_obs = vec![5.0, 11.0]; // y = 2x + 3 at x=1,4
1319        let sigma = vec![1.0, 1.0];
1320
1321        let model = LinearModel { x: vec![1.0, 4.0] };
1322        let mut params = ParameterSet::new(vec![
1323            FitParameter::unbounded("a", 1.0),
1324            FitParameter::unbounded("b", 1.0),
1325        ]);
1326
1327        let result =
1328            levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1329
1330        assert!(
1331            result.converged,
1332            "Exactly-determined system should converge"
1333        );
1334        assert!(
1335            result.chi_squared < 1e-6,
1336            "chi2 should be ~0, got {}",
1337            result.chi_squared
1338        );
1339        // dof=0 → reduced chi2 is NaN
1340        assert!(
1341            result.reduced_chi_squared.is_nan(),
1342            "reduced_chi2 should be NaN for dof=0, got {}",
1343            result.reduced_chi_squared
1344        );
1345        // No covariance when dof=0
1346        assert!(result.covariance.is_none());
1347        assert!(result.uncertainties.is_none());
1348    }
1349
1350    #[test]
1351    fn test_lambda_breakout() {
1352        // #125.6: A model that never improves should trigger lambda breakout.
1353        struct ConstantModel;
1354        impl FitModel for ConstantModel {
1355            fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
1356                // Returns constant output regardless of parameters,
1357                // so the Jacobian is zero and no step can improve chi2.
1358                Ok(vec![42.0; 5])
1359            }
1360        }
1361
1362        let y_obs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1363        let sigma = vec![1.0; 5];
1364        let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 1.0)]);
1365
1366        let config = LmConfig {
1367            max_iter: 1000,
1368            ..LmConfig::default()
1369        };
1370
1371        let result =
1372            levenberg_marquardt(&ConstantModel, &y_obs, &sigma, &mut params, &config).unwrap();
1373
1374        assert!(
1375            !result.converged,
1376            "Flat model should not converge (lambda breakout)"
1377        );
1378        assert!(
1379            result.covariance.is_none(),
1380            "unconverged fit should not report covariance"
1381        );
1382        assert!(
1383            result.uncertainties.is_none(),
1384            "unconverged fit should not report uncertainties"
1385        );
1386    }
1387
1388    #[test]
1389    fn test_nan_model_during_iteration() {
1390        // #125.6: Model that produces NaN for certain parameter values.
1391        // The optimizer should treat NaN steps as bad and try smaller steps.
1392        struct NanAtLargeModel {
1393            x: Vec<f64>,
1394        }
1395        impl FitModel for NanAtLargeModel {
1396            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1397                let a = params[0];
1398                Ok(self
1399                    .x
1400                    .iter()
1401                    .map(|&x| if a > 5.0 { f64::NAN } else { a * x + 1.0 })
1402                    .collect())
1403            }
1404        }
1405
1406        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1407        let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1408        let sigma = vec![1.0; 10];
1409
1410        let model = NanAtLargeModel { x };
1411        let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 3.0)]);
1412
1413        let result =
1414            levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1415
1416        // Should converge to a≈2 while avoiding the NaN region a>5.
1417        assert!(result.converged, "Should converge avoiding NaN region");
1418        assert!(
1419            (result.params[0] - 2.0).abs() < 0.1,
1420            "a = {}, expected ~2.0",
1421            result.params[0]
1422        );
1423    }
1424
1425    #[test]
1426    fn test_err_model_during_trial_step() {
1427        // Model that returns Err for large parameter values.
1428        // The optimizer should treat Err trial steps as bad steps (increase λ)
1429        // and converge without panicking.
1430        struct ErrAtLargeModel {
1431            x: Vec<f64>,
1432        }
1433        impl FitModel for ErrAtLargeModel {
1434            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1435                let a = params[0];
1436                if a > 5.0 {
1437                    return Err(FittingError::EvaluationFailed(
1438                        "parameter out of valid range".into(),
1439                    ));
1440                }
1441                Ok(self.x.iter().map(|&x| a * x + 1.0).collect())
1442            }
1443        }
1444
1445        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1446        let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1447        let sigma = vec![1.0; 10];
1448
1449        let model = ErrAtLargeModel { x };
1450        let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 3.0)]);
1451
1452        let result =
1453            levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1454
1455        // Should converge to a≈2 while avoiding the Err region a>5.
1456        assert!(result.converged, "Should converge avoiding Err region");
1457        assert!(
1458            (result.params[0] - 2.0).abs() < 0.1,
1459            "a = {}, expected ~2.0",
1460            result.params[0]
1461        );
1462    }
1463
1464    #[test]
1465    fn test_fit_linear_no_covariance() {
1466        // When compute_covariance is false, the fit should still converge and
1467        // produce correct parameters, but covariance and uncertainties are None.
1468        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1469        let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1470        let sigma = vec![1.0; 10];
1471
1472        let model = LinearModel { x };
1473        let mut params = ParameterSet::new(vec![
1474            FitParameter::unbounded("a", 1.0),
1475            FitParameter::unbounded("b", 1.0),
1476        ]);
1477
1478        let config = LmConfig {
1479            compute_covariance: false,
1480            ..LmConfig::default()
1481        };
1482
1483        let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1484
1485        assert!(result.converged, "Fit did not converge");
1486        assert!(
1487            (result.params[0] - 2.0).abs() < 1e-4,
1488            "a = {}, expected 2.0",
1489            result.params[0]
1490        );
1491        assert!(
1492            (result.params[1] - 3.0).abs() < 1e-4,
1493            "b = {}, expected 3.0",
1494            result.params[1]
1495        );
1496        assert!(result.chi_squared < 1e-6);
1497        assert!(
1498            result.covariance.is_none(),
1499            "covariance should be None when compute_covariance=false"
1500        );
1501        assert!(
1502            result.uncertainties.is_none(),
1503            "uncertainties should be None when compute_covariance=false"
1504        );
1505    }
1506
1507    #[test]
1508    fn test_zero_negative_sigma_clamping() {
1509        // #125.6: Zero and negative sigma should be clamped to huge sigma (tiny weight),
1510        // not cause NaN/panic.
1511        let y_obs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1512        let sigma = vec![0.0, -1.0, f64::NAN, f64::INFINITY, 1.0];
1513
1514        let model = LinearModel {
1515            x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
1516        };
1517        let mut params = ParameterSet::new(vec![
1518            FitParameter::unbounded("a", 1.0),
1519            FitParameter::unbounded("b", 0.0),
1520        ]);
1521
1522        // Should not panic and should produce a finite result.
1523        let result =
1524            levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1525
1526        assert!(
1527            result.chi_squared.is_finite(),
1528            "chi2 should be finite despite bad sigma, got {}",
1529            result.chi_squared
1530        );
1531        assert!(
1532            result.converged,
1533            "Fit should converge despite bad sigma values"
1534        );
1535        // The only valid data point with sigma=1.0 is (x=4, y=5).
1536        // The fitted line y = a*x + b should pass near that point.
1537        let y_at_4 = result.params[0] * 4.0 + result.params[1];
1538        assert!(
1539            (y_at_4 - 5.0).abs() < 1.0,
1540            "Fitted line should pass near (4, 5): a={}, b={}, y(4)={}",
1541            result.params[0],
1542            result.params[1],
1543            y_at_4,
1544        );
1545    }
1546
1547    // ------------------------------------------------------------------
1548    // Active-bin mask (SAMMY EMIN/EMAX-equivalent fit-energy-range, #514).
1549    // ------------------------------------------------------------------
1550
1551    /// LM with an `active_mask` that restricts to a subset of bins must
1552    /// produce a fit equivalent to running on that subset directly —
1553    /// validates that masking propagates through both the residual /
1554    /// χ² accumulation AND the dof / reduced-χ² book-keeping.
1555    #[test]
1556    fn test_lm_active_mask_subset_equivalence() {
1557        // Linear fit y = 2x + 3 on x = 0..10.  Mask = bins 0..5 only.
1558        let x_full: Vec<f64> = (0..10).map(|i| i as f64).collect();
1559        let y_full: Vec<f64> = x_full.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1560        let sigma_full = vec![1.0; 10];
1561        let mask = vec![
1562            true, true, true, true, true, false, false, false, false, false,
1563        ];
1564
1565        let model = LinearModel { x: x_full.clone() };
1566        let mut params = ParameterSet::new(vec![
1567            FitParameter::unbounded("a", 1.0),
1568            FitParameter::unbounded("b", 1.0),
1569        ]);
1570        let result_masked = levenberg_marquardt_with_mask(
1571            &model,
1572            &y_full,
1573            &sigma_full,
1574            &mut params,
1575            &LmConfig::default(),
1576            Some(&mask),
1577        )
1578        .unwrap();
1579        assert!(result_masked.converged);
1580        assert!((result_masked.params[0] - 2.0).abs() < 1e-6);
1581        assert!((result_masked.params[1] - 3.0).abs() < 1e-6);
1582
1583        // Reference: same fit on the subset directly (no mask).
1584        let x_sub = x_full[..5].to_vec();
1585        let y_sub = y_full[..5].to_vec();
1586        let sigma_sub = vec![1.0; 5];
1587        let model_sub = LinearModel { x: x_sub };
1588        let mut params_sub = ParameterSet::new(vec![
1589            FitParameter::unbounded("a", 1.0),
1590            FitParameter::unbounded("b", 1.0),
1591        ]);
1592        let result_sub = levenberg_marquardt(
1593            &model_sub,
1594            &y_sub,
1595            &sigma_sub,
1596            &mut params_sub,
1597            &LmConfig::default(),
1598        )
1599        .unwrap();
1600
1601        // Recovered parameters and reduced-χ² (both fits noiseless,
1602        // so reduced-χ² ≈ 0 in either case) must match.
1603        for j in 0..2 {
1604            assert!(
1605                (result_masked.params[j] - result_sub.params[j]).abs() < 1e-6,
1606                "param {j}: masked={} subset={}",
1607                result_masked.params[j],
1608                result_sub.params[j]
1609            );
1610        }
1611        assert!(
1612            (result_masked.reduced_chi_squared - result_sub.reduced_chi_squared).abs() < 1e-6,
1613            "reduced-χ²: masked={} subset={}",
1614            result_masked.reduced_chi_squared,
1615            result_sub.reduced_chi_squared
1616        );
1617    }
1618
1619    /// Without the mask, residuals from the out-of-range half of the
1620    /// grid would dominate χ² and pull the fit way off — the masked
1621    /// fit must NOT be biased by them.
1622    #[test]
1623    fn test_lm_active_mask_excludes_out_of_range_residuals() {
1624        // y = 2x + 3 inside the masked range; corrupt outliers outside.
1625        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1626        let mut y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1627        for yi in y.iter_mut().skip(5) {
1628            *yi += 1000.0; // huge corruption outside the active mask
1629        }
1630        let sigma = vec![1.0; 10];
1631        let mask = vec![true; 5]
1632            .into_iter()
1633            .chain(std::iter::repeat_n(false, 5))
1634            .collect::<Vec<bool>>();
1635
1636        let model = LinearModel { x };
1637        let mut params = ParameterSet::new(vec![
1638            FitParameter::unbounded("a", 1.0),
1639            FitParameter::unbounded("b", 1.0),
1640        ]);
1641        let result = levenberg_marquardt_with_mask(
1642            &model,
1643            &y,
1644            &sigma,
1645            &mut params,
1646            &LmConfig::default(),
1647            Some(&mask),
1648        )
1649        .unwrap();
1650        assert!(result.converged);
1651        assert!(
1652            (result.params[0] - 2.0).abs() < 1e-6,
1653            "slope should be 2.0 (corrupt outliers must be masked out), got {}",
1654            result.params[0]
1655        );
1656        assert!(
1657            (result.params[1] - 3.0).abs() < 1e-6,
1658            "intercept should be 3.0 (corrupt outliers must be masked out), got {}",
1659            result.params[1]
1660        );
1661    }
1662
1663    /// Out-of-mask `y_obs` containing `NaN` must not poison χ² /
1664    /// JᵀWJ / JᵀWr.  Without explicit row-skip in the accumulator
1665    /// loops, `0.0 (weight) * NaN (residual) = NaN` would propagate
1666    /// through the fit despite the masked weight being zero.
1667    #[test]
1668    fn test_lm_active_mask_tolerates_nan_outside_range() {
1669        // y = 2x + 3 inside the active mask; NaN outside.  A naive
1670        // zero-weight implementation would return NaN χ² and fail to
1671        // converge; the row-skip path should fit cleanly.
1672        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1673        let mut y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1674        for yi in y.iter_mut().skip(5) {
1675            *yi = f64::NAN;
1676        }
1677        let sigma = vec![1.0; 10];
1678        let mask = vec![true; 5]
1679            .into_iter()
1680            .chain(std::iter::repeat_n(false, 5))
1681            .collect::<Vec<bool>>();
1682
1683        let model = LinearModel { x };
1684        let mut params = ParameterSet::new(vec![
1685            FitParameter::unbounded("a", 1.0),
1686            FitParameter::unbounded("b", 1.0),
1687        ]);
1688        let result = levenberg_marquardt_with_mask(
1689            &model,
1690            &y,
1691            &sigma,
1692            &mut params,
1693            &LmConfig::default(),
1694            Some(&mask),
1695        )
1696        .unwrap();
1697
1698        assert!(
1699            result.converged,
1700            "fit should converge despite NaN outside the active mask"
1701        );
1702        assert!(
1703            result.chi_squared.is_finite(),
1704            "χ² should be finite (NaN poisoning prevented), got {}",
1705            result.chi_squared
1706        );
1707        assert!(
1708            (result.params[0] - 2.0).abs() < 1e-6,
1709            "slope should be 2.0, got {}",
1710            result.params[0]
1711        );
1712        assert!(
1713            (result.params[1] - 3.0).abs() < 1e-6,
1714            "intercept should be 3.0, got {}",
1715            result.params[1]
1716        );
1717    }
1718
1719    /// The global finite checks on `y_model` / `y_trial` must skip
1720    /// masked bins.  A model that returns NaN only at masked margin
1721    /// bins should not abort the fit or get its trial step rejected.
1722    #[test]
1723    fn test_lm_active_mask_tolerates_model_nan_outside_range() {
1724        // Linear model that injects NaN into masked bins.  The fit
1725        // should still converge cleanly — the contract is that masked
1726        // rows are completely irrelevant to the fit.
1727        struct LinearModelWithMaskedNaN {
1728            x: Vec<f64>,
1729            mask: Vec<bool>,
1730        }
1731        impl FitModel for LinearModelWithMaskedNaN {
1732            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1733                let a = params[0];
1734                let b = params[1];
1735                Ok(self
1736                    .x
1737                    .iter()
1738                    .enumerate()
1739                    .map(|(i, &x)| if self.mask[i] { a * x + b } else { f64::NAN })
1740                    .collect())
1741            }
1742        }
1743
1744        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1745        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1746        let sigma = vec![1.0; 10];
1747        let mask: Vec<bool> = vec![true; 5]
1748            .into_iter()
1749            .chain(std::iter::repeat_n(false, 5))
1750            .collect();
1751
1752        let model = LinearModelWithMaskedNaN {
1753            x: x.clone(),
1754            mask: mask.clone(),
1755        };
1756        let mut params = ParameterSet::new(vec![
1757            FitParameter::unbounded("a", 1.0),
1758            FitParameter::unbounded("b", 1.0),
1759        ]);
1760        let result = levenberg_marquardt_with_mask(
1761            &model,
1762            &y,
1763            &sigma,
1764            &mut params,
1765            &LmConfig::default(),
1766            Some(&mask),
1767        )
1768        .unwrap();
1769
1770        assert!(
1771            result.converged,
1772            "fit should converge despite model NaN at masked bins"
1773        );
1774        assert!(result.chi_squared.is_finite());
1775        assert!((result.params[0] - 2.0).abs() < 1e-6);
1776        assert!((result.params[1] - 3.0).abs() < 1e-6);
1777    }
1778
1779    /// A zero-active mask (the user's range misses the grid entirely)
1780    /// must return non-converged regardless of whether parameters are
1781    /// free or fixed.  Pre-fix the all-fixed fast-return path would
1782    /// report `converged: true, chi_squared: 0` from a sum over zero
1783    /// rows — a deceptive "success" with no data behind it.
1784    #[test]
1785    fn test_lm_active_mask_all_false_returns_non_converged() {
1786        let x: Vec<f64> = (0..5).map(|i| i as f64).collect();
1787        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1788        let sigma = vec![1.0; 5];
1789        let mask = vec![false; 5];
1790        let model = LinearModel { x: x.clone() };
1791
1792        // (a) All parameters free.
1793        let mut params_free = ParameterSet::new(vec![
1794            FitParameter::unbounded("a", 1.0),
1795            FitParameter::unbounded("b", 1.0),
1796        ]);
1797        let r_free = levenberg_marquardt_with_mask(
1798            &model,
1799            &y,
1800            &sigma,
1801            &mut params_free,
1802            &LmConfig::default(),
1803            Some(&mask),
1804        )
1805        .unwrap();
1806        assert!(
1807            !r_free.converged,
1808            "n_free > 0 + zero-active mask must NOT report converged"
1809        );
1810        assert!(r_free.chi_squared.is_nan());
1811        assert!(r_free.reduced_chi_squared.is_nan());
1812
1813        // (b) All parameters fixed → n_free == 0 (the path that
1814        //     failed in #517 before the n_active==0 early-return).
1815        let mut params_fixed = ParameterSet::new(vec![
1816            FitParameter::fixed("a", 1.0),
1817            FitParameter::fixed("b", 0.0),
1818        ]);
1819        let r_fixed = levenberg_marquardt_with_mask(
1820            &model,
1821            &y,
1822            &sigma,
1823            &mut params_fixed,
1824            &LmConfig::default(),
1825            Some(&mask),
1826        )
1827        .unwrap();
1828        assert!(
1829            !r_fixed.converged,
1830            "n_free == 0 + zero-active mask must NOT report converged \
1831             (sum over zero rows would be 0, masquerading as a perfect fit)"
1832        );
1833        assert!(r_fixed.chi_squared.is_nan());
1834        assert!(r_fixed.reduced_chi_squared.is_nan());
1835    }
1836
1837    // ==================================================================
1838    // NaN-in-Jacobian during FD probes.
1839    //
1840    // The main LM loop guards the trial step at the `model.evaluate`
1841    // site via `trial_has_active_nonfinite`.  The silent surface is the
1842    // post-convergence covariance Jacobian: it calls `compute_jacobian`
1843    // directly, which has no finiteness check on the per-column FD
1844    // probe.  A NaN at any active row of the perturbed model output
1845    // gets divided by `actual_step` and turned into NaN entries in the
1846    // Jacobian — these poison `JᵀWJ` and the inverse covariance, which
1847    // is reported as the "uncertainty" of the fit.
1848    //
1849    // `compute_jacobian`'s FD path zeros per-cell entries whose probe
1850    // returned a non-finite value, mirroring the existing `Err` branch.
1851    // ==================================================================
1852
1853    /// `compute_jacobian`'s FD path zeroes per-cell entries whose
1854    /// perturbed model output is non-finite, rather than baking NaN
1855    /// into the Jacobian (which then poisons the post-convergence
1856    /// covariance computation).  Per-cell (not whole-column) so that a
1857    /// NaN at a masked / inactive row leaves the rest of the column
1858    /// usable — see `test_lm_active_mask_tolerates_model_nan_outside_range`
1859    /// for the masked-NaN contract.
1860    #[test]
1861    fn test_compute_jacobian_skips_nan_perturbed_column() {
1862        use crate::parameters::FitParameter;
1863        // Two-parameter model.  Param 0 is well-behaved (returns
1864        // `θ_0` constant).  Param 1 produces a NaN row at the +FD
1865        // probe — exactly the "NaN-in-Jacobian during FD probes"
1866        // signature from issue #552.
1867        struct NanInColumn1 {
1868            // Base value of param 1 — used to detect the perturbation.
1869            p1_base: f64,
1870        }
1871        impl FitModel for NanInColumn1 {
1872            fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1873                // The +FD probe perturbs params[1] by ε; everything else
1874                // keeps params[1] == self.p1_base.
1875                let nan_row = (params[1] - self.p1_base).abs() > 1e-12;
1876                let v = if nan_row { f64::NAN } else { params[0] };
1877                Ok(vec![v; 4])
1878            }
1879            // No analytical_jacobian -> FD fallback drives compute_jacobian.
1880        }
1881        let model = NanInColumn1 { p1_base: 0.3 };
1882        let mut params = ParameterSet::new(vec![
1883            FitParameter::unbounded("p0", 0.5),
1884            FitParameter::unbounded("p1", 0.3),
1885        ]);
1886        let y_current = vec![0.5; 4];
1887        let mut all_vals_buf: Vec<f64> = Vec::new();
1888        let mut free_idx_buf: Vec<usize> = Vec::new();
1889        let jac = compute_jacobian(
1890            &model,
1891            &mut params,
1892            &y_current,
1893            1e-6,
1894            &mut all_vals_buf,
1895            &mut free_idx_buf,
1896        )
1897        .unwrap();
1898        // Every entry must be finite — column 1 must have been skipped.
1899        for (i, v) in jac.data.iter().enumerate() {
1900            assert!(
1901                v.is_finite(),
1902                "compute_jacobian produced non-finite entry at index {i}: {v}"
1903            );
1904        }
1905        // Column 1 was skipped -> all zeros.  Column 0 has the normal
1906        // ∂T/∂θ_0 = 1 column from the well-behaved param.
1907        for i in 0..jac.nrows {
1908            assert_eq!(
1909                jac.get(i, 1),
1910                0.0,
1911                "column 1 (NaN probe) should be zeroed, row {i}"
1912            );
1913        }
1914    }
1915}