1use super::config::BehavioralDriftConfig;
19use super::inspector::{AirlockViolation, RiskLevel, ViolationType};
20use fd_core::AgentId;
21use serde::{Deserialize, Serialize};
22use std::collections::{HashMap, VecDeque};
23use tokio::sync::RwLock;
24use tracing::warn;
25
26#[derive(Debug, Clone, Copy, Default)]
30pub struct Observation {
31 pub cost_cents: Option<u64>,
32 pub latency_ms: Option<u64>,
33 pub refused: Option<bool>,
34 pub schema_violation: Option<bool>,
35}
36
37impl Observation {
38 pub fn with_cost(cents: u64) -> Self {
39 Self {
40 cost_cents: Some(cents),
41 ..Default::default()
42 }
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct BehavioralDriftDetails {
50 pub agent_id: String,
51 pub dimension: String,
53 pub z_score: f64,
54 pub mean: f64,
55 pub stddev: f64,
56 pub observed: f64,
57 pub window_size: usize,
58}
59
60struct RollingState {
62 cost_cents: VecDeque<f64>,
63 latency_ms: VecDeque<f64>,
64 refused: VecDeque<f64>,
65 schema_violation: VecDeque<f64>,
66}
67
68impl RollingState {
69 fn new() -> Self {
70 Self {
71 cost_cents: VecDeque::new(),
72 latency_ms: VecDeque::new(),
73 refused: VecDeque::new(),
74 schema_violation: VecDeque::new(),
75 }
76 }
77}
78
79fn push_bounded(buf: &mut VecDeque<f64>, value: f64, cap: usize) {
80 if buf.len() == cap {
81 buf.pop_front();
82 }
83 buf.push_back(value);
84}
85
86fn mean_stddev(buf: &VecDeque<f64>) -> (f64, f64) {
90 let n = buf.len() as f64;
91 if n == 0.0 {
92 return (0.0, 0.0);
93 }
94 let sum: f64 = buf.iter().sum();
95 let mean = sum / n;
96 let variance: f64 = buf.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
97 (mean, variance.sqrt())
98}
99
100pub struct BehavioralDriftMonitor {
106 agents: RwLock<HashMap<AgentId, RollingState>>,
107}
108
109impl Default for BehavioralDriftMonitor {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114
115impl BehavioralDriftMonitor {
116 pub fn new() -> Self {
117 Self {
118 agents: RwLock::new(HashMap::new()),
119 }
120 }
121
122 pub async fn is_tracked(&self, agent_id: &AgentId) -> bool {
125 self.agents.read().await.contains_key(agent_id)
126 }
127
128 pub async fn clear_agent(&self, agent_id: &AgentId) {
131 self.agents.write().await.remove(agent_id);
132 }
133
134 pub async fn observe(
140 &self,
141 agent_id: AgentId,
142 obs: Observation,
143 config: &BehavioralDriftConfig,
144 ) -> Option<AirlockViolation> {
145 if !config.enabled {
146 return None;
147 }
148
149 let mut agents = self.agents.write().await;
150 let state = agents.entry(agent_id).or_insert_with(RollingState::new);
151
152 let mut violation: Option<AirlockViolation> = check_dimension(
157 "cost_cents",
158 &state.cost_cents,
159 obs.cost_cents,
160 &agent_id,
161 config,
162 )
163 .or_else(|| {
164 check_dimension(
165 "latency_ms",
166 &state.latency_ms,
167 obs.latency_ms,
168 &agent_id,
169 config,
170 )
171 })
172 .or_else(|| check_bool_dimension("refused", &state.refused, obs.refused, &agent_id, config))
173 .or_else(|| {
174 check_bool_dimension(
175 "schema_violation",
176 &state.schema_violation,
177 obs.schema_violation,
178 &agent_id,
179 config,
180 )
181 });
182
183 if let Some(v) = obs.cost_cents {
185 push_bounded(&mut state.cost_cents, v as f64, config.window_size);
186 }
187 if let Some(v) = obs.latency_ms {
188 push_bounded(&mut state.latency_ms, v as f64, config.window_size);
189 }
190 if let Some(v) = obs.refused {
191 push_bounded(
192 &mut state.refused,
193 if v { 1.0 } else { 0.0 },
194 config.window_size,
195 );
196 }
197 if let Some(v) = obs.schema_violation {
198 push_bounded(
199 &mut state.schema_violation,
200 if v { 1.0 } else { 0.0 },
201 config.window_size,
202 );
203 }
204
205 if let Some(ref mut v) = violation {
207 v.risk_score = v.risk_score.min(100);
208 v.risk_level = RiskLevel::from_score(v.risk_score);
209 }
210
211 violation
212 }
213}
214
215fn check_dimension(
216 name: &'static str,
217 buf: &VecDeque<f64>,
218 incoming: Option<u64>,
219 agent_id: &AgentId,
220 config: &BehavioralDriftConfig,
221) -> Option<AirlockViolation> {
222 let value = incoming? as f64;
223 if buf.len() < config.min_observations {
224 return None;
225 }
226 let (mean, stddev) = mean_stddev(buf);
227 if stddev == 0.0 {
228 return None;
232 }
233 let z = (value - mean).abs() / stddev;
234 if z <= config.z_threshold as f64 {
235 return None;
236 }
237
238 let details = BehavioralDriftDetails {
239 agent_id: agent_id.to_string(),
240 dimension: name.to_string(),
241 z_score: z,
242 mean,
243 stddev,
244 observed: value,
245 window_size: buf.len(),
246 };
247 let serialized = serde_json::to_string(&details)
248 .unwrap_or_else(|_| format!("behavioral_drift {} agent={} z={:.2}", name, agent_id, z));
249
250 warn!(
251 agent_id = %agent_id,
252 dimension = name,
253 z_score = z,
254 mean = mean,
255 stddev = stddev,
256 observed = value,
257 "behavioral drift detected"
258 );
259
260 Some(AirlockViolation {
261 violation_type: ViolationType::BehavioralDrift,
262 risk_score: config.risk_score,
263 risk_level: RiskLevel::from_score(config.risk_score),
264 details: serialized,
265 trigger: format!("behavioral_drift:{name}"),
266 })
267}
268
269fn check_bool_dimension(
270 name: &'static str,
271 buf: &VecDeque<f64>,
272 incoming: Option<bool>,
273 agent_id: &AgentId,
274 config: &BehavioralDriftConfig,
275) -> Option<AirlockViolation> {
276 let incoming = incoming?;
278 let as_u64 = if incoming { 1 } else { 0 };
279 check_dimension(name, buf, Some(as_u64), agent_id, config)
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::airlock::config::BehavioralDriftConfig;
286
287 fn config() -> BehavioralDriftConfig {
288 BehavioralDriftConfig {
289 enabled: true,
290 window_size: 100,
291 min_observations: 20,
292 z_threshold: 3.0,
293 risk_score: 65,
294 }
295 }
296
297 #[tokio::test]
298 async fn under_window_does_not_fire() {
299 let monitor = BehavioralDriftMonitor::new();
300 let agent = AgentId::new();
301 for _ in 0..15 {
304 let v = monitor
305 .observe(agent, Observation::with_cost(10), &config())
306 .await;
307 assert!(v.is_none());
308 }
309 let spike = monitor
310 .observe(agent, Observation::with_cost(10_000), &config())
311 .await;
312 assert!(
313 spike.is_none(),
314 "expected no violation before warmup completes, got {spike:?}"
315 );
316 }
317
318 #[tokio::test]
319 async fn clean_traffic_does_not_fire() {
320 let monitor = BehavioralDriftMonitor::new();
321 let agent = AgentId::new();
322 for i in 0..50 {
325 let v = monitor
326 .observe(
327 agent,
328 Observation::with_cost(if i % 2 == 0 { 10 } else { 12 }),
329 &config(),
330 )
331 .await;
332 assert!(v.is_none(), "clean traffic triggered at i={i}: {v:?}");
333 }
334 }
335
336 #[tokio::test]
337 async fn cost_spike_fires_after_warmup() {
338 let monitor = BehavioralDriftMonitor::new();
339 let agent = AgentId::new();
340 for i in 0..50 {
342 monitor
343 .observe(
344 agent,
345 Observation::with_cost(if i % 2 == 0 { 10 } else { 11 }),
346 &config(),
347 )
348 .await;
349 }
350 let violation = monitor
352 .observe(agent, Observation::with_cost(1000), &config())
353 .await
354 .expect("cost spike should fire");
355 assert_eq!(violation.violation_type, ViolationType::BehavioralDrift);
356 let details: BehavioralDriftDetails = serde_json::from_str(&violation.details).unwrap();
357 assert_eq!(details.dimension, "cost_cents");
358 assert!(
359 details.z_score > 3.0,
360 "z_score {} should exceed threshold",
361 details.z_score
362 );
363 }
364
365 #[tokio::test]
366 async fn latency_spike_fires_after_warmup() {
367 let monitor = BehavioralDriftMonitor::new();
368 let agent = AgentId::new();
369 for i in 0..50 {
370 monitor
371 .observe(
372 agent,
373 Observation {
374 latency_ms: Some(if i % 2 == 0 { 100 } else { 110 }),
375 ..Default::default()
376 },
377 &config(),
378 )
379 .await;
380 }
381 let violation = monitor
382 .observe(
383 agent,
384 Observation {
385 latency_ms: Some(5_000),
386 ..Default::default()
387 },
388 &config(),
389 )
390 .await
391 .expect("latency spike should fire");
392 let details: BehavioralDriftDetails = serde_json::from_str(&violation.details).unwrap();
393 assert_eq!(details.dimension, "latency_ms");
394 }
395
396 #[tokio::test]
397 async fn disabled_config_short_circuits() {
398 let monitor = BehavioralDriftMonitor::new();
399 let agent = AgentId::new();
400 let mut cfg = config();
401 cfg.enabled = false;
402 for _ in 0..50 {
404 monitor
405 .observe(agent, Observation::with_cost(10), &cfg)
406 .await;
407 }
408 let v = monitor
409 .observe(agent, Observation::with_cost(10_000), &cfg)
410 .await;
411 assert!(v.is_none());
412 }
413
414 #[tokio::test]
415 async fn clear_agent_resets_state() {
416 let monitor = BehavioralDriftMonitor::new();
417 let agent = AgentId::new();
418 for _ in 0..50 {
419 monitor
420 .observe(agent, Observation::with_cost(10), &config())
421 .await;
422 }
423 assert!(monitor.is_tracked(&agent).await);
424 monitor.clear_agent(&agent).await;
425 assert!(!monitor.is_tracked(&agent).await);
426 let v = monitor
429 .observe(agent, Observation::with_cost(10_000), &config())
430 .await;
431 assert!(v.is_none());
432 }
433
434 #[tokio::test]
435 async fn zero_stddev_does_not_panic() {
436 let monitor = BehavioralDriftMonitor::new();
440 let agent = AgentId::new();
441 for _ in 0..30 {
442 monitor
443 .observe(agent, Observation::with_cost(10), &config())
444 .await;
445 }
446 let v = monitor
447 .observe(agent, Observation::with_cost(99_999), &config())
448 .await;
449 assert!(
450 v.is_none(),
451 "zero-stddev path must not divide-by-zero or fire spurious violation"
452 );
453 }
454
455 #[tokio::test]
456 async fn window_caps_at_window_size() {
457 let monitor = BehavioralDriftMonitor::new();
458 let agent = AgentId::new();
459 let mut cfg = config();
460 cfg.window_size = 30;
461 cfg.min_observations = 10;
462 for i in 0..100 {
463 monitor
464 .observe(agent, Observation::with_cost(i), &cfg)
465 .await;
466 }
467 let agents = monitor.agents.read().await;
468 let state = agents.get(&agent).expect("agent tracked");
469 assert_eq!(state.cost_cents.len(), 30);
470 assert_eq!(state.cost_cents.front().copied(), Some(70.0));
472 }
473}