Skip to main content

edda_aggregate/
quality.rs

1//! Model/runtime quality aggregation from Karvi execution events.
2//!
3//! Groups `execution_event` entries by `(model, runtime)` and computes
4//! success rate, average cost, average latency, and token totals.
5
6use std::collections::HashMap;
7
8use edda_core::Event;
9use serde::{Deserialize, Serialize};
10
11use crate::aggregate::DateRange;
12
13/// Per-(model, runtime) quality summary.
14#[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/// Full quality aggregation result.
31#[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
39/// Compute quality metrics from a slice of events (typically `execution_event` type).
40///
41/// Events outside the given `DateRange` are excluded.
42pub fn model_quality_from_events(events: &[Event], range: &DateRange) -> QualityReport {
43    // Accumulator per (model, runtime) key.
44    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, // unknown status counted as failed
121        }
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    // Sort by model then runtime for deterministic output.
163    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/// Aggregate model quality across all registered projects.
182#[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        // Event with no model/runtime in payload
301        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); // only the 03-15 event
336    }
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        // This tests that non-execution events (which should not be passed in)
357        // still produce sensible results if they happen to lack expected fields.
358        let note = new_note_event("main", None, "user", "just a note", &[]).unwrap();
359        let report = model_quality_from_events(&[note], &DateRange::default());
360        // The note event has no result/usage, so it gets grouped under unknown
361        assert_eq!(report.total_steps, 1);
362        assert_eq!(report.models[0].model, "unknown");
363    }
364}