|
| 1 | +//! Behavioral profiling with Mahalanobis-inspired anomaly scoring. |
| 2 | +//! |
| 3 | +//! ADR-041 AI Security module. Maintains a 6D behavior profile and detects |
| 4 | +//! anomalous deviations using online Welford statistics and combined Z-scores. |
| 5 | +//! |
| 6 | +//! Dimensions: presence_rate, avg_motion, avg_n_persons, activity_variance, |
| 7 | +//! transition_rate, dwell_time. |
| 8 | +//! |
| 9 | +//! Events: BEHAVIOR_ANOMALY(825), PROFILE_DEVIATION(826), NOVEL_PATTERN(827), |
| 10 | +//! PROFILE_MATURITY(828). Budget: S (< 5 ms). |
| 11 | +
|
| 12 | +#[cfg(not(feature = "std"))] |
| 13 | +use libm::sqrtf; |
| 14 | +#[cfg(feature = "std")] |
| 15 | +fn sqrtf(x: f32) -> f32 { x.sqrt() } |
| 16 | + |
| 17 | +const N_DIM: usize = 6; |
| 18 | +const LEARNING_FRAMES: u32 = 1000; |
| 19 | +const ANOMALY_Z: f32 = 3.0; |
| 20 | +const NOVEL_Z: f32 = 2.0; |
| 21 | +const NOVEL_MIN: u32 = 3; |
| 22 | +const OBS_WIN: usize = 200; |
| 23 | +const COOLDOWN: u16 = 100; |
| 24 | +const MATURITY_INTERVAL: u32 = 72000; |
| 25 | +const VAR_FLOOR: f32 = 1e-6; |
| 26 | + |
| 27 | +pub const EVENT_BEHAVIOR_ANOMALY: i32 = 825; |
| 28 | +pub const EVENT_PROFILE_DEVIATION: i32 = 826; |
| 29 | +pub const EVENT_NOVEL_PATTERN: i32 = 827; |
| 30 | +pub const EVENT_PROFILE_MATURITY: i32 = 828; |
| 31 | + |
| 32 | +/// Welford's online mean/variance accumulator (single dimension). |
| 33 | +#[derive(Clone, Copy)] |
| 34 | +struct Welford { count: u32, mean: f32, m2: f32 } |
| 35 | +impl Welford { |
| 36 | + const fn new() -> Self { Self { count: 0, mean: 0.0, m2: 0.0 } } |
| 37 | + fn update(&mut self, x: f32) { |
| 38 | + self.count += 1; |
| 39 | + let d = x - self.mean; |
| 40 | + self.mean += d / (self.count as f32); |
| 41 | + self.m2 += d * (x - self.mean); |
| 42 | + } |
| 43 | + fn variance(&self) -> f32 { |
| 44 | + if self.count < 2 { 0.0 } else { self.m2 / (self.count as f32) } |
| 45 | + } |
| 46 | + fn z_score(&self, x: f32) -> f32 { |
| 47 | + let v = self.variance(); |
| 48 | + if v < VAR_FLOOR { return 0.0; } |
| 49 | + let z = (x - self.mean) / sqrtf(v); |
| 50 | + if z < 0.0 { -z } else { z } |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +/// Ring buffer for observation window. |
| 55 | +struct ObsWindow { |
| 56 | + pres: [u8; OBS_WIN], |
| 57 | + motion: [f32; OBS_WIN], |
| 58 | + persons: [u8; OBS_WIN], |
| 59 | + idx: usize, |
| 60 | + len: usize, |
| 61 | +} |
| 62 | +impl ObsWindow { |
| 63 | + const fn new() -> Self { |
| 64 | + Self { pres: [0; OBS_WIN], motion: [0.0; OBS_WIN], persons: [0; OBS_WIN], idx: 0, len: 0 } |
| 65 | + } |
| 66 | + fn push(&mut self, present: bool, mot: f32, np: u8) { |
| 67 | + self.pres[self.idx] = present as u8; |
| 68 | + self.motion[self.idx] = mot; |
| 69 | + self.persons[self.idx] = np; |
| 70 | + self.idx = (self.idx + 1) % OBS_WIN; |
| 71 | + if self.len < OBS_WIN { self.len += 1; } |
| 72 | + } |
| 73 | + /// Compute 6D feature vector from current window. |
| 74 | + fn features(&self) -> [f32; N_DIM] { |
| 75 | + if self.len == 0 { return [0.0; N_DIM]; } |
| 76 | + let n = self.len as f32; |
| 77 | + let start = if self.len < OBS_WIN { 0 } else { self.idx }; |
| 78 | + // Sums |
| 79 | + let (mut ps, mut ms, mut ns) = (0u32, 0.0f32, 0u32); |
| 80 | + for i in 0..self.len { ps += self.pres[i] as u32; ms += self.motion[i]; ns += self.persons[i] as u32; } |
| 81 | + let avg_m = ms / n; |
| 82 | + // Variance of motion |
| 83 | + let mut mv = 0.0f32; |
| 84 | + for i in 0..self.len { let d = self.motion[i] - avg_m; mv += d * d; } |
| 85 | + // Transitions |
| 86 | + let mut tr = 0u32; |
| 87 | + let mut prev_p = self.pres[start]; |
| 88 | + for s in 1..self.len { |
| 89 | + let cur = self.pres[(start + s) % OBS_WIN]; |
| 90 | + if cur != prev_p { tr += 1; } |
| 91 | + prev_p = cur; |
| 92 | + } |
| 93 | + // Dwell time (avg consecutive presence run length) |
| 94 | + let (mut dsum, mut druns, mut rlen) = (0u32, 0u32, 0u32); |
| 95 | + for s in 0..self.len { |
| 96 | + if self.pres[(start + s) % OBS_WIN] == 1 { rlen += 1; } |
| 97 | + else if rlen > 0 { dsum += rlen; druns += 1; rlen = 0; } |
| 98 | + } |
| 99 | + if rlen > 0 { dsum += rlen; druns += 1; } |
| 100 | + let dwell = if druns > 0 { dsum as f32 / druns as f32 } else { 0.0 }; |
| 101 | + [ps as f32 / n, avg_m, ns as f32 / n, mv / n, tr as f32 / n, dwell] |
| 102 | + } |
| 103 | +} |
| 104 | + |
| 105 | +/// Behavioral profiler with Mahalanobis-inspired anomaly scoring. |
| 106 | +pub struct BehavioralProfiler { |
| 107 | + stats: [Welford; N_DIM], |
| 108 | + obs: ObsWindow, |
| 109 | + mature: bool, |
| 110 | + frame_count: u32, |
| 111 | + obs_cycles: u32, |
| 112 | + cooldown: u16, |
| 113 | + anomaly_count: u32, |
| 114 | +} |
| 115 | + |
| 116 | +impl BehavioralProfiler { |
| 117 | + pub const fn new() -> Self { |
| 118 | + Self { |
| 119 | + stats: [Welford::new(); N_DIM], obs: ObsWindow::new(), |
| 120 | + mature: false, frame_count: 0, obs_cycles: 0, cooldown: 0, anomaly_count: 0, |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + /// Process one frame. Returns `(event_id, value)` pairs. |
| 125 | + pub fn process_frame(&mut self, present: bool, motion: f32, n_persons: u8) -> &[(i32, f32)] { |
| 126 | + self.frame_count += 1; |
| 127 | + self.cooldown = self.cooldown.saturating_sub(1); |
| 128 | + self.obs.push(present, motion, n_persons); |
| 129 | + |
| 130 | + static mut EV: [(i32, f32); 4] = [(0, 0.0); 4]; |
| 131 | + let mut ne = 0usize; |
| 132 | + |
| 133 | + if self.frame_count % (OBS_WIN as u32) == 0 && self.obs.len == OBS_WIN { |
| 134 | + let feat = self.obs.features(); |
| 135 | + self.obs_cycles += 1; |
| 136 | + |
| 137 | + if !self.mature { |
| 138 | + for d in 0..N_DIM { self.stats[d].update(feat[d]); } |
| 139 | + if self.obs_cycles >= LEARNING_FRAMES / (OBS_WIN as u32) { |
| 140 | + self.mature = true; |
| 141 | + let days = self.frame_count as f32 / (20.0 * 86400.0); |
| 142 | + unsafe { EV[ne] = (EVENT_PROFILE_MATURITY, days); } |
| 143 | + ne += 1; |
| 144 | + } |
| 145 | + } else { |
| 146 | + // Score before updating. |
| 147 | + let mut zsq = 0.0f32; |
| 148 | + let mut hi_z = 0u32; |
| 149 | + let (mut max_z, mut max_d) = (0.0f32, 0usize); |
| 150 | + for d in 0..N_DIM { |
| 151 | + let z = self.stats[d].z_score(feat[d]); |
| 152 | + zsq += z * z; |
| 153 | + if z > NOVEL_Z { hi_z += 1; } |
| 154 | + if z > max_z { max_z = z; max_d = d; } |
| 155 | + } |
| 156 | + let cz = sqrtf(zsq / N_DIM as f32); |
| 157 | + for d in 0..N_DIM { self.stats[d].update(feat[d]); } |
| 158 | + |
| 159 | + if self.cooldown == 0 { |
| 160 | + if cz > ANOMALY_Z { |
| 161 | + self.anomaly_count += 1; |
| 162 | + unsafe { EV[ne] = (EVENT_BEHAVIOR_ANOMALY, cz); } ne += 1; |
| 163 | + if ne < 4 { unsafe { EV[ne] = (EVENT_PROFILE_DEVIATION, max_d as f32); } ne += 1; } |
| 164 | + self.cooldown = COOLDOWN; |
| 165 | + } |
| 166 | + if hi_z >= NOVEL_MIN && ne < 4 { |
| 167 | + unsafe { EV[ne] = (EVENT_NOVEL_PATTERN, hi_z as f32); } ne += 1; |
| 168 | + if self.cooldown == 0 { self.cooldown = COOLDOWN; } |
| 169 | + } |
| 170 | + } |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + // Periodic maturity report. |
| 175 | + if self.mature && self.frame_count % MATURITY_INTERVAL == 0 && ne < 4 { |
| 176 | + unsafe { EV[ne] = (EVENT_PROFILE_MATURITY, self.frame_count as f32 / (20.0 * 86400.0)); } |
| 177 | + ne += 1; |
| 178 | + } |
| 179 | + unsafe { &EV[..ne] } |
| 180 | + } |
| 181 | + |
| 182 | + pub fn is_mature(&self) -> bool { self.mature } |
| 183 | + pub fn frame_count(&self) -> u32 { self.frame_count } |
| 184 | + pub fn total_anomalies(&self) -> u32 { self.anomaly_count } |
| 185 | + pub fn dim_mean(&self, d: usize) -> f32 { if d < N_DIM { self.stats[d].mean } else { 0.0 } } |
| 186 | + pub fn dim_variance(&self, d: usize) -> f32 { if d < N_DIM { self.stats[d].variance() } else { 0.0 } } |
| 187 | +} |
| 188 | + |
| 189 | +#[cfg(test)] |
| 190 | +mod tests { |
| 191 | + use super::*; |
| 192 | + |
| 193 | + #[test] |
| 194 | + fn test_init() { |
| 195 | + let bp = BehavioralProfiler::new(); |
| 196 | + assert_eq!(bp.frame_count(), 0); |
| 197 | + assert!(!bp.is_mature()); |
| 198 | + assert_eq!(bp.total_anomalies(), 0); |
| 199 | + } |
| 200 | + |
| 201 | + #[test] |
| 202 | + fn test_welford() { |
| 203 | + let mut w = Welford::new(); |
| 204 | + for _ in 0..100 { w.update(5.0); } |
| 205 | + assert!((w.mean - 5.0).abs() < 0.001); |
| 206 | + assert!(w.variance() < 0.001); |
| 207 | + // Z-score at mean ~ 0, far from mean > 3. |
| 208 | + assert!(w.z_score(5.0) < 0.1); |
| 209 | + } |
| 210 | + |
| 211 | + #[test] |
| 212 | + fn test_welford_z_far() { |
| 213 | + let mut w = Welford::new(); |
| 214 | + for i in 1..=100 { w.update(i as f32); } |
| 215 | + assert!(w.z_score(200.0) > 3.0); |
| 216 | + } |
| 217 | + |
| 218 | + #[test] |
| 219 | + fn test_learning_phase() { |
| 220 | + let mut bp = BehavioralProfiler::new(); |
| 221 | + for _ in 0..LEARNING_FRAMES { bp.process_frame(true, 0.5, 1); } |
| 222 | + assert!(bp.is_mature()); |
| 223 | + } |
| 224 | + |
| 225 | + #[test] |
| 226 | + fn test_normal_no_anomaly() { |
| 227 | + let mut bp = BehavioralProfiler::new(); |
| 228 | + for _ in 0..LEARNING_FRAMES { bp.process_frame(true, 0.5, 1); } |
| 229 | + for _ in 0..2000 { |
| 230 | + let ev = bp.process_frame(true, 0.5, 1); |
| 231 | + for &(t, _) in ev { assert_ne!(t, EVENT_BEHAVIOR_ANOMALY); } |
| 232 | + } |
| 233 | + assert_eq!(bp.total_anomalies(), 0); |
| 234 | + } |
| 235 | + |
| 236 | + #[test] |
| 237 | + fn test_anomaly_detection() { |
| 238 | + let mut bp = BehavioralProfiler::new(); |
| 239 | + // Learning phase: vary motion energy across observation windows so that |
| 240 | + // Welford stats accumulate non-zero variance. Each observation window |
| 241 | + // is OBS_WIN=200 frames; we need LEARNING_FRAMES/OBS_WIN = 5 cycles. |
| 242 | + // By giving each window a different motion level, inter-window variance |
| 243 | + // builds up, enabling z_score to detect anomalies after maturity. |
| 244 | + for i in 0..LEARNING_FRAMES { |
| 245 | + // Vary presence AND motion across observation windows so all |
| 246 | + // dimensions build non-zero variance. |
| 247 | + let window_id = i / (OBS_WIN as u32); |
| 248 | + let pres = window_id % 2 != 0; |
| 249 | + let mot = 0.1 + (window_id as f32) * 0.05; |
| 250 | + let per = (window_id % 3) as u8; |
| 251 | + bp.process_frame(pres, mot, per); |
| 252 | + } |
| 253 | + assert!(bp.is_mature()); |
| 254 | + let mut found = false; |
| 255 | + // Now inject a dramatically different behaviour. |
| 256 | + for _ in 0..4000 { |
| 257 | + let ev = bp.process_frame(true, 10.0, 5); |
| 258 | + if ev.iter().any(|&(t,_)| t == EVENT_BEHAVIOR_ANOMALY) { found = true; } |
| 259 | + } |
| 260 | + assert!(found, "dramatic change should trigger anomaly"); |
| 261 | + } |
| 262 | + |
| 263 | + #[test] |
| 264 | + fn test_obs_features() { |
| 265 | + let mut obs = ObsWindow::new(); |
| 266 | + for _ in 0..OBS_WIN { obs.push(true, 1.0, 2); } |
| 267 | + let f = obs.features(); |
| 268 | + assert!((f[0] - 1.0).abs() < 0.01); // presence_rate |
| 269 | + assert!((f[1] - 1.0).abs() < 0.01); // avg_motion |
| 270 | + assert!((f[2] - 2.0).abs() < 0.01); // avg_n_persons |
| 271 | + assert!(f[3] < 0.01); // activity_variance |
| 272 | + assert!(f[4] < 0.01); // transition_rate |
| 273 | + } |
| 274 | + |
| 275 | + #[test] |
| 276 | + fn test_maturity_event() { |
| 277 | + let mut bp = BehavioralProfiler::new(); |
| 278 | + let mut found = false; |
| 279 | + for _ in 0..LEARNING_FRAMES { |
| 280 | + let ev = bp.process_frame(true, 0.5, 1); |
| 281 | + if ev.iter().any(|&(t,_)| t == EVENT_PROFILE_MATURITY) { found = true; } |
| 282 | + } |
| 283 | + assert!(found, "maturity event should be emitted"); |
| 284 | + } |
| 285 | +} |
0 commit comments