Skip to main content

inspector_analysis/
latency.rs

1//! Deterministic latency rule. No LLM involved.
2use inspector_core::{Diagnosis, FindingStatus, MetricKind, Snapshot};
3
4/// Threshold: current p50 must exceed `threshold_ratio * baseline p50`
5/// with at least `min_samples` observations. Tuned from Experiment C data.
6pub const DEFAULT_THRESHOLD_RATIO: f64 = 1.5;
7pub const MIN_SAMPLES: u64 = 30;
8
9pub fn investigate_latency(
10    base: &Snapshot,
11    current: &Snapshot,
12    base_id: &str,
13    current_id: &str,
14) -> Diagnosis {
15    investigate_latency_with_threshold(base, current, base_id, current_id, DEFAULT_THRESHOLD_RATIO)
16}
17
18pub fn investigate_latency_with_threshold(
19    base: &Snapshot,
20    current: &Snapshot,
21    base_id: &str,
22    current_id: &str,
23    threshold_ratio: f64,
24) -> Diagnosis {
25    let evidence = vec![base_id.to_string(), current_id.to_string()];
26    let (bp50, bcount) = latency_p50_count(base);
27    let (cp50, ccount) = latency_p50_count(current);
28
29    match (bp50, cp50) {
30        (Some(b), Some(c)) if b > 0.0 => {
31            if ccount < MIN_SAMPLES {
32                return Diagnosis {
33                    status: FindingStatus::Observed,
34                    finding: format!(
35                        "insufficient samples for verdict (current count {ccount} < {MIN_SAMPLES})"
36                    ),
37                    hypothesis: None,
38                    confidence: 0.4,
39                    evidence,
40                    verification: Some("capture a longer window, then re-compare".into()),
41                };
42            }
43            let ratio = c / b;
44            let _ = bcount;
45            if ratio >= threshold_ratio {
46                Diagnosis {
47                    status: FindingStatus::Hypothesis,
48                    finding: format!("request latency rose {b:.0}ms -> {c:.0}ms (x{ratio:.1})"),
49                    hypothesis: Some(
50                        "increase may be associated with resource contention or blocking (see task/resource evidence)".into(),
51                    ),
52                    confidence: 0.72,
53                    evidence,
54                    verification: Some(
55                        "reproduce with controlled workload; remove suspected bottleneck and re-capture".into(),
56                    ),
57                }
58            } else {
59                Diagnosis {
60                    status: FindingStatus::NoIssue,
61                    finding: format!(
62                        "no latency regression (ratio x{ratio:.2}, threshold x{threshold_ratio})"
63                    ),
64                    hypothesis: None,
65                    confidence: 0.9,
66                    evidence,
67                    verification: None,
68                }
69            }
70        }
71        _ => Diagnosis {
72            status: FindingStatus::Observed,
73            finding: "latency data missing in one or both snapshots; no verdict possible".into(),
74            hypothesis: None,
75            confidence: 0.3,
76            evidence,
77            verification: Some(
78                "check completeness.missing_fields for histogram availability".into(),
79            ),
80        },
81    }
82}
83
84fn latency_p50_count(snap: &Snapshot) -> (Option<f64>, u64) {
85    for m in &snap.metrics {
86        if m.name == "request_latency_ms" {
87            if let MetricKind::Histogram { p50, count, .. } = &m.kind {
88                return (*p50, *count);
89            }
90        }
91    }
92    (None, 0)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use inspector_core::{MetricObservation, RuntimeOverview};
99
100    fn snap_with(p50: Option<f64>, count: u64) -> Snapshot {
101        Snapshot {
102            overview: RuntimeOverview {
103                service_name: None,
104                console_addr: "t".into(),
105                active_tasks: Some(10),
106                completed_tasks: None,
107                tasks_truncated: false,
108                metric_names: vec![],
109            },
110            tasks: vec![],
111            resources: vec![],
112            metrics: p50
113                .map(|p| {
114                    vec![MetricObservation {
115                        name: "request_latency_ms".into(),
116                        kind: MetricKind::Histogram {
117                            count,
118                            sum: p * count as f64,
119                            p50: Some(p),
120                            p95: None,
121                            p99: None,
122                        },
123                        unit: "ms".into(),
124                        observed_at_unix_ms: 0,
125                    }]
126                })
127                .unwrap_or_default(),
128        }
129    }
130
131    #[test]
132    fn fires_on_regression() {
133        let d = investigate_latency(
134            &snap_with(Some(40.0), 100),
135            &snap_with(Some(310.0), 100),
136            "a",
137            "b",
138        );
139        assert_eq!(d.status, FindingStatus::Hypothesis);
140        assert!(d.is_valid());
141    }
142
143    #[test]
144    fn quiet_on_normal() {
145        let d = investigate_latency(
146            &snap_with(Some(40.0), 100),
147            &snap_with(Some(42.0), 100),
148            "a",
149            "b",
150        );
151        assert_eq!(d.status, FindingStatus::NoIssue);
152    }
153
154    #[test]
155    fn missing_has_no_verdict() {
156        let d = investigate_latency(&snap_with(None, 0), &snap_with(Some(40.0), 100), "a", "b");
157        assert_eq!(d.status, FindingStatus::Observed);
158    }
159}