Skip to main content

agentic_evolve_core/metrics/
audit.rs

1//! Audit log — records every query with token usage for analysis.
2
3use std::sync::Mutex;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use super::tokens::Layer;
9use crate::query::intent::ExtractionIntent;
10
11/// A single audit entry recording one query's token usage.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AuditEntry {
14    /// When this query was made.
15    pub timestamp: DateTime<Utc>,
16    /// The tool/endpoint that was called.
17    pub tool: String,
18    /// Which layer served the response.
19    pub layer: Layer,
20    /// Tokens actually used.
21    pub tokens_used: u64,
22    /// Tokens saved compared to a full retrieval.
23    pub tokens_saved: u64,
24    /// Whether the cache was hit.
25    pub cache_hit: bool,
26    /// The extraction intent used.
27    pub intent: ExtractionIntent,
28    /// Size of the source data (items or bytes).
29    pub source_size: u64,
30    /// Size of the result data.
31    pub result_size: u64,
32}
33
34impl AuditEntry {
35    /// Create a new audit entry with the current timestamp.
36    #[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
61/// An append-only audit log of query entries.
62pub struct AuditLog {
63    entries: Mutex<Vec<AuditEntry>>,
64}
65
66impl AuditLog {
67    /// Create an empty audit log.
68    pub fn new() -> Self {
69        Self {
70            entries: Mutex::new(Vec::new()),
71        }
72    }
73
74    /// Append an entry.
75    pub fn record(&self, entry: AuditEntry) {
76        self.entries.lock().unwrap().push(entry);
77    }
78
79    /// Number of recorded entries.
80    pub fn len(&self) -> usize {
81        self.entries.lock().unwrap().len()
82    }
83
84    /// Whether the log is empty.
85    pub fn is_empty(&self) -> bool {
86        self.len() == 0
87    }
88
89    /// Total tokens used across all entries.
90    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    /// Total tokens saved across all entries.
100    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    /// Cache hit rate across all entries.
110    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    /// Distribution of queries across layers: `(layer, count)`.
120    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    /// Get a clone of all entries.
148    pub fn entries(&self) -> Vec<AuditEntry> {
149        self.entries.lock().unwrap().clone()
150    }
151
152    /// Clear the log.
153    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}