vellaveto_engine/
agent_baseline.rs1use std::collections::HashMap;
18use vellaveto_types::provenance::SinkClass;
19
20const MAX_AGENTS: usize = 1000;
22
23#[derive(Debug, Clone, Default)]
25pub struct AgentBaseline {
26 pub tool_counts: HashMap<String, u32>,
28 pub sink_counts: HashMap<u8, u32>,
30 pub total_calls: u32,
32 pub established: bool,
34}
35
36#[derive(Debug, Clone)]
38pub struct DeviationFinding {
39 pub deviation_type: DeviationType,
40 pub agent_id: String,
41 pub confidence: u32,
42 pub description: String,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum DeviationType {
47 NovelToolUsage,
49 SinkClassDeviation,
51 RateAnomaly,
53 BehaviorShift,
55}
56
57pub struct AgentBaselineTracker {
59 baselines: HashMap<String, AgentBaseline>,
60 min_baseline_calls: u32,
62}
63
64impl AgentBaselineTracker {
65 pub fn new(min_baseline_calls: u32) -> Self {
66 Self {
67 baselines: HashMap::new(),
68 min_baseline_calls: min_baseline_calls.max(5),
69 }
70 }
71
72 pub fn record_and_check(
74 &mut self,
75 agent_id: &str,
76 tool_name: &str,
77 sink_class: SinkClass,
78 ) -> Vec<DeviationFinding> {
79 if self.baselines.len() >= MAX_AGENTS && !self.baselines.contains_key(agent_id) {
80 return Vec::new();
81 }
82
83 let baseline = self.baselines.entry(agent_id.to_string()).or_default();
84
85 let mut findings = Vec::new();
86
87 if baseline.established {
88 if !baseline.tool_counts.contains_key(tool_name) {
90 findings.push(DeviationFinding {
91 deviation_type: DeviationType::NovelToolUsage,
92 agent_id: agent_id.to_string(),
93 confidence: 60,
94 description: format!(
95 "Agent '{}' using novel tool '{}' not in baseline ({} known tools)",
96 &agent_id[..agent_id.len().min(32)],
97 &tool_name[..tool_name.len().min(32)],
98 baseline.tool_counts.len()
99 ),
100 });
101 }
102
103 let sink_rank = sink_class.rank();
105 if !baseline.sink_counts.contains_key(&sink_rank)
106 && sink_rank >= SinkClass::CodeExecution.rank()
107 {
108 findings.push(DeviationFinding {
109 deviation_type: DeviationType::SinkClassDeviation,
110 agent_id: agent_id.to_string(),
111 confidence: 75,
112 description: format!(
113 "Agent '{}' targeting {:?} — not in behavioral baseline",
114 &agent_id[..agent_id.len().min(32)],
115 sink_class
116 ),
117 });
118 }
119 }
120
121 *baseline
123 .tool_counts
124 .entry(tool_name[..tool_name.len().min(256)].to_string())
125 .or_insert(0) = baseline
126 .tool_counts
127 .get(tool_name)
128 .copied()
129 .unwrap_or(0)
130 .saturating_add(1);
131 *baseline.sink_counts.entry(sink_class.rank()).or_insert(0) = baseline
132 .sink_counts
133 .get(&sink_class.rank())
134 .copied()
135 .unwrap_or(0)
136 .saturating_add(1);
137 baseline.total_calls = baseline.total_calls.saturating_add(1);
138
139 if !baseline.established && baseline.total_calls >= self.min_baseline_calls {
140 baseline.established = true;
141 }
142
143 findings
144 }
145
146 pub fn get_baseline(&self, agent_id: &str) -> Option<&AgentBaseline> {
148 self.baselines.get(agent_id).filter(|b| b.established)
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn test_baseline_not_established_no_findings() {
158 let mut tracker = AgentBaselineTracker::new(10);
159 let findings = tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
160 assert!(findings.is_empty());
161 assert!(tracker.get_baseline("agent-1").is_none());
162 }
163
164 #[test]
165 fn test_baseline_established_after_min_calls() {
166 let mut tracker = AgentBaselineTracker::new(5);
167 for _ in 0..5 {
168 tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
169 }
170 assert!(tracker.get_baseline("agent-1").is_some());
171 }
172
173 #[test]
174 fn test_novel_tool_deviation() {
175 let mut tracker = AgentBaselineTracker::new(5);
176 for _ in 0..5 {
177 tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
178 }
179 let findings =
181 tracker.record_and_check("agent-1", "execute_command", SinkClass::CodeExecution);
182 assert!(findings
183 .iter()
184 .any(|f| f.deviation_type == DeviationType::NovelToolUsage));
185 }
186
187 #[test]
188 fn test_sink_class_deviation() {
189 let mut tracker = AgentBaselineTracker::new(5);
190 for _ in 0..5 {
191 tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
192 }
193 let findings = tracker.record_and_check("agent-1", "read_file", SinkClass::CodeExecution);
195 assert!(findings
196 .iter()
197 .any(|f| f.deviation_type == DeviationType::SinkClassDeviation));
198 }
199
200 #[test]
201 fn test_known_tool_no_finding() {
202 let mut tracker = AgentBaselineTracker::new(5);
203 for _ in 0..5 {
204 tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
205 }
206 let findings = tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
208 assert!(findings.is_empty());
209 }
210}