Skip to main content

rch_common/
telemetry_freshness.rs

1//! Adaptive telemetry freshness model.
2//!
3//! A worker's telemetry is "stale" only relative to how fast we could possibly
4//! have re-sampled it. A fixed age threshold mis-classifies workers whenever the
5//! *observer* is slow: a behind-schedule poll loop, a saturated poll-permit
6//! pool, a high-latency cross-region SSH hop, or a run of poll timeouts all
7//! inflate the age of perfectly healthy telemetry. Evicting those workers (and
8//! falling back to local builds) "without explanation" is exactly the failure
9//! this model prevents.
10//!
11//! [`assess`] turns the raw signals (poll interval, SSH timeout, last poll
12//! duration, recent timeout count, host RTT, poller saturation, current sample
13//! age) into a [`FreshnessAssessment`] carrying the bead's required outputs —
14//! expected next sample, tolerated age, last poll duration, timeout count, and
15//! a confidence — plus a verdict and a human reason. The model is pure: every
16//! input is supplied by the caller, so it is fully deterministic and unit
17//! testable.
18
19use std::time::Duration;
20
21use serde::{Deserialize, Serialize};
22
23/// Raw inputs to the freshness model. All durations are observer-side facts.
24#[derive(Debug, Clone)]
25pub struct FreshnessInputs {
26    /// Base poll cadence.
27    pub poll_interval: Duration,
28    /// Per-poll SSH timeout.
29    pub ssh_timeout: Duration,
30    /// Wall-clock duration of the most recent poll *cycle* (a slow loop runs
31    /// long; this is the primary "observer is behind" signal).
32    pub last_poll_duration: Duration,
33    /// Consecutive/recent poll timeouts observed for this worker.
34    pub recent_timeout_count: u32,
35    /// Round-trip latency to the worker host, if known ("host distance").
36    pub host_rtt: Option<Duration>,
37    /// Poll-permit-pool saturation in `[0.0, 1.0]` (1.0 = fully saturated).
38    pub concurrency_pressure: f64,
39    /// Age of the worker's latest telemetry sample. `None` = no sample yet.
40    pub age: Option<Duration>,
41}
42
43impl FreshnessInputs {
44    /// Minimal constructor for a worker with a known sample age and no
45    /// adversity (no timeouts, no measured RTT, idle poller, on-pace loop).
46    #[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/// Freshness classification.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum FreshnessVerdict {
64    /// Within the expected sampling window — fully fresh.
65    Fresh,
66    /// Older than expected, but the excess is explained by observer slowness
67    /// (slow loop / latency / timeouts / saturation). Still usable — do NOT
68    /// evict.
69    SlowObserver,
70    /// Genuinely stale beyond the adaptive tolerance. Not usable.
71    Stale,
72    /// No telemetry sample yet — cannot assess. Not usable, but explained.
73    Unknown,
74}
75
76impl FreshnessVerdict {
77    /// Whether a worker with this verdict should remain eligible. Only a
78    /// genuinely `Stale` (or never-sampled `Unknown`) worker is ineligible; a
79    /// `SlowObserver` worker stays in the pool.
80    #[must_use]
81    pub const fn is_usable(self) -> bool {
82        matches!(self, Self::Fresh | Self::SlowObserver)
83    }
84}
85
86/// The model's verdict plus the explanatory metrics the bead requires.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct FreshnessAssessment {
89    pub verdict: FreshnessVerdict,
90    /// When the next sample is expected, as an age (relative to the last
91    /// sample). Ages at or below this are fully fresh.
92    pub expected_next_sample_ms: u64,
93    /// Maximum age tolerated before a worker is genuinely stale.
94    pub tolerated_age_ms: u64,
95    /// Echoed last poll-cycle duration.
96    pub last_poll_duration_ms: u64,
97    /// Echoed recent timeout count.
98    pub timeout_count: u32,
99    /// Confidence in `[0.0, 1.0]` that the telemetry reflects current reality.
100    pub confidence: f64,
101    /// Current sample age (`None` when no sample exists).
102    pub age_ms: Option<u64>,
103    /// Whether the worker should remain eligible (mirror of `verdict.is_usable`).
104    pub usable: bool,
105    /// Short, stable human explanation (operator-facing). Owned so the struct
106    /// remains `Deserialize` for any lifetime (it nests inside other records).
107    pub reason: String,
108}
109
110/// Saturating conversion of a `Duration` to whole milliseconds.
111fn ms(d: Duration) -> u64 {
112    u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
113}
114
115/// Compute the adaptive freshness assessment.
116// Confidence is a heuristic ratio over millisecond counts; f64 precision loss
117// at that magnitude is immaterial.
118#[allow(clippy::cast_precision_loss)]
119#[must_use]
120pub fn assess(inputs: &FreshnessInputs) -> FreshnessAssessment {
121    // `f64::clamp` does NOT sanitize NaN (`NaN.clamp(0.0, 1.0) == NaN`), and a
122    // non-finite pressure flows into `Duration::from_secs_f64` in
123    // `dominant_reason`, which PANICS on NaN/inf — in a classifier documented
124    // as never-failing. Coerce any non-finite pressure to 0.0 first.
125    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    // How far behind the loop is relative to its nominal cadence.
132    let observer_extra = inputs
133        .last_poll_duration
134        .saturating_sub(inputs.poll_interval);
135    // Slow SSH: budget two round trips of slack.
136    let rtt_extra = inputs
137        .host_rtt
138        .map_or(Duration::ZERO, |r| r.saturating_mul(2));
139    // Each recent timeout means a poll burned (up to) the full SSH timeout and
140    // had to retry. Cap so a pathological streak cannot inflate tolerance
141    // unboundedly.
142    let timeout_units = inputs.recent_timeout_count.min(3);
143    let timeout_extra = inputs.ssh_timeout.saturating_mul(timeout_units);
144
145    // The next sample is expected one cadence out, plus however far the loop is
146    // behind, plus a latency hop.
147    let expected_next = inputs
148        .poll_interval
149        .saturating_add(observer_extra)
150        .saturating_add(rtt_extra);
151
152    // Base worst case for a single clean cycle: one interval + one SSH timeout.
153    let base_tolerated = inputs.poll_interval.saturating_add(inputs.ssh_timeout);
154    // Adaptive tolerance widens with observer slowness, latency, and timeouts,
155    // then scales up under poller saturation (a saturated poller defers polls).
156    let tolerated_base = base_tolerated
157        .saturating_add(observer_extra)
158        .saturating_add(rtt_extra)
159        .saturating_add(timeout_extra);
160    // Scale by (1 + pressure) without `Duration::mul_f64`, which PANICS when the
161    // product overflows `Duration` — and `tolerated_base` can saturate toward
162    // `Duration::MAX` via the chain above (e.g. a pathological `ssh_timeout` or
163    // `host_rtt`). Do the scale on the saturating millisecond count and clamp,
164    // so this never-failing classifier never panics on extreme input.
165    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    // Tolerance must never sit below the expected next sample.
168    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        // Confidence decays linearly across the explained band.
192        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
221/// Pick the dominant explanation for an extended tolerance.
222fn dominant_reason(
223    observer_extra: Duration,
224    rtt_extra: Duration,
225    timeout_extra: Duration,
226    pressure: f64,
227) -> &'static str {
228    // Express saturation as a comparable duration weight so one ranking covers
229    // all factors (1.0 pressure ~ "as significant as a 30s slowdown").
230    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        // Regression: the tolerance scale must never `Duration::mul_f64`-panic
258        // on a saturated chain. A pathological ssh_timeout/host_rtt that pushes
259        // the saturating chain toward Duration::MAX, under full pressure, must
260        // still yield a definite verdict rather than panicking.
261        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); // must not panic
271        // A tiny age against an enormous tolerance is fresh.
272        assert_eq!(a.verdict, FreshnessVerdict::Fresh);
273        // And the unknown-age path is equally panic-free.
274        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        // On-pace loop, no adversity, but the sample is ancient.
307        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        // A high-RTT host whose age would exceed the *base* threshold (50s) but
317        // is explained by latency — must stay usable.
318        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        // Tolerance widened beyond the static base (poll + timeout = 50s).
328        assert!(a.tolerated_age_ms > 50_000);
329    }
330
331    #[test]
332    fn overloaded_poller_extends_tolerance() {
333        // The loop ran long (90s vs 30s nominal) and the permit pool is
334        // saturated; a 70s-old sample is still usable, not stale.
335        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        // The 90s loop alone pushes the expected-next window past 70s, so
348        // tolerance far exceeds the 50s static base.
349        assert!(a.tolerated_age_ms > 50_000);
350
351        // Push the age into the explained band to exercise the SlowObserver
352        // reason path (between expected-next and the saturation-scaled cap).
353        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        // 2 timeouts already widen tolerance beyond the base.
381        assert!(a_two.tolerated_age_ms > 50_000);
382        // The cap (3 units) means 50 timeouts tolerate no more than 3.
383        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    /// Property/fuzz + metamorphic coverage for `assess`
428    /// (bd-review-test-freshness-fuzz). Mock-free, pure-function fuzzing.
429    mod fuzz {
430        use super::*;
431        use proptest::prelude::*;
432
433        /// Durations spanning the full u64 millisecond range — including the
434        /// extremes that exercise the saturating/overflow tolerance paths.
435        fn any_duration() -> impl Strategy<Value = Duration> {
436            any::<u64>().prop_map(Duration::from_millis)
437        }
438
439        /// Pressure including out-of-range and non-finite values, so the clamp
440        /// and scale paths are stressed (must never panic or produce NaN
441        /// confidence).
442        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        /// Severity rank for the metamorphic monotonicity check (Unknown only
476        /// arises when there is no sample, so it never participates here).
477        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            /// Never panics; confidence is always finite and in [0,1]; `usable`
490            /// mirrors the verdict; `Unknown` iff there is no sample.
491            #[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                // tolerance is always at least the expected-next window.
509                prop_assert!(a.tolerated_age_ms >= a.expected_next_sample_ms);
510            }
511
512            /// Metamorphic: with a sample present, a larger age never produces a
513            /// FRESHER verdict (rank is monotonic non-decreasing in age).
514            #[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            /// Metamorphic: higher concurrency pressure never SHRINKS the
529            /// tolerated age (a saturated poller defers polls → wider tolerance).
530            #[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}