use crate::eval_stats::{bootstrap_ci, ridge_fit, ridge_predict, spearman};
use crate::impairment_eval::{
auc, generate_corpus, stratified_split, threshold_for_pfa, AgcDetector, CorpusConfig,
EnergyDetector, FusedDetector, ImpairmentClass, ImpairmentDetector, LabeledCase,
ParityDetector, SqmDetector,
};
use crate::impairment_ml::{LogisticRegression, Mlp};
pub fn auc_per_class<D: ImpairmentDetector + ?Sized>(
det: &D,
corpus: &[LabeledCase],
class: ImpairmentClass,
) -> f64 {
let pos: Vec<f64> = corpus
.iter()
.filter(|c| c.class == class)
.map(|c| det.score(&c.obs))
.collect();
let neg: Vec<f64> = corpus
.iter()
.filter(|c| c.class == ImpairmentClass::Nominal)
.map(|c| det.score(&c.obs))
.collect();
auc(&pos, &neg)
}
pub fn optimism_gap<D: ImpairmentDetector + ?Sized>(
det: &D,
in_corpus: &[LabeledCase],
out_corpus: &[LabeledCase],
class: ImpairmentClass,
) -> f64 {
auc_per_class(det, in_corpus, class) - auc_per_class(det, out_corpus, class)
}
#[derive(Clone, Debug)]
pub struct GridConfig {
pub n_per_class: usize,
pub frac_train: f64,
pub severities: Vec<f64>,
pub seeds: Vec<u64>,
pub target_pfa: f64,
pub bootstrap_resamples: usize,
pub bootstrap_alpha: f64,
pub logreg_epochs: usize,
pub logreg_lr: f64,
pub mlp_hidden_sizes: Vec<usize>,
pub mlp_epochs: usize,
pub mlp_lr: f64,
}
#[derive(Clone, Debug)]
pub struct Cell {
pub detector: String,
pub class: ImpairmentClass,
pub severity: f64,
pub mean_gap: f64,
pub boot_lo: f64,
pub boot_hi: f64,
pub seed_se: f64,
pub gaps: Vec<f64>,
}
#[derive(Clone, Debug)]
pub struct Trend {
pub detector: String,
pub class: ImpairmentClass,
pub spearman_rho: f64,
pub spearman_p: f64,
pub slope: f64,
pub id_auc_mean: f64,
}
#[derive(Clone, Debug)]
pub struct GridResult {
pub cells: Vec<Cell>,
pub trends: Vec<Trend>,
pub detectors: Vec<String>,
pub config: GridConfig,
}
fn detectors_for_seed(
train: &[LabeledCase],
cfg: &GridConfig,
seed: u64,
) -> Vec<(String, Box<dyn ImpairmentDetector>)> {
let mut v: Vec<(String, Box<dyn ImpairmentDetector>)> = vec![
(EnergyDetector.name().to_string(), Box::new(EnergyDetector)),
(AgcDetector.name().to_string(), Box::new(AgcDetector)),
(SqmDetector.name().to_string(), Box::new(SqmDetector)),
(ParityDetector.name().to_string(), Box::new(ParityDetector)),
(FusedDetector.name().to_string(), Box::new(FusedDetector)),
(
"logreg".to_string(),
Box::new(LogisticRegression::fit(
train,
cfg.logreg_epochs,
cfg.logreg_lr,
)),
),
];
for &h in &cfg.mlp_hidden_sizes {
v.push((
format!("mlp{h}"),
Box::new(Mlp::fit(
train,
h,
cfg.mlp_epochs,
cfg.mlp_lr,
seed.wrapping_add(h as u64),
)),
));
}
for (name, idx) in [
("logreg-cn0", 0usize),
("logreg-agc", 1),
("logreg-parity", 3),
] {
let mut mask = [false; 5];
mask[idx] = true;
v.push((
name.to_string(),
Box::new(LogisticRegression::fit_masked(
train,
mask,
cfg.logreg_epochs,
cfg.logreg_lr,
)),
));
}
v
}
fn ood_seed(seed: u64, severity_index: usize) -> u64 {
seed.wrapping_mul(0x1_0000_000B)
.wrapping_add(severity_index as u64 + 1)
}
pub fn run_grid(cfg: &GridConfig) -> GridResult {
let classes = ImpairmentClass::impaired();
let severities = &cfg.severities;
let mut det_names: Vec<String> = Vec::new();
let mut raw: Vec<Vec<Vec<Vec<f64>>>> = Vec::new();
let mut raw_id_auc: Vec<Vec<Vec<f64>>> = Vec::new();
for &seed in &cfg.seeds {
let id = generate_corpus(
&CorpusConfig {
n_per_class: cfg.n_per_class,
..Default::default()
},
seed,
);
let split = stratified_split(&id, cfg.frac_train, seed);
assert!(
!split.near_duplicate_leakage(1e-6),
"leakage guard tripped: ID train/test split for seed {seed} is not a genuine \
generalisation split"
);
let dets = detectors_for_seed(&split.train, cfg, seed);
if det_names.is_empty() {
det_names = dets.iter().map(|(n, _)| n.clone()).collect();
raw = vec![vec![vec![Vec::new(); severities.len()]; classes.len()]; det_names.len()];
raw_id_auc = vec![vec![Vec::new(); classes.len()]; det_names.len()];
}
let id_auc: Vec<Vec<f64>> = dets
.iter()
.map(|(_, d)| {
classes
.iter()
.map(|&c| auc_per_class(d.as_ref(), &split.test, c))
.collect()
})
.collect();
for (di, row) in id_auc.iter().enumerate() {
for (ci, &a) in row.iter().enumerate() {
raw_id_auc[di][ci].push(a);
}
}
for (si, &s) in severities.iter().enumerate() {
let ood = generate_corpus(
&CorpusConfig {
n_per_class: cfg.n_per_class,
severity_scale: s,
..Default::default()
},
ood_seed(seed, si),
);
for (di, (_, d)) in dets.iter().enumerate() {
for (ci, &c) in classes.iter().enumerate() {
let gap = id_auc[di][ci] - auc_per_class(d.as_ref(), &ood, c);
raw[di][ci][si].push(gap);
}
}
}
}
let mut cells = Vec::new();
let mut trends = Vec::new();
for (di, name) in det_names.iter().enumerate() {
for (ci, &class) in classes.iter().enumerate() {
let mut tx = Vec::new();
let mut ty = Vec::new();
for (si, &s) in severities.iter().enumerate() {
let gaps = &raw[di][ci][si];
let k = gaps.len().max(1) as f64;
let mean = gaps.iter().sum::<f64>() / k;
let var = if gaps.len() > 1 {
gaps.iter().map(|g| (g - mean).powi(2)).sum::<f64>() / (gaps.len() as f64 - 1.0)
} else {
0.0
};
let (boot_lo, boot_hi) = bootstrap_ci(
gaps,
cfg.bootstrap_resamples,
ood_seed(0xB007, si * 31 + di),
cfg.bootstrap_alpha,
);
cells.push(Cell {
detector: name.clone(),
class,
severity: s,
mean_gap: mean,
boot_lo,
boot_hi,
seed_se: (var / k).sqrt(),
gaps: gaps.clone(),
});
for &g in gaps {
tx.push(1.0 - s);
ty.push(g);
}
}
let (rho, p) = spearman(&tx, &ty);
let xcol: Vec<Vec<f64>> = tx.iter().map(|&v| vec![v]).collect();
let slope = ridge_fit(&xcol, &ty, 0.0).get(1).copied().unwrap_or(0.0);
let id_aucs = &raw_id_auc[di][ci];
let id_auc_mean = id_aucs.iter().sum::<f64>() / id_aucs.len().max(1) as f64;
trends.push(Trend {
detector: name.clone(),
class,
spearman_rho: rho,
spearman_p: p,
slope,
id_auc_mean,
});
}
}
GridResult {
cells,
trends,
detectors: det_names,
config: cfg.clone(),
}
}
pub const ID_FEATURE_NAMES: [&str; 6] = [
"auc_in",
"dprime",
"overlap",
"var_ratio",
"tail_margin",
"pd_at_pfa",
];
fn mean_of(v: &[f64]) -> f64 {
if v.is_empty() {
0.0
} else {
v.iter().sum::<f64>() / v.len() as f64
}
}
fn std_pop(v: &[f64], m: f64) -> f64 {
if v.is_empty() {
0.0
} else {
(v.iter().map(|x| (x - m).powi(2)).sum::<f64>() / v.len() as f64).sqrt()
}
}
fn quantile(sorted: &[f64], q: f64) -> f64 {
if sorted.is_empty() {
return f64::NAN;
}
let idx = (q.clamp(0.0, 1.0) * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
pub fn id_features<D: ImpairmentDetector + ?Sized>(
det: &D,
corpus: &[LabeledCase],
class: ImpairmentClass,
target_pfa: f64,
) -> [f64; 6] {
let pos: Vec<f64> = corpus
.iter()
.filter(|c| c.class == class)
.map(|c| det.score(&c.obs))
.collect();
let neg: Vec<f64> = corpus
.iter()
.filter(|c| c.class == ImpairmentClass::Nominal)
.map(|c| det.score(&c.obs))
.collect();
id_features_from_scores(&pos, &neg, target_pfa)
}
pub fn id_features_from_scores(pos: &[f64], neg: &[f64], target_pfa: f64) -> [f64; 6] {
let auc_in = auc(pos, neg);
let (mp, mn) = (mean_of(pos), mean_of(neg));
let (sp, sn) = (std_pop(pos, mp), std_pop(neg, mn));
let pooled = (((sp * sp + sn * sn) / 2.0).sqrt()).max(1e-9);
let dprime = (mp - mn) / pooled;
let var_ratio = sp / sn.max(1e-9);
let mut posv = pos.to_vec();
let mut negv = neg.to_vec();
posv.sort_by(|a, b| a.total_cmp(b));
negv.sort_by(|a, b| a.total_cmp(b));
let q95_neg = quantile(&negv, 0.95);
let tail_margin = (quantile(&posv, 0.05) - q95_neg) / pooled;
let overlap = posv.iter().filter(|&&p| p <= q95_neg).count() as f64 / posv.len().max(1) as f64;
let thr = threshold_for_pfa(&negv, target_pfa);
let pd_at_pfa = posv.iter().filter(|&&p| p >= thr).count() as f64 / posv.len().max(1) as f64;
[auc_in, dprime, overlap, var_ratio, tail_margin, pd_at_pfa]
}
fn self_perturbation_slope<D: ImpairmentDetector + ?Sized>(
det: &D,
probes: &[(f64, Vec<LabeledCase>)],
class: ImpairmentClass,
) -> f64 {
let xcol: Vec<Vec<f64>> = probes.iter().map(|(s, _)| vec![1.0 - s]).collect();
let ys: Vec<f64> = probes
.iter()
.map(|(_, c)| auc_per_class(det, c, class))
.collect();
ridge_fit(&xcol, &ys, 0.0).get(1).copied().unwrap_or(0.0)
}
#[derive(Clone, Debug)]
pub struct PredictorConfig {
pub grid: GridConfig,
pub include_self_slope: bool,
pub probe_scales: Vec<f64>,
pub ridge_lambda: f64,
}
#[derive(Clone, Debug)]
pub struct GapRow {
pub detector: String,
pub class: ImpairmentClass,
pub seed: u64,
pub features: Vec<f64>,
pub gap: f64,
}
fn probe_seed(seed: u64, index: usize) -> u64 {
seed.wrapping_mul(0x9E37_79B1)
.wrapping_add(index as u64 + 1000)
}
pub fn build_gap_rows(pc: &PredictorConfig) -> Vec<GapRow> {
let cfg = &pc.grid;
let classes = ImpairmentClass::impaired();
let mut rows = Vec::new();
for &seed in &cfg.seeds {
let id = generate_corpus(
&CorpusConfig {
n_per_class: cfg.n_per_class,
..Default::default()
},
seed,
);
let split = stratified_split(&id, cfg.frac_train, seed);
assert!(
!split.near_duplicate_leakage(1e-6),
"leakage guard tripped for seed {seed}"
);
let dets = detectors_for_seed(&split.train, cfg, seed);
let oods: Vec<Vec<LabeledCase>> = cfg
.severities
.iter()
.enumerate()
.map(|(si, &s)| {
generate_corpus(
&CorpusConfig {
n_per_class: cfg.n_per_class,
severity_scale: s,
..Default::default()
},
ood_seed(seed, si),
)
})
.collect();
let probes: Vec<(f64, Vec<LabeledCase>)> = pc
.probe_scales
.iter()
.enumerate()
.map(|(pi, &s)| {
(
s,
generate_corpus(
&CorpusConfig {
n_per_class: cfg.n_per_class,
severity_scale: s,
..Default::default()
},
probe_seed(seed, pi),
),
)
})
.collect();
for (name, d) in &dets {
for &class in &classes {
let id_auc = auc_per_class(d.as_ref(), &split.test, class);
let mean_ood = oods
.iter()
.map(|o| auc_per_class(d.as_ref(), o, class))
.sum::<f64>()
/ oods.len().max(1) as f64;
let mut feats =
id_features(d.as_ref(), &split.test, class, cfg.target_pfa).to_vec();
if pc.include_self_slope {
feats.push(self_perturbation_slope(d.as_ref(), &probes, class));
}
rows.push(GapRow {
detector: name.clone(),
class,
seed,
features: feats,
gap: id_auc - mean_ood,
});
}
}
}
rows
}
fn col_stats(rows: &[Vec<f64>]) -> (Vec<f64>, Vec<f64>) {
let p = rows.first().map(|r| r.len()).unwrap_or(0);
let n = rows.len().max(1) as f64;
let mut mean = vec![0.0; p];
for r in rows {
for (m, &v) in mean.iter_mut().zip(r.iter()) {
*m += v;
}
}
for m in &mut mean {
*m /= n;
}
let mut std = vec![0.0; p];
for r in rows {
for (k, &v) in r.iter().enumerate() {
std[k] += (v - mean[k]).powi(2);
}
}
for s in &mut std {
*s = (*s / n).sqrt().max(1e-9);
}
(mean, std)
}
fn zrow(row: &[f64], mean: &[f64], std: &[f64]) -> Vec<f64> {
row.iter()
.zip(mean.iter().zip(std.iter()))
.map(|(&v, (&m, &s))| (v - m) / s)
.collect()
}
#[derive(Clone, Debug)]
pub struct GapPredictor {
pub mean: Vec<f64>,
pub std: Vec<f64>,
pub coeffs: Vec<f64>,
}
impl GapPredictor {
pub fn predict(&self, features: &[f64]) -> f64 {
ridge_predict(&self.coeffs, &zrow(features, &self.mean, &self.std))
}
}
pub fn fit_gap_predictor(rows: &[GapRow], lambda: f64) -> GapPredictor {
let x: Vec<Vec<f64>> = rows.iter().map(|r| r.features.clone()).collect();
let y: Vec<f64> = rows.iter().map(|r| r.gap).collect();
let (mean, std) = col_stats(&x);
let xz: Vec<Vec<f64>> = x.iter().map(|row| zrow(row, &mean, &std)).collect();
let coeffs = ridge_fit(&xz, &y, lambda);
GapPredictor { mean, std, coeffs }
}
#[derive(Clone, Debug)]
pub struct CvResult {
pub r2: f64,
pub rmse: f64,
pub pred_actual: Vec<(f64, f64)>,
pub n_folds: usize,
}
fn cv_metrics(pa: Vec<(f64, f64)>, n_folds: usize) -> CvResult {
let n = pa.len().max(1) as f64;
let ybar = pa.iter().map(|(_, a)| a).sum::<f64>() / n;
let ss_tot: f64 = pa.iter().map(|(_, a)| (a - ybar).powi(2)).sum();
let ss_res: f64 = pa.iter().map(|(p, a)| (a - p).powi(2)).sum();
let r2 = if ss_tot > 0.0 {
1.0 - ss_res / ss_tot
} else {
0.0
};
CvResult {
r2,
rmse: (ss_res / n).sqrt(),
pred_actual: pa,
n_folds,
}
}
#[derive(Clone, Debug)]
pub struct GapSample {
pub detector: String,
pub class: String,
pub features: Vec<f64>,
pub gap: f64,
}
fn gaprow_to_sample(r: &GapRow) -> GapSample {
GapSample {
detector: r.detector.clone(),
class: r.class.label().to_string(),
features: r.features.clone(),
gap: r.gap,
}
}
fn loocv_samples(samples: &[GapSample], lambda: f64, by_detector: bool) -> CvResult {
use std::collections::BTreeSet;
let key = |s: &GapSample| {
if by_detector {
s.detector.clone()
} else {
s.class.clone()
}
};
let groups: BTreeSet<String> = samples.iter().map(&key).collect();
let mut pred_actual = Vec::new();
for g in &groups {
let xtr: Vec<Vec<f64>> = samples
.iter()
.filter(|s| key(s) != *g)
.map(|s| s.features.clone())
.collect();
let ytr: Vec<f64> = samples
.iter()
.filter(|s| key(s) != *g)
.map(|s| s.gap)
.collect();
if xtr.is_empty() {
continue;
}
let (mean, std) = col_stats(&xtr);
let xz: Vec<Vec<f64>> = xtr.iter().map(|row| zrow(row, &mean, &std)).collect();
let coeffs = ridge_fit(&xz, &ytr, lambda);
for s in samples.iter().filter(|s| key(s) == *g) {
pred_actual.push((
ridge_predict(&coeffs, &zrow(&s.features, &mean, &std)),
s.gap,
));
}
}
cv_metrics(pred_actual, groups.len())
}
fn permutation_samples(
samples: &[GapSample],
lambda: f64,
by_detector: bool,
n_perms: usize,
seed: u64,
) -> f64 {
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
let observed = loocv_samples(samples, lambda, by_detector).r2;
let gaps: Vec<f64> = samples.iter().map(|s| s.gap).collect();
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let mut ge = 0usize;
for _ in 0..n_perms.max(1) {
let mut g = gaps.clone();
for i in (1..g.len()).rev() {
g.swap(i, rng.gen_range(0..=i));
}
let permuted: Vec<GapSample> = samples
.iter()
.zip(g.iter())
.map(|(s, &gap)| GapSample { gap, ..s.clone() })
.collect();
if loocv_samples(&permuted, lambda, by_detector).r2 >= observed {
ge += 1;
}
}
(ge as f64 + 1.0) / (n_perms.max(1) as f64 + 1.0)
}
pub fn loocv_by_detector(rows: &[GapRow], lambda: f64) -> CvResult {
loocv_samples(
&rows.iter().map(gaprow_to_sample).collect::<Vec<_>>(),
lambda,
true,
)
}
pub fn loocv_by_class(rows: &[GapRow], lambda: f64) -> CvResult {
loocv_samples(
&rows.iter().map(gaprow_to_sample).collect::<Vec<_>>(),
lambda,
false,
)
}
pub fn select_features(rows: &[GapRow], keep: &[usize]) -> Vec<GapRow> {
rows.iter()
.map(|r| GapRow {
detector: r.detector.clone(),
class: r.class,
seed: r.seed,
features: keep
.iter()
.filter_map(|&k| r.features.get(k).copied())
.collect(),
gap: r.gap,
})
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CvAxis {
Detector,
Class,
}
pub fn permutation_pvalue(
rows: &[GapRow],
lambda: f64,
axis: CvAxis,
n_perms: usize,
seed: u64,
) -> f64 {
let samples: Vec<GapSample> = rows.iter().map(gaprow_to_sample).collect();
permutation_samples(&samples, lambda, axis == CvAxis::Detector, n_perms, seed)
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ProbeRecord {
pub detector: String,
pub class: String,
pub shift_bin: String,
pub score: f64,
pub is_nominal: bool,
}
impl ProbeRecord {
pub fn new(
detector: impl Into<String>,
class: impl Into<String>,
shift_bin: impl Into<String>,
score: f64,
is_nominal: bool,
) -> Self {
Self {
detector: detector.into(),
class: class.into(),
shift_bin: shift_bin.into(),
score,
is_nominal,
}
}
}
pub fn build_real_gap_rows(
records: &[ProbeRecord],
id_bin: &str,
target_pfa: f64,
) -> Vec<GapSample> {
use std::collections::BTreeSet;
let detectors: BTreeSet<&str> = records.iter().map(|r| r.detector.as_str()).collect();
let bins: BTreeSet<&str> = records.iter().map(|r| r.shift_bin.as_str()).collect();
let classes: BTreeSet<&str> = records
.iter()
.filter(|r| !r.is_nominal)
.map(|r| r.class.as_str())
.collect();
let scores = |det: &str, bin: &str, cls: Option<&str>| -> Vec<f64> {
records
.iter()
.filter(|r| {
r.detector == det
&& r.shift_bin == bin
&& match cls {
Some(c) => !r.is_nominal && r.class == c,
None => r.is_nominal,
}
})
.map(|r| r.score)
.collect()
};
let mut out = Vec::new();
for det in &detectors {
let neg_in = scores(det, id_bin, None);
if neg_in.is_empty() {
continue;
}
for cls in &classes {
let pos_in = scores(det, id_bin, Some(cls));
if pos_in.is_empty() {
continue;
}
let auc_in = auc(&pos_in, &neg_in);
let shifted: Vec<f64> = bins
.iter()
.filter(|b| **b != id_bin)
.filter_map(|b| {
let neg_b = scores(det, b, None);
let pos_b = scores(det, b, Some(cls));
if neg_b.is_empty() || pos_b.is_empty() {
None
} else {
Some(auc(&pos_b, &neg_b))
}
})
.collect();
if shifted.is_empty() {
continue;
}
let mean_shifted = shifted.iter().sum::<f64>() / shifted.len() as f64;
out.push(GapSample {
detector: det.to_string(),
class: cls.to_string(),
features: id_features_from_scores(&pos_in, &neg_in, target_pfa).to_vec(),
gap: auc_in - mean_shifted,
});
}
}
out
}
pub fn real_loocv(samples: &[GapSample], lambda: f64, axis: CvAxis) -> CvResult {
loocv_samples(samples, lambda, axis == CvAxis::Detector)
}
pub fn real_permutation_pvalue(
samples: &[GapSample],
lambda: f64,
axis: CvAxis,
n_perms: usize,
seed: u64,
) -> f64 {
permutation_samples(samples, lambda, axis == CvAxis::Detector, n_perms, seed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::impairment_eval::{
generate_corpus, stratified_split, CaseObservables, CorpusConfig,
};
use crate::impairment_ml::Mlp;
struct Oracle;
impl ImpairmentDetector for Oracle {
fn name(&self) -> &str {
"oracle"
}
fn score(&self, o: &CaseObservables) -> f64 {
o.cn0_drop_db
.max(o.agc_excess_db)
.max(o.sqm_el_metric.abs() * 100.0)
.max(o.parity_stat)
}
}
#[test]
fn auc_per_class_oracle_is_near_one_and_learned_gap_is_positive() {
let clean = generate_corpus(
&CorpusConfig {
n_per_class: 150,
meas_noise: 0.0,
..Default::default()
},
2,
);
for class in ImpairmentClass::impaired() {
let a = auc_per_class(&Oracle, &clean, class);
assert!(a > 0.95, "oracle per-class AUC {a} for {}", class.label());
}
let id = generate_corpus(
&CorpusConfig {
n_per_class: 300,
..Default::default()
},
5,
);
let split = stratified_split(&id, 0.7, 5);
assert!(
!split.near_duplicate_leakage(1e-6),
"train/test must be a genuine generalisation split"
);
let mlp = Mlp::fit(&split.train, 12, 1500, 0.1, 9);
let ood = generate_corpus(
&CorpusConfig {
n_per_class: 300,
severity_scale: 0.3,
..Default::default()
},
6,
);
let mean_gap = ImpairmentClass::impaired()
.iter()
.map(|&c| optimism_gap(&mlp, &split.test, &ood, c))
.sum::<f64>()
/ 4.0;
assert!(
mean_gap > 0.0,
"mean optimism gap {mean_gap} should be positive"
);
}
fn small_grid_config() -> GridConfig {
GridConfig {
n_per_class: 120,
frac_train: 0.7,
severities: vec![0.3, 0.6, 0.9],
seeds: vec![1, 2, 3],
target_pfa: 0.05,
bootstrap_resamples: 500,
bootstrap_alpha: 0.05,
logreg_epochs: 300,
logreg_lr: 0.3,
mlp_hidden_sizes: vec![4, 8],
mlp_epochs: 500,
mlp_lr: 0.1,
}
}
fn roster_size(cfg: &GridConfig) -> usize {
5 + 1 + cfg.mlp_hidden_sizes.len() + 3
}
#[test]
fn run_grid_has_shape_brackets_mean_and_shows_trend() {
let cfg = small_grid_config();
let res = run_grid(&cfg);
let n_det = res.detectors.len();
let n_class = ImpairmentClass::impaired().len();
let n_sev = cfg.severities.len();
assert_eq!(res.cells.len(), n_det * n_class * n_sev, "cell count");
assert_eq!(res.trends.len(), n_det * n_class, "trend count");
assert_eq!(
n_det,
roster_size(&cfg),
"physics + learned + control roster"
);
for cell in &res.cells {
assert_eq!(cell.gaps.len(), cfg.seeds.len(), "one gap per seed");
assert!(cell.gaps.iter().all(|g| g.is_finite()), "no NaN gaps");
assert!(
cell.boot_lo <= cell.mean_gap + 1e-9 && cell.mean_gap <= cell.boot_hi + 1e-9,
"cell CI [{}, {}] must bracket mean {}",
cell.boot_lo,
cell.boot_hi,
cell.mean_gap
);
assert!(cell.seed_se >= 0.0, "across-seed SE is non-negative");
}
assert!(
res.trends
.iter()
.any(|t| t.detector.starts_with("mlp") && t.spearman_rho > 0.0),
"an MLP should show a positive optimism-vs-severity trend on some class"
);
}
fn predictor_config() -> PredictorConfig {
PredictorConfig {
grid: small_grid_config(),
include_self_slope: true,
probe_scales: vec![0.8, 0.9, 1.0],
ridge_lambda: 0.1,
}
}
#[test]
fn id_features_are_finite_and_sized() {
let corpus = generate_corpus(
&CorpusConfig {
n_per_class: 150,
..Default::default()
},
4,
);
let f = id_features(
&crate::impairment_eval::FusedDetector,
&corpus,
ImpairmentClass::Jamming,
0.05,
);
assert_eq!(f.len(), 6);
assert!(f.iter().all(|v| v.is_finite()), "features {f:?}");
assert!(f[0] > 0.8, "auc_in feature {} should be high", f[0]);
}
#[test]
fn gap_predictor_beats_mean_across_held_out_detectors_and_classes() {
let pc = predictor_config();
let rows = build_gap_rows(&pc);
let n_det = roster_size(&pc.grid);
let expected = n_det * ImpairmentClass::impaired().len() * pc.grid.seeds.len();
assert_eq!(rows.len(), expected, "one row per (detector, class, seed)");
assert!(
rows.iter()
.all(|r| r.features.len() == 7 && r.gap.is_finite()),
"6 ID features + self-slope, finite gaps"
);
let by_det = loocv_by_detector(&rows, pc.ridge_lambda);
let by_class = loocv_by_class(&rows, pc.ridge_lambda);
assert!(
by_det.r2 > 0.0,
"LOO-by-detector R² {} must beat predict-the-mean",
by_det.r2
);
assert!(
by_class.r2 > 0.0,
"LOO-by-class R² {} must beat predict-the-mean",
by_class.r2
);
assert_eq!(by_det.n_folds, n_det);
assert_eq!(by_class.n_folds, ImpairmentClass::impaired().len());
let rows2 = build_gap_rows(&pc);
let by_det2 = loocv_by_detector(&rows2, pc.ridge_lambda);
assert_eq!(
by_det.r2.to_bits(),
by_det2.r2.to_bits(),
"predictor pipeline must be reproducible"
);
let predictor = fit_gap_predictor(&rows, pc.ridge_lambda);
assert!(predictor.predict(&rows[0].features).is_finite());
}
#[test]
fn shape_only_features_and_permutation_null_behave() {
let pc = predictor_config();
let rows = build_gap_rows(&pc);
let shape = select_features(&rows, &[1, 2, 3, 4, 5, 6]);
assert_eq!(shape[0].features.len(), rows[0].features.len() - 1);
let r2a = loocv_by_class(&shape, pc.ridge_lambda).r2;
let r2b = loocv_by_class(
&select_features(&rows, &[1, 2, 3, 4, 5, 6]),
pc.ridge_lambda,
)
.r2;
assert!(r2a.is_finite() && r2a.to_bits() == r2b.to_bits());
let p = permutation_pvalue(&rows, pc.ridge_lambda, CvAxis::Class, 100, 7);
let p2 = permutation_pvalue(&rows, pc.ridge_lambda, CvAxis::Class, 100, 7);
assert_eq!(
p.to_bits(),
p2.to_bits(),
"permutation p must be reproducible"
);
assert!(
(1.0 / 101.0..=1.0).contains(&p),
"p {p} must be a probability"
);
assert!(
p < 0.5,
"real cross-class R² should beat most permutations (p={p})"
);
}
#[test]
fn real_data_probe_ingests_records_and_runs_the_pipeline() {
let id_corpus = generate_corpus(
&CorpusConfig {
n_per_class: 200,
..Default::default()
},
7,
);
let probe_dets: Vec<(&str, Box<dyn ImpairmentDetector>)> = vec![
("energy", Box::new(EnergyDetector)),
("agc", Box::new(AgcDetector)),
("sqm", Box::new(SqmDetector)),
("parity", Box::new(ParityDetector)),
("fused", Box::new(FusedDetector)),
(
"logreg",
Box::new(LogisticRegression::fit(&id_corpus, 400, 0.3)),
),
("mlp", Box::new(Mlp::fit(&id_corpus, 16, 800, 0.1, 1))),
];
let bins = [(1.0_f64, "id"), (0.6, "s060"), (0.3, "s030")];
let mut records = Vec::new();
for (s, bin) in bins {
let corpus = generate_corpus(
&CorpusConfig {
n_per_class: 200,
severity_scale: s,
..Default::default()
},
7,
);
for (name, d) in &probe_dets {
for case in &corpus {
records.push(ProbeRecord {
detector: name.to_string(),
class: case.class.label().to_string(),
shift_bin: bin.to_string(),
score: d.score(&case.obs),
is_nominal: !case.is_impaired(),
});
}
}
}
let samples = build_real_gap_rows(&records, "id", 0.05);
assert_eq!(
samples.len(),
7 * 4,
"one sample per (detector, impaired class)"
);
assert!(samples
.iter()
.all(|s| s.features.len() == 6 && s.gap.is_finite()));
let mean_gap = samples.iter().map(|s| s.gap).sum::<f64>() / samples.len() as f64;
assert!(
mean_gap > 0.0,
"mean real-ingest gap {mean_gap} should be positive"
);
let by_class = real_loocv(&samples, 0.1, CvAxis::Class);
assert!(by_class.r2.is_finite() && by_class.n_folds == 4);
let again = real_loocv(
&build_real_gap_rows(&records, "id", 0.05),
0.1,
CvAxis::Class,
);
assert_eq!(
by_class.r2.to_bits(),
again.r2.to_bits(),
"ingest pipeline must be reproducible"
);
let p = real_permutation_pvalue(&samples, 0.1, CvAxis::Class, 200, 7);
assert!(
(1.0 / 201.0..=1.0).contains(&p),
"permutation p {p} must be a probability"
);
}
}