mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
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:
@@ -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(¢ered, fs, 0.1, 0.6);
|
||||
let (heart_hz, heart_score) = autocorr_dominant(¢ered, 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![
|
||||
|
||||
Reference in New Issue
Block a user