12,126 lines of new Rust code across 22 modules with 285 tests: ADR-029 RuvSense Core (signal crate, 10 modules): - multiband.rs: Multi-band CSI frame fusion from channel hopping - phase_align.rs: Cross-channel LO phase rotation correction - multistatic.rs: Attention-weighted cross-node viewpoint fusion - coherence.rs: Z-score per-subcarrier coherence scoring - coherence_gate.rs: Accept/PredictOnly/Reject/Recalibrate gating - pose_tracker.rs: 17-keypoint Kalman tracker with re-ID - mod.rs: Pipeline orchestrator ADR-030 Persistent Field Model (signal crate, 7 modules): - field_model.rs: SVD-based room eigenstructure, Welford stats - tomography.rs: Coarse RF tomography from link attenuations (ISTA) - longitudinal.rs: Personal baseline drift detection over days - intention.rs: Pre-movement prediction (200-500ms lead signals) - cross_room.rs: Cross-room identity continuity - gesture.rs: Gesture classification via DTW template matching - adversarial.rs: Physically impossible signal detection ADR-031 RuView (ruvector crate, 5 modules): - attention.rs: Scaled dot-product with geometric bias - geometry.rs: Geometric Diversity Index, Cramer-Rao bounds - coherence.rs: Phase phasor coherence gating - fusion.rs: MultistaticArray aggregate, fusion orchestrator - mod.rs: Module exports Training & Hardware: - ruview_metrics.rs: 3-metric acceptance test (PCK/OKS, MOTA, vitals) - esp32/tdm.rs: TDM sensing protocol, sync beacons, drift compensation - Firmware: channel hopping, NDP injection, NVS config extensions Security fixes: - field_model.rs: saturating_sub prevents timestamp underflow - longitudinal.rs: FIFO eviction note for bounded buffer README updated with RuvSense section, new feature badges, changelog v3.1.0. Co-Authored-By: claude-flow <ruv@ruv.net>
587 lines
19 KiB
Rust
587 lines
19 KiB
Rust
//! Adversarial detection: physically impossible signal identification.
|
|
//!
|
|
//! Detects spoofed or injected WiFi signals by checking multi-link
|
|
//! consistency, field model constraint violations, and physical
|
|
//! plausibility. A single-link injection cannot fool a multistatic
|
|
//! mesh because it would violate geometric constraints across links.
|
|
//!
|
|
//! # Checks
|
|
//! 1. **Multi-link consistency**: A real body perturbs all links that
|
|
//! traverse its location. An injection affects only the targeted link.
|
|
//! 2. **Field model constraints**: Perturbation must be consistent with
|
|
//! the room's eigenmode structure.
|
|
//! 3. **Temporal continuity**: Real movement is smooth; injections cause
|
|
//! discontinuities in embedding space.
|
|
//! 4. **Energy conservation**: Total perturbation energy across links
|
|
//! must be consistent with the number and size of bodies present.
|
|
//!
|
|
//! # References
|
|
//! - ADR-030 Tier 7: Adversarial Detection
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Errors from adversarial detection.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AdversarialError {
|
|
/// Insufficient links for multi-link consistency check.
|
|
#[error("Insufficient links: need >= {needed}, got {got}")]
|
|
InsufficientLinks { needed: usize, got: usize },
|
|
|
|
/// Dimension mismatch.
|
|
#[error("Dimension mismatch: expected {expected}, got {got}")]
|
|
DimensionMismatch { expected: usize, got: usize },
|
|
|
|
/// No baseline available for constraint checking.
|
|
#[error("No baseline available — calibrate field model first")]
|
|
NoBaseline,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Configuration for adversarial detection.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AdversarialConfig {
|
|
/// Number of links in the mesh.
|
|
pub n_links: usize,
|
|
/// Minimum links for multi-link consistency (default 4).
|
|
pub min_links: usize,
|
|
/// Consistency threshold: fraction of links that must agree (0.0-1.0).
|
|
pub consistency_threshold: f64,
|
|
/// Maximum allowed energy ratio between any single link and total.
|
|
pub max_single_link_energy_ratio: f64,
|
|
/// Maximum allowed temporal discontinuity in embedding space.
|
|
pub max_temporal_discontinuity: f64,
|
|
/// Maximum allowed perturbation energy per body.
|
|
pub max_energy_per_body: f64,
|
|
}
|
|
|
|
impl Default for AdversarialConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
n_links: 12,
|
|
min_links: 4,
|
|
consistency_threshold: 0.6,
|
|
max_single_link_energy_ratio: 0.5,
|
|
max_temporal_discontinuity: 5.0,
|
|
max_energy_per_body: 100.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Detection results
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Type of adversarial anomaly detected.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AnomalyType {
|
|
/// Single link shows perturbation inconsistent with other links.
|
|
SingleLinkInjection,
|
|
/// Perturbation violates field model eigenmode structure.
|
|
FieldModelViolation,
|
|
/// Sudden discontinuity in embedding trajectory.
|
|
TemporalDiscontinuity,
|
|
/// Total perturbation energy inconsistent with occupancy.
|
|
EnergyViolation,
|
|
/// Multiple anomaly types detected simultaneously.
|
|
MultipleViolations,
|
|
}
|
|
|
|
impl AnomalyType {
|
|
/// Human-readable name.
|
|
pub fn name(&self) -> &'static str {
|
|
match self {
|
|
AnomalyType::SingleLinkInjection => "single_link_injection",
|
|
AnomalyType::FieldModelViolation => "field_model_violation",
|
|
AnomalyType::TemporalDiscontinuity => "temporal_discontinuity",
|
|
AnomalyType::EnergyViolation => "energy_violation",
|
|
AnomalyType::MultipleViolations => "multiple_violations",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of adversarial detection on one frame.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AdversarialResult {
|
|
/// Whether any anomaly was detected.
|
|
pub anomaly_detected: bool,
|
|
/// Type of anomaly (if detected).
|
|
pub anomaly_type: Option<AnomalyType>,
|
|
/// Anomaly score (0.0 = clean, 1.0 = definitely adversarial).
|
|
pub anomaly_score: f64,
|
|
/// Per-check results.
|
|
pub checks: CheckResults,
|
|
/// Affected link indices (if single-link injection).
|
|
pub affected_links: Vec<usize>,
|
|
/// Timestamp (microseconds).
|
|
pub timestamp_us: u64,
|
|
}
|
|
|
|
/// Results of individual checks.
|
|
#[derive(Debug, Clone)]
|
|
pub struct CheckResults {
|
|
/// Multi-link consistency score (0.0 = inconsistent, 1.0 = fully consistent).
|
|
pub consistency_score: f64,
|
|
/// Field model residual score (lower = more consistent with modes).
|
|
pub field_model_residual: f64,
|
|
/// Temporal continuity score (lower = smoother).
|
|
pub temporal_continuity: f64,
|
|
/// Energy conservation score (closer to 1.0 = consistent).
|
|
pub energy_ratio: f64,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Adversarial detector
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Adversarial signal detector for the multistatic mesh.
|
|
///
|
|
/// Checks each frame for physical plausibility across multiple
|
|
/// independent criteria. A spoofed signal that passes one check
|
|
/// is unlikely to pass all of them.
|
|
#[derive(Debug)]
|
|
pub struct AdversarialDetector {
|
|
config: AdversarialConfig,
|
|
/// Previous frame's per-link energies (for temporal continuity).
|
|
prev_energies: Option<Vec<f64>>,
|
|
/// Previous frame's total energy.
|
|
prev_total_energy: Option<f64>,
|
|
/// Total frames processed.
|
|
total_frames: u64,
|
|
/// Total anomalies detected.
|
|
anomaly_count: u64,
|
|
}
|
|
|
|
impl AdversarialDetector {
|
|
/// Create a new adversarial detector.
|
|
pub fn new(config: AdversarialConfig) -> Result<Self, AdversarialError> {
|
|
if config.n_links < config.min_links {
|
|
return Err(AdversarialError::InsufficientLinks {
|
|
needed: config.min_links,
|
|
got: config.n_links,
|
|
});
|
|
}
|
|
Ok(Self {
|
|
config,
|
|
prev_energies: None,
|
|
prev_total_energy: None,
|
|
total_frames: 0,
|
|
anomaly_count: 0,
|
|
})
|
|
}
|
|
|
|
/// Check a frame for adversarial anomalies.
|
|
///
|
|
/// `link_energies`: per-link perturbation energy (from field model).
|
|
/// `n_bodies`: estimated number of bodies present.
|
|
/// `timestamp_us`: frame timestamp.
|
|
pub fn check(
|
|
&mut self,
|
|
link_energies: &[f64],
|
|
n_bodies: usize,
|
|
timestamp_us: u64,
|
|
) -> Result<AdversarialResult, AdversarialError> {
|
|
if link_energies.len() != self.config.n_links {
|
|
return Err(AdversarialError::DimensionMismatch {
|
|
expected: self.config.n_links,
|
|
got: link_energies.len(),
|
|
});
|
|
}
|
|
|
|
self.total_frames += 1;
|
|
|
|
let total_energy: f64 = link_energies.iter().sum();
|
|
|
|
// Check 1: Multi-link consistency
|
|
let consistency = self.check_consistency(link_energies, total_energy);
|
|
|
|
// Check 2: Field model residual (simplified — check energy distribution)
|
|
let field_residual = self.check_field_model(link_energies, total_energy);
|
|
|
|
// Check 3: Temporal continuity
|
|
let temporal = self.check_temporal(link_energies, total_energy);
|
|
|
|
// Check 4: Energy conservation
|
|
let energy_ratio = self.check_energy(total_energy, n_bodies);
|
|
|
|
// Store for next frame
|
|
self.prev_energies = Some(link_energies.to_vec());
|
|
self.prev_total_energy = Some(total_energy);
|
|
|
|
let checks = CheckResults {
|
|
consistency_score: consistency,
|
|
field_model_residual: field_residual,
|
|
temporal_continuity: temporal,
|
|
energy_ratio,
|
|
};
|
|
|
|
// Aggregate anomaly score
|
|
let mut violations = Vec::new();
|
|
|
|
if consistency < self.config.consistency_threshold {
|
|
violations.push(AnomalyType::SingleLinkInjection);
|
|
}
|
|
if field_residual > 0.8 {
|
|
violations.push(AnomalyType::FieldModelViolation);
|
|
}
|
|
if temporal > self.config.max_temporal_discontinuity {
|
|
violations.push(AnomalyType::TemporalDiscontinuity);
|
|
}
|
|
if energy_ratio > 2.0 || (n_bodies > 0 && energy_ratio < 0.1) {
|
|
violations.push(AnomalyType::EnergyViolation);
|
|
}
|
|
|
|
let anomaly_detected = !violations.is_empty();
|
|
let anomaly_type = match violations.len() {
|
|
0 => None,
|
|
1 => Some(violations[0]),
|
|
_ => Some(AnomalyType::MultipleViolations),
|
|
};
|
|
|
|
// Score: weighted combination
|
|
let anomaly_score = ((1.0 - consistency) * 0.4
|
|
+ field_residual * 0.2
|
|
+ (temporal / self.config.max_temporal_discontinuity).min(1.0) * 0.2
|
|
+ ((energy_ratio - 1.0).abs() / 2.0).min(1.0) * 0.2)
|
|
.clamp(0.0, 1.0);
|
|
|
|
// Find affected links (highest single-link energy ratio)
|
|
let affected_links = if anomaly_detected {
|
|
self.find_anomalous_links(link_energies, total_energy)
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
if anomaly_detected {
|
|
self.anomaly_count += 1;
|
|
}
|
|
|
|
Ok(AdversarialResult {
|
|
anomaly_detected,
|
|
anomaly_type,
|
|
anomaly_score,
|
|
checks,
|
|
affected_links,
|
|
timestamp_us,
|
|
})
|
|
}
|
|
|
|
/// Multi-link consistency: what fraction of links have correlated energy?
|
|
///
|
|
/// A real body perturbs many links. An injection affects few.
|
|
fn check_consistency(&self, energies: &[f64], total: f64) -> f64 {
|
|
if total < 1e-15 {
|
|
return 1.0; // No perturbation = consistent (empty room)
|
|
}
|
|
|
|
let mean = total / energies.len() as f64;
|
|
let threshold = mean * 0.1; // link must have at least 10% of mean energy
|
|
|
|
let active_count = energies.iter().filter(|&&e| e > threshold).count();
|
|
active_count as f64 / energies.len() as f64
|
|
}
|
|
|
|
/// Field model check: is energy distribution consistent with physical propagation?
|
|
///
|
|
/// In a real scenario, energy should be distributed across links
|
|
/// based on geometry. A concentrated injection scores high residual.
|
|
fn check_field_model(&self, energies: &[f64], total: f64) -> f64 {
|
|
if total < 1e-15 {
|
|
return 0.0;
|
|
}
|
|
|
|
// Compute Gini coefficient of energy distribution
|
|
// Gini = 0 → perfectly uniform, Gini = 1 → all in one link
|
|
let n = energies.len() as f64;
|
|
let mut sorted: Vec<f64> = energies.to_vec();
|
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
|
|
let numerator: f64 = sorted
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, &x)| (2.0 * (i + 1) as f64 - n - 1.0) * x)
|
|
.sum();
|
|
|
|
let gini = numerator / (n * total);
|
|
gini.clamp(0.0, 1.0)
|
|
}
|
|
|
|
/// Temporal continuity: how much did per-link energies change from previous frame?
|
|
fn check_temporal(&self, energies: &[f64], _total: f64) -> f64 {
|
|
match &self.prev_energies {
|
|
None => 0.0, // First frame, no temporal check
|
|
Some(prev) => {
|
|
let diff_energy: f64 = energies
|
|
.iter()
|
|
.zip(prev.iter())
|
|
.map(|(&a, &b)| (a - b) * (a - b))
|
|
.sum::<f64>()
|
|
.sqrt();
|
|
diff_energy
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Energy conservation: is total energy consistent with body count?
|
|
fn check_energy(&self, total_energy: f64, n_bodies: usize) -> f64 {
|
|
if n_bodies == 0 {
|
|
// No bodies: any energy is suspicious
|
|
return if total_energy > 1e-10 {
|
|
total_energy
|
|
} else {
|
|
0.0
|
|
};
|
|
}
|
|
let expected = n_bodies as f64 * self.config.max_energy_per_body;
|
|
if expected < 1e-15 {
|
|
return 0.0;
|
|
}
|
|
total_energy / expected
|
|
}
|
|
|
|
/// Find links that are anomalously high relative to the mean.
|
|
fn find_anomalous_links(&self, energies: &[f64], total: f64) -> Vec<usize> {
|
|
if total < 1e-15 {
|
|
return Vec::new();
|
|
}
|
|
|
|
energies
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, &e)| e / total > self.config.max_single_link_energy_ratio)
|
|
.map(|(i, _)| i)
|
|
.collect()
|
|
}
|
|
|
|
/// Total frames processed.
|
|
pub fn total_frames(&self) -> u64 {
|
|
self.total_frames
|
|
}
|
|
|
|
/// Total anomalies detected.
|
|
pub fn anomaly_count(&self) -> u64 {
|
|
self.anomaly_count
|
|
}
|
|
|
|
/// Anomaly rate (anomalies / total frames).
|
|
pub fn anomaly_rate(&self) -> f64 {
|
|
if self.total_frames == 0 {
|
|
0.0
|
|
} else {
|
|
self.anomaly_count as f64 / self.total_frames as f64
|
|
}
|
|
}
|
|
|
|
/// Reset detector state.
|
|
pub fn reset(&mut self) {
|
|
self.prev_energies = None;
|
|
self.prev_total_energy = None;
|
|
self.total_frames = 0;
|
|
self.anomaly_count = 0;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn default_config() -> AdversarialConfig {
|
|
AdversarialConfig {
|
|
n_links: 6,
|
|
min_links: 4,
|
|
consistency_threshold: 0.6,
|
|
max_single_link_energy_ratio: 0.5,
|
|
max_temporal_discontinuity: 5.0,
|
|
max_energy_per_body: 10.0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_detector_creation() {
|
|
let det = AdversarialDetector::new(default_config()).unwrap();
|
|
assert_eq!(det.total_frames(), 0);
|
|
assert_eq!(det.anomaly_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_insufficient_links() {
|
|
let config = AdversarialConfig {
|
|
n_links: 2,
|
|
min_links: 4,
|
|
..default_config()
|
|
};
|
|
assert!(matches!(
|
|
AdversarialDetector::new(config),
|
|
Err(AdversarialError::InsufficientLinks { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_frame_no_anomaly() {
|
|
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
|
|
|
// Uniform energy across all links (real body)
|
|
let energies = vec![1.0, 1.1, 0.9, 1.0, 1.05, 0.95];
|
|
let result = det.check(&energies, 1, 0).unwrap();
|
|
|
|
assert!(
|
|
!result.anomaly_detected,
|
|
"Uniform energy should not trigger anomaly"
|
|
);
|
|
assert!(result.anomaly_score < 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_single_link_injection_detected() {
|
|
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
|
|
|
// All energy on one link (injection)
|
|
let energies = vec![10.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
|
let result = det.check(&energies, 0, 0).unwrap();
|
|
|
|
assert!(
|
|
result.anomaly_detected,
|
|
"Single-link injection should be detected"
|
|
);
|
|
assert!(result.affected_links.contains(&0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_room_no_anomaly() {
|
|
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
|
|
|
let energies = vec![0.0; 6];
|
|
let result = det.check(&energies, 0, 0).unwrap();
|
|
|
|
assert!(!result.anomaly_detected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_temporal_discontinuity() {
|
|
let mut det = AdversarialDetector::new(AdversarialConfig {
|
|
max_temporal_discontinuity: 1.0, // strict
|
|
..default_config()
|
|
})
|
|
.unwrap();
|
|
|
|
// Frame 1: low energy
|
|
let energies1 = vec![0.1; 6];
|
|
det.check(&energies1, 0, 0).unwrap();
|
|
|
|
// Frame 2: sudden massive energy (discontinuity)
|
|
let energies2 = vec![100.0; 6];
|
|
let result = det.check(&energies2, 0, 50_000).unwrap();
|
|
|
|
assert!(
|
|
result.anomaly_detected,
|
|
"Temporal discontinuity should be detected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_energy_violation_too_high() {
|
|
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
|
|
|
// Way more energy than 1 body should produce
|
|
let energies = vec![100.0; 6]; // total = 600, max_per_body = 10
|
|
let result = det.check(&energies, 1, 0).unwrap();
|
|
|
|
assert!(
|
|
result.anomaly_detected,
|
|
"Excessive energy should trigger anomaly"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_dimension_mismatch() {
|
|
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
|
let result = det.check(&[1.0, 2.0], 0, 0);
|
|
assert!(matches!(
|
|
result,
|
|
Err(AdversarialError::DimensionMismatch { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_anomaly_rate() {
|
|
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
|
|
|
// 2 clean frames
|
|
det.check(&vec![1.0; 6], 1, 0).unwrap();
|
|
det.check(&vec![1.0; 6], 1, 50_000).unwrap();
|
|
|
|
// 1 anomalous frame
|
|
det.check(&vec![10.0, 0.0, 0.0, 0.0, 0.0, 0.0], 0, 100_000)
|
|
.unwrap();
|
|
|
|
assert_eq!(det.total_frames(), 3);
|
|
assert!(det.anomaly_count() >= 1);
|
|
assert!(det.anomaly_rate() > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_reset() {
|
|
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
|
det.check(&vec![1.0; 6], 1, 0).unwrap();
|
|
det.reset();
|
|
|
|
assert_eq!(det.total_frames(), 0);
|
|
assert_eq!(det.anomaly_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_anomaly_type_names() {
|
|
assert_eq!(
|
|
AnomalyType::SingleLinkInjection.name(),
|
|
"single_link_injection"
|
|
);
|
|
assert_eq!(
|
|
AnomalyType::FieldModelViolation.name(),
|
|
"field_model_violation"
|
|
);
|
|
assert_eq!(
|
|
AnomalyType::TemporalDiscontinuity.name(),
|
|
"temporal_discontinuity"
|
|
);
|
|
assert_eq!(AnomalyType::EnergyViolation.name(), "energy_violation");
|
|
assert_eq!(
|
|
AnomalyType::MultipleViolations.name(),
|
|
"multiple_violations"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gini_coefficient_uniform() {
|
|
let det = AdversarialDetector::new(default_config()).unwrap();
|
|
let energies = vec![1.0; 6];
|
|
let total = 6.0;
|
|
let gini = det.check_field_model(&energies, total);
|
|
assert!(
|
|
gini < 0.1,
|
|
"Uniform distribution should have low Gini: {}",
|
|
gini
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gini_coefficient_concentrated() {
|
|
let det = AdversarialDetector::new(default_config()).unwrap();
|
|
let energies = vec![6.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
|
let total = 6.0;
|
|
let gini = det.check_field_model(&energies, total);
|
|
assert!(
|
|
gini > 0.5,
|
|
"Concentrated distribution should have high Gini: {}",
|
|
gini
|
|
);
|
|
}
|
|
}
|