Skip to main content

evidential_protocol/
fingerprint.rs

1//! Behavioral Fingerprinting for the Evidential Protocol.
2//!
3//! Builds statistical fingerprints from agent behavior samples. Detects
4//! anomalies by comparing new observations against established baselines,
5//! and measures similarity between agents.
6
7use crate::types::*;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11// ---------------------------------------------------------------------------
12// BehaviorSample
13// ---------------------------------------------------------------------------
14
15/// A single observed behavior from an agent.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct BehaviorSample {
18    /// Identifier of the agent that produced this sample.
19    pub agent_id: String,
20    /// ISO-8601 timestamp of when the behavior was observed.
21    pub timestamp: String,
22    /// The tool that was used.
23    pub tool_used: String,
24    /// Evidence classification of the tool's output.
25    pub evidence_class: EvidenceClass,
26    /// Confidence of the tool's output.
27    pub confidence: f64,
28    /// How long the tool invocation took in milliseconds.
29    pub response_time_ms: u64,
30    /// Whether the invocation succeeded.
31    pub success: bool,
32}
33
34// ---------------------------------------------------------------------------
35// AgentFingerprint
36// ---------------------------------------------------------------------------
37
38/// Statistical fingerprint derived from an agent's behavior history.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct AgentFingerprint {
41    /// Agent identifier.
42    pub agent_id: String,
43    /// Number of samples ingested.
44    pub sample_count: usize,
45    /// Mean response time in milliseconds.
46    pub avg_response_time: f64,
47    /// Fraction of samples that failed (0.0 to 1.0).
48    pub error_rate: f64,
49    /// Distribution of evidence classes as percentages (0.0 to 1.0).
50    pub evidence_distribution: HashMap<EvidenceClass, f64>,
51    /// Most frequently used tools, ordered by frequency.
52    pub top_tools: Vec<String>,
53    /// Mean confidence across all samples.
54    pub confidence_mean: f64,
55    /// Standard deviation of confidence.
56    pub confidence_stddev: f64,
57    /// ISO-8601 timestamp of the most recent sample.
58    pub last_active: String,
59}
60
61// ---------------------------------------------------------------------------
62// AnomalyResult
63// ---------------------------------------------------------------------------
64
65/// Result of anomaly detection against an agent's baseline.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct AnomalyResult {
68    /// Whether the sample was flagged as anomalous.
69    pub anomalous: bool,
70    /// Human-readable reasons for the anomaly flag.
71    pub reasons: Vec<String>,
72    /// Severity score from 0.0 (normal) to 1.0 (extreme).
73    pub severity: f64,
74}
75
76// ---------------------------------------------------------------------------
77// HealthStatus
78// ---------------------------------------------------------------------------
79
80/// High-level health classification of an agent.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub enum HealthStatus {
83    /// Agent is operating within expected parameters.
84    Healthy,
85    /// Agent shows minor deviations from baseline.
86    Degraded,
87    /// Agent shows significant deviations; investigation needed.
88    Anomalous,
89    /// Agent has not been active recently.
90    Inactive,
91}
92
93// ---------------------------------------------------------------------------
94// FingerprintEngine
95// ---------------------------------------------------------------------------
96
97/// Engine that ingests behavior samples and computes agent fingerprints.
98#[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    /// Create an empty fingerprint engine.
111    pub fn new() -> Self {
112        Self {
113            samples: HashMap::new(),
114        }
115    }
116
117    /// Ingest a new behavior sample.
118    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    /// Compute the fingerprint for a given agent, or `None` if no samples exist.
126    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        // Evidence distribution as percentages.
139        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        // Top tools by frequency.
149        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        // Confidence mean and stddev.
158        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    /// Detect whether a new sample is anomalous relative to the agent's baseline.
188    ///
189    /// Checks:
190    /// - Response time > 2x the baseline mean
191    /// - Error rate > 3x the baseline rate (inferred from a single failing sample)
192    /// - Confidence drop > 0.2 below the baseline mean
193    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        // Response time check: > 2x mean.
209        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        // Error rate check: if this sample fails and baseline error rate is low.
221        if !sample.success {
222            let implied_error_rate = 1.0; // single failing sample = 100%
223            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                // Baseline has zero errors — any failure is anomalous.
234                reasons.push("failure against zero-error baseline".to_string());
235                severity += 0.3;
236            }
237        }
238
239        // Confidence drop check: > 0.2 below mean.
240        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    /// Compute a similarity score (0.0 to 1.0) between two agents.
260    ///
261    /// Compares evidence distribution, confidence mean, error rate, and
262    /// average response time. Returns 0.0 if either agent has no samples.
263    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        // Evidence distribution similarity (cosine-like overlap).
274        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        // Confidence similarity.
300        let conf_sim = 1.0 - (fp_a.confidence_mean - fp_b.confidence_mean).abs();
301
302        // Error rate similarity.
303        let err_sim = 1.0 - (fp_a.error_rate - fp_b.error_rate).abs();
304
305        // Response time similarity (normalized, capped).
306        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        // Weighted average of all axes.
314        (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        // Normal sample — not anomalous.
347        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        // Anomalous sample — slow + low confidence.
352        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}