Skip to main content

gam_problem/
diagnostics.rs

1//! Analytic diagnostic helpers for LAML/REML optimization.
2//!
3//! Production diagnostics inspect analytic invariants only. Runtime fitting,
4//! prediction, and diagnostic APIs must consume quantities the optimizer
5//! already computes. This module implements diagnostic strategies that identify
6//! root causes of gradient pathologies from those analytic quantities:
7//!
8//! 1. KKT Audit (Envelope Theorem Check): Detects violations of the stationarity
9//!    assumption used in implicit differentiation.
10//!
11//! 2. Spectral Bleed Trace: Detects when truncated eigenspace corrections are
12//!    inconsistent with the penalty's energy in that subspace.
13//!
14//! 3. Dual-Ridge Consistency Check: Verifies that the ridge used by the inner
15//!    solver (PIRLS) matches what the outer gradient calculation assumes.
16
17use ndarray::Array1;
18use std::collections::BTreeMap;
19use std::fmt;
20use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
21
22// =============================================================================
23// Rate-Limited Diagnostic Output
24// =============================================================================
25// These helpers prevent diagnostic spam while ensuring important messages are seen.
26// Pattern: show first occurrence, then every Nth occurrence, with count indicator.
27
28/// Rate-limited diagnostic for Hessian minimum eigenvalue warnings
29pub static H_MIN_EIG_LOG_BUCKET: AtomicI32 = AtomicI32::new(i32::MIN);
30/// Count of `should_emit_h_min_eig_diag` invocations that have ever been
31/// considered for emission; used together with `H_MIN_EIG_LOG_BUCKET` to
32/// rate-limit one diagnostic per decade-magnitude bucket and per
33/// `MIN_EIG_DIAG_EVERY` repeats within the same bucket.
34pub static H_MIN_EIG_LOG_COUNT: AtomicUsize = AtomicUsize::new(0);
35/// Repeat period within a magnitude bucket for the Hessian-minimum-eigenvalue
36/// diagnostic: after the first emission for a bucket, every Nth subsequent
37/// invocation also emits.
38pub const MIN_EIG_DIAG_EVERY: usize = 200;
39/// Threshold below which a positive Hessian minimum eigenvalue is treated as
40/// nearly-singular and routed through the rate-limited diagnostic.
41pub const MIN_EIG_DIAG_THRESHOLD: f64 = 1e-4;
42
43/// Diagnostic formatter shared across the outer optimizer and the custom-family
44/// fitter: shows the `max_items` entries of `values` with largest absolute
45/// value, formatted as `label=[i:value, ...]`.
46pub fn format_top_abs(values: &Array1<f64>, label: &str, max_items: usize) -> String {
47    if values.is_empty() {
48        return format!("{label}=<empty>");
49    }
50    let mut ranked: Vec<(usize, f64)> = values.iter().copied().enumerate().collect();
51    ranked.sort_by(|(_, left), (_, right)| {
52        right
53            .abs()
54            .partial_cmp(&left.abs())
55            .unwrap_or(std::cmp::Ordering::Equal)
56    });
57    let parts: Vec<String> = ranked
58        .into_iter()
59        .take(max_items)
60        .map(|(idx, value)| format!("{idx}:{value:.3e}"))
61        .collect();
62    format!("{label}=[{}]", parts.join(", "))
63}
64
65/// Rate-limited check for Hessian minimum eigenvalue diagnostics.
66/// Returns true if this eigenvalue warrants a diagnostic message.
67pub fn should_emit_h_min_eig_diag(min_eig: f64) -> bool {
68    if !min_eig.is_finite() || min_eig <= 0.0 {
69        return true;
70    }
71    if min_eig >= MIN_EIG_DIAG_THRESHOLD {
72        return false;
73    }
74    let bucket = if min_eig.is_finite() && min_eig > 0.0 {
75        min_eig.log10().floor() as i32
76    } else {
77        i32::MIN
78    };
79    let last = H_MIN_EIG_LOG_BUCKET.load(Ordering::Relaxed);
80    let count = H_MIN_EIG_LOG_COUNT.fetch_add(1, Ordering::Relaxed);
81    if bucket != last || count.is_multiple_of(MIN_EIG_DIAG_EVERY) {
82        H_MIN_EIG_LOG_BUCKET.store(bucket, Ordering::Relaxed);
83        true
84    } else {
85        false
86    }
87}
88
89// =============================================================================
90// Formatting Utilities for Diagnostic Output
91// =============================================================================
92
93/// Configuration for gradient diagnostics
94#[derive(Clone, Debug)]
95pub struct DiagnosticConfig {
96    /// Tolerance for KKT residual norm (envelope theorem violation)
97    pub kkt_tolerance: f64,
98    /// Relative error threshold for flagging issues
99    pub rel_error_threshold: f64,
100    /// Whether to emit warnings to stderr
101    pub emitwarnings: bool,
102}
103
104impl Default for DiagnosticConfig {
105    fn default() -> Self {
106        Self {
107            kkt_tolerance: 1e-4,
108            rel_error_threshold: 0.1,
109            emitwarnings: true,
110        }
111    }
112}
113
114/// Result of envelope theorem (KKT) audit
115#[derive(Clone, Debug)]
116pub struct EnvelopeAudit {
117    /// Norm of the inner KKT residual ∇_β L(β*, ρ)
118    pub kkt_residual_norm: f64,
119    /// Ridge used by the inner solver
120    pub innerridge: f64,
121    /// Ridge assumed by the outer gradient calculation
122    pub outerridge: f64,
123    /// Whether the envelope theorem is violated
124    pub isviolated: bool,
125    /// Human-readable diagnostic message
126    pub message: String,
127}
128
129impl fmt::Display for EnvelopeAudit {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(f, "{}", self.message)
132    }
133}
134
135/// Result of spectral bleed trace diagnostic
136#[derive(Clone, Debug)]
137pub struct SpectralBleedResult {
138    pub penalty_k: usize,
139    /// Energy of penalty S_k in the truncated subspace: trace(U_⊥' S_k U_⊥)
140    pub truncated_energy: f64,
141    /// Correction term actually applied in the gradient
142    pub applied_correction: f64,
143    /// Whether there's a spectral bleed issue
144    pub has_bleed: bool,
145    /// Human-readable diagnostic message
146    pub message: String,
147}
148
149impl fmt::Display for SpectralBleedResult {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(f, "{}", self.message)
152    }
153}
154
155/// Result of dual-ridge consistency check
156#[derive(Clone, Debug)]
157pub struct DualRidgeResult {
158    /// Ridge used during P-IRLS optimization
159    pub pirlsridge: f64,
160    /// Ridge used in LAML cost function
161    pub costridge: f64,
162    /// Ridge used in gradient calculation
163    pub gradientridge: f64,
164    /// Effective ridge impact: ||ridge * β||
165    pub ridge_impact: f64,
166    /// Phantom penalty contribution: 0.5 * ridge * ||β||²
167    pub phantom_penalty: f64,
168    /// Whether there's a ridge mismatch
169    pub has_mismatch: bool,
170    /// Human-readable diagnostic message
171    pub message: String,
172}
173
174impl fmt::Display for DualRidgeResult {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        write!(f, "{}", self.message)
177    }
178}
179
180/// Residual diagnostics for observed values and predicted means.
181#[derive(Clone, Debug, PartialEq)]
182pub struct PredictionDiagnostics {
183    pub n_obs: usize,
184    pub mae: f64,
185    pub rmse: f64,
186    pub bias: f64,
187    pub r_squared: Option<f64>,
188    pub residuals: Vec<f64>,
189}
190
191/// Probability clipping used by the bundled classification diagnostic panel.
192/// Individual log-loss and Nagelkerke APIs accept an explicit clipping value;
193/// this named policy keeps the combined Rust/Python diagnostic contract in one
194/// core location.
195pub const DEFAULT_PROBABILITY_CLIP: f64 = 1.0e-12;
196
197/// Smallest standard deviation used by the bundled Gaussian score panel.
198pub const DEFAULT_GAUSSIAN_SCALE_FLOOR: f64 = 1.0e-12;
199
200/// Number of equal-width probability bins in the bundled expected-calibration
201/// error diagnostic.
202pub const DEFAULT_CALIBRATION_BINS: usize = 20;
203
204/// Production classification scores computed from one prediction vector.
205#[derive(Clone, Debug, PartialEq)]
206pub struct ClassificationPredictionMetrics {
207    pub auc: f64,
208    pub precision_recall_auc: f64,
209    pub brier: f64,
210    pub log_loss: f64,
211    pub nagelkerke_r_squared: Option<f64>,
212    pub expected_calibration_error: f64,
213}
214
215fn validate_metric_inputs(
216    metric: &str,
217    observed: &[f64],
218    predicted_mean: &[f64],
219) -> Result<(), String> {
220    if observed.is_empty() {
221        return Err(format!("{metric} requires at least one observation"));
222    }
223    if observed.len() != predicted_mean.len() {
224        return Err(format!(
225            "{metric} length mismatch: observed={} predicted={}",
226            observed.len(),
227            predicted_mean.len()
228        ));
229    }
230    if let Some((index, value)) = observed
231        .iter()
232        .copied()
233        .enumerate()
234        .find(|(_, value)| !value.is_finite())
235    {
236        return Err(format!(
237            "{metric}: observed[{index}] must be finite; got {value}"
238        ));
239    }
240    if let Some((index, value)) = predicted_mean
241        .iter()
242        .copied()
243        .enumerate()
244        .find(|(_, value)| !value.is_finite())
245    {
246        return Err(format!(
247            "{metric}: predicted_mean[{index}] must be finite; got {value}"
248        ));
249    }
250    Ok(())
251}
252
253fn validate_probability_clip(metric: &str, probability_clip: f64) -> Result<(), String> {
254    if !(probability_clip.is_finite() && probability_clip > 0.0 && probability_clip < 0.5) {
255        return Err(format!(
256            "{metric}: probability_clip must be finite and in (0, 0.5); got {probability_clip}"
257        ));
258    }
259    Ok(())
260}
261
262fn validate_probability_inputs(
263    metric: &str,
264    observed: &[f64],
265    predicted_mean: &[f64],
266) -> Result<(), String> {
267    validate_metric_inputs(metric, observed, predicted_mean)?;
268    if let Some((index, value)) = observed
269        .iter()
270        .copied()
271        .enumerate()
272        .find(|(_, value)| *value < 0.0 || *value > 1.0)
273    {
274        return Err(format!(
275            "{metric}: observed[{index}] must be in [0, 1]; got {value}"
276        ));
277    }
278    if let Some((index, value)) = predicted_mean
279        .iter()
280        .copied()
281        .enumerate()
282        .find(|(_, value)| *value < 0.0 || *value > 1.0)
283    {
284        return Err(format!(
285            "{metric}: predicted_mean[{index}] must be in [0, 1]; got {value}"
286        ));
287    }
288    Ok(())
289}
290
291/// Tie-aware Mann-Whitney AUC. Observations greater than `0.5` are the
292/// positive class, matching the package's binomial response convention.
293pub fn auc_from_predictions(observed: &[f64], predicted_mean: &[f64]) -> Result<f64, String> {
294    weighted_auc_from_predictions(observed, predicted_mean, None)
295}
296
297/// Weighted tie-aware Mann-Whitney AUC. Each positive/negative pair carries
298/// weight `w_positive * w_negative`; `None` is exactly the unit-weight score.
299pub fn weighted_auc_from_predictions(
300    observed: &[f64],
301    predicted_mean: &[f64],
302    weights: Option<&[f64]>,
303) -> Result<f64, String> {
304    validate_metric_inputs("auc", observed, predicted_mean)?;
305    if let Some(weights) = weights {
306        if weights.len() != observed.len() {
307            return Err(format!(
308                "auc length mismatch: observed={} weights={}",
309                observed.len(),
310                weights.len()
311            ));
312        }
313        if let Some((index, value)) = weights
314            .iter()
315            .copied()
316            .enumerate()
317            .find(|(_, value)| !value.is_finite() || *value < 0.0)
318        {
319            return Err(format!(
320                "auc: weights[{index}] must be finite and non-negative; got {value}"
321            ));
322        }
323    }
324
325    let weight_at = |index: usize| weights.map_or(1.0, |values| values[index]);
326    let mut pairs: Vec<(f64, bool, f64)> = observed
327        .iter()
328        .zip(predicted_mean)
329        .enumerate()
330        .map(|(index, (&y, &prediction))| (prediction, y > 0.5, weight_at(index)))
331        .collect();
332    // AUC is invariant to independent positive/negative class weight scales.
333    // Normalize twice (by class maximum, then class total) so a harmless common
334    // factor near f64::MAX cannot overflow the pair products or denominator.
335    let positive_scale = pairs
336        .iter()
337        .filter(|(_, positive, _)| *positive)
338        .map(|(_, _, weight)| weight)
339        .copied()
340        .fold(0.0_f64, f64::max);
341    let negative_scale = pairs
342        .iter()
343        .filter(|(_, positive, _)| !*positive)
344        .map(|(_, _, weight)| weight)
345        .copied()
346        .fold(0.0_f64, f64::max);
347    if positive_scale <= 0.0 || negative_scale <= 0.0 {
348        return Ok(0.5);
349    }
350    let positive_weight: f64 = pairs
351        .iter()
352        .filter(|(_, positive, _)| *positive)
353        .map(|(_, _, weight)| weight / positive_scale)
354        .sum();
355    let negative_weight: f64 = pairs
356        .iter()
357        .filter(|(_, positive, _)| !*positive)
358        .map(|(_, _, weight)| weight / negative_scale)
359        .sum();
360    pairs.sort_by(|(left, _, _), (right, _, _)| left.total_cmp(right));
361
362    let mut concordant = 0.0_f64;
363    let mut negative_weight_below = 0.0_f64;
364    let mut start = 0usize;
365    while start < pairs.len() {
366        let mut end = start + 1;
367        while end < pairs.len() && pairs[end].0 == pairs[start].0 {
368            end += 1;
369        }
370        let positive_in_group: f64 = pairs[start..end]
371            .iter()
372            .filter(|(_, positive, _)| *positive)
373            .map(|(_, _, weight)| (weight / positive_scale) / positive_weight)
374            .sum();
375        let negative_in_group: f64 = pairs[start..end]
376            .iter()
377            .filter(|(_, positive, _)| !*positive)
378            .map(|(_, _, weight)| (weight / negative_scale) / negative_weight)
379            .sum();
380        concordant += positive_in_group * negative_weight_below;
381        concordant += 0.5 * positive_in_group * negative_in_group;
382        negative_weight_below += negative_in_group;
383        start = end;
384    }
385    let auc = concordant;
386    if auc.is_finite() {
387        Ok(auc)
388    } else {
389        Err("auc: weighted pair total is not representable in f64".to_string())
390    }
391}
392
393/// Mean squared probability error.
394pub fn brier_from_predictions(observed: &[f64], predicted_mean: &[f64]) -> Result<f64, String> {
395    validate_probability_inputs("brier", observed, predicted_mean)?;
396    let diagnostics = diagnostics_from_predictions(observed, predicted_mean)?;
397    Ok(diagnostics.rmse * diagnostics.rmse)
398}
399
400/// Mean Bernoulli log loss with a caller-selected probability clip.
401pub fn binary_log_loss_from_predictions(
402    observed: &[f64],
403    predicted_mean: &[f64],
404    probability_clip: f64,
405) -> Result<f64, String> {
406    validate_probability_inputs("log_loss", observed, predicted_mean)?;
407    validate_probability_clip("log_loss", probability_clip)?;
408    let loss = observed
409        .iter()
410        .zip(predicted_mean)
411        .map(|(&y, &prediction)| {
412            let probability = prediction.clamp(probability_clip, 1.0 - probability_clip);
413            -(y * probability.ln() + (1.0 - y) * (1.0 - probability).ln())
414        })
415        .sum::<f64>()
416        / observed.len() as f64;
417    if loss.is_finite() {
418        Ok(loss)
419    } else {
420        Err("log_loss: result is not representable in f64".to_string())
421    }
422}
423
424/// Nagelkerke's rescaling of Cox-Snell R² from model/null log likelihoods.
425/// Returns `None` when the null normalization is undefined.
426pub fn nagelkerke_r_squared_from_log_likelihoods(
427    model_log_likelihood: f64,
428    null_log_likelihood: f64,
429    n_observations: usize,
430) -> Option<f64> {
431    if n_observations == 0 || !model_log_likelihood.is_finite() || !null_log_likelihood.is_finite()
432    {
433        return None;
434    }
435    let scale = 2.0 / n_observations as f64;
436    let cox_snell = -(scale * (null_log_likelihood - model_log_likelihood)).exp_m1();
437    let maximum_cox_snell = -(scale * null_log_likelihood).exp_m1();
438    if cox_snell.is_finite() && maximum_cox_snell.is_finite() && maximum_cox_snell > 0.0 {
439        Some(cox_snell / maximum_cox_snell)
440    } else {
441        None
442    }
443}
444
445/// Nagelkerke R² for binomial predictions against an explicit null mean.
446pub fn nagelkerke_r_squared_from_predictions(
447    observed: &[f64],
448    predicted_mean: &[f64],
449    null_mean: f64,
450    probability_clip: f64,
451) -> Result<Option<f64>, String> {
452    if observed.is_empty() {
453        return Ok(None);
454    }
455    validate_probability_inputs("nagelkerke_r_squared", observed, predicted_mean)?;
456    validate_probability_clip("nagelkerke_r_squared", probability_clip)?;
457    if !null_mean.is_finite() || null_mean <= 0.0 || null_mean >= 1.0 {
458        return Ok(None);
459    }
460
461    let log_null = null_mean.ln();
462    let log_not_null = (1.0 - null_mean).ln();
463    let null_log_likelihood = observed
464        .iter()
465        .map(|&y| y * log_null + (1.0 - y) * log_not_null)
466        .sum::<f64>();
467    let model_log_likelihood = observed
468        .iter()
469        .zip(predicted_mean)
470        .map(|(&y, &prediction)| {
471            let probability = prediction.clamp(probability_clip, 1.0 - probability_clip);
472            y * probability.ln() + (1.0 - y) * (1.0 - probability).ln()
473        })
474        .sum::<f64>();
475    Ok(nagelkerke_r_squared_from_log_likelihoods(
476        model_log_likelihood,
477        null_log_likelihood,
478        observed.len(),
479    ))
480}
481
482/// Trapezoidal area under the precision-recall curve. Equal predicted scores
483/// enter as one threshold group, so row order within a tie cannot change the
484/// score.
485pub fn precision_recall_auc_from_predictions(
486    observed: &[f64],
487    predicted_mean: &[f64],
488) -> Result<f64, String> {
489    validate_metric_inputs("precision_recall_auc", observed, predicted_mean)?;
490    let mut pairs: Vec<(f64, bool)> = observed
491        .iter()
492        .zip(predicted_mean)
493        .map(|(&y, &prediction)| (prediction, y > 0.5))
494        .collect();
495    let positives = pairs.iter().filter(|(_, positive)| *positive).count();
496    if positives == 0 {
497        return Ok(0.0);
498    }
499    pairs.sort_by(|(left, _), (right, _)| right.total_cmp(left));
500    let mut true_positives = 0usize;
501    let mut false_positives = 0usize;
502    let mut previous_precision = 1.0_f64;
503    let mut previous_recall = 0.0_f64;
504    let mut area = 0.0_f64;
505    let mut start = 0usize;
506    while start < pairs.len() {
507        let score = pairs[start].0;
508        let mut end = start;
509        while end < pairs.len() && pairs[end].0 == score {
510            if pairs[end].1 {
511                true_positives += 1;
512            } else {
513                false_positives += 1;
514            }
515            end += 1;
516        }
517        let precision = true_positives as f64 / (true_positives + false_positives) as f64;
518        let recall = true_positives as f64 / positives as f64;
519        area += 0.5 * (precision + previous_precision) * (recall - previous_recall);
520        previous_precision = precision;
521        previous_recall = recall;
522        start = end;
523    }
524    Ok(area)
525}
526
527/// Equal-width-bin expected calibration error.
528pub fn expected_calibration_error_from_predictions(
529    observed: &[f64],
530    predicted_mean: &[f64],
531    n_bins: usize,
532) -> Result<f64, String> {
533    validate_probability_inputs("expected_calibration_error", observed, predicted_mean)?;
534    if n_bins == 0 {
535        return Err("expected_calibration_error requires at least one bin".to_string());
536    }
537    // Only occupied bins need storage. A dense `vec![..; n_bins]` lets an
538    // otherwise valid diagnostic request allocate independently of the data
539    // size, whereas at most `observed.len()` bins can be occupied.
540    let mut bins: BTreeMap<usize, (usize, f64, f64)> = BTreeMap::new();
541    for (&y, &prediction) in observed.iter().zip(predicted_mean) {
542        let index = ((prediction.clamp(0.0, 1.0) * n_bins as f64).floor() as usize).min(n_bins - 1);
543        let bin = bins.entry(index).or_insert((0, 0.0, 0.0));
544        bin.0 += 1;
545        bin.1 += y;
546        bin.2 += prediction;
547    }
548    let n = observed.len() as f64;
549    Ok(bins
550        .into_values()
551        .map(|(count, observed_sum, predicted_sum)| {
552            let count = count as f64;
553            (count / n) * ((observed_sum / count) - (predicted_sum / count)).abs()
554        })
555        .sum())
556}
557
558/// Gaussian negative log predictive density, allowing either one shared
559/// standard deviation or one per observation.
560pub fn gaussian_log_loss_from_predictions(
561    observed: &[f64],
562    predicted_mean: &[f64],
563    sigma: &[f64],
564    sigma_floor: f64,
565) -> Result<f64, String> {
566    validate_metric_inputs("gaussian_log_loss", observed, predicted_mean)?;
567    if sigma.len() != 1 && sigma.len() != observed.len() {
568        return Err(format!(
569            "gaussian_log_loss: sigma length must be 1 or {}; got {}",
570            observed.len(),
571            sigma.len()
572        ));
573    }
574    if !(sigma_floor.is_finite() && sigma_floor > 0.0) {
575        return Err(format!(
576            "gaussian_log_loss: sigma_floor must be finite and positive; got {sigma_floor}"
577        ));
578    }
579    let shared_sigma = sigma.len() == 1;
580    let mut total = 0.0_f64;
581    for (index, (&y, &mean)) in observed.iter().zip(predicted_mean).enumerate() {
582        let raw_sigma = if shared_sigma { sigma[0] } else { sigma[index] };
583        if !raw_sigma.is_finite() || raw_sigma <= 0.0 {
584            return Err(format!(
585                "gaussian_log_loss: sigma[{}] must be finite and positive; got {raw_sigma}",
586                if shared_sigma { 0 } else { index }
587            ));
588        }
589        let sigma = raw_sigma.max(sigma_floor);
590        let standardized_residual = (y - mean) / sigma;
591        total += 0.5 * std::f64::consts::TAU.ln()
592            + sigma.ln()
593            + 0.5 * standardized_residual * standardized_residual;
594    }
595    let loss = total / observed.len() as f64;
596    if loss.is_finite() {
597        Ok(loss)
598    } else {
599        Err("gaussian_log_loss: result is not representable in f64".to_string())
600    }
601}
602
603/// The package's standard classification diagnostic panel.
604pub fn classification_metrics_from_predictions(
605    observed: &[f64],
606    predicted_mean: &[f64],
607    null_mean: f64,
608) -> Result<ClassificationPredictionMetrics, String> {
609    validate_probability_inputs("classification_metrics", observed, predicted_mean)?;
610    Ok(ClassificationPredictionMetrics {
611        auc: auc_from_predictions(observed, predicted_mean)?,
612        precision_recall_auc: precision_recall_auc_from_predictions(observed, predicted_mean)?,
613        brier: brier_from_predictions(observed, predicted_mean)?,
614        log_loss: binary_log_loss_from_predictions(
615            observed,
616            predicted_mean,
617            DEFAULT_PROBABILITY_CLIP,
618        )?,
619        nagelkerke_r_squared: nagelkerke_r_squared_from_predictions(
620            observed,
621            predicted_mean,
622            null_mean,
623            DEFAULT_PROBABILITY_CLIP,
624        )?,
625        expected_calibration_error: expected_calibration_error_from_predictions(
626            observed,
627            predicted_mean,
628            DEFAULT_CALIBRATION_BINS,
629        )?,
630    })
631}
632
633/// Compute prediction residual diagnostics from observed values and predicted means.
634pub fn diagnostics_from_predictions(
635    observed: &[f64],
636    predicted_mean: &[f64],
637) -> Result<PredictionDiagnostics, String> {
638    if observed.is_empty() {
639        return Err("diagnostics_from_predictions requires at least one observation".to_string());
640    }
641    if observed.len() != predicted_mean.len() {
642        return Err(format!(
643            "diagnostics_from_predictions length mismatch: observed has {} values but predicted mean has {}",
644            observed.len(),
645            predicted_mean.len()
646        ));
647    }
648    if observed.iter().any(|value| !value.is_finite()) {
649        return Err("observed values must contain only finite numbers".to_string());
650    }
651    if predicted_mean.iter().any(|value| !value.is_finite()) {
652        return Err("predicted mean values must contain only finite numbers".to_string());
653    }
654
655    let n_obs = observed.len();
656    let n_obs_f = n_obs as f64;
657    let mut residuals = Vec::with_capacity(n_obs);
658    let mut abs_sum = 0.0_f64;
659    let mut residual_sum = 0.0_f64;
660    let mut residual_sum_squares = 0.0_f64;
661    let mut observed_sum = 0.0_f64;
662    for (obs, pred) in observed.iter().zip(predicted_mean.iter()) {
663        let residual = obs - pred;
664        residuals.push(residual);
665        abs_sum += residual.abs();
666        residual_sum += residual;
667        residual_sum_squares += residual * residual;
668        observed_sum += obs;
669    }
670
671    let observed_mean = observed_sum / n_obs_f;
672    let total_sum_squares = observed
673        .iter()
674        .map(|value| {
675            let centered = value - observed_mean;
676            centered * centered
677        })
678        .sum::<f64>();
679    let r_squared = if total_sum_squares > 0.0 {
680        Some(1.0 - residual_sum_squares / total_sum_squares)
681    } else {
682        None
683    };
684
685    Ok(PredictionDiagnostics {
686        n_obs,
687        mae: abs_sum / n_obs_f,
688        rmse: (residual_sum_squares / n_obs_f).sqrt(),
689        bias: residual_sum / n_obs_f,
690        r_squared,
691        residuals,
692    })
693}
694
695/// Complete diagnostic report for a gradient evaluation
696#[derive(Clone, Debug, Default)]
697pub struct GradientDiagnosticReport {
698    /// Envelope theorem audit results
699    pub envelopeaudit: Option<EnvelopeAudit>,
700    /// Spectral bleed results for each penalty
701    pub spectral_bleed: Vec<SpectralBleedResult>,
702    /// Dual-ridge consistency result
703    pub dualridge: Option<DualRidgeResult>,
704}
705
706impl GradientDiagnosticReport {
707    /// Create an empty report
708    pub fn new() -> Self {
709        Self::default()
710    }
711
712    /// Generate a summary string of all issues found
713    pub fn summary(&self) -> String {
714        let mut lines = Vec::new();
715
716        if let Some(ref audit) = self.envelopeaudit
717            && audit.isviolated
718        {
719            lines.push(format!("[DIAG] {}", audit));
720        }
721
722        for bleed in &self.spectral_bleed {
723            if bleed.has_bleed {
724                lines.push(format!("[DIAG] {}", bleed));
725            }
726        }
727
728        if let Some(ref ridge) = self.dualridge
729            && ridge.has_mismatch
730        {
731            lines.push(format!("[DIAG] {}", ridge));
732        }
733
734        if lines.is_empty() {
735            "No gradient diagnostic issues detected.".to_string()
736        } else {
737            lines.join("\n")
738        }
739    }
740}
741
742// =============================================================================
743// Strategy 1: Envelope Theorem (KKT) Audit
744// =============================================================================
745
746/// Compute the inner KKT residual to detect envelope theorem violations.
747///
748/// The analytic gradient calculation assumes that P-IRLS found an exact stationary
749/// point where ∇_β L = 0. If this is not true (due to stabilization ridge, Firth
750/// adjustments, or early termination), the "indirect term" of the chain rule becomes
751/// significant and the gradient will be wrong.
752///
753/// # Arguments
754/// * `kkt_residual_norm` - Norm of the full inner gradient ||∇_β L|| at the PIRLS solution
755/// * `referencegradient` - Reference gradient scale (typically S_λ β) for relative normalization
756/// * `ridge_used` - Ridge added by PIRLS for stabilization
757/// * `beta` - Current coefficient estimate
758/// * `tolerance` - Threshold for flagging violations
759pub fn compute_envelopeaudit(
760    kkt_residual_norm: f64,
761    referencegradient: &Array1<f64>,
762    ridge_used: f64,
763    ridge_assumed: f64,
764    beta: &Array1<f64>,
765    abs_tolerance: f64,
766    rel_tolerance: f64,
767) -> EnvelopeAudit {
768    let kkt_norm = kkt_residual_norm;
769    let penalty_norm = referencegradient.dot(referencegradient).sqrt();
770    let beta_norm = beta.dot(beta).sqrt();
771    let scale = penalty_norm.max((ridge_assumed.abs() * beta_norm).max(1e-12));
772    let rel_kkt = if scale > 0.0 { kkt_norm / scale } else { 0.0 };
773    let ridge_mismatch = (ridge_used - ridge_assumed).abs() > 1e-12;
774    let kktviolation = kkt_norm > abs_tolerance && rel_kkt > rel_tolerance;
775    let isviolated = kktviolation || ridge_mismatch;
776
777    let message = if ridge_mismatch && kktviolation {
778        format!(
779            "Envelope Violation: Inner solver ridge = {:.2e}, Outer gradient assumes ridge = {:.2e}. \
780             KKT residual norm = {:.2e} (abs tol = {:.2e}, rel tol = {:.2e}). Unaccounted gradient energy: {:.2e}",
781            ridge_used, ridge_assumed, kkt_norm, abs_tolerance, rel_tolerance, kkt_norm
782        )
783    } else if ridge_mismatch {
784        format!(
785            "Ridge Mismatch: PIRLS optimized for H + {:.2e}*I, but Gradient calculated for H + {:.2e}*I",
786            ridge_used, ridge_assumed
787        )
788    } else if kktviolation {
789        format!(
790            "Envelope Violation: KKT residual ||∇_β L|| = {:.2e} (rel {:.2e}) exceeds tolerances (abs {:.2e}, rel {:.2e}). \
791             Inner solver may not have converged to true stationary point.",
792            kkt_norm, rel_kkt, abs_tolerance, rel_tolerance
793        )
794    } else {
795        format!(
796            "Envelope OK: KKT residual = {:.2e} (rel {:.2e}), ridge match = {:.2e}",
797            kkt_norm, rel_kkt, ridge_used
798        )
799    };
800
801    EnvelopeAudit {
802        kkt_residual_norm: kkt_norm,
803        innerridge: ridge_used,
804        outerridge: ridge_assumed,
805        isviolated,
806        message,
807    }
808}
809
810// =============================================================================
811// Strategy 4: Dual-Ridge Consistency Check
812// =============================================================================
813
814/// Check consistency between the ridge used in different stages of computation.
815///
816/// When the Hessian is non-positive-definite, ensure_positive_definitewithridge
817/// adds a stabilization ridge during P-IRLS. This ridge changes the objective
818/// surface being optimized. If the gradient calculation uses a different ridge
819/// value, it will point in the wrong direction.
820///
821/// # Arguments
822/// * `pirlsridge` - Ridge actually used during P-IRLS iteration
823/// * `costridge` - Ridge used when computing LAML cost
824/// * `gradientridge` - Ridge assumed when computing analytic gradient
825/// * `beta` - Current coefficient estimate
826pub fn compute_dualridge_check(
827    pirlsridge: f64,
828    costridge: f64,
829    gradientridge: f64,
830    beta: &Array1<f64>,
831) -> DualRidgeResult {
832    let beta_norm_sq = beta.dot(beta);
833    let beta_norm = beta_norm_sq.sqrt();
834
835    let ridge_impact = pirlsridge * beta_norm;
836    let phantom_penalty = 0.5 * pirlsridge * beta_norm_sq;
837
838    let pirlscost_mismatch = (pirlsridge - costridge).abs() > 1e-12;
839    let pirlsgrad_mismatch = (pirlsridge - gradientridge).abs() > 1e-12;
840    let costgrad_mismatch = (costridge - gradientridge).abs() > 1e-12;
841    let has_mismatch = pirlscost_mismatch || pirlsgrad_mismatch || costgrad_mismatch;
842
843    let message = if has_mismatch {
844        let mut mismatches = Vec::new();
845        if pirlscost_mismatch {
846            mismatches.push(format!(
847                "PIRLS({:.2e}) vs Cost({:.2e})",
848                pirlsridge, costridge
849            ));
850        }
851        if pirlsgrad_mismatch {
852            mismatches.push(format!(
853                "PIRLS({:.2e}) vs Gradient({:.2e})",
854                pirlsridge, gradientridge
855            ));
856        }
857        if costgrad_mismatch {
858            mismatches.push(format!(
859                "Cost({:.2e}) vs Gradient({:.2e})",
860                costridge, gradientridge
861            ));
862        }
863        format!(
864            "Ridge Mismatch detected: {}. Effective ridge impact on ||β|| = {:.2e}. \
865             Phantom penalty = {:.2e}. The surface being differentiated differs from \
866             the surface being optimized.",
867            mismatches.join(", "),
868            ridge_impact,
869            phantom_penalty
870        )
871    } else if pirlsridge > 0.0 {
872        format!(
873            "Ridge Consistency OK: All stages use ridge = {:.2e}. ||β|| = {:.2e}, phantom penalty = {:.2e}",
874            pirlsridge, beta_norm, phantom_penalty
875        )
876    } else {
877        "Ridge Consistency OK: No stabilization ridge required.".to_string()
878    };
879
880    DualRidgeResult {
881        pirlsridge,
882        costridge,
883        gradientridge,
884        ridge_impact,
885        phantom_penalty,
886        has_mismatch,
887        message,
888    }
889}
890
891/// Three-way classification of why the cert refused, computed from the
892/// H_pen spectrum and the projected residual at the refusing iterate.
893/// `RankDeficientHPen` is the regression canary the nullspace lead's
894/// smooth-construction rework is intended to eliminate; keep this variant
895/// intact when extending — it doubles as the user-facing signal for
896/// "an unconstrained polynomial null space slipped past absorption."
897///
898/// Relocated from `gam-solve`'s `custom_family/joint_newton.rs` (issue #1521
899/// crate carve): this is the neutral diagnostic carrier that `gam-solve`'s
900/// REML/PIRLS core consumes when classifying a custom-family cert refusal,
901/// so it must live BELOW both the core and the (extracted) custom-family
902/// subsystem.
903#[derive(Clone, Copy, Debug, PartialEq, Eq)]
904pub enum KktRefusalDiagnosis {
905    RankDeficientHPen,
906    PhantomMultiplierWithWellConditionedH,
907    ActiveSetIncomplete,
908    /// Cross-block identifiability aliasing surfaced mid-inner-solve
909    /// (e.g., a binding active set materialised a 2-way alias that
910    /// the pre-fit audit could not see at the cold design). The fix
911    /// is structural — drop or reparameterise the aliased block;
912    /// rho-anneal will not recover.
913    AliasingDetectedAtFit,
914}
915
916impl KktRefusalDiagnosis {
917    pub fn as_str(&self) -> &'static str {
918        match self {
919            KktRefusalDiagnosis::RankDeficientHPen => "rank_deficient_H_pen",
920            KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH => {
921                "phantom_multiplier_with_well_conditioned_H"
922            }
923            KktRefusalDiagnosis::ActiveSetIncomplete => "active_set_incomplete",
924            KktRefusalDiagnosis::AliasingDetectedAtFit => "aliasing_detected_at_fit",
925        }
926    }
927
928    /// Parse the textual `diagnosis:` field embedded in the structured
929    /// bubbled error string. Returns `None` when no recognised label is
930    /// present (legacy / non-cert-refusal error strings).
931    pub fn parse_from_error(message: &str) -> Option<Self> {
932        let marker = "diagnosis: ";
933        let start = message.rfind(marker)? + marker.len();
934        let tail = &message[start..];
935        let end = tail
936            .find(|c: char| c == ';' || c == '\n' || c == ' ')
937            .unwrap_or(tail.len());
938        match &tail[..end] {
939            "rank_deficient_H_pen" => Some(KktRefusalDiagnosis::RankDeficientHPen),
940            "phantom_multiplier_with_well_conditioned_H" => {
941                Some(KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH)
942            }
943            "active_set_incomplete" => Some(KktRefusalDiagnosis::ActiveSetIncomplete),
944            "aliasing_detected_at_fit" => Some(KktRefusalDiagnosis::AliasingDetectedAtFit),
945            _ => None,
946        }
947    }
948
949    pub fn guidance(self) -> &'static str {
950        match self {
951            KktRefusalDiagnosis::RankDeficientHPen => {
952                "check whether the named block has a structural or numerical null direction \
953                 not identified by the likelihood/penalty combination; for Duchon-style \
954                 smooths this may be a polynomial null space, while marginal-slope fits can \
955                 also expose callback-owned weak directions"
956            }
957            KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH => {
958                "check whether the named block has a near-separated or weakly identified \
959                 direction despite a well-conditioned penalized Hessian; in marginal-slope \
960                 fits this often indicates marginal/logslope coupling rather than a \
961                 Matérn/Duchon polynomial-nullspace failure"
962            }
963            KktRefusalDiagnosis::ActiveSetIncomplete => {
964                "check whether the named block's linear constraints need an additional \
965                 active row or a tighter constrained re-solve; this is an active-set \
966                 certification failure, not a polynomial-nullspace diagnosis"
967            }
968            KktRefusalDiagnosis::AliasingDetectedAtFit => {
969                "check whether the named block aliases another block after runtime \
970                 constraints or callbacks materialize; drop or reparameterize the aliased \
971                 direction before fitting"
972            }
973        }
974    }
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980    use ndarray::arr1;
981
982    #[test]
983    fn test_envelopeaudit_noviolation() {
984        let reference = arr1(&[0.0, 0.0, 0.0]);
985        let beta = arr1(&[0.1, 0.2, 0.3]);
986        let result = compute_envelopeaudit(0.0, &reference, 0.0, 0.0, &beta, 1e-8, 1e-6);
987
988        assert!(!result.isviolated);
989    }
990
991    #[test]
992    fn test_envelopeaudit_detects_ridge_mismatch() {
993        let reference = arr1(&[1.0, 0.0, 0.0]);
994        let beta = arr1(&[0.1, 0.2, 0.3]);
995        let result = compute_envelopeaudit(1e-10, &reference, 0.1, 0.0, &beta, 1e-8, 1e-6);
996
997        assert!(result.isviolated);
998        assert!(result.message.contains("Ridge Mismatch"));
999    }
1000
1001    #[test]
1002    fn test_dualridge_check_no_mismatch() {
1003        let beta = arr1(&[0.1, 0.2, 0.3]);
1004        let result = compute_dualridge_check(0.0, 0.0, 0.0, &beta);
1005
1006        assert!(!result.has_mismatch);
1007    }
1008
1009    #[test]
1010    fn test_dualridge_check_detects_mismatch() {
1011        let beta = arr1(&[0.1, 0.2, 0.3]);
1012        let result = compute_dualridge_check(1e-4, 0.0, 0.0, &beta);
1013
1014        assert!(result.has_mismatch);
1015        assert!(result.message.contains("Ridge Mismatch detected"));
1016    }
1017
1018    #[test]
1019    fn diagnostics_from_predictions_computes_residual_metrics() {
1020        let observed = [1.0, 2.0, 4.0];
1021        let predicted = [1.5, 1.5, 3.0];
1022
1023        let result = diagnostics_from_predictions(&observed, &predicted).unwrap();
1024
1025        assert_eq!(result.residuals, vec![-0.5, 0.5, 1.0]);
1026        assert_eq!(result.n_obs, 3);
1027        assert_eq!(result.mae, 2.0 / 3.0);
1028        assert_eq!(result.bias, 1.0 / 3.0);
1029        assert_eq!(result.rmse, (1.5_f64 / 3.0).sqrt());
1030        assert_eq!(result.r_squared, Some(1.0 - 1.5 / (14.0 / 3.0)));
1031    }
1032
1033    #[test]
1034    fn diagnostics_from_predictions_omits_r_squared_for_constant_observed() {
1035        let observed = [2.0, 2.0];
1036        let predicted = [1.0, 3.0];
1037
1038        let result = diagnostics_from_predictions(&observed, &predicted).unwrap();
1039
1040        assert_eq!(result.r_squared, None);
1041    }
1042
1043    #[test]
1044    fn diagnostics_from_predictions_rejects_invalid_inputs() {
1045        assert_eq!(
1046            diagnostics_from_predictions(&[], &[]),
1047            Err("diagnostics_from_predictions requires at least one observation".to_string())
1048        );
1049        assert_eq!(
1050            diagnostics_from_predictions(&[1.0], &[1.0, 2.0]),
1051            Err(
1052                "diagnostics_from_predictions length mismatch: observed has 1 values but predicted mean has 2"
1053                    .to_string()
1054            )
1055        );
1056        assert_eq!(
1057            diagnostics_from_predictions(&[f64::NAN], &[1.0]),
1058            Err("observed values must contain only finite numbers".to_string())
1059        );
1060        assert_eq!(
1061            diagnostics_from_predictions(&[1.0], &[f64::INFINITY]),
1062            Err("predicted mean values must contain only finite numbers".to_string())
1063        );
1064    }
1065
1066    #[test]
1067    fn auc_is_tie_aware_and_weighted_auc_reduces_to_unit_weights() {
1068        let observed = [0.0, 1.0, 0.0, 1.0];
1069        let predicted = [0.1, 0.8, 0.8, 0.9];
1070        let auc = auc_from_predictions(&observed, &predicted).unwrap();
1071        assert_eq!(auc, 0.875);
1072        assert_eq!(
1073            weighted_auc_from_predictions(&observed, &predicted, Some(&[1.0; 4])).unwrap(),
1074            auc
1075        );
1076
1077        let weighted = weighted_auc_from_predictions(
1078            &[1.0, 0.0, 1.0],
1079            &[0.5, 0.5, 0.9],
1080            Some(&[2.0, 3.0, 1.0]),
1081        )
1082        .unwrap();
1083        assert_eq!(weighted, 2.0 / 3.0);
1084        assert!(
1085            weighted_auc_from_predictions(&[0.0, 1.0], &[0.2, 0.8], Some(&[1.0, -1.0])).is_err()
1086        );
1087        assert_eq!(
1088            weighted_auc_from_predictions(&[1.0, 0.0], &[0.5, 0.5], Some(&[f64::MAX, f64::MAX]),)
1089                .unwrap(),
1090            0.5
1091        );
1092    }
1093
1094    #[test]
1095    fn precision_recall_auc_consumes_ties_as_one_threshold() {
1096        let first = precision_recall_auc_from_predictions(&[1.0, 0.0], &[0.5, 0.5]).unwrap();
1097        let reversed = precision_recall_auc_from_predictions(&[0.0, 1.0], &[0.5, 0.5]).unwrap();
1098        assert_eq!(first, 0.75);
1099        assert_eq!(first, reversed);
1100    }
1101
1102    #[test]
1103    fn probability_scores_match_closed_forms() {
1104        let observed = [0.0, 1.0];
1105        let predicted = [0.5, 0.5];
1106        let log_loss =
1107            binary_log_loss_from_predictions(&observed, &predicted, DEFAULT_PROBABILITY_CLIP)
1108                .unwrap();
1109        assert!((log_loss - std::f64::consts::LN_2).abs() < 1.0e-15);
1110        assert_eq!(brier_from_predictions(&observed, &predicted).unwrap(), 0.25);
1111        assert_eq!(
1112            expected_calibration_error_from_predictions(&observed, &predicted, 2).unwrap(),
1113            0.0
1114        );
1115        assert!(
1116            binary_log_loss_from_predictions(&observed, &predicted, 0.5).is_err(),
1117            "a clip that collapses the probability interval must be rejected"
1118        );
1119        assert!(classification_metrics_from_predictions(&observed, &[0.5, 2.0], 0.5).is_err());
1120        assert!(classification_metrics_from_predictions(&[-0.1, 1.0], &predicted, 0.5).is_err());
1121    }
1122
1123    #[test]
1124    fn nagelkerke_and_classification_panel_share_the_core_kernels() {
1125        let observed = [0.0, 0.0, 1.0, 1.0];
1126        let predicted = [0.1, 0.2, 0.8, 0.9];
1127        let metrics = classification_metrics_from_predictions(&observed, &predicted, 0.5).unwrap();
1128        assert_eq!(metrics.auc, 1.0);
1129        assert_eq!(metrics.precision_recall_auc, 1.0);
1130        assert_eq!(
1131            metrics.nagelkerke_r_squared,
1132            nagelkerke_r_squared_from_predictions(
1133                &observed,
1134                &predicted,
1135                0.5,
1136                DEFAULT_PROBABILITY_CLIP,
1137            )
1138            .unwrap()
1139        );
1140        assert!(metrics.nagelkerke_r_squared.unwrap() > 0.8);
1141        assert_eq!(
1142            nagelkerke_r_squared_from_predictions(
1143                &observed,
1144                &predicted,
1145                1.0,
1146                DEFAULT_PROBABILITY_CLIP,
1147            )
1148            .unwrap(),
1149            None
1150        );
1151    }
1152
1153    #[test]
1154    fn gaussian_log_loss_matches_closed_form_and_rejects_invalid_sigma() {
1155        let observed = [1.0, 2.0, 3.0, 4.0];
1156        let predicted = observed;
1157        let sigma = [1.5];
1158        let got = gaussian_log_loss_from_predictions(
1159            &observed,
1160            &predicted,
1161            &sigma,
1162            DEFAULT_GAUSSIAN_SCALE_FLOOR,
1163        )
1164        .unwrap();
1165        let expected = 0.5 * (std::f64::consts::TAU * 1.5 * 1.5).ln();
1166        assert!((got - expected).abs() < 1.0e-12);
1167        assert!(
1168            gaussian_log_loss_from_predictions(
1169                &observed,
1170                &predicted,
1171                &[1.0, 2.0],
1172                DEFAULT_GAUSSIAN_SCALE_FLOOR,
1173            )
1174            .is_err()
1175        );
1176        assert!(
1177            gaussian_log_loss_from_predictions(
1178                &observed,
1179                &predicted,
1180                &[0.0],
1181                DEFAULT_GAUSSIAN_SCALE_FLOOR,
1182            )
1183            .is_err()
1184        );
1185    }
1186}