1use std::collections::HashMap;
38use std::sync::Mutex;
39use std::time::{SystemTime, UNIX_EPOCH};
40
41use chio_core::receipt::ChioReceipt;
42use chio_kernel::operator_report::EmaBaselineState;
43use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
44
45pub const DEFAULT_EMA_ALPHA: f64 = 0.2;
47pub const DEFAULT_SIGMA_THRESHOLD: f64 = 2.0;
49pub const DEFAULT_WINDOW_SECS: u64 = 60;
51pub const DEFAULT_BASELINE_MIN_WINDOWS: u64 = 3;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum BehavioralMetric {
59 CallRate,
61 DenyRate,
63 UniqueTools,
65 AvgParameterEntropy,
67}
68
69impl BehavioralMetric {
70 #[must_use]
72 pub fn as_str(&self) -> &'static str {
73 match self {
74 Self::CallRate => "call_rate",
75 Self::DenyRate => "deny_rate",
76 Self::UniqueTools => "unique_tools",
77 Self::AvgParameterEntropy => "avg_parameter_entropy",
78 }
79 }
80}
81
82pub trait ReceiptFeedSource: Send + Sync {
85 fn receipts_for_agent(
90 &self,
91 agent_id: &str,
92 since: u64,
93 until: u64,
94 ) -> Result<Vec<ChioReceipt>, KernelError>;
95}
96
97#[derive(Default)]
105pub struct InMemoryReceiptFeed {
106 inner: Mutex<Vec<(String, ChioReceipt)>>,
107}
108
109impl InMemoryReceiptFeed {
110 #[must_use]
112 pub fn new() -> Self {
113 Self::default()
114 }
115
116 pub fn push(&self, agent_id: &str, receipt: ChioReceipt) -> Result<(), KernelError> {
118 let mut inner = self
119 .inner
120 .lock()
121 .map_err(|_| KernelError::Internal("behavioral feed lock poisoned".to_string()))?;
122 inner.push((agent_id.to_string(), receipt));
123 Ok(())
124 }
125
126 pub fn len(&self) -> Result<usize, KernelError> {
128 let inner = self
129 .inner
130 .lock()
131 .map_err(|_| KernelError::Internal("behavioral feed lock poisoned".to_string()))?;
132 Ok(inner.len())
133 }
134
135 pub fn is_empty(&self) -> Result<bool, KernelError> {
137 Ok(self.len()? == 0)
138 }
139}
140
141impl ReceiptFeedSource for InMemoryReceiptFeed {
142 fn receipts_for_agent(
143 &self,
144 agent_id: &str,
145 since: u64,
146 until: u64,
147 ) -> Result<Vec<ChioReceipt>, KernelError> {
148 let inner = self
149 .inner
150 .lock()
151 .map_err(|_| KernelError::Internal("behavioral feed lock poisoned".to_string()))?;
152 Ok(inner
153 .iter()
154 .filter(|(id, r)| id == agent_id && r.timestamp >= since && r.timestamp <= until)
155 .map(|(_, r)| r.clone())
156 .collect())
157 }
158}
159
160#[derive(Debug, Clone, Copy)]
162pub struct BehavioralProfileConfig {
163 pub ema_alpha: f64,
165 pub sigma_threshold: f64,
167 pub window_secs: u64,
169 pub baseline_min_windows: u64,
172}
173
174impl Default for BehavioralProfileConfig {
175 fn default() -> Self {
176 Self {
177 ema_alpha: DEFAULT_EMA_ALPHA,
178 sigma_threshold: DEFAULT_SIGMA_THRESHOLD,
179 window_secs: DEFAULT_WINDOW_SECS,
180 baseline_min_windows: DEFAULT_BASELINE_MIN_WINDOWS,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Default)]
187struct BaselineEntry {
188 state: EmaBaselineState,
189 last_window_start: u64,
190}
191
192pub struct BehavioralProfileGuard {
195 name: String,
196 config: BehavioralProfileConfig,
197 feed: Box<dyn ReceiptFeedSource>,
198 baselines: Mutex<HashMap<(String, BehavioralMetric), BaselineEntry>>,
200 now: Box<dyn Fn() -> u64 + Send + Sync>,
201}
202
203impl BehavioralProfileGuard {
204 pub fn new(feed: Box<dyn ReceiptFeedSource>) -> Self {
207 Self::with_config(feed, BehavioralProfileConfig::default())
208 }
209
210 pub fn with_config(feed: Box<dyn ReceiptFeedSource>, config: BehavioralProfileConfig) -> Self {
212 Self {
213 name: "behavioral-profile".to_string(),
214 config,
215 feed,
216 baselines: Mutex::new(HashMap::new()),
217 now: Box::new(default_now),
218 }
219 }
220
221 pub fn with_clock(mut self, clock: Box<dyn Fn() -> u64 + Send + Sync>) -> Self {
223 self.now = clock;
224 self
225 }
226
227 pub fn observe_sample(
231 &self,
232 agent_id: &str,
233 metric: BehavioralMetric,
234 sample: f64,
235 window_start: u64,
236 ) -> Result<ObservationOutcome, KernelError> {
237 let mut baselines = self
238 .baselines
239 .lock()
240 .map_err(|_| KernelError::Internal("baseline lock poisoned".to_string()))?;
241 let entry = baselines.entry((agent_id.to_string(), metric)).or_default();
242
243 if entry.last_window_start == window_start && entry.state.sample_count > 0 {
247 let z = robust_z_score(&entry.state, sample);
248 let anomaly = z
249 .map(|z| z.abs() > self.config.sigma_threshold)
250 .unwrap_or(false);
251 return Ok(ObservationOutcome {
252 z_score: z,
253 anomaly,
254 baseline: entry.state.clone(),
255 sample,
256 });
257 }
258
259 let z = robust_z_score(&entry.state, sample);
260 let seen_enough = entry.state.sample_count >= self.config.baseline_min_windows;
261 let anomaly = seen_enough
262 && z.map(|z| z.abs() > self.config.sigma_threshold)
263 .unwrap_or(false);
264
265 entry
266 .state
267 .update(sample, self.config.ema_alpha, window_start);
268 entry.last_window_start = window_start;
269 let baseline = entry.state.clone();
270
271 Ok(ObservationOutcome {
272 z_score: z,
273 anomaly,
274 baseline,
275 sample,
276 })
277 }
278
279 pub fn baseline(
281 &self,
282 agent_id: &str,
283 metric: BehavioralMetric,
284 ) -> Result<Option<EmaBaselineState>, KernelError> {
285 let baselines = self
286 .baselines
287 .lock()
288 .map_err(|_| KernelError::Internal("baseline lock poisoned".to_string()))?;
289 Ok(baselines
290 .get(&(agent_id.to_string(), metric))
291 .map(|e| e.state.clone()))
292 }
293
294 fn current_window_start(&self, now: u64) -> u64 {
295 let window = self.config.window_secs.max(1);
296 (now / window) * window
297 }
298
299 fn sample_for_window(&self, agent_id: &str, window_start: u64) -> Result<f64, KernelError> {
300 let window_end = window_start + self.config.window_secs.max(1);
301 let receipts =
302 self.feed
303 .receipts_for_agent(agent_id, window_start, window_end.saturating_sub(1))?;
304 Ok(receipts.len() as f64)
305 }
306}
307
308#[derive(Debug, Clone)]
310pub struct ObservationOutcome {
311 pub z_score: Option<f64>,
314 pub anomaly: bool,
316 pub baseline: EmaBaselineState,
318 pub sample: f64,
320}
321
322fn default_now() -> u64 {
323 SystemTime::now()
324 .duration_since(UNIX_EPOCH)
325 .map(|d| d.as_secs())
326 .unwrap_or(0)
327}
328
329fn robust_z_score(state: &EmaBaselineState, sample: f64) -> Option<f64> {
338 if state.sample_count < 2 {
339 return None;
340 }
341 let measured = state.stddev();
342 let floor = state.ema_mean.max(1.0).sqrt();
343 let effective = measured.max(floor);
344 if effective <= f64::EPSILON {
345 return None;
346 }
347 Some((sample - state.ema_mean) / effective)
348}
349
350impl Guard for BehavioralProfileGuard {
351 fn name(&self) -> &str {
352 &self.name
353 }
354
355 fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
356 let now = (self.now)();
357 let window_start = self.current_window_start(now);
358 let agent_id = ctx.agent_id.as_str();
359 let sample = self.sample_for_window(agent_id, window_start)?;
360 let _ = self.observe_sample(agent_id, BehavioralMetric::CallRate, sample, window_start)?;
364 Ok(Verdict::Allow)
365 }
366}
367
368#[cfg(test)]
369#[allow(clippy::unwrap_used, clippy::expect_used)]
370mod tests {
371 use super::*;
372
373 fn guard_for_tests(feed: InMemoryReceiptFeed) -> BehavioralProfileGuard {
374 BehavioralProfileGuard::with_config(
375 Box::new(feed),
376 BehavioralProfileConfig {
377 baseline_min_windows: 2,
378 ..Default::default()
379 },
380 )
381 }
382
383 #[test]
384 fn ema_baseline_stabilizes_under_steady_sample() {
385 let guard = guard_for_tests(InMemoryReceiptFeed::new());
386 for i in 0..20 {
387 let outcome = guard
388 .observe_sample("agent-steady", BehavioralMetric::CallRate, 10.0, i)
389 .unwrap();
390 if i >= 10 {
392 assert!(
393 (outcome.baseline.ema_mean - 10.0).abs() < 0.1,
394 "ema_mean should stabilize near 10 after 10 samples, got {}",
395 outcome.baseline.ema_mean
396 );
397 assert!(!outcome.anomaly);
398 }
399 }
400 }
401
402 #[test]
403 fn spike_fifty_x_triggers_anomaly() {
404 let guard = guard_for_tests(InMemoryReceiptFeed::new());
405 for i in 0..15 {
407 let _ = guard
408 .observe_sample("agent-spiky", BehavioralMetric::CallRate, 10.0, i)
409 .unwrap();
410 }
411 let outcome = guard
413 .observe_sample("agent-spiky", BehavioralMetric::CallRate, 500.0, 100)
414 .unwrap();
415 assert!(
416 outcome.anomaly,
417 "50x spike should flag an anomaly (z={:?})",
418 outcome.z_score
419 );
420 assert!(outcome.z_score.unwrap_or(0.0).abs() > DEFAULT_SIGMA_THRESHOLD);
421 }
422
423 #[test]
424 fn cold_baseline_does_not_flag() {
425 let guard = guard_for_tests(InMemoryReceiptFeed::new());
426 let outcome = guard
427 .observe_sample("agent-cold", BehavioralMetric::CallRate, 1_000.0, 1)
428 .unwrap();
429 assert!(
430 !outcome.anomaly,
431 "cold baseline must not flag anomalies (observed in isolation)"
432 );
433 }
434}