1use std::collections::HashMap;
7
8use edda_core::Event;
9use serde::{Deserialize, Serialize};
10
11use crate::aggregate::DateRange;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ModelQuality {
16 pub model: String,
17 pub runtime: String,
18 pub total_steps: u64,
19 pub success_count: u64,
20 pub failed_count: u64,
21 pub cancelled_count: u64,
22 pub success_rate: f64,
23 pub avg_cost_usd: f64,
24 pub avg_latency_ms: f64,
25 pub total_cost_usd: f64,
26 pub total_tokens_in: u64,
27 pub total_tokens_out: u64,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct QualityReport {
33 pub models: Vec<ModelQuality>,
34 pub total_steps: u64,
35 pub overall_success_rate: f64,
36 pub total_cost_usd: f64,
37}
38
39pub fn model_quality_from_events(events: &[Event], range: &DateRange) -> QualityReport {
43 struct Accum {
45 success: u64,
46 failed: u64,
47 cancelled: u64,
48 cost_sum: f64,
49 latency_sum: f64,
50 tokens_in: u64,
51 tokens_out: u64,
52 }
53
54 let mut groups: HashMap<(String, String), Accum> = HashMap::new();
55
56 for event in events {
57 if !range.matches(&event.ts) {
58 continue;
59 }
60
61 let model = event
62 .payload
63 .get("model")
64 .and_then(|v| v.as_str())
65 .unwrap_or("unknown")
66 .to_string();
67 let runtime = event
68 .payload
69 .get("runtime")
70 .and_then(|v| v.as_str())
71 .unwrap_or("unknown")
72 .to_string();
73
74 let status = event
75 .payload
76 .get("result")
77 .and_then(|r| r.get("status"))
78 .and_then(|v| v.as_str())
79 .unwrap_or("unknown");
80
81 let cost = event
82 .payload
83 .get("usage")
84 .and_then(|u| u.get("cost_usd"))
85 .and_then(|v| v.as_f64())
86 .unwrap_or(0.0);
87 let latency = event
88 .payload
89 .get("usage")
90 .and_then(|u| u.get("latency_ms"))
91 .and_then(|v| v.as_f64())
92 .unwrap_or(0.0);
93 let tok_in = event
94 .payload
95 .get("usage")
96 .and_then(|u| u.get("token_in"))
97 .and_then(|v| v.as_u64())
98 .unwrap_or(0);
99 let tok_out = event
100 .payload
101 .get("usage")
102 .and_then(|u| u.get("token_out"))
103 .and_then(|v| v.as_u64())
104 .unwrap_or(0);
105
106 let acc = groups.entry((model, runtime)).or_insert(Accum {
107 success: 0,
108 failed: 0,
109 cancelled: 0,
110 cost_sum: 0.0,
111 latency_sum: 0.0,
112 tokens_in: 0,
113 tokens_out: 0,
114 });
115
116 match status {
117 "success" => acc.success += 1,
118 "failed" => acc.failed += 1,
119 "cancelled" => acc.cancelled += 1,
120 _ => acc.failed += 1, }
122
123 acc.cost_sum += cost;
124 acc.latency_sum += latency;
125 acc.tokens_in += tok_in;
126 acc.tokens_out += tok_out;
127 }
128
129 let mut models: Vec<ModelQuality> = groups
130 .into_iter()
131 .map(|((model, runtime), acc)| {
132 let total = acc.success + acc.failed + acc.cancelled;
133 ModelQuality {
134 model,
135 runtime,
136 total_steps: total,
137 success_count: acc.success,
138 failed_count: acc.failed,
139 cancelled_count: acc.cancelled,
140 success_rate: if total > 0 {
141 acc.success as f64 / total as f64
142 } else {
143 0.0
144 },
145 avg_cost_usd: if total > 0 {
146 acc.cost_sum / total as f64
147 } else {
148 0.0
149 },
150 avg_latency_ms: if total > 0 {
151 acc.latency_sum / total as f64
152 } else {
153 0.0
154 },
155 total_cost_usd: acc.cost_sum,
156 total_tokens_in: acc.tokens_in,
157 total_tokens_out: acc.tokens_out,
158 }
159 })
160 .collect();
161
162 models.sort_by(|a, b| (&a.model, &a.runtime).cmp(&(&b.model, &b.runtime)));
164
165 let total_steps: u64 = models.iter().map(|m| m.total_steps).sum();
166 let total_success: u64 = models.iter().map(|m| m.success_count).sum();
167 let total_cost: f64 = models.iter().map(|m| m.total_cost_usd).sum();
168
169 QualityReport {
170 models,
171 total_steps,
172 overall_success_rate: if total_steps > 0 {
173 total_success as f64 / total_steps as f64
174 } else {
175 0.0
176 },
177 total_cost_usd: total_cost,
178 }
179}
180
181#[cfg(test)]
183mod tests {
184 use super::*;
185 use edda_core::event::{new_execution_event, new_note_event};
186
187 fn make_exec_event(
188 model: &str,
189 runtime: &str,
190 status: &str,
191 cost: f64,
192 latency: f64,
193 ts: &str,
194 ) -> Event {
195 let payload = serde_json::json!({
196 "runtime": runtime,
197 "model": model,
198 "usage": { "token_in": 100, "token_out": 50, "cost_usd": cost, "latency_ms": latency },
199 "result": { "status": status },
200 "event_type": "step_completed",
201 });
202 new_execution_event(
203 "main",
204 None,
205 &format!("evt_{}", rand_id()),
206 ts,
207 payload,
208 None,
209 )
210 .unwrap()
211 }
212
213 fn rand_id() -> String {
214 use std::sync::atomic::{AtomicU64, Ordering};
215 static CTR: AtomicU64 = AtomicU64::new(0);
216 format!("{}", CTR.fetch_add(1, Ordering::SeqCst))
217 }
218
219 #[test]
220 fn model_quality_empty_events() {
221 let report = model_quality_from_events(&[], &DateRange::default());
222 assert_eq!(report.total_steps, 0);
223 assert_eq!(report.overall_success_rate, 0.0);
224 assert_eq!(report.total_cost_usd, 0.0);
225 assert!(report.models.is_empty());
226 }
227
228 #[test]
229 fn model_quality_single_model() {
230 let events = vec![
231 make_exec_event(
232 "claude-3-opus",
233 "claude",
234 "success",
235 0.01,
236 500.0,
237 "2026-03-11T00:00:00Z",
238 ),
239 make_exec_event(
240 "claude-3-opus",
241 "claude",
242 "success",
243 0.02,
244 600.0,
245 "2026-03-11T01:00:00Z",
246 ),
247 make_exec_event(
248 "claude-3-opus",
249 "claude",
250 "failed",
251 0.005,
252 300.0,
253 "2026-03-11T02:00:00Z",
254 ),
255 ];
256
257 let report = model_quality_from_events(&events, &DateRange::default());
258 assert_eq!(report.total_steps, 3);
259 assert_eq!(report.models.len(), 1);
260
261 let m = &report.models[0];
262 assert_eq!(m.model, "claude-3-opus");
263 assert_eq!(m.runtime, "claude");
264 assert_eq!(m.success_count, 2);
265 assert_eq!(m.failed_count, 1);
266 assert!((m.success_rate - 2.0 / 3.0).abs() < 1e-9);
267 assert!((m.total_cost_usd - 0.035).abs() < 1e-9);
268 assert!((m.avg_latency_ms - (500.0 + 600.0 + 300.0) / 3.0).abs() < 1e-9);
269 }
270
271 #[test]
272 fn model_quality_multiple_models() {
273 let events = vec![
274 make_exec_event(
275 "claude-3-opus",
276 "claude",
277 "success",
278 0.01,
279 500.0,
280 "2026-03-11T00:00:00Z",
281 ),
282 make_exec_event(
283 "gpt-4",
284 "codex",
285 "failed",
286 0.02,
287 800.0,
288 "2026-03-11T01:00:00Z",
289 ),
290 ];
291
292 let report = model_quality_from_events(&events, &DateRange::default());
293 assert_eq!(report.total_steps, 2);
294 assert_eq!(report.models.len(), 2);
295 assert!((report.overall_success_rate - 0.5).abs() < 1e-9);
296 }
297
298 #[test]
299 fn model_quality_missing_fields() {
300 let payload = serde_json::json!({
302 "usage": { "cost_usd": 0.01, "latency_ms": 100 },
303 "result": { "status": "success" },
304 });
305 let event = new_execution_event(
306 "main",
307 None,
308 "evt_missing",
309 "2026-03-11T00:00:00Z",
310 payload,
311 None,
312 )
313 .unwrap();
314
315 let report = model_quality_from_events(&[event], &DateRange::default());
316 assert_eq!(report.models.len(), 1);
317 assert_eq!(report.models[0].model, "unknown");
318 assert_eq!(report.models[0].runtime, "unknown");
319 }
320
321 #[test]
322 fn model_quality_date_range_filter() {
323 let events = vec![
324 make_exec_event("m1", "r1", "success", 0.01, 100.0, "2026-03-10T00:00:00Z"),
325 make_exec_event("m1", "r1", "success", 0.01, 100.0, "2026-03-15T00:00:00Z"),
326 make_exec_event("m1", "r1", "success", 0.01, 100.0, "2026-03-20T00:00:00Z"),
327 ];
328
329 let range = DateRange {
330 after: Some("2026-03-12".to_string()),
331 before: Some("2026-03-18".to_string()),
332 };
333
334 let report = model_quality_from_events(&events, &range);
335 assert_eq!(report.total_steps, 1); }
337
338 #[test]
339 fn model_quality_cancelled_status() {
340 let events = vec![make_exec_event(
341 "m1",
342 "r1",
343 "cancelled",
344 0.0,
345 50.0,
346 "2026-03-11T00:00:00Z",
347 )];
348
349 let report = model_quality_from_events(&events, &DateRange::default());
350 assert_eq!(report.models[0].cancelled_count, 1);
351 assert_eq!(report.models[0].success_rate, 0.0);
352 }
353
354 #[test]
355 fn model_quality_note_events_ignored_by_type_convention() {
356 let note = new_note_event("main", None, "user", "just a note", &[]).unwrap();
359 let report = model_quality_from_events(&[note], &DateRange::default());
360 assert_eq!(report.total_steps, 1);
362 assert_eq!(report.models[0].model, "unknown");
363 }
364}