1use crate::types::*;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct BehaviorSample {
18 pub agent_id: String,
20 pub timestamp: String,
22 pub tool_used: String,
24 pub evidence_class: EvidenceClass,
26 pub confidence: f64,
28 pub response_time_ms: u64,
30 pub success: bool,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct AgentFingerprint {
41 pub agent_id: String,
43 pub sample_count: usize,
45 pub avg_response_time: f64,
47 pub error_rate: f64,
49 pub evidence_distribution: HashMap<EvidenceClass, f64>,
51 pub top_tools: Vec<String>,
53 pub confidence_mean: f64,
55 pub confidence_stddev: f64,
57 pub last_active: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct AnomalyResult {
68 pub anomalous: bool,
70 pub reasons: Vec<String>,
72 pub severity: f64,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub enum HealthStatus {
83 Healthy,
85 Degraded,
87 Anomalous,
89 Inactive,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct FingerprintEngine {
100 samples: HashMap<String, Vec<BehaviorSample>>,
101}
102
103impl Default for FingerprintEngine {
104 fn default() -> Self {
105 Self::new()
106 }
107}
108
109impl FingerprintEngine {
110 pub fn new() -> Self {
112 Self {
113 samples: HashMap::new(),
114 }
115 }
116
117 pub fn ingest(&mut self, sample: BehaviorSample) {
119 self.samples
120 .entry(sample.agent_id.clone())
121 .or_default()
122 .push(sample);
123 }
124
125 pub fn get_fingerprint(&self, agent_id: &str) -> Option<AgentFingerprint> {
127 let samples = self.samples.get(agent_id)?;
128 if samples.is_empty() {
129 return None;
130 }
131
132 let count = samples.len();
133 let avg_response_time =
134 samples.iter().map(|s| s.response_time_ms as f64).sum::<f64>() / count as f64;
135 let error_count = samples.iter().filter(|s| !s.success).count();
136 let error_rate = error_count as f64 / count as f64;
137
138 let mut class_counts: HashMap<EvidenceClass, usize> = HashMap::new();
140 for s in samples {
141 *class_counts.entry(s.evidence_class).or_insert(0) += 1;
142 }
143 let evidence_distribution: HashMap<EvidenceClass, f64> = class_counts
144 .into_iter()
145 .map(|(k, v)| (k, v as f64 / count as f64))
146 .collect();
147
148 let mut tool_counts: HashMap<&str, usize> = HashMap::new();
150 for s in samples {
151 *tool_counts.entry(s.tool_used.as_str()).or_insert(0) += 1;
152 }
153 let mut tool_vec: Vec<(&str, usize)> = tool_counts.into_iter().collect();
154 tool_vec.sort_by(|a, b| b.1.cmp(&a.1));
155 let top_tools: Vec<String> = tool_vec.into_iter().map(|(t, _)| t.to_string()).collect();
156
157 let confidence_mean =
159 samples.iter().map(|s| s.confidence).sum::<f64>() / count as f64;
160 let variance = samples
161 .iter()
162 .map(|s| (s.confidence - confidence_mean).powi(2))
163 .sum::<f64>()
164 / count as f64;
165 let confidence_stddev = variance.sqrt();
166
167 let last_active = samples
168 .iter()
169 .map(|s| s.timestamp.as_str())
170 .max()
171 .unwrap_or("")
172 .to_string();
173
174 Some(AgentFingerprint {
175 agent_id: agent_id.to_string(),
176 sample_count: count,
177 avg_response_time,
178 error_rate,
179 evidence_distribution,
180 top_tools,
181 confidence_mean,
182 confidence_stddev,
183 last_active,
184 })
185 }
186
187 pub fn detect_anomaly(&self, agent_id: &str, sample: &BehaviorSample) -> AnomalyResult {
194 let fingerprint = match self.get_fingerprint(agent_id) {
195 Some(fp) => fp,
196 None => {
197 return AnomalyResult {
198 anomalous: false,
199 reasons: vec!["no baseline established".to_string()],
200 severity: 0.0,
201 }
202 }
203 };
204
205 let mut reasons = Vec::new();
206 let mut severity: f64 = 0.0;
207
208 if fingerprint.avg_response_time > 0.0 {
210 let ratio = sample.response_time_ms as f64 / fingerprint.avg_response_time;
211 if ratio > 2.0 {
212 reasons.push(format!(
213 "response time {:.0}ms is {:.1}x baseline {:.0}ms",
214 sample.response_time_ms as f64, ratio, fingerprint.avg_response_time
215 ));
216 severity += (ratio - 2.0).min(1.0) * 0.4;
217 }
218 }
219
220 if !sample.success {
222 let implied_error_rate = 1.0; if fingerprint.error_rate > 0.0 {
224 let ratio = implied_error_rate / fingerprint.error_rate;
225 if ratio > 3.0 {
226 reasons.push(format!(
227 "failure with baseline error rate {:.1}%",
228 fingerprint.error_rate * 100.0
229 ));
230 severity += 0.3;
231 }
232 } else {
233 reasons.push("failure against zero-error baseline".to_string());
235 severity += 0.3;
236 }
237 }
238
239 let confidence_drop = fingerprint.confidence_mean - sample.confidence;
241 if confidence_drop > 0.2 {
242 reasons.push(format!(
243 "confidence {:.2} is {:.2} below baseline mean {:.2}",
244 sample.confidence, confidence_drop, fingerprint.confidence_mean
245 ));
246 severity += confidence_drop.min(1.0) * 0.3;
247 }
248
249 severity = severity.min(1.0);
250 let anomalous = !reasons.is_empty();
251
252 AnomalyResult {
253 anomalous,
254 reasons,
255 severity,
256 }
257 }
258
259 pub fn similarity(&self, a: &str, b: &str) -> f64 {
264 let fp_a = match self.get_fingerprint(a) {
265 Some(fp) => fp,
266 None => return 0.0,
267 };
268 let fp_b = match self.get_fingerprint(b) {
269 Some(fp) => fp,
270 None => return 0.0,
271 };
272
273 let all_classes = [
275 EvidenceClass::Direct,
276 EvidenceClass::Inferred,
277 EvidenceClass::Reported,
278 EvidenceClass::Conjecture,
279 ];
280 let dist_sim = {
281 let mut dot = 0.0_f64;
282 let mut mag_a = 0.0_f64;
283 let mut mag_b = 0.0_f64;
284 for class in &all_classes {
285 let va = fp_a.evidence_distribution.get(class).copied().unwrap_or(0.0);
286 let vb = fp_b.evidence_distribution.get(class).copied().unwrap_or(0.0);
287 dot += va * vb;
288 mag_a += va * va;
289 mag_b += vb * vb;
290 }
291 let denom = mag_a.sqrt() * mag_b.sqrt();
292 if denom > 0.0 {
293 dot / denom
294 } else {
295 0.0
296 }
297 };
298
299 let conf_sim = 1.0 - (fp_a.confidence_mean - fp_b.confidence_mean).abs();
301
302 let err_sim = 1.0 - (fp_a.error_rate - fp_b.error_rate).abs();
304
305 let max_rt = fp_a.avg_response_time.max(fp_b.avg_response_time);
307 let rt_sim = if max_rt > 0.0 {
308 1.0 - (fp_a.avg_response_time - fp_b.avg_response_time).abs() / max_rt
309 } else {
310 1.0
311 };
312
313 (dist_sim * 0.4 + conf_sim * 0.3 + err_sim * 0.15 + rt_sim * 0.15).clamp(0.0, 1.0)
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 fn sample(agent: &str, tool: &str, class: EvidenceClass, conf: f64, ms: u64, ok: bool) -> BehaviorSample {
323 BehaviorSample {
324 agent_id: agent.to_string(),
325 timestamp: chrono::Utc::now().to_rfc3339(),
326 tool_used: tool.to_string(),
327 evidence_class: class,
328 confidence: conf,
329 response_time_ms: ms,
330 success: ok,
331 }
332 }
333
334 #[test]
335 fn fingerprint_and_anomaly() {
336 let mut engine = FingerprintEngine::new();
337 for _ in 0..10 {
338 engine.ingest(sample("a", "Read", EvidenceClass::Direct, 0.9, 50, true));
339 }
340
341 let fp = engine.get_fingerprint("a").unwrap();
342 assert_eq!(fp.sample_count, 10);
343 assert!((fp.avg_response_time - 50.0).abs() < 1e-10);
344 assert!((fp.error_rate - 0.0).abs() < 1e-10);
345
346 let normal = sample("a", "Read", EvidenceClass::Direct, 0.85, 60, true);
348 let result = engine.detect_anomaly("a", &normal);
349 assert!(!result.anomalous);
350
351 let bad = sample("a", "Read", EvidenceClass::Conjecture, 0.3, 200, false);
353 let result = engine.detect_anomaly("a", &bad);
354 assert!(result.anomalous);
355 assert!(result.severity > 0.0);
356 }
357
358 #[test]
359 fn similarity_identical() {
360 let mut engine = FingerprintEngine::new();
361 for _ in 0..5 {
362 engine.ingest(sample("x", "Read", EvidenceClass::Direct, 0.9, 50, true));
363 engine.ingest(sample("y", "Read", EvidenceClass::Direct, 0.9, 50, true));
364 }
365 let sim = engine.similarity("x", "y");
366 assert!(sim > 0.95, "identical agents should have high similarity: {sim}");
367 }
368}