feat: implement ADR-029/030/031 — RuvSense multistatic sensing + field model + RuView fusion
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>
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user