fix(calibration): NaN-poisoning silently disabled presence specialist (Features::from_series unguarded) + de-magic (#1077)

* fix(calibration): drop non-finite samples in Features::from_series (ADR-151)

A single NaN/inf scalar sample (corrupt CSI frame) poisoned mean/variance
into NaN, which — baked into a persisted PresenceSpecialist::threshold —
silently disabled presence detection (every `f.variance > NaN` is false),
no error raised. extract.rs is the live-inference + training feature path,
yet (unlike geometry_embedding.rs) had no non-finite guard.

Fix at the production boundary: filter non-finite samples before computing
any statistic; an all-non-finite series degrades to Features::ZERO, same as
the empty series. Value-identical for all-finite input (full_loop + existing
extract tests unchanged). Pinned by two fails-on-old tests.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(calibration): de-magic specialist thresholds to named consts (ADR-151)

Promote the bare default min-score literals (breathing 0.25, heartbeat 0.3)
and the anomaly score scale / label cutoff (2.0× spread, > 0.5) to documented
named consts. Value-identical — pinned by characterization tests asserting the
consts equal the prior literals and the gate boundary (score >= floor).

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(calibration): record ADR-151 review — NaN fix + clean dimensions

CHANGELOG [Unreleased] Security entry and ADR-151 §6.1 review note for the
beyond-SOTA correctness+security review: NaN-poisoning fail-closed fix,
file/path (no I/O in crate), untrusted-load, receipt/hash (absent), and the
clean numerical paths — all with evidence.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv
2026-06-14 17:22:20 -04:00
committed by GitHub
parent db3d94a313
commit ebfaee4437
4 changed files with 164 additions and 19 deletions
@@ -43,6 +43,20 @@ pub struct Features {
pub const EMBED_MIN_SCORE: f32 = 0.25;
impl Features {
/// The all-zero feature vector — the well-defined result of an empty (or
/// wholly non-finite) capture. Total by construction: downstream
/// specialists read it as "no signal" rather than panicking or poisoning a
/// threshold (see [`Features::from_series`]).
pub const ZERO: Features = Features {
mean: 0.0,
variance: 0.0,
motion: 0.0,
breathing_score: 0.0,
breathing_hz: 0.0,
heart_score: 0.0,
heart_hz: 0.0,
};
/// A fixed-length numeric embedding for nearest-prototype classifiers.
///
/// The hz components are zeroed unless their periodicity score clears
@@ -77,29 +91,33 @@ impl Features {
}
/// Extract features from a per-frame scalar series sampled at `fs` Hz.
///
/// **Total / fail-closed:** non-finite samples (`NaN`/`±inf`) are dropped
/// before any statistic is computed, so a single garbage CSI frame cannot
/// poison `mean`/`variance` into `NaN` and silently disable a persisted
/// specialist (a `NaN` threshold makes every `>` comparison false). A
/// series with no finite samples yields [`Features::ZERO`], exactly like
/// the empty series. Same defensive contract as
/// [`GeometryEmbedding`](crate::geometry_embedding::GeometryEmbedding):
/// adversarial input degrades to "no signal", never to `NaN`.
pub fn from_series(series: &[f32], fs: f32) -> Features {
let n = series.len();
// Drop non-finite samples: a corrupt frame counts as no frame, not as
// a NaN that propagates through every downstream statistic.
let clean: Vec<f32> = series.iter().copied().filter(|v| v.is_finite()).collect();
let n = clean.len();
if n == 0 {
return Features {
mean: 0.0,
variance: 0.0,
motion: 0.0,
breathing_score: 0.0,
breathing_hz: 0.0,
heart_score: 0.0,
heart_hz: 0.0,
};
return Features::ZERO;
}
let mean = series.iter().copied().sum::<f32>() / n as f32;
let variance = series.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n as f32;
let mean = clean.iter().copied().sum::<f32>() / n as f32;
let variance = clean.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n as f32;
let motion = if n > 1 {
series.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f32>() / (n - 1) as f32
clean.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f32>() / (n - 1) as f32
} else {
0.0
};
// De-mean before periodicity search.
let centered: Vec<f32> = series.iter().map(|v| v - mean).collect();
let centered: Vec<f32> = clean.iter().map(|v| v - mean).collect();
let (breathing_hz, breathing_score) = autocorr_dominant(&centered, fs, 0.1, 0.6);
let (heart_hz, heart_score) = autocorr_dominant(&centered, fs, 0.8, 3.0);
@@ -254,6 +272,36 @@ mod tests {
assert_eq!(f.breathing_hz, 0.0);
}
/// Fail-closed regression: a NaN/inf in the scalar series (corrupt CSI
/// frame) must NOT poison the features into `NaN`/`inf`. Pre-fix, a single
/// `NaN` made `mean`/`variance` `NaN`, which — baked into a persisted
/// `PresenceSpecialist::threshold` — silently disabled presence detection
/// (every `f.variance > NaN` is false). Non-finite samples are dropped.
#[test]
fn non_finite_samples_do_not_poison_features() {
let f = Features::from_series(&[1.0, 2.0, f32::NAN, 4.0, f32::INFINITY, 6.0], 15.0);
assert!(f.mean.is_finite(), "mean must stay finite, got {}", f.mean);
assert!(f.variance.is_finite(), "variance must stay finite, got {}", f.variance);
assert!(f.motion.is_finite(), "motion must stay finite, got {}", f.motion);
for x in f.embedding() {
assert!(x.is_finite(), "embedding slot non-finite: {x}");
}
// Mean is over the 4 finite samples {1,2,4,6} only.
assert!((f.mean - 3.25).abs() < 1e-5, "mean over finite samples, got {}", f.mean);
// Equivalence: dropping the non-finite samples must equal feeding only
// the finite ones — proves the filter, not just finiteness.
let only_finite = Features::from_series(&[1.0, 2.0, 4.0, 6.0], 15.0);
assert_eq!(f, only_finite);
}
/// A series with no finite samples degrades to the all-zero `ZERO`, exactly
/// like the empty series — never `NaN`.
#[test]
fn all_non_finite_series_is_zero() {
let f = Features::from_series(&[f32::NAN, f32::INFINITY, f32::NEG_INFINITY], 15.0);
assert_eq!(f, Features::ZERO);
}
/// ADR-152 "heart-band leakage" regression: a strong breathing rhythm must
/// NOT register as a heart-band periodicity — its in-band autocorr maximum
/// sits at the band edge (monotonic leak), not an interior peak.
@@ -15,6 +15,28 @@ use serde::{Deserialize, Serialize};
use crate::anchor::{AnchorLabel, Posture};
use crate::extract::{AnchorFeature, Features};
/// Default minimum breathing-band periodicity score to report a rate, used when
/// a [`BreathingSpecialist`] carries no explicit `min_score` (the serde / pre-
/// trained-default case). Respiration is a strong, narrowband modulation, so a
/// moderate floor rejects noise windows without dropping real breaths.
pub const DEFAULT_BREATHING_MIN_SCORE: f32 = 0.25;
/// Default minimum HR-band periodicity score, used when a [`HeartbeatSpecialist`]
/// carries no explicit `min_score`. Higher than breathing's: sub-mm chest
/// displacement at HR frequencies sits near the CSI noise floor (ADR-151 §3.2),
/// so the heartbeat head demands a cleaner peak before reporting.
pub const DEFAULT_HEARTBEAT_MIN_SCORE: f32 = 0.3;
/// Multiple of the typical inter-anchor spread ([`AnomalySpecialist::scale`])
/// beyond which a live window is fully out-of-distribution (anomaly score 1.0):
/// a window more than this many spreads from every enrolled prototype is novel.
pub const ANOMALY_OUTLIER_SPREADS: f32 = 2.0;
/// Anomaly score above which the window is *labelled* "anomalous" (vs "normal").
/// Distinct from the runtime veto threshold ([`crate::runtime`]); this only
/// drives the human-readable label.
pub const ANOMALY_LABEL_CUTOFF: f32 = 0.5;
/// Which biological signal a specialist estimates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpecialistKind {
@@ -229,7 +251,7 @@ impl Specialist for BreathingSpecialist {
let min = if self.min_score > 0.0 {
self.min_score
} else {
0.25
DEFAULT_BREATHING_MIN_SCORE
};
if f.breathing_score < min || f.breathing_hz <= 0.0 {
return None;
@@ -258,7 +280,7 @@ impl Specialist for HeartbeatSpecialist {
let min = if self.min_score > 0.0 {
self.min_score
} else {
0.3
DEFAULT_HEARTBEAT_MIN_SCORE
};
if f.heart_score < min || f.heart_hz <= 0.0 {
return None;
@@ -383,13 +405,13 @@ impl Specialist for AnomalySpecialist {
.sqrt();
best = best.min(d);
}
// >2× the typical spread → anomalous.
let score = (best / (2.0 * self.scale)).clamp(0.0, 1.0);
// Beyond ANOMALY_OUTLIER_SPREADS× the typical spread → fully anomalous.
let score = (best / (ANOMALY_OUTLIER_SPREADS * self.scale)).clamp(0.0, 1.0);
Some(SpecialistReading {
kind: SpecialistKind::Anomaly,
value: score,
confidence: 0.6,
label: Some(if score > 0.5 { "anomalous" } else { "normal" }.into()),
label: Some(if score > ANOMALY_LABEL_CUTOFF { "anomalous" } else { "normal" }.into()),
})
}
}
@@ -505,6 +527,32 @@ mod tests {
assert!(b.infer(&feat(5.0, 0.2, 0.3, 0.1)).is_none()); // low score → none
}
/// De-magic pin: the named default min-scores must equal the historical
/// literal values, and the gate boundary must be `score >= min` (a window
/// exactly at the default floor reports; a hair below does not).
#[test]
fn default_min_score_constants_match_prior_literals() {
assert_eq!(DEFAULT_BREATHING_MIN_SCORE, 0.25);
assert_eq!(DEFAULT_HEARTBEAT_MIN_SCORE, 0.3);
let b = BreathingSpecialist::default(); // min_score = 0.0 → uses default
assert!(
b.infer(&feat(5.0, 0.2, 0.3, DEFAULT_BREATHING_MIN_SCORE)).is_some(),
"score exactly at the default floor must report"
);
assert!(
b.infer(&feat(5.0, 0.2, 0.3, DEFAULT_BREATHING_MIN_SCORE - 1e-3)).is_none(),
"score below the default floor must not report"
);
}
/// De-magic pin for the anomaly score scale + label cutoff (value-identical
/// to the prior `2.0 * scale` / `> 0.5` literals).
#[test]
fn anomaly_constants_match_prior_literals() {
assert_eq!(ANOMALY_OUTLIER_SPREADS, 2.0);
assert_eq!(ANOMALY_LABEL_CUTOFF, 0.5);
}
#[test]
fn restlessness_normalizes() {
let anchors = vec![