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    // so divide each class by its own MAXIMUM weight. That is what keeps a
334    // harmless common factor near `f64::MAX` from overflowing the pair
335    // products: every scaled weight is then <= 1, so the concordant total is
336    // bounded by `n_positive * n_negative` and the denominator by the same.
337    //
338    // The class TOTAL is deliberately NOT folded in here. It used to be, and
339    // dividing before summing destroyed the statistic's exactness: with unit
340    // weights each row contributed `1/n`, and n copies of `1/n` do not sum to
341    // 1.0 unless n is a power of two. A perfectly separable ranking — whose
342    // Mann-Whitney value is exactly 1 — came back as 0.9999999999999986,
343    // failing `test_gamclassifier_score_is_auc_and_metrics_panel_is_sane`'s
344    // `assert perfect == 1.0`. Dividing ONCE at the end instead keeps the
345    // unit-weight accumulation in exact integer-and-half arithmetic (every
346    // term is a whole number or a whole number plus 0.5, all exact in f64 well
347    // past any realistic row count), so the exact rational answer is returned
348    // exactly.
349    let positive_scale = pairs
350        .iter()
351        .filter(|(_, positive, _)| *positive)
352        .map(|(_, _, weight)| weight)
353        .copied()
354        .fold(0.0_f64, f64::max);
355    let negative_scale = pairs
356        .iter()
357        .filter(|(_, positive, _)| !*positive)
358        .map(|(_, _, weight)| weight)
359        .copied()
360        .fold(0.0_f64, f64::max);
361    if positive_scale <= 0.0 || negative_scale <= 0.0 {
362        return Ok(0.5);
363    }
364    let positive_weight: f64 = pairs
365        .iter()
366        .filter(|(_, positive, _)| *positive)
367        .map(|(_, _, weight)| weight / positive_scale)
368        .sum();
369    let negative_weight: f64 = pairs
370        .iter()
371        .filter(|(_, positive, _)| !*positive)
372        .map(|(_, _, weight)| weight / negative_scale)
373        .sum();
374    pairs.sort_by(|(left, _, _), (right, _, _)| left.total_cmp(right));
375
376    let mut concordant = 0.0_f64;
377    let mut negative_weight_below = 0.0_f64;
378    let mut start = 0usize;
379    while start < pairs.len() {
380        let mut end = start + 1;
381        while end < pairs.len() && pairs[end].0 == pairs[start].0 {
382            end += 1;
383        }
384        let positive_in_group: f64 = pairs[start..end]
385            .iter()
386            .filter(|(_, positive, _)| *positive)
387            .map(|(_, _, weight)| weight / positive_scale)
388            .sum();
389        let negative_in_group: f64 = pairs[start..end]
390            .iter()
391            .filter(|(_, positive, _)| !*positive)
392            .map(|(_, _, weight)| weight / negative_scale)
393            .sum();
394        concordant += positive_in_group * negative_weight_below;
395        concordant += 0.5 * positive_in_group * negative_in_group;
396        negative_weight_below += negative_in_group;
397        start = end;
398    }
399    // One division, at the end. `positive_weight` and `negative_weight` are
400    // both strictly positive here: each class maximum is > 0 (checked above), so
401    // the row achieving it contributes exactly 1.0 to its class total.
402    let auc = concordant / (positive_weight * negative_weight);
403    if auc.is_finite() {
404        Ok(auc)
405    } else {
406        Err("auc: weighted pair total is not representable in f64".to_string())
407    }
408}
409
410/// Mean squared probability error.
411pub fn brier_from_predictions(observed: &[f64], predicted_mean: &[f64]) -> Result<f64, String> {
412    validate_probability_inputs("brier", observed, predicted_mean)?;
413    let diagnostics = diagnostics_from_predictions(observed, predicted_mean)?;
414    Ok(diagnostics.rmse * diagnostics.rmse)
415}
416
417/// Mean Bernoulli log loss with a caller-selected probability clip.
418pub fn binary_log_loss_from_predictions(
419    observed: &[f64],
420    predicted_mean: &[f64],
421    probability_clip: f64,
422) -> Result<f64, String> {
423    validate_probability_inputs("log_loss", observed, predicted_mean)?;
424    validate_probability_clip("log_loss", probability_clip)?;
425    let loss = observed
426        .iter()
427        .zip(predicted_mean)
428        .map(|(&y, &prediction)| {
429            let probability = prediction.clamp(probability_clip, 1.0 - probability_clip);
430            -(y * probability.ln() + (1.0 - y) * (1.0 - probability).ln())
431        })
432        .sum::<f64>()
433        / observed.len() as f64;
434    if loss.is_finite() {
435        Ok(loss)
436    } else {
437        Err("log_loss: result is not representable in f64".to_string())
438    }
439}
440
441/// Nagelkerke's rescaling of Cox-Snell R² from model/null log likelihoods.
442/// Returns `None` when the null normalization is undefined.
443pub fn nagelkerke_r_squared_from_log_likelihoods(
444    model_log_likelihood: f64,
445    null_log_likelihood: f64,
446    n_observations: usize,
447) -> Option<f64> {
448    if n_observations == 0 || !model_log_likelihood.is_finite() || !null_log_likelihood.is_finite()
449    {
450        return None;
451    }
452    let scale = 2.0 / n_observations as f64;
453    let cox_snell = -(scale * (null_log_likelihood - model_log_likelihood)).exp_m1();
454    let maximum_cox_snell = -(scale * null_log_likelihood).exp_m1();
455    if cox_snell.is_finite() && maximum_cox_snell.is_finite() && maximum_cox_snell > 0.0 {
456        Some(cox_snell / maximum_cox_snell)
457    } else {
458        None
459    }
460}
461
462/// Nagelkerke R² for binomial predictions against an explicit null mean.
463pub fn nagelkerke_r_squared_from_predictions(
464    observed: &[f64],
465    predicted_mean: &[f64],
466    null_mean: f64,
467    probability_clip: f64,
468) -> Result<Option<f64>, String> {
469    if observed.is_empty() {
470        return Ok(None);
471    }
472    validate_probability_inputs("nagelkerke_r_squared", observed, predicted_mean)?;
473    validate_probability_clip("nagelkerke_r_squared", probability_clip)?;
474    if !null_mean.is_finite() || null_mean <= 0.0 || null_mean >= 1.0 {
475        return Ok(None);
476    }
477
478    let log_null = null_mean.ln();
479    let log_not_null = (1.0 - null_mean).ln();
480    let null_log_likelihood = observed
481        .iter()
482        .map(|&y| y * log_null + (1.0 - y) * log_not_null)
483        .sum::<f64>();
484    let model_log_likelihood = observed
485        .iter()
486        .zip(predicted_mean)
487        .map(|(&y, &prediction)| {
488            let probability = prediction.clamp(probability_clip, 1.0 - probability_clip);
489            y * probability.ln() + (1.0 - y) * (1.0 - probability).ln()
490        })
491        .sum::<f64>();
492    Ok(nagelkerke_r_squared_from_log_likelihoods(
493        model_log_likelihood,
494        null_log_likelihood,
495        observed.len(),
496    ))
497}
498
499/// Trapezoidal area under the precision-recall curve. Equal predicted scores
500/// enter as one threshold group, so row order within a tie cannot change the
501/// score.
502pub fn precision_recall_auc_from_predictions(
503    observed: &[f64],
504    predicted_mean: &[f64],
505) -> Result<f64, String> {
506    validate_metric_inputs("precision_recall_auc", observed, predicted_mean)?;
507    let mut pairs: Vec<(f64, bool)> = observed
508        .iter()
509        .zip(predicted_mean)
510        .map(|(&y, &prediction)| (prediction, y > 0.5))
511        .collect();
512    let positives = pairs.iter().filter(|(_, positive)| *positive).count();
513    if positives == 0 {
514        return Ok(0.0);
515    }
516    pairs.sort_by(|(left, _), (right, _)| right.total_cmp(left));
517    let mut true_positives = 0usize;
518    let mut false_positives = 0usize;
519    let mut previous_precision = 1.0_f64;
520    let mut previous_recall = 0.0_f64;
521    let mut area = 0.0_f64;
522    let mut start = 0usize;
523    while start < pairs.len() {
524        let score = pairs[start].0;
525        let mut end = start;
526        while end < pairs.len() && pairs[end].0 == score {
527            if pairs[end].1 {
528                true_positives += 1;
529            } else {
530                false_positives += 1;
531            }
532            end += 1;
533        }
534        let precision = true_positives as f64 / (true_positives + false_positives) as f64;
535        let recall = true_positives as f64 / positives as f64;
536        area += 0.5 * (precision + previous_precision) * (recall - previous_recall);
537        previous_precision = precision;
538        previous_recall = recall;
539        start = end;
540    }
541    Ok(area)
542}
543
544/// Equal-width-bin expected calibration error.
545pub fn expected_calibration_error_from_predictions(
546    observed: &[f64],
547    predicted_mean: &[f64],
548    n_bins: usize,
549) -> Result<f64, String> {
550    validate_probability_inputs("expected_calibration_error", observed, predicted_mean)?;
551    if n_bins == 0 {
552        return Err("expected_calibration_error requires at least one bin".to_string());
553    }
554    // Only occupied bins need storage. A dense `vec![..; n_bins]` lets an
555    // otherwise valid diagnostic request allocate independently of the data
556    // size, whereas at most `observed.len()` bins can be occupied.
557    let mut bins: BTreeMap<usize, (usize, f64, f64)> = BTreeMap::new();
558    for (&y, &prediction) in observed.iter().zip(predicted_mean) {
559        let index = ((prediction.clamp(0.0, 1.0) * n_bins as f64).floor() as usize).min(n_bins - 1);
560        let bin = bins.entry(index).or_insert((0, 0.0, 0.0));
561        bin.0 += 1;
562        bin.1 += y;
563        bin.2 += prediction;
564    }
565    let n = observed.len() as f64;
566    Ok(bins
567        .into_values()
568        .map(|(count, observed_sum, predicted_sum)| {
569            let count = count as f64;
570            (count / n) * ((observed_sum / count) - (predicted_sum / count)).abs()
571        })
572        .sum())
573}
574
575/// Gaussian negative log predictive density, allowing either one shared
576/// standard deviation or one per observation.
577pub fn gaussian_log_loss_from_predictions(
578    observed: &[f64],
579    predicted_mean: &[f64],
580    sigma: &[f64],
581    sigma_floor: f64,
582) -> Result<f64, String> {
583    validate_metric_inputs("gaussian_log_loss", observed, predicted_mean)?;
584    if sigma.len() != 1 && sigma.len() != observed.len() {
585        return Err(format!(
586            "gaussian_log_loss: sigma length must be 1 or {}; got {}",
587            observed.len(),
588            sigma.len()
589        ));
590    }
591    if !(sigma_floor.is_finite() && sigma_floor > 0.0) {
592        return Err(format!(
593            "gaussian_log_loss: sigma_floor must be finite and positive; got {sigma_floor}"
594        ));
595    }
596    let shared_sigma = sigma.len() == 1;
597    let mut total = 0.0_f64;
598    for (index, (&y, &mean)) in observed.iter().zip(predicted_mean).enumerate() {
599        let raw_sigma = if shared_sigma { sigma[0] } else { sigma[index] };
600        if !raw_sigma.is_finite() || raw_sigma <= 0.0 {
601            return Err(format!(
602                "gaussian_log_loss: sigma[{}] must be finite and positive; got {raw_sigma}",
603                if shared_sigma { 0 } else { index }
604            ));
605        }
606        let sigma = raw_sigma.max(sigma_floor);
607        let standardized_residual = (y - mean) / sigma;
608        total += 0.5 * std::f64::consts::TAU.ln()
609            + sigma.ln()
610            + 0.5 * standardized_residual * standardized_residual;
611    }
612    let loss = total / observed.len() as f64;
613    if loss.is_finite() {
614        Ok(loss)
615    } else {
616        Err("gaussian_log_loss: result is not representable in f64".to_string())
617    }
618}
619
620/// The package's standard classification diagnostic panel.
621pub fn classification_metrics_from_predictions(
622    observed: &[f64],
623    predicted_mean: &[f64],
624    null_mean: f64,
625) -> Result<ClassificationPredictionMetrics, String> {
626    validate_probability_inputs("classification_metrics", observed, predicted_mean)?;
627    Ok(ClassificationPredictionMetrics {
628        auc: auc_from_predictions(observed, predicted_mean)?,
629        precision_recall_auc: precision_recall_auc_from_predictions(observed, predicted_mean)?,
630        brier: brier_from_predictions(observed, predicted_mean)?,
631        log_loss: binary_log_loss_from_predictions(
632            observed,
633            predicted_mean,
634            DEFAULT_PROBABILITY_CLIP,
635        )?,
636        nagelkerke_r_squared: nagelkerke_r_squared_from_predictions(
637            observed,
638            predicted_mean,
639            null_mean,
640            DEFAULT_PROBABILITY_CLIP,
641        )?,
642        expected_calibration_error: expected_calibration_error_from_predictions(
643            observed,
644            predicted_mean,
645            DEFAULT_CALIBRATION_BINS,
646        )?,
647    })
648}
649
650/// Compute prediction residual diagnostics from observed values and predicted means.
651pub fn diagnostics_from_predictions(
652    observed: &[f64],
653    predicted_mean: &[f64],
654) -> Result<PredictionDiagnostics, String> {
655    if observed.is_empty() {
656        return Err("diagnostics_from_predictions requires at least one observation".to_string());
657    }
658    if observed.len() != predicted_mean.len() {
659        return Err(format!(
660            "diagnostics_from_predictions length mismatch: observed has {} values but predicted mean has {}",
661            observed.len(),
662            predicted_mean.len()
663        ));
664    }
665    if observed.iter().any(|value| !value.is_finite()) {
666        return Err("observed values must contain only finite numbers".to_string());
667    }
668    if predicted_mean.iter().any(|value| !value.is_finite()) {
669        return Err("predicted mean values must contain only finite numbers".to_string());
670    }
671
672    let n_obs = observed.len();
673    let n_obs_f = n_obs as f64;
674    let mut residuals = Vec::with_capacity(n_obs);
675    let mut abs_sum = 0.0_f64;
676    let mut residual_sum = 0.0_f64;
677    let mut residual_sum_squares = 0.0_f64;
678    let mut observed_sum = 0.0_f64;
679    for (obs, pred) in observed.iter().zip(predicted_mean.iter()) {
680        let residual = obs - pred;
681        residuals.push(residual);
682        abs_sum += residual.abs();
683        residual_sum += residual;
684        residual_sum_squares += residual * residual;
685        observed_sum += obs;
686    }
687
688    let observed_mean = observed_sum / n_obs_f;
689    let total_sum_squares = observed
690        .iter()
691        .map(|value| {
692            let centered = value - observed_mean;
693            centered * centered
694        })
695        .sum::<f64>();
696    let r_squared = if total_sum_squares > 0.0 {
697        Some(1.0 - residual_sum_squares / total_sum_squares)
698    } else {
699        None
700    };
701
702    Ok(PredictionDiagnostics {
703        n_obs,
704        mae: abs_sum / n_obs_f,
705        rmse: (residual_sum_squares / n_obs_f).sqrt(),
706        bias: residual_sum / n_obs_f,
707        r_squared,
708        residuals,
709    })
710}
711
712/// Complete diagnostic report for a gradient evaluation
713#[derive(Clone, Debug, Default)]
714pub struct GradientDiagnosticReport {
715    /// Envelope theorem audit results
716    pub envelopeaudit: Option<EnvelopeAudit>,
717    /// Spectral bleed results for each penalty
718    pub spectral_bleed: Vec<SpectralBleedResult>,
719    /// Dual-ridge consistency result
720    pub dualridge: Option<DualRidgeResult>,
721}
722
723impl GradientDiagnosticReport {
724    /// Create an empty report
725    pub fn new() -> Self {
726        Self::default()
727    }
728
729    /// Generate a summary string of all issues found
730    pub fn summary(&self) -> String {
731        let mut lines = Vec::new();
732
733        if let Some(ref audit) = self.envelopeaudit
734            && audit.isviolated
735        {
736            lines.push(format!("[DIAG] {}", audit));
737        }
738
739        for bleed in &self.spectral_bleed {
740            if bleed.has_bleed {
741                lines.push(format!("[DIAG] {}", bleed));
742            }
743        }
744
745        if let Some(ref ridge) = self.dualridge
746            && ridge.has_mismatch
747        {
748            lines.push(format!("[DIAG] {}", ridge));
749        }
750
751        if lines.is_empty() {
752            "No gradient diagnostic issues detected.".to_string()
753        } else {
754            lines.join("\n")
755        }
756    }
757}
758
759// =============================================================================
760// Strategy 1: Envelope Theorem (KKT) Audit
761// =============================================================================
762
763// =============================================================================
764// Strategy 4: Dual-Ridge Consistency Check
765// =============================================================================
766
767/// Three-way classification of why the cert refused, computed from the
768/// H_pen spectrum and the projected residual at the refusing iterate.
769/// `RankDeficientHPen` is the regression canary the nullspace lead's
770/// smooth-construction rework is intended to eliminate; keep this variant
771/// intact when extending — it doubles as the user-facing signal for
772/// "an unconstrained polynomial null space slipped past absorption."
773///
774/// Relocated from `gam-solve`'s `custom_family/joint_newton.rs` (issue #1521
775/// crate carve): this is the neutral diagnostic carrier that `gam-solve`'s
776/// REML/PIRLS core consumes when classifying a custom-family cert refusal,
777/// so it must live BELOW both the core and the (extracted) custom-family
778/// subsystem.
779#[derive(Clone, Copy, Debug, PartialEq, Eq)]
780pub enum KktRefusalDiagnosis {
781    RankDeficientHPen,
782    PhantomMultiplierWithWellConditionedH,
783    ActiveSetIncomplete,
784    /// Cross-block identifiability aliasing surfaced mid-inner-solve
785    /// (e.g., a binding active set materialised a 2-way alias that
786    /// the pre-fit audit could not see at the cold design). The fix
787    /// is structural — drop or reparameterise the aliased block;
788    /// rho-anneal will not recover.
789    AliasingDetectedAtFit,
790}
791
792impl KktRefusalDiagnosis {
793    pub fn as_str(&self) -> &'static str {
794        match self {
795            KktRefusalDiagnosis::RankDeficientHPen => "rank_deficient_H_pen",
796            KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH => {
797                "phantom_multiplier_with_well_conditioned_H"
798            }
799            KktRefusalDiagnosis::ActiveSetIncomplete => "active_set_incomplete",
800            KktRefusalDiagnosis::AliasingDetectedAtFit => "aliasing_detected_at_fit",
801        }
802    }
803
804    /// Parse the textual `diagnosis:` field embedded in the structured
805    /// bubbled error string. Returns `None` when no recognised label is
806    /// present (legacy / non-cert-refusal error strings).
807    pub fn parse_from_error(message: &str) -> Option<Self> {
808        let marker = "diagnosis: ";
809        let start = message.rfind(marker)? + marker.len();
810        let tail = &message[start..];
811        let end = tail
812            .find(|c: char| c == ';' || c == '\n' || c == ' ')
813            .unwrap_or(tail.len());
814        match &tail[..end] {
815            "rank_deficient_H_pen" => Some(KktRefusalDiagnosis::RankDeficientHPen),
816            "phantom_multiplier_with_well_conditioned_H" => {
817                Some(KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH)
818            }
819            "active_set_incomplete" => Some(KktRefusalDiagnosis::ActiveSetIncomplete),
820            "aliasing_detected_at_fit" => Some(KktRefusalDiagnosis::AliasingDetectedAtFit),
821            _ => None,
822        }
823    }
824
825    pub fn guidance(self) -> &'static str {
826        match self {
827            KktRefusalDiagnosis::RankDeficientHPen => {
828                "check whether the named block has a structural or numerical null direction \
829                 not identified by the likelihood/penalty combination; for Duchon-style \
830                 smooths this may be a polynomial null space, while marginal-slope fits can \
831                 also expose callback-owned weak directions"
832            }
833            KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH => {
834                "check whether the named block has a near-separated or weakly identified \
835                 direction despite a well-conditioned penalized Hessian; in marginal-slope \
836                 fits this often indicates marginal/slope coupling rather than a \
837                 Matérn/Duchon polynomial-nullspace failure"
838            }
839            KktRefusalDiagnosis::ActiveSetIncomplete => {
840                "check whether the named block's linear constraints need an additional \
841                 active row or a tighter constrained re-solve; this is an active-set \
842                 certification failure, not a polynomial-nullspace diagnosis"
843            }
844            KktRefusalDiagnosis::AliasingDetectedAtFit => {
845                "check whether the named block aliases another block after runtime \
846                 constraints or callbacks materialize; drop or reparameterize the aliased \
847                 direction before fitting"
848            }
849        }
850    }
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856
857    #[test]
858    fn diagnostics_from_predictions_computes_residual_metrics() {
859        let observed = [1.0, 2.0, 4.0];
860        let predicted = [1.5, 1.5, 3.0];
861
862        let result = diagnostics_from_predictions(&observed, &predicted).unwrap();
863
864        assert_eq!(result.residuals, vec![-0.5, 0.5, 1.0]);
865        assert_eq!(result.n_obs, 3);
866        assert_eq!(result.mae, 2.0 / 3.0);
867        assert_eq!(result.bias, 1.0 / 3.0);
868        assert_eq!(result.rmse, (1.5_f64 / 3.0).sqrt());
869        assert_eq!(result.r_squared, Some(1.0 - 1.5 / (14.0 / 3.0)));
870    }
871
872    #[test]
873    fn diagnostics_from_predictions_omits_r_squared_for_constant_observed() {
874        let observed = [2.0, 2.0];
875        let predicted = [1.0, 3.0];
876
877        let result = diagnostics_from_predictions(&observed, &predicted).unwrap();
878
879        assert_eq!(result.r_squared, None);
880    }
881
882    #[test]
883    fn diagnostics_from_predictions_rejects_invalid_inputs() {
884        assert_eq!(
885            diagnostics_from_predictions(&[], &[]),
886            Err("diagnostics_from_predictions requires at least one observation".to_string())
887        );
888        assert_eq!(
889            diagnostics_from_predictions(&[1.0], &[1.0, 2.0]),
890            Err(
891                "diagnostics_from_predictions length mismatch: observed has 1 values but predicted mean has 2"
892                    .to_string()
893            )
894        );
895        assert_eq!(
896            diagnostics_from_predictions(&[f64::NAN], &[1.0]),
897            Err("observed values must contain only finite numbers".to_string())
898        );
899        assert_eq!(
900            diagnostics_from_predictions(&[1.0], &[f64::INFINITY]),
901            Err("predicted mean values must contain only finite numbers".to_string())
902        );
903    }
904
905    #[test]
906    fn auc_is_tie_aware_and_weighted_auc_reduces_to_unit_weights() {
907        let observed = [0.0, 1.0, 0.0, 1.0];
908        let predicted = [0.1, 0.8, 0.8, 0.9];
909        let auc = auc_from_predictions(&observed, &predicted).unwrap();
910        assert_eq!(auc, 0.875);
911        assert_eq!(
912            weighted_auc_from_predictions(&observed, &predicted, Some(&[1.0; 4])).unwrap(),
913            auc
914        );
915
916        let weighted = weighted_auc_from_predictions(
917            &[1.0, 0.0, 1.0],
918            &[0.5, 0.5, 0.9],
919            Some(&[2.0, 3.0, 1.0]),
920        )
921        .unwrap();
922        assert_eq!(weighted, 2.0 / 3.0);
923        assert!(
924            weighted_auc_from_predictions(&[0.0, 1.0], &[0.2, 0.8], Some(&[1.0, -1.0])).is_err()
925        );
926        assert_eq!(
927            weighted_auc_from_predictions(&[1.0, 0.0], &[0.5, 0.5], Some(&[f64::MAX, f64::MAX]),)
928                .unwrap(),
929            0.5
930        );
931    }
932
933    /// A perfect ranking has Mann-Whitney AUC exactly 1, and the estimator must
934    /// return exactly that — not 1 - 6ulp.
935    ///
936    /// The old accumulation divided each row by its class TOTAL before summing,
937    /// so it added `n` copies of `1/n`; that equals 1.0 only when `n` is a power
938    /// of two. `n = 100` (and 97, and 63) are the ordinary cases where it does
939    /// not, and `test_gamclassifier_score_is_auc_and_metrics_panel_is_sane` had
940    /// been failing on exactly this: `separable ranking must give AUC 1.0; got
941    /// 0.9999999999999986`.
942    ///
943    /// The class sizes below are deliberately NOT powers of two, and are
944    /// unequal, so the test would have failed before the fix and cannot pass by
945    /// accident of a representable denominator.
946    #[test]
947    fn a_perfectly_separable_ranking_scores_exactly_one() {
948        for (negatives, positives) in [(100usize, 100usize), (97, 63), (13, 501)] {
949            let mut observed = Vec::with_capacity(negatives + positives);
950            let mut predicted = Vec::with_capacity(negatives + positives);
951            for index in 0..negatives {
952                observed.push(0.0);
953                predicted.push(index as f64);
954            }
955            for index in 0..positives {
956                observed.push(1.0);
957                predicted.push((negatives + index) as f64);
958            }
959            let auc = auc_from_predictions(&observed, &predicted).unwrap();
960            assert_eq!(
961                auc, 1.0,
962                "separable ranking with {negatives} negatives and {positives} positives \
963                 must score exactly 1.0, got {auc:?}"
964            );
965
966            // The mirror image is exactly 0 by the same argument; an estimator
967            // that is exact at one end and not the other is still rounding.
968            let reversed: Vec<f64> = observed.iter().map(|y| 1.0 - y).collect();
969            let auc_reversed = auc_from_predictions(&reversed, &predicted).unwrap();
970            assert_eq!(auc_reversed, 0.0, "reversed ranking must score exactly 0.0");
971        }
972    }
973
974    /// Unit weights must remain the identity of the weighted path after the
975    /// normalization change, at a size where the old per-row division rounded.
976    #[test]
977    fn unit_weights_match_the_unweighted_score_at_scale() {
978        let observed: Vec<f64> = (0..200).map(|i| f64::from(i % 3 == 0)).collect();
979        let predicted: Vec<f64> = (0..200).map(|i| ((i * 37) % 101) as f64).collect();
980        let plain = auc_from_predictions(&observed, &predicted).unwrap();
981        let unit =
982            weighted_auc_from_predictions(&observed, &predicted, Some(&vec![1.0; 200])).unwrap();
983        assert_eq!(plain, unit);
984        // A common positive factor is a no-op: AUC is scale-invariant per class.
985        let doubled =
986            weighted_auc_from_predictions(&observed, &predicted, Some(&vec![2.0; 200])).unwrap();
987        assert_eq!(plain, doubled);
988    }
989
990    #[test]
991    fn precision_recall_auc_consumes_ties_as_one_threshold() {
992        let first = precision_recall_auc_from_predictions(&[1.0, 0.0], &[0.5, 0.5]).unwrap();
993        let reversed = precision_recall_auc_from_predictions(&[0.0, 1.0], &[0.5, 0.5]).unwrap();
994        assert_eq!(first, 0.75);
995        assert_eq!(first, reversed);
996    }
997
998    #[test]
999    fn probability_scores_match_closed_forms() {
1000        let observed = [0.0, 1.0];
1001        let predicted = [0.5, 0.5];
1002        let log_loss =
1003            binary_log_loss_from_predictions(&observed, &predicted, DEFAULT_PROBABILITY_CLIP)
1004                .unwrap();
1005        assert!((log_loss - std::f64::consts::LN_2).abs() < 1.0e-15);
1006        assert_eq!(brier_from_predictions(&observed, &predicted).unwrap(), 0.25);
1007        assert_eq!(
1008            expected_calibration_error_from_predictions(&observed, &predicted, 2).unwrap(),
1009            0.0
1010        );
1011        assert!(
1012            binary_log_loss_from_predictions(&observed, &predicted, 0.5).is_err(),
1013            "a clip that collapses the probability interval must be rejected"
1014        );
1015        assert!(classification_metrics_from_predictions(&observed, &[0.5, 2.0], 0.5).is_err());
1016        assert!(classification_metrics_from_predictions(&[-0.1, 1.0], &predicted, 0.5).is_err());
1017    }
1018
1019    #[test]
1020    fn nagelkerke_and_classification_panel_share_the_core_kernels() {
1021        let observed = [0.0, 0.0, 1.0, 1.0];
1022        let predicted = [0.1, 0.2, 0.8, 0.9];
1023        let metrics = classification_metrics_from_predictions(&observed, &predicted, 0.5).unwrap();
1024        assert_eq!(metrics.auc, 1.0);
1025        assert_eq!(metrics.precision_recall_auc, 1.0);
1026        assert_eq!(
1027            metrics.nagelkerke_r_squared,
1028            nagelkerke_r_squared_from_predictions(
1029                &observed,
1030                &predicted,
1031                0.5,
1032                DEFAULT_PROBABILITY_CLIP,
1033            )
1034            .unwrap()
1035        );
1036        assert!(metrics.nagelkerke_r_squared.unwrap() > 0.8);
1037        assert_eq!(
1038            nagelkerke_r_squared_from_predictions(
1039                &observed,
1040                &predicted,
1041                1.0,
1042                DEFAULT_PROBABILITY_CLIP,
1043            )
1044            .unwrap(),
1045            None
1046        );
1047    }
1048
1049    #[test]
1050    fn gaussian_log_loss_matches_closed_form_and_rejects_invalid_sigma() {
1051        let observed = [1.0, 2.0, 3.0, 4.0];
1052        let predicted = observed;
1053        let sigma = [1.5];
1054        let got = gaussian_log_loss_from_predictions(
1055            &observed,
1056            &predicted,
1057            &sigma,
1058            DEFAULT_GAUSSIAN_SCALE_FLOOR,
1059        )
1060        .unwrap();
1061        let expected = 0.5 * (std::f64::consts::TAU * 1.5 * 1.5).ln();
1062        assert!((got - expected).abs() < 1.0e-12);
1063        assert!(
1064            gaussian_log_loss_from_predictions(
1065                &observed,
1066                &predicted,
1067                &[1.0, 2.0],
1068                DEFAULT_GAUSSIAN_SCALE_FLOOR,
1069            )
1070            .is_err()
1071        );
1072        assert!(
1073            gaussian_log_loss_from_predictions(
1074                &observed,
1075                &predicted,
1076                &[0.0],
1077                DEFAULT_GAUSSIAN_SCALE_FLOOR,
1078            )
1079            .is_err()
1080        );
1081    }
1082}