shadow_core/diff/
latency.rs1use crate::agentlog::Record;
4use crate::diff::axes::{Axis, AxisStat};
5use crate::diff::bootstrap::{median, paired_ci};
6
7fn latency_ms(r: &Record) -> Option<f64> {
9 r.payload.get("latency_ms").and_then(|v| v.as_f64())
10}
11
12pub fn compute(pairs: &[(&Record, &Record)], seed: Option<u64>) -> AxisStat {
14 let mut baseline_vals = Vec::with_capacity(pairs.len());
15 let mut candidate_vals = Vec::with_capacity(pairs.len());
16 for (b, c) in pairs {
17 if let (Some(bv), Some(cv)) = (latency_ms(b), latency_ms(c)) {
18 baseline_vals.push(bv);
19 candidate_vals.push(cv);
20 }
21 }
22 if baseline_vals.is_empty() {
23 return AxisStat::empty(Axis::Latency);
24 }
25 let baseline_median = median(&baseline_vals);
26 let candidate_median = median(&candidate_vals);
27 let delta = candidate_median - baseline_median;
28 let ci = paired_ci(
29 &baseline_vals,
30 &candidate_vals,
31 |b, c| median(c) - median(b),
32 0,
33 seed,
34 );
35 AxisStat::new_value(
36 Axis::Latency,
37 baseline_median,
38 candidate_median,
39 delta,
40 ci.low,
41 ci.high,
42 baseline_vals.len(),
43 )
44}
45
46#[cfg(test)]
47mod tests {
48 use super::*;
49 use crate::agentlog::Kind;
50 use serde_json::json;
51
52 fn response(latency: f64) -> Record {
53 Record::new(
54 Kind::ChatResponse,
55 json!({
56 "model": "x",
57 "content": [],
58 "stop_reason": "end_turn",
59 "latency_ms": latency,
60 "usage": {"input_tokens": 1, "output_tokens": 1, "thinking_tokens": 0},
61 }),
62 "2026-04-21T10:00:00Z",
63 None,
64 )
65 }
66
67 use crate::diff::axes::Severity;
68
69 #[test]
70 fn equal_latency_has_zero_delta_and_no_severity() {
71 let rs: Vec<Record> = (0..20).map(|i| response(100.0 + i as f64)).collect();
72 let pairs: Vec<(&Record, &Record)> = rs.iter().zip(rs.iter()).collect();
73 let stat = compute(&pairs, Some(1));
74 assert_eq!(stat.axis, Axis::Latency);
75 assert_eq!(stat.severity, Severity::None);
76 assert!(stat.delta.abs() < 1e-6);
77 }
78
79 #[test]
80 fn candidate_2x_slower_is_moderate_or_severe() {
81 let baseline: Vec<Record> = (0..20).map(|i| response(100.0 + i as f64)).collect();
82 let candidate: Vec<Record> = (0..20).map(|i| response(200.0 + 2.0 * i as f64)).collect();
83 let pairs: Vec<(&Record, &Record)> = baseline.iter().zip(candidate.iter()).collect();
84 let stat = compute(&pairs, Some(1));
85 assert!(stat.delta > 90.0);
86 assert!(matches!(
87 stat.severity,
88 Severity::Moderate | Severity::Severe
89 ));
90 }
91
92 #[test]
93 fn missing_latency_is_skipped() {
94 let without_latency = Record::new(
95 Kind::ChatResponse,
96 json!({"model": "x"}),
97 "2026-04-21T10:00:00Z",
98 None,
99 );
100 let with_latency = response(100.0);
101 let pairs = [(&without_latency, &with_latency)];
102 let stat = compute(&pairs, Some(1));
103 assert_eq!(stat.n, 0);
104 }
105}