1use std::time::Duration;
20
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone)]
25pub struct FreshnessInputs {
26 pub poll_interval: Duration,
28 pub ssh_timeout: Duration,
30 pub last_poll_duration: Duration,
33 pub recent_timeout_count: u32,
35 pub host_rtt: Option<Duration>,
37 pub concurrency_pressure: f64,
39 pub age: Option<Duration>,
41}
42
43impl FreshnessInputs {
44 #[must_use]
47 pub fn new(poll_interval: Duration, ssh_timeout: Duration, age: Duration) -> Self {
48 Self {
49 poll_interval,
50 ssh_timeout,
51 last_poll_duration: poll_interval,
52 recent_timeout_count: 0,
53 host_rtt: None,
54 concurrency_pressure: 0.0,
55 age: Some(age),
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum FreshnessVerdict {
64 Fresh,
66 SlowObserver,
70 Stale,
72 Unknown,
74}
75
76impl FreshnessVerdict {
77 #[must_use]
81 pub const fn is_usable(self) -> bool {
82 matches!(self, Self::Fresh | Self::SlowObserver)
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct FreshnessAssessment {
89 pub verdict: FreshnessVerdict,
90 pub expected_next_sample_ms: u64,
93 pub tolerated_age_ms: u64,
95 pub last_poll_duration_ms: u64,
97 pub timeout_count: u32,
99 pub confidence: f64,
101 pub age_ms: Option<u64>,
103 pub usable: bool,
105 pub reason: String,
108}
109
110fn ms(d: Duration) -> u64 {
112 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
113}
114
115#[allow(clippy::cast_precision_loss)]
119#[must_use]
120pub fn assess(inputs: &FreshnessInputs) -> FreshnessAssessment {
121 let pressure = if inputs.concurrency_pressure.is_finite() {
126 inputs.concurrency_pressure.clamp(0.0, 1.0)
127 } else {
128 0.0
129 };
130
131 let observer_extra = inputs
133 .last_poll_duration
134 .saturating_sub(inputs.poll_interval);
135 let rtt_extra = inputs
137 .host_rtt
138 .map_or(Duration::ZERO, |r| r.saturating_mul(2));
139 let timeout_units = inputs.recent_timeout_count.min(3);
143 let timeout_extra = inputs.ssh_timeout.saturating_mul(timeout_units);
144
145 let expected_next = inputs
148 .poll_interval
149 .saturating_add(observer_extra)
150 .saturating_add(rtt_extra);
151
152 let base_tolerated = inputs.poll_interval.saturating_add(inputs.ssh_timeout);
154 let tolerated_base = base_tolerated
157 .saturating_add(observer_extra)
158 .saturating_add(rtt_extra)
159 .saturating_add(timeout_extra);
160 let scaled_ms = (ms(tolerated_base) as f64 * (1.0 + pressure)).min(u64::MAX as f64);
166 let tolerated = Duration::from_millis(scaled_ms as u64);
167 let tolerated = tolerated.max(expected_next);
169
170 let Some(age) = inputs.age else {
171 return FreshnessAssessment {
172 verdict: FreshnessVerdict::Unknown,
173 expected_next_sample_ms: ms(expected_next),
174 tolerated_age_ms: ms(tolerated),
175 last_poll_duration_ms: ms(inputs.last_poll_duration),
176 timeout_count: inputs.recent_timeout_count,
177 confidence: 0.0,
178 age_ms: None,
179 usable: false,
180 reason: "no telemetry sample yet".to_string(),
181 };
182 };
183
184 let (verdict, confidence, reason) = if age <= expected_next {
185 (
186 FreshnessVerdict::Fresh,
187 1.0,
188 "within expected sampling window",
189 )
190 } else if age <= tolerated {
191 let span = ms(tolerated).saturating_sub(ms(expected_next)).max(1);
193 let over = ms(age).saturating_sub(ms(expected_next));
194 let confidence = (1.0 - (over as f64 / span as f64)).clamp(0.0, 1.0);
195 (
196 FreshnessVerdict::SlowObserver,
197 confidence,
198 dominant_reason(observer_extra, rtt_extra, timeout_extra, pressure),
199 )
200 } else {
201 (
202 FreshnessVerdict::Stale,
203 0.0,
204 "telemetry genuinely stale beyond adaptive tolerance",
205 )
206 };
207
208 FreshnessAssessment {
209 verdict,
210 expected_next_sample_ms: ms(expected_next),
211 tolerated_age_ms: ms(tolerated),
212 last_poll_duration_ms: ms(inputs.last_poll_duration),
213 timeout_count: inputs.recent_timeout_count,
214 confidence,
215 age_ms: Some(ms(age)),
216 usable: verdict.is_usable(),
217 reason: reason.to_string(),
218 }
219}
220
221fn dominant_reason(
223 observer_extra: Duration,
224 rtt_extra: Duration,
225 timeout_extra: Duration,
226 pressure: f64,
227) -> &'static str {
228 let pressure_weight = Duration::from_secs_f64(30.0 * pressure);
231 let mut best = ("poller saturation", pressure_weight);
232 for (label, value) in [
233 ("observer loop behind schedule", observer_extra),
234 ("high host latency", rtt_extra),
235 ("recent poll timeouts", timeout_extra),
236 ] {
237 if value > best.1 {
238 best = (label, value);
239 }
240 }
241 if best.1.is_zero() {
242 "older than expected (observer effects)"
243 } else {
244 best.0
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 const POLL: Duration = Duration::from_secs(30);
253 const TIMEOUT: Duration = Duration::from_secs(20);
254
255 #[test]
256 fn extreme_inputs_do_not_panic_in_tolerance_scaling() {
257 let inputs = FreshnessInputs {
262 poll_interval: Duration::from_secs(u64::MAX),
263 ssh_timeout: Duration::from_secs(u64::MAX),
264 last_poll_duration: Duration::from_secs(u64::MAX),
265 recent_timeout_count: u32::MAX,
266 host_rtt: Some(Duration::from_secs(u64::MAX)),
267 concurrency_pressure: 1.0,
268 age: Some(Duration::from_secs(1)),
269 };
270 let a = assess(&inputs); assert_eq!(a.verdict, FreshnessVerdict::Fresh);
273 let unknown = FreshnessInputs {
275 age: None,
276 ..inputs
277 };
278 assert_eq!(assess(&unknown).verdict, FreshnessVerdict::Unknown);
279 }
280
281 #[test]
282 fn fresh_within_expected_window() {
283 let inputs = FreshnessInputs::new(POLL, TIMEOUT, Duration::from_secs(10));
284 let a = assess(&inputs);
285 assert_eq!(a.verdict, FreshnessVerdict::Fresh);
286 assert!(a.usable);
287 assert!((a.confidence - 1.0).abs() < f64::EPSILON);
288 }
289
290 #[test]
291 fn missing_telemetry_is_unknown_and_unusable() {
292 let inputs = FreshnessInputs {
293 age: None,
294 ..FreshnessInputs::new(POLL, TIMEOUT, Duration::ZERO)
295 };
296 let a = assess(&inputs);
297 assert_eq!(a.verdict, FreshnessVerdict::Unknown);
298 assert!(!a.usable);
299 assert_eq!(a.age_ms, None);
300 assert_eq!(a.confidence, 0.0);
301 assert_eq!(a.reason, "no telemetry sample yet");
302 }
303
304 #[test]
305 fn genuinely_stale_worker_is_evicted() {
306 let inputs = FreshnessInputs::new(POLL, TIMEOUT, Duration::from_secs(600));
308 let a = assess(&inputs);
309 assert_eq!(a.verdict, FreshnessVerdict::Stale);
310 assert!(!a.usable);
311 assert_eq!(a.confidence, 0.0);
312 }
313
314 #[test]
315 fn cross_region_slow_polling_is_explained_not_stale() {
316 let inputs = FreshnessInputs {
319 host_rtt: Some(Duration::from_secs(8)),
320 age: Some(Duration::from_secs(60)),
321 ..FreshnessInputs::new(POLL, TIMEOUT, Duration::from_secs(60))
322 };
323 let a = assess(&inputs);
324 assert_eq!(a.verdict, FreshnessVerdict::SlowObserver);
325 assert!(a.usable, "cross-region worker must not be evicted");
326 assert_eq!(a.reason, "high host latency");
327 assert!(a.tolerated_age_ms > 50_000);
329 }
330
331 #[test]
332 fn overloaded_poller_extends_tolerance() {
333 let inputs = FreshnessInputs {
336 last_poll_duration: Duration::from_secs(90),
337 concurrency_pressure: 1.0,
338 age: Some(Duration::from_secs(70)),
339 ..FreshnessInputs::new(POLL, TIMEOUT, Duration::from_secs(70))
340 };
341 let a = assess(&inputs);
342 assert!(a.usable, "overloaded-poller staleness must not evict");
343 assert!(matches!(
344 a.verdict,
345 FreshnessVerdict::SlowObserver | FreshnessVerdict::Fresh
346 ));
347 assert!(a.tolerated_age_ms > 50_000);
350
351 let aged = FreshnessInputs {
354 age: Some(Duration::from_secs(150)),
355 ..inputs
356 };
357 let a2 = assess(&aged);
358 assert_eq!(a2.verdict, FreshnessVerdict::SlowObserver);
359 assert!(a2.usable);
360 assert!(
361 a2.reason == "observer loop behind schedule" || a2.reason == "poller saturation",
362 "unexpected reason: {}",
363 a2.reason
364 );
365 }
366
367 #[test]
368 fn recent_timeouts_widen_tolerance_but_are_capped() {
369 let two = FreshnessInputs {
370 recent_timeout_count: 2,
371 age: Some(Duration::from_secs(80)),
372 ..FreshnessInputs::new(POLL, TIMEOUT, Duration::from_secs(80))
373 };
374 let many = FreshnessInputs {
375 recent_timeout_count: 50,
376 ..two.clone()
377 };
378 let a_two = assess(&two);
379 let a_many = assess(&many);
380 assert!(a_two.tolerated_age_ms > 50_000);
382 let capped = FreshnessInputs {
384 recent_timeout_count: 3,
385 ..two.clone()
386 };
387 assert_eq!(assess(&capped).tolerated_age_ms, a_many.tolerated_age_ms);
388 }
389
390 #[test]
391 fn confidence_decays_monotonically_through_the_explained_band() {
392 let mk = |age_secs: u64| FreshnessInputs {
393 host_rtt: Some(Duration::from_secs(5)),
394 age: Some(Duration::from_secs(age_secs)),
395 ..FreshnessInputs::new(POLL, TIMEOUT, Duration::from_secs(age_secs))
396 };
397 let young = assess(&mk(45)).confidence;
398 let mid = assess(&mk(55)).confidence;
399 let old = assess(&mk(60)).confidence;
400 assert!(young >= mid && mid >= old, "{young} {mid} {old}");
401 assert!((0.0..=1.0).contains(&young));
402 }
403
404 #[test]
405 fn assessment_serializes_with_required_fields() {
406 let a = assess(&FreshnessInputs::new(
407 POLL,
408 TIMEOUT,
409 Duration::from_secs(10),
410 ));
411 let v = serde_json::to_value(&a).unwrap();
412 for key in [
413 "verdict",
414 "expected_next_sample_ms",
415 "tolerated_age_ms",
416 "last_poll_duration_ms",
417 "timeout_count",
418 "confidence",
419 "usable",
420 "reason",
421 ] {
422 assert!(v.get(key).is_some(), "missing field {key}");
423 }
424 assert_eq!(v["verdict"], "fresh");
425 }
426
427 mod fuzz {
430 use super::*;
431 use proptest::prelude::*;
432
433 fn any_duration() -> impl Strategy<Value = Duration> {
436 any::<u64>().prop_map(Duration::from_millis)
437 }
438
439 fn any_pressure() -> impl Strategy<Value = f64> {
443 prop_oneof![
444 Just(f64::NAN),
445 Just(f64::INFINITY),
446 Just(f64::NEG_INFINITY),
447 -5.0_f64..5.0_f64,
448 any::<f64>(),
449 ]
450 }
451
452 fn any_inputs() -> impl Strategy<Value = FreshnessInputs> {
453 (
454 any_duration(),
455 any_duration(),
456 any_duration(),
457 any::<u32>(),
458 proptest::option::of(any_duration()),
459 any_pressure(),
460 proptest::option::of(any_duration()),
461 )
462 .prop_map(|(poll, timeout, last, timeouts, rtt, pressure, age)| {
463 FreshnessInputs {
464 poll_interval: poll,
465 ssh_timeout: timeout,
466 last_poll_duration: last,
467 recent_timeout_count: timeouts,
468 host_rtt: rtt,
469 concurrency_pressure: pressure,
470 age,
471 }
472 })
473 }
474
475 fn verdict_rank(v: FreshnessVerdict) -> u8 {
478 match v {
479 FreshnessVerdict::Fresh => 0,
480 FreshnessVerdict::SlowObserver => 1,
481 FreshnessVerdict::Stale => 2,
482 FreshnessVerdict::Unknown => 3,
483 }
484 }
485
486 proptest! {
487 #![proptest_config(ProptestConfig::with_cases(2048))]
488
489 #[test]
492 fn assess_never_panics_and_holds_invariants(inputs in any_inputs()) {
493 let a = assess(&inputs);
494 prop_assert!(a.confidence.is_finite(), "confidence not finite: {}", a.confidence);
495 prop_assert!(
496 (0.0..=1.0).contains(&a.confidence),
497 "confidence out of range: {}",
498 a.confidence
499 );
500 prop_assert_eq!(
501 a.usable,
502 matches!(a.verdict, FreshnessVerdict::Fresh | FreshnessVerdict::SlowObserver)
503 );
504 prop_assert_eq!(
505 matches!(a.verdict, FreshnessVerdict::Unknown),
506 inputs.age.is_none()
507 );
508 prop_assert!(a.tolerated_age_ms >= a.expected_next_sample_ms);
510 }
511
512 #[test]
515 fn larger_age_is_never_fresher(base in any_inputs(), a in any_duration(), b in any_duration()) {
516 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
517 let mut inputs = base;
518 inputs.age = Some(lo);
519 let v_lo = assess(&inputs).verdict;
520 inputs.age = Some(hi);
521 let v_hi = assess(&inputs).verdict;
522 prop_assert!(
523 verdict_rank(v_hi) >= verdict_rank(v_lo),
524 "monotonicity violated: {lo:?}->{v_lo:?} then {hi:?}->{v_hi:?}"
525 );
526 }
527
528 #[test]
531 fn higher_pressure_never_shrinks_tolerance(base in any_inputs(), age in any_duration()) {
532 let mut inputs = base;
533 inputs.age = Some(age);
534 inputs.concurrency_pressure = 0.0;
535 let tol_lo = assess(&inputs).tolerated_age_ms;
536 inputs.concurrency_pressure = 1.0;
537 let tol_hi = assess(&inputs).tolerated_age_ms;
538 prop_assert!(tol_hi >= tol_lo, "pressure shrank tolerance: {tol_lo} -> {tol_hi}");
539 }
540 }
541 }
542}