Skip to main content

shadow_core/diff/
latency.rs

1//! Axis 5: end-to-end latency (SPEC §4.2 `chat_response.latency_ms`).
2
3use crate::agentlog::Record;
4use crate::diff::axes::{Axis, AxisStat};
5use crate::diff::bootstrap::{median, paired_ci};
6
7/// Extract `payload.latency_ms` from a chat_response, or `None` if missing.
8fn latency_ms(r: &Record) -> Option<f64> {
9    r.payload.get("latency_ms").and_then(|v| v.as_f64())
10}
11
12/// Compute the latency axis from paired response records.
13pub 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}