agentic_evolve_core/metrics/
audit.rs1use std::sync::Mutex;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use super::tokens::Layer;
9use crate::query::intent::ExtractionIntent;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AuditEntry {
14 pub timestamp: DateTime<Utc>,
16 pub tool: String,
18 pub layer: Layer,
20 pub tokens_used: u64,
22 pub tokens_saved: u64,
24 pub cache_hit: bool,
26 pub intent: ExtractionIntent,
28 pub source_size: u64,
30 pub result_size: u64,
32}
33
34impl AuditEntry {
35 #[allow(clippy::too_many_arguments)]
37 pub fn new(
38 tool: impl Into<String>,
39 layer: Layer,
40 tokens_used: u64,
41 tokens_saved: u64,
42 cache_hit: bool,
43 intent: ExtractionIntent,
44 source_size: u64,
45 result_size: u64,
46 ) -> Self {
47 Self {
48 timestamp: Utc::now(),
49 tool: tool.into(),
50 layer,
51 tokens_used,
52 tokens_saved,
53 cache_hit,
54 intent,
55 source_size,
56 result_size,
57 }
58 }
59}
60
61pub struct AuditLog {
63 entries: Mutex<Vec<AuditEntry>>,
64}
65
66impl AuditLog {
67 pub fn new() -> Self {
69 Self {
70 entries: Mutex::new(Vec::new()),
71 }
72 }
73
74 pub fn record(&self, entry: AuditEntry) {
76 self.entries.lock().unwrap().push(entry);
77 }
78
79 pub fn len(&self) -> usize {
81 self.entries.lock().unwrap().len()
82 }
83
84 pub fn is_empty(&self) -> bool {
86 self.len() == 0
87 }
88
89 pub fn total_tokens_used(&self) -> u64 {
91 self.entries
92 .lock()
93 .unwrap()
94 .iter()
95 .map(|e| e.tokens_used)
96 .sum()
97 }
98
99 pub fn total_tokens_saved(&self) -> u64 {
101 self.entries
102 .lock()
103 .unwrap()
104 .iter()
105 .map(|e| e.tokens_saved)
106 .sum()
107 }
108
109 pub fn cache_hit_rate(&self) -> f64 {
111 let entries = self.entries.lock().unwrap();
112 if entries.is_empty() {
113 return 0.0;
114 }
115 let hits = entries.iter().filter(|e| e.cache_hit).count() as f64;
116 hits / entries.len() as f64
117 }
118
119 pub fn layer_distribution(&self) -> Vec<(Layer, usize)> {
121 let entries = self.entries.lock().unwrap();
122 let mut cache = 0usize;
123 let mut index = 0usize;
124 let mut scoped = 0usize;
125 let mut delta = 0usize;
126 let mut full = 0usize;
127
128 for e in entries.iter() {
129 match e.layer {
130 Layer::Cache => cache += 1,
131 Layer::Index => index += 1,
132 Layer::Scoped => scoped += 1,
133 Layer::Delta => delta += 1,
134 Layer::Full => full += 1,
135 }
136 }
137
138 vec![
139 (Layer::Cache, cache),
140 (Layer::Index, index),
141 (Layer::Scoped, scoped),
142 (Layer::Delta, delta),
143 (Layer::Full, full),
144 ]
145 }
146
147 pub fn entries(&self) -> Vec<AuditEntry> {
149 self.entries.lock().unwrap().clone()
150 }
151
152 pub fn clear(&self) {
154 self.entries.lock().unwrap().clear();
155 }
156}
157
158impl Default for AuditLog {
159 fn default() -> Self {
160 Self::new()
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn make_entry(tool: &str, layer: Layer, used: u64, saved: u64, hit: bool) -> AuditEntry {
169 AuditEntry::new(
170 tool,
171 layer,
172 used,
173 saved,
174 hit,
175 ExtractionIntent::IdsOnly,
176 100,
177 10,
178 )
179 }
180
181 #[test]
182 fn empty_log() {
183 let log = AuditLog::new();
184 assert!(log.is_empty());
185 assert_eq!(log.total_tokens_used(), 0);
186 assert_eq!(log.cache_hit_rate(), 0.0);
187 }
188
189 #[test]
190 fn record_and_count() {
191 let log = AuditLog::new();
192 log.record(make_entry("tool_a", Layer::Cache, 0, 100, true));
193 log.record(make_entry("tool_b", Layer::Full, 100, 0, false));
194 assert_eq!(log.len(), 2);
195 }
196
197 #[test]
198 fn total_tokens_used() {
199 let log = AuditLog::new();
200 log.record(make_entry("a", Layer::Cache, 0, 100, true));
201 log.record(make_entry("b", Layer::Scoped, 10, 90, false));
202 assert_eq!(log.total_tokens_used(), 10);
203 }
204
205 #[test]
206 fn total_tokens_saved() {
207 let log = AuditLog::new();
208 log.record(make_entry("a", Layer::Cache, 0, 100, true));
209 log.record(make_entry("b", Layer::Full, 100, 0, false));
210 assert_eq!(log.total_tokens_saved(), 100);
211 }
212
213 #[test]
214 fn cache_hit_rate_mixed() {
215 let log = AuditLog::new();
216 log.record(make_entry("a", Layer::Cache, 0, 100, true));
217 log.record(make_entry("b", Layer::Full, 100, 0, false));
218 assert!((log.cache_hit_rate() - 0.5).abs() < f64::EPSILON);
219 }
220
221 #[test]
222 fn layer_distribution_counts() {
223 let log = AuditLog::new();
224 log.record(make_entry("a", Layer::Cache, 0, 100, true));
225 log.record(make_entry("b", Layer::Cache, 0, 50, true));
226 log.record(make_entry("c", Layer::Full, 100, 0, false));
227 let dist = log.layer_distribution();
228 let cache_count = dist.iter().find(|(l, _)| *l == Layer::Cache).unwrap().1;
229 let full_count = dist.iter().find(|(l, _)| *l == Layer::Full).unwrap().1;
230 assert_eq!(cache_count, 2);
231 assert_eq!(full_count, 1);
232 }
233
234 #[test]
235 fn clear_empties_log() {
236 let log = AuditLog::new();
237 log.record(make_entry("a", Layer::Cache, 0, 100, true));
238 log.clear();
239 assert!(log.is_empty());
240 }
241
242 #[test]
243 fn entries_returns_clone() {
244 let log = AuditLog::new();
245 log.record(make_entry("a", Layer::Scoped, 10, 90, false));
246 let entries = log.entries();
247 assert_eq!(entries.len(), 1);
248 assert_eq!(entries[0].tool, "a");
249 }
250}