Skip to main content

fd_policy/airlock/
behavioral_drift.rs

1//! Behavioral-drift inspection — fifth Airlock RASP layer.
2//!
3//! Per-agent rolling-window z-score over scalar call metadata. When the
4//! latest observation for any tracked dimension exceeds `z_threshold`
5//! standard deviations from the rolling mean (after a warmup of at least
6//! `min_observations` samples), the call is flagged as drifted.
7//!
8//! Anchored on GPRL (arxiv:2605.18721) — applying the closed-loop
9//! drift-monitor primitive at inference time rather than during policy
10//! training. Unlike [`super::schema_drift`], which watches payloads
11//! against a static contract, this layer watches behavior against the
12//! agent's own recent history.
13//!
14//! The v1 scope only feeds `cost_cents` from `InspectionContext`. Latency,
15//! refusal, and schema-violation observations are post-hoc and will be
16//! pushed by the worker in a follow-up PR.
17
18use 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/// A single per-call observation. Pre-call fields are populated by the
27/// gateway; post-call fields are filled by the worker once results come
28/// back. `None` for a dimension means "skip this dimension on this call".
29#[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/// Structured payload encoded into [`AirlockViolation::details`] when a
47/// behavioral-drift violation fires.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct BehavioralDriftDetails {
50    pub agent_id: String,
51    /// One of `cost_cents`, `latency_ms`, `refused`, `schema_violation`.
52    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
60/// Per-agent rolling state. Held under the monitor's `RwLock` map.
61struct 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
86/// Returns (mean, stddev). `stddev` is the population stddev (divide by N,
87/// not N-1) — we treat the window as the entire population we care about
88/// rather than a sample of a larger distribution.
89fn 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
100/// Tracks per-agent rolling observations and reports z-score outliers.
101///
102/// Mirror the construction pattern used elsewhere in `airlock/`: instantiate
103/// at gateway boot via [`BehavioralDriftMonitor::new`], wrap in `Arc`, and
104/// hand to [`super::inspector::AirlockInspector::with_behavioral_drift_monitor`].
105pub 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    /// Whether this monitor has any state recorded for the given agent —
123    /// primarily for tests.
124    pub async fn is_tracked(&self, agent_id: &AgentId) -> bool {
125        self.agents.read().await.contains_key(agent_id)
126    }
127
128    /// Drop all rolling state for an agent. Call when a run completes if
129    /// you don't want drift signal to bleed across runs of the same agent.
130    pub async fn clear_agent(&self, agent_id: &AgentId) {
131        self.agents.write().await.remove(agent_id);
132    }
133
134    /// Record an observation and, if it pushes any dimension past
135    /// `config.z_threshold`, return the corresponding violation. The
136    /// observation is always added to the window — even when it fires a
137    /// violation — so subsequent calls see today's behavior as the new
138    /// baseline.
139    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        // Compute z-score against the window AS IT STANDS BEFORE this obs,
153        // then insert. This is what makes "the new sample is anomalous"
154        // meaningful — otherwise the new sample shifts the mean and dampens
155        // its own z-score.
156        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        // Always insert, capped at window_size.
184        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        // Clamp the risk score now that we've decided to surface this.
206        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        // Constant traffic — a deviation is meaningful but z is undefined.
229        // Be conservative: don't fire, and let the window grow until variance
230        // emerges. Avoids spurious flags on a long flat warmup.
231        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    // Reuse the scalar path with bool→{0.0, 1.0}.
277    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        // 15 normal observations then a spike — but we haven't reached
302        // min_observations=20 yet, so no violation.
303        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        // 50 observations alternating between 10 and 12 — small variance,
323        // nothing past 3σ.
324        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        // Warm up with 50 cheap calls (small variance around 10).
341        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        // 1000-cent call should be many standard deviations away.
351        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        // Push 50 normal then a spike — should never fire when disabled.
403        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        // Post-clear: a single spike should NOT fire (window is empty,
427        // can't compute drift on an unseen agent).
428        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        // 30 identical observations followed by a deviant one. Stddev is 0
437        // until the new sample arrives. We skip the z-check rather than
438        // divide by zero, so no violation fires here either.
439        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        // Latest 30 values were 70..100 — the front of the deque is 70.
471        assert_eq!(state.cost_cents.front().copied(), Some(70.0));
472    }
473}