Skip to main content

max / makenotwork

pom: move drift/staleness analysis out of the HTTP probe checks/http.rs had grown into a god-module: detect_latency_drift, detect_test_duration_drift, and compute_test_staleness compute trends over stored history and have nothing to do with issuing an HTTP request. Move all three (and their tests) into a new checks/drift.rs so http.rs is only the request/response path. Pure relocation — callers updated, no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 17:22 UTC
Commit: a884abe0887d347e353581cc72215e5c999c5057
Parent: 20095e9
8 files changed, +339 insertions, -334 deletions
M pom/src/api.rs +1 -1
@@ -11,7 +11,7 @@ use axum::routing::get;
11 11 use axum::{Json, Router};
12 12 use serde::Serialize;
13 13
14 - use crate::checks::http::{compute_test_staleness, detect_test_duration_drift};
14 + use crate::checks::drift::{compute_test_staleness, detect_test_duration_drift};
15 15 use crate::config::Config;
16 16 use crate::db;
17 17 use crate::peer::SharedMeshState;
@@ -0,0 +1,327 @@
1 + //! Trend/staleness analysis over stored check history.
2 + //!
3 + //! Extracted from the HTTP probe (`checks/http.rs`): latency-drift, test-duration-
4 + //! drift, and test-staleness are computed over recorded data, not part of issuing
5 + //! a request. Kept here so the HTTP module is only the request/response path.
6 +
7 + /// Detect sustained latency drift by checking if all recent response times
8 + /// exceed the baseline average by the given threshold multiplier.
9 + ///
10 + /// Returns a description string if drift is detected, `None` otherwise.
11 + /// Requires at least 10 baseline samples to avoid false positives.
12 + pub fn detect_latency_drift(
13 + recent_times: &[i64],
14 + baseline: &crate::types::LatencyStats,
15 + threshold: f64,
16 + ) -> Option<String> {
17 + if baseline.sample_count < 10 || recent_times.is_empty() {
18 + return None;
19 + }
20 + let drift_threshold = baseline.avg_ms * threshold;
21 + let all_over = recent_times.iter().all(|&t| t as f64 > drift_threshold);
22 + if all_over {
23 + let avg_recent: f64 = recent_times.iter().sum::<i64>() as f64 / recent_times.len() as f64;
24 + Some(format!(
25 + "latency drift: last {} checks avg {:.0}ms (baseline avg {:.0}ms, threshold {:.0}ms)",
26 + recent_times.len(),
27 + avg_recent,
28 + baseline.avg_ms,
29 + drift_threshold,
30 + ))
31 + } else {
32 + None
33 + }
34 + }
35 +
36 + /// Detect sustained test duration drift by checking if all recent durations
37 + /// exceed the baseline average by the given threshold multiplier.
38 + ///
39 + /// Returns a description string if drift is detected, `None` otherwise.
40 + /// Requires at least `baseline_count` samples for the baseline window.
41 + pub fn detect_test_duration_drift(
42 + durations: &[(String, i64)],
43 + baseline_count: usize,
44 + recent_count: usize,
45 + threshold: f64,
46 + ) -> Option<String> {
47 + if durations.len() < baseline_count + recent_count {
48 + return None;
49 + }
50 +
51 + // durations are ordered most recent first from get_test_durations
52 + let recent = &durations[..recent_count];
53 + let baseline = &durations[recent_count..];
54 +
55 + if baseline.is_empty() {
56 + return None;
57 + }
58 +
59 + let baseline_avg = baseline.iter().map(|(_, d)| *d).sum::<i64>() as f64 / baseline.len() as f64;
60 + let drift_threshold = baseline_avg * threshold;
61 +
62 + let all_over = recent.iter().all(|(_, d)| *d as f64 > drift_threshold);
63 + if all_over {
64 + let recent_avg = recent.iter().map(|(_, d)| *d).sum::<i64>() as f64 / recent.len() as f64;
65 + Some(format!(
66 + "test duration drift: last {} runs avg {:.0}s (baseline avg {:.0}s, threshold {:.0}s)",
67 + recent_count,
68 + recent_avg,
69 + baseline_avg,
70 + drift_threshold,
71 + ))
72 + } else {
73 + None
74 + }
75 + }
76 +
77 + /// Compute test staleness from version and timing data.
78 + ///
79 + /// A target's tests are considered stale when:
80 + /// 1. No tests have ever been run
81 + /// 2. Tests are older than `staleness_days`
82 + /// 3. The deployed version has changed since the last test run
83 + pub fn compute_test_staleness(
84 + current_version: Option<&str>,
85 + tested_version: Option<&str>,
86 + last_test_at: Option<&str>,
87 + staleness_days: u64,
88 + ) -> crate::types::TestStaleness {
89 + let Some(last_test_at) = last_test_at else {
90 + return crate::types::TestStaleness {
91 + stale: true,
92 + reason: Some("no tests have been run".to_string()),
93 + current_version: current_version.map(String::from),
94 + tested_version: None,
95 + last_test_at: None,
96 + days_since_test: None,
97 + };
98 + };
99 +
100 + let days_since = chrono::DateTime::parse_from_rfc3339(last_test_at)
101 + .ok()
102 + .map(|dt| {
103 + let now = chrono::Utc::now();
104 + (now - dt.with_timezone(&chrono::Utc)).num_days()
105 + });
106 +
107 + if let Some(days) = days_since
108 + && days >= staleness_days as i64
109 + {
110 + return crate::types::TestStaleness {
111 + stale: true,
112 + reason: Some(format!("tests are {days} days old (threshold: {staleness_days}d)")),
113 + current_version: current_version.map(String::from),
114 + tested_version: tested_version.map(String::from),
115 + last_test_at: Some(last_test_at.to_string()),
116 + days_since_test: Some(days),
117 + };
118 + }
119 +
120 + if let (Some(current), Some(tested)) = (current_version, tested_version)
121 + && current != tested
122 + {
123 + return crate::types::TestStaleness {
124 + stale: true,
125 + reason: Some(format!("version changed: {tested} -> {current}")),
126 + current_version: Some(current.to_string()),
127 + tested_version: Some(tested.to_string()),
128 + last_test_at: Some(last_test_at.to_string()),
129 + days_since_test: days_since,
130 + };
131 + }
132 +
133 + crate::types::TestStaleness {
134 + stale: false,
135 + reason: None,
136 + current_version: current_version.map(String::from),
137 + tested_version: tested_version.map(String::from),
138 + last_test_at: Some(last_test_at.to_string()),
139 + days_since_test: days_since,
140 + }
141 + }
142 +
143 + #[cfg(test)]
144 + mod tests {
145 + use super::*;
146 +
147 + fn baseline(avg: f64, count: i64) -> crate::types::LatencyStats {
148 + crate::types::LatencyStats {
149 + min_ms: avg as i64 / 2,
150 + max_ms: avg as i64 * 2,
151 + avg_ms: avg,
152 + p95_ms: (avg * 1.5) as i64,
153 + sample_count: count,
154 + }
155 + }
156 +
157 + fn dur(name: &str, secs: i64) -> (String, i64) {
158 + (name.to_string(), secs)
159 + }
160 +
161 + #[test]
162 + fn drift_all_over_threshold() {
163 + let bl = baseline(100.0, 100);
164 + let recent = vec![250, 260, 270]; // all > 200 (100 * 2.0)
165 + let result = detect_latency_drift(&recent, &bl, 2.0);
166 + assert!(result.is_some());
167 + assert!(result.unwrap().contains("latency drift"));
168 + }
169 +
170 + #[test]
171 + fn drift_one_under_threshold() {
172 + let bl = baseline(100.0, 100);
173 + let recent = vec![250, 150, 270]; // 150 < 200
174 + let result = detect_latency_drift(&recent, &bl, 2.0);
175 + assert!(result.is_none());
176 + }
177 +
178 + #[test]
179 + fn drift_insufficient_baseline() {
180 + let bl = baseline(100.0, 5); // < 10 samples
181 + let recent = vec![250, 260, 270];
182 + let result = detect_latency_drift(&recent, &bl, 2.0);
183 + assert!(result.is_none());
184 + }
185 +
186 + #[test]
187 + fn drift_empty_recent() {
188 + let bl = baseline(100.0, 100);
189 + let result = detect_latency_drift(&[], &bl, 2.0);
190 + assert!(result.is_none());
191 + }
192 +
193 + #[test]
194 + fn drift_threshold_edge() {
195 + let bl = baseline(100.0, 100);
196 + // Exactly at threshold (200): not strictly over
197 + let recent = vec![200, 200, 200];
198 + let result = detect_latency_drift(&recent, &bl, 2.0);
199 + assert!(result.is_none()); // must be strictly greater
200 + }
201 +
202 + #[test]
203 + fn drift_just_over_threshold() {
204 + let bl = baseline(100.0, 100);
205 + let recent = vec![201, 201, 201];
206 + let result = detect_latency_drift(&recent, &bl, 2.0);
207 + assert!(result.is_some());
208 + }
209 +
210 + #[test]
211 + fn staleness_no_test_run() {
212 + let result = compute_test_staleness(Some("1.0.0"), None, None, 7);
213 + assert!(result.stale);
214 + assert_eq!(result.reason.as_deref(), Some("no tests have been run"));
215 + assert!(result.last_test_at.is_none());
216 + }
217 +
218 + #[test]
219 + fn staleness_stale_by_age() {
220 + let old = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
221 + let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&old), 7);
222 + assert!(result.stale);
223 + let reason = result.reason.unwrap();
224 + assert!(reason.contains("days old"), "reason was: {reason}");
225 + assert!(reason.contains("threshold: 7d"), "reason was: {reason}");
226 + }
227 +
228 + #[test]
229 + fn staleness_stale_by_version() {
230 + let recent = chrono::Utc::now().to_rfc3339();
231 + let result = compute_test_staleness(Some("1.1.0"), Some("1.0.0"), Some(&recent), 7);
232 + assert!(result.stale);
233 + let reason = result.reason.unwrap();
234 + assert!(reason.contains("version changed: 1.0.0 -> 1.1.0"), "reason was: {reason}");
235 + }
236 +
237 + #[test]
238 + fn staleness_fresh() {
239 + let recent = chrono::Utc::now().to_rfc3339();
240 + let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&recent), 7);
241 + assert!(!result.stale);
242 + assert!(result.reason.is_none());
243 + }
244 +
245 + #[test]
246 + fn staleness_missing_versions_not_stale() {
247 + let recent = chrono::Utc::now().to_rfc3339();
248 + let result = compute_test_staleness(None, None, Some(&recent), 7);
249 + assert!(!result.stale);
250 + }
251 +
252 + #[test]
253 + fn staleness_at_threshold_is_stale() {
254 + // Pins `days >= staleness_days` (vs `>`). days == 7, threshold == 7
255 + // must report stale.
256 + let exactly = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339();
257 + let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&exactly), 7);
258 + assert!(result.stale, "days == threshold must be stale");
259 + }
260 +
261 + #[test]
262 + fn staleness_one_day_under_threshold_is_fresh() {
263 + let just_under = (chrono::Utc::now() - chrono::Duration::days(6) - chrono::Duration::hours(20)).to_rfc3339();
264 + let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&just_under), 7);
265 + assert!(!result.stale, "days < threshold must not be stale");
266 + }
267 +
268 + #[test]
269 + fn duration_drift_all_recent_over_threshold() {
270 + // 3 recent + 4 baseline, all recent > 2x baseline avg → drift detected.
271 + // baseline_count=4, recent_count=3, total=7
272 + let durations = vec![
273 + dur("a", 200), dur("b", 210), dur("c", 220), // recent (most recent first)
274 + dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110), // baseline avg = 95
275 + ];
276 + let result = detect_test_duration_drift(&durations, 4, 3, 2.0);
277 + let msg = result.expect("drift should be detected");
278 + assert!(msg.contains("test duration drift"));
279 + assert!(msg.contains("last 3 runs"));
280 + }
281 +
282 + #[test]
283 + fn duration_drift_one_recent_under_threshold_no_drift() {
284 + // One recent (105) is below 2x baseline avg (95 * 2 = 190).
285 + let durations = vec![
286 + dur("a", 250), dur("b", 105), dur("c", 270),
287 + dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110),
288 + ];
289 + assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
290 + }
291 +
292 + #[test]
293 + fn duration_drift_insufficient_samples() {
294 + // Total samples < baseline_count + recent_count → no drift, return None.
295 + let durations = vec![dur("a", 500), dur("b", 500), dur("c", 100)];
296 + assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
297 + }
298 +
299 + #[test]
300 + fn duration_drift_at_threshold_is_not_drift() {
301 + // Pins `*d as f64 > drift_threshold` (strictly greater). At exactly
302 + // 2x baseline avg, must NOT report drift.
303 + // baseline avg = 100, threshold = 100 * 2.0 = 200. Recent values == 200.
304 + let durations = vec![
305 + dur("a", 200), dur("b", 200), dur("c", 200),
306 + dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100),
307 + ];
308 + assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
309 + }
310 +
311 + #[test]
312 + fn duration_drift_just_over_threshold_detects() {
313 + let durations = vec![
314 + dur("a", 201), dur("b", 201), dur("c", 201),
315 + dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100),
316 + ];
317 + assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_some());
318 + }
319 +
320 + #[test]
321 + fn duration_drift_zero_baseline_count_returns_none() {
322 + // Edge: baseline_count=0 means `baseline` slice is empty; should None
323 + // (pins the `if baseline.is_empty()` early return).
324 + let durations = vec![dur("a", 100), dur("b", 200)];
325 + assert!(detect_test_duration_drift(&durations, 0, 2, 2.0).is_none());
326 + }
327 + }
@@ -212,142 +212,6 @@ pub fn classify_non_json(status_code: u16) -> HealthStatus {
212 212 }
213 213 }
214 214
215 - /// Detect sustained latency drift by checking if all recent response times
216 - /// exceed the baseline average by the given threshold multiplier.
217 - ///
218 - /// Returns a description string if drift is detected, `None` otherwise.
219 - /// Requires at least 10 baseline samples to avoid false positives.
220 - pub fn detect_latency_drift(
221 - recent_times: &[i64],
222 - baseline: &crate::types::LatencyStats,
223 - threshold: f64,
224 - ) -> Option<String> {
225 - if baseline.sample_count < 10 || recent_times.is_empty() {
226 - return None;
227 - }
228 - let drift_threshold = baseline.avg_ms * threshold;
229 - let all_over = recent_times.iter().all(|&t| t as f64 > drift_threshold);
230 - if all_over {
231 - let avg_recent: f64 = recent_times.iter().sum::<i64>() as f64 / recent_times.len() as f64;
232 - Some(format!(
233 - "latency drift: last {} checks avg {:.0}ms (baseline avg {:.0}ms, threshold {:.0}ms)",
234 - recent_times.len(),
235 - avg_recent,
236 - baseline.avg_ms,
237 - drift_threshold,
238 - ))
239 - } else {
240 - None
241 - }
242 - }
243 -
244 - /// Detect sustained test duration drift by checking if all recent durations
245 - /// exceed the baseline average by the given threshold multiplier.
246 - ///
247 - /// Returns a description string if drift is detected, `None` otherwise.
248 - /// Requires at least `baseline_count` samples for the baseline window.
249 - pub fn detect_test_duration_drift(
250 - durations: &[(String, i64)],
251 - baseline_count: usize,
252 - recent_count: usize,
253 - threshold: f64,
254 - ) -> Option<String> {
255 - if durations.len() < baseline_count + recent_count {
256 - return None;
257 - }
258 -
259 - // durations are ordered most recent first from get_test_durations
260 - let recent = &durations[..recent_count];
261 - let baseline = &durations[recent_count..];
262 -
263 - if baseline.is_empty() {
264 - return None;
265 - }
266 -
267 - let baseline_avg = baseline.iter().map(|(_, d)| *d).sum::<i64>() as f64 / baseline.len() as f64;
268 - let drift_threshold = baseline_avg * threshold;
269 -
270 - let all_over = recent.iter().all(|(_, d)| *d as f64 > drift_threshold);
271 - if all_over {
272 - let recent_avg = recent.iter().map(|(_, d)| *d).sum::<i64>() as f64 / recent.len() as f64;
273 - Some(format!(
274 - "test duration drift: last {} runs avg {:.0}s (baseline avg {:.0}s, threshold {:.0}s)",
275 - recent_count,
276 - recent_avg,
277 - baseline_avg,
278 - drift_threshold,
279 - ))
280 - } else {
281 - None
282 - }
283 - }
284 -
285 - /// Compute test staleness from version and timing data.
286 - ///
287 - /// A target's tests are considered stale when:
288 - /// 1. No tests have ever been run
289 - /// 2. Tests are older than `staleness_days`
290 - /// 3. The deployed version has changed since the last test run
291 - pub fn compute_test_staleness(
292 - current_version: Option<&str>,
293 - tested_version: Option<&str>,
294 - last_test_at: Option<&str>,
295 - staleness_days: u64,
296 - ) -> crate::types::TestStaleness {
297 - let Some(last_test_at) = last_test_at else {
298 - return crate::types::TestStaleness {
299 - stale: true,
300 - reason: Some("no tests have been run".to_string()),
301 - current_version: current_version.map(String::from),
302 - tested_version: None,
303 - last_test_at: None,
304 - days_since_test: None,
305 - };
306 - };
307 -
308 - let days_since = chrono::DateTime::parse_from_rfc3339(last_test_at)
309 - .ok()
310 - .map(|dt| {
311 - let now = chrono::Utc::now();
312 - (now - dt.with_timezone(&chrono::Utc)).num_days()
313 - });
314 -
315 - if let Some(days) = days_since
316 - && days >= staleness_days as i64
317 - {
318 - return crate::types::TestStaleness {
319 - stale: true,
320 - reason: Some(format!("tests are {days} days old (threshold: {staleness_days}d)")),
321 - current_version: current_version.map(String::from),
322 - tested_version: tested_version.map(String::from),
323 - last_test_at: Some(last_test_at.to_string()),
324 - days_since_test: Some(days),
325 - };
326 - }
327 -
328 - if let (Some(current), Some(tested)) = (current_version, tested_version)
329 - && current != tested
330 - {
331 - return crate::types::TestStaleness {
332 - stale: true,
333 - reason: Some(format!("version changed: {tested} -> {current}")),
334 - current_version: Some(current.to_string()),
335 - tested_version: Some(tested.to_string()),
336 - last_test_at: Some(last_test_at.to_string()),
337 - days_since_test: days_since,
338 - };
339 - }
340 -
341 - crate::types::TestStaleness {
342 - stale: false,
343 - reason: None,
344 - current_version: current_version.map(String::from),
345 - tested_version: tested_version.map(String::from),
346 - last_test_at: Some(last_test_at.to_string()),
347 - days_since_test: days_since,
348 - }
349 - }
350 -
351 215 #[cfg(test)]
352 216 mod tests {
353 217 use super::*;
@@ -576,127 +440,6 @@ mod tests {
576 440 assert!(failures.is_empty());
577 441 }
578 442
579 - // --- detect_latency_drift ---
580 -
581 - fn baseline(avg: f64, count: i64) -> crate::types::LatencyStats {
582 - crate::types::LatencyStats {
583 - min_ms: avg as i64 / 2,
584 - max_ms: avg as i64 * 2,
585 - avg_ms: avg,
586 - p95_ms: (avg * 1.5) as i64,
587 - sample_count: count,
588 - }
589 - }
590 -
591 - #[test]
592 - fn drift_all_over_threshold() {
593 - let bl = baseline(100.0, 100);
594 - let recent = vec![250, 260, 270]; // all > 200 (100 * 2.0)
595 - let result = detect_latency_drift(&recent, &bl, 2.0);
596 - assert!(result.is_some());
597 - assert!(result.unwrap().contains("latency drift"));
598 - }
599 -
600 - #[test]
601 - fn drift_one_under_threshold() {
602 - let bl = baseline(100.0, 100);
603 - let recent = vec![250, 150, 270]; // 150 < 200
604 - let result = detect_latency_drift(&recent, &bl, 2.0);
605 - assert!(result.is_none());
606 - }
607 -
608 - #[test]
609 - fn drift_insufficient_baseline() {
610 - let bl = baseline(100.0, 5); // < 10 samples
611 - let recent = vec![250, 260, 270];
612 - let result = detect_latency_drift(&recent, &bl, 2.0);
613 - assert!(result.is_none());
614 - }
615 -
616 - #[test]
617 - fn drift_empty_recent() {
618 - let bl = baseline(100.0, 100);
619 - let result = detect_latency_drift(&[], &bl, 2.0);
620 - assert!(result.is_none());
621 - }
622 -
623 - #[test]
624 - fn drift_threshold_edge() {
625 - let bl = baseline(100.0, 100);
626 - // Exactly at threshold (200): not strictly over
627 - let recent = vec![200, 200, 200];
628 - let result = detect_latency_drift(&recent, &bl, 2.0);
629 - assert!(result.is_none()); // must be strictly greater
630 - }
631 -
632 - #[test]
633 - fn drift_just_over_threshold() {
634 - let bl = baseline(100.0, 100);
635 - let recent = vec![201, 201, 201];
636 - let result = detect_latency_drift(&recent, &bl, 2.0);
637 - assert!(result.is_some());
638 - }
639 -
640 - // --- compute_test_staleness ---
641 -
642 - #[test]
643 - fn staleness_no_test_run() {
644 - let result = compute_test_staleness(Some("1.0.0"), None, None, 7);
645 - assert!(result.stale);
646 - assert_eq!(result.reason.as_deref(), Some("no tests have been run"));
647 - assert!(result.last_test_at.is_none());
648 - }
649 -
650 - #[test]
651 - fn staleness_stale_by_age() {
652 - let old = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
653 - let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&old), 7);
654 - assert!(result.stale);
655 - let reason = result.reason.unwrap();
656 - assert!(reason.contains("days old"), "reason was: {reason}");
657 - assert!(reason.contains("threshold: 7d"), "reason was: {reason}");
658 - }
659 -
660 - #[test]
661 - fn staleness_stale_by_version() {
662 - let recent = chrono::Utc::now().to_rfc3339();
663 - let result = compute_test_staleness(Some("1.1.0"), Some("1.0.0"), Some(&recent), 7);
664 - assert!(result.stale);
665 - let reason = result.reason.unwrap();
666 - assert!(reason.contains("version changed: 1.0.0 -> 1.1.0"), "reason was: {reason}");
667 - }
668 -
669 - #[test]
670 - fn staleness_fresh() {
671 - let recent = chrono::Utc::now().to_rfc3339();
672 - let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&recent), 7);
673 - assert!(!result.stale);
674 - assert!(result.reason.is_none());
675 - }
676 -
677 - #[test]
678 - fn staleness_missing_versions_not_stale() {
679 - let recent = chrono::Utc::now().to_rfc3339();
680 - let result = compute_test_staleness(None, None, Some(&recent), 7);
681 - assert!(!result.stale);
682 - }
683 -
684 - #[test]
685 - fn staleness_at_threshold_is_stale() {
686 - // Pins `days >= staleness_days` (vs `>`). days == 7, threshold == 7
687 - // must report stale.
688 - let exactly = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339();
689 - let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&exactly), 7);
690 - assert!(result.stale, "days == threshold must be stale");
691 - }
692 -
693 - #[test]
694 - fn staleness_one_day_under_threshold_is_fresh() {
695 - let just_under = (chrono::Utc::now() - chrono::Duration::days(6) - chrono::Duration::hours(20)).to_rfc3339();
696 - let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&just_under), 7);
697 - assert!(!result.stale, "days < threshold must not be stale");
698 - }
699 -
700 443 // --- classify_non_json: 200..300 range boundaries ---
701 444
702 445 #[test]
@@ -718,72 +461,6 @@ mod tests {
718 461 assert_eq!(classify_json_response(199, &json).0, HealthStatus::Error);
719 462 }
720 463
721 - // --- detect_test_duration_drift ---
722 -
723 - fn dur(name: &str, secs: i64) -> (String, i64) {
724 - (name.to_string(), secs)
725 - }
726 -
727 - #[test]
728 - fn duration_drift_all_recent_over_threshold() {
729 - // 3 recent + 4 baseline, all recent > 2x baseline avg → drift detected.
730 - // baseline_count=4, recent_count=3, total=7
731 - let durations = vec![
732 - dur("a", 200), dur("b", 210), dur("c", 220), // recent (most recent first)
733 - dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110), // baseline avg = 95
734 - ];
735 - let result = detect_test_duration_drift(&durations, 4, 3, 2.0);
736 - let msg = result.expect("drift should be detected");
737 - assert!(msg.contains("test duration drift"));
738 - assert!(msg.contains("last 3 runs"));
739 - }
740 -
741 - #[test]
742 - fn duration_drift_one_recent_under_threshold_no_drift() {
743 - // One recent (105) is below 2x baseline avg (95 * 2 = 190).
744 - let durations = vec![
745 - dur("a", 250), dur("b", 105), dur("c", 270),
746 - dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110),
747 - ];
748 - assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
749 - }
750 -
751 - #[test]
752 - fn duration_drift_insufficient_samples() {
753 - // Total samples < baseline_count + recent_count → no drift, return None.
754 - let durations = vec![dur("a", 500), dur("b", 500), dur("c", 100)];
755 - assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
756 - }
757 -
758 - #[test]
759 - fn duration_drift_at_threshold_is_not_drift() {
760 - // Pins `*d as f64 > drift_threshold` (strictly greater). At exactly
761 - // 2x baseline avg, must NOT report drift.
762 - // baseline avg = 100, threshold = 100 * 2.0 = 200. Recent values == 200.
763 - let durations = vec![
764 - dur("a", 200), dur("b", 200), dur("c", 200),
765 - dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100),
766 - ];
767 - assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
768 - }
769 -
770 - #[test]
771 - fn duration_drift_just_over_threshold_detects() {
772 - let durations = vec![
773 - dur("a", 201), dur("b", 201), dur("c", 201),
774 - dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100),
775 - ];
776 - assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_some());
777 - }
778 -
779 - #[test]
780 - fn duration_drift_zero_baseline_count_returns_none() {
781 - // Edge: baseline_count=0 means `baseline` slice is empty; should None
782 - // (pins the `if baseline.is_empty()` early return).
783 - let durations = vec![dur("a", 100), dur("b", 200)];
784 - assert!(detect_test_duration_drift(&durations, 0, 2, 2.0).is_none());
785 - }
786 -
787 464 // --- resolve_json_path edge cases ---
788 465
789 466 #[test]
@@ -1,6 +1,7 @@
1 1 pub mod backup;
2 2 pub mod cors;
3 3 pub mod dns;
4 + pub mod drift;
4 5 pub mod http;
5 6 pub mod parse;
6 7 pub mod routes;
@@ -1,4 +1,4 @@
1 - use pom::checks::http;
1 + use pom::checks::drift;
2 2 use pom::config::Config;
3 3 use pom::db;
4 4 use pom::display;
@@ -45,7 +45,7 @@ pub(crate) async fn cmd_status(
45 45 None
46 46 };
47 47
48 - Some(http::compute_test_staleness(
48 + Some(drift::compute_test_staleness(
49 49 current_version.as_deref(),
50 50 tested_version.as_deref(),
51 51 test.as_ref().map(|t| t.started_at.as_str()),
@@ -62,7 +62,7 @@ pub(crate) async fn cmd_status(
62 62 vec![]
63 63 };
64 64 let duration_drift = if !test_durations.is_empty() {
65 - http::detect_test_duration_drift(&test_durations, 10, 3, 1.5)
65 + drift::detect_test_duration_drift(&test_durations, 10, 3, 1.5)
66 66 } else {
67 67 None
68 68 };
@@ -2,7 +2,7 @@ use tokio::task::JoinHandle;
2 2 use tracing::info;
3 3
4 4 use pom::alerts::Alerter;
5 - use pom::checks::{http, ssh_banner};
5 + use pom::checks::{drift, http, ssh_banner};
6 6 use pom::config::Config;
7 7 use pom::db;
8 8 use pom::types::{HealthStatus, LatencyStats};
@@ -123,7 +123,7 @@ pub(crate) fn spawn_health_tasks(
123 123 .unwrap_or_default();
124 124
125 125 if let Some(ref bl) = baseline {
126 - if let Some(msg) = http::detect_latency_drift(&recent, bl, trending.spike_threshold) {
126 + if let Some(msg) = drift::detect_latency_drift(&recent, bl, trending.spike_threshold) {
127 127 if !in_drift {
128 128 info!("{name}: {msg}");
129 129 if let Some(ref alerter) = alerter {
@@ -2,7 +2,7 @@ use schemars::JsonSchema;
2 2 use serde::Deserialize;
3 3 use tracing::instrument;
4 4
5 - use crate::checks::http;
5 + use crate::checks::{drift, http};
6 6 use crate::db;
7 7 use crate::types::{LatencyStats, TargetInfo};
8 8
@@ -129,7 +129,7 @@ impl PomServer {
129 129 None
130 130 };
131 131
132 - let staleness = http::compute_test_staleness(
132 + let staleness = drift::compute_test_staleness(
133 133 current_version.as_deref(),
134 134 tested_version.as_deref(),
135 135 latest_test.as_ref().map(|t| t.started_at.as_str()),
@@ -3013,7 +3013,7 @@ async fn regression_ignores_already_failing_tests() {
3013 3013
3014 3014 #[tokio::test]
3015 3015 async fn test_duration_drift_detected() {
3016 - use pom::checks::http::detect_test_duration_drift;
3016 + use pom::checks::drift::detect_test_duration_drift;
3017 3017
3018 3018 // 10 baseline runs at 60s, 3 recent runs at 120s (2x baseline > 1.5x threshold)
3019 3019 let mut durations: Vec<(String, i64)> = Vec::new();
@@ -3033,7 +3033,7 @@ async fn test_duration_drift_detected() {
3033 3033
3034 3034 #[tokio::test]
3035 3035 async fn test_duration_no_drift_when_stable() {
3036 - use pom::checks::http::detect_test_duration_drift;
3036 + use pom::checks::drift::detect_test_duration_drift;
3037 3037
3038 3038 // All runs at ~60s
3039 3039 let mut durations: Vec<(String, i64)> = Vec::new();
@@ -3047,7 +3047,7 @@ async fn test_duration_no_drift_when_stable() {
3047 3047
3048 3048 #[tokio::test]
3049 3049 async fn test_duration_drift_not_enough_data() {
3050 - use pom::checks::http::detect_test_duration_drift;
3050 + use pom::checks::drift::detect_test_duration_drift;
3051 3051
3052 3052 // Only 5 runs (need 13 for baseline 10 + recent 3)
3053 3053 let durations: Vec<(String, i64)> = (0..5)