Skip to main content

dig_peer_selector/
quality.rs

1//! [`PeerQuality`] — the per-peer, **measured-only** capacity/RTT/reliability model (SPEC.md §3).
2//!
3//! A peer's quality is refined EXCLUSIVELY from measured [`crate::TransferOutcome`]s (SPEC §3.1,
4//! §9.2). There is deliberately no input path by which a peer raises its own score: observed capacity
5//! always overrides advertised (SPEC §9.3). Every estimate here moves only when `record_outcome`
6//! folds a real, executor-measured `(bytes, duration_ms)` into it.
7//!
8//! # The estimator and its normative properties (SPEC §3.2)
9//!
10//! Each of `throughput` and `rtt` is a recency-weighted [`Estimate`] over the peer's outcome stream.
11//! The estimator is an EWMA whose smoothing factor is **derived from the peer's own observed
12//! volatility** — there is NO baked policy decay constant (SPEC §3.2 P-volatility-tracking, §4.3):
13//!
14//! - **P-recency** — the newest sample always gets a non-trivial weight; older samples' influence
15//!   decays geometrically.
16//! - **P-monotone-convergence** — under a stationary stream (value `T` repeated) the estimate
17//!   converges toward `T` and stays in a bounded neighborhood.
18//! - **P-responsive-degradation** — when the true value shifts to `T_low` and stays, the estimate
19//!   converges to `T_low` within a bounded number of samples (it does not stay pinned near `T_high`).
20//! - **P-volatility-tracking** — the *effective memory* adapts to observed volatility: a steady peer
21//!   is smoothed over a long window (small α); a swinging peer decays fast (large α) so a stale
22//!   once-good reading falls off. Volatility is measured as the EWMA mean-absolute-deviation relative
23//!   to the mean (a unitless coefficient of variation), so the decay is derived from the data.
24//!
25//! The estimator exposes a **confidence** (via `samples` and the observed relative volatility) so the
26//! scorer can distinguish a well-measured peer from a barely-sampled one (SPEC §3.2, §4.4-E).
27
28/// A recency-weighted estimate of a scalar quantity (throughput bytes/s, or RTT ms) with an
29/// **adaptive, volatility-derived** smoothing factor — no baked decay constant (SPEC §3.2, §4.3).
30///
31/// Internally it tracks the EWMA mean and an EWMA of the absolute deviation (the volatility). The
32/// smoothing factor `alpha` for the next update is a function of the *relative* volatility: steadier
33/// streams get a smaller alpha (longer memory), volatile streams a larger alpha (faster decay). A
34/// sample-count bootstrap makes early estimates converge quickly, satisfying P-recency + convergence
35/// before enough history exists to measure volatility.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct Estimate {
38    /// The current recency-weighted mean. `None` until the first sample.
39    mean: Option<f64>,
40    /// EWMA of the absolute deviation from the mean — the observed volatility (same units as `mean`).
41    mad: f64,
42    /// Number of samples folded in.
43    samples: u64,
44}
45
46impl Estimate {
47    /// A fresh estimate with no samples.
48    pub const fn new() -> Self {
49        Estimate {
50            mean: None,
51            mad: 0.0,
52            samples: 0,
53        }
54    }
55
56    /// The current estimated value, or `None` if no sample has been folded in yet.
57    pub fn value(&self) -> Option<f64> {
58        self.mean
59    }
60
61    /// The number of samples folded in (a confidence signal).
62    pub fn samples(&self) -> u64 {
63        self.samples
64    }
65
66    /// The observed **relative volatility** (coefficient of variation): the EWMA absolute deviation
67    /// divided by the mean. `0.0` for a steady peer, growing as outcomes swing. Used both to drive
68    /// the adaptive decay (§4.3) and as a tail-risk signal for P99 orientation (§4.4-C).
69    pub fn relative_volatility(&self) -> f64 {
70        match self.mean {
71            Some(m) if m.abs() > f64::EPSILON => (self.mad / m.abs()).clamp(0.0, 1.0),
72            _ => 0.0,
73        }
74    }
75
76    /// The adaptive smoothing factor for the *next* update — derived from sample count (bootstrap)
77    /// and observed relative volatility (SPEC §3.2 P-volatility-tracking). No policy constant is
78    /// exposed; the mapping is a fixed internal function of the data.
79    ///
80    /// - Bootstrap: with `n` prior samples the count-driven floor is `1/(n+1)` so the first samples
81    ///   average in fully (fast early convergence, P-recency).
82    /// - Volatility: a steady stream (relative volatility ~0) keeps alpha near the bootstrap floor
83    ///   (long memory); a volatile stream lifts alpha toward a responsive ceiling so stale readings
84    ///   fall off fast (P-responsive-degradation).
85    fn next_alpha(&self) -> f64 {
86        // Count bootstrap: 1/(n+1), so sample 1 => 1.0, sample 2 => 0.5, ... (classic running mean
87        // for the cold phase). This guarantees P-monotone-convergence before volatility is known.
88        let bootstrap = 1.0 / (self.samples as f64 + 1.0);
89        // Volatility lift: interpolate between a steady floor and a responsive ceiling by the
90        // observed relative volatility. These endpoints are estimator internals (not user knobs):
91        //   steady   -> 0.15 (≈ 12-sample effective window: trust a consistent history)
92        //   volatile -> 0.60 (≈ 2-3 sample window: a swinging peer's old readings are worthless)
93        let vol = self.relative_volatility();
94        let volatility_driven = 0.15 + (0.60 - 0.15) * vol;
95        // Use the *larger* of the two so (a) the cold bootstrap dominates early and (b) once warm,
96        // the volatility-derived rate takes over — never slower than the volatility says it should be.
97        bootstrap.max(volatility_driven).clamp(0.0, 1.0)
98    }
99
100    /// Fold a measured `sample` into the estimate, updating the mean, the observed volatility, and the
101    /// sample count. This is the ONLY way an estimate moves (measured-only, SPEC §3.1, §9.2).
102    pub fn observe(&mut self, sample: f64) {
103        match self.mean {
104            None => {
105                self.mean = Some(sample);
106                self.mad = 0.0;
107            }
108            Some(prev) => {
109                let alpha = self.next_alpha();
110                let deviation = (sample - prev).abs();
111                // Update volatility BEFORE the mean so alpha reflects volatility observed up to now;
112                // the MAD uses the same adaptive alpha so it, too, tracks recent behavior.
113                self.mad = (1.0 - alpha) * self.mad + alpha * deviation;
114                self.mean = Some((1.0 - alpha) * prev + alpha * sample);
115            }
116        }
117        self.samples = self.samples.saturating_add(1);
118    }
119
120    /// Seed a **prior** value without counting it as a measured sample (SPEC §3.3 connection-class
121    /// prior). A prior is a starting point for a cold peer; the first real `observe` overrides it, so
122    /// priors never cap a measured peer. `samples` stays 0 so the peer is still treated as cold for
123    /// exploration purposes (SPEC §3.5).
124    pub fn seed_prior(&mut self, value: f64) {
125        if self.samples == 0 {
126            self.mean = Some(value);
127        }
128    }
129}
130
131impl Default for Estimate {
132    fn default() -> Self {
133        Estimate::new()
134    }
135}
136
137/// A recency-weighted reliability estimate in `[0, 1]` over `{success, failure}` outcomes (SPEC §3.4).
138///
139/// Satisfies P-recency and P-responsive-degradation: a peer that starts failing sees reliability
140/// fall; a recovering peer sees it rise. A **hard** (verification) failure penalizes more sharply
141/// than a soft transport failure (SPEC §3.4, §6.3) — it is folded in as multiple failure weights so a
142/// bad/hostile source drops below cold peers (SPEC §9.4).
143#[derive(Debug, Clone, Copy, PartialEq)]
144pub struct Reliability {
145    /// The recency-weighted success rate. `None` until the first success/failure.
146    rate: Option<f64>,
147    /// Count of `{success, failure}` samples folded in.
148    samples: u64,
149    /// Count of hard (verification) failures observed — surfaced so the scorer can floor a bad source.
150    hard_failures: u64,
151}
152
153impl Reliability {
154    /// A fresh reliability estimate.
155    pub const fn new() -> Self {
156        Reliability {
157            rate: None,
158            samples: 0,
159            hard_failures: 0,
160        }
161    }
162
163    /// The current success probability in `[0,1]`, or `None` if unmeasured.
164    pub fn rate(&self) -> Option<f64> {
165        self.rate
166    }
167
168    /// Number of `{success, failure}` samples folded in.
169    pub fn samples(&self) -> u64 {
170        self.samples
171    }
172
173    /// Number of hard (verification) failures observed (SPEC §9.4).
174    pub fn hard_failures(&self) -> u64 {
175        self.hard_failures
176    }
177
178    /// The recency-weighting alpha: a `1/(n+1)` bootstrap floored at a responsive minimum so a run of
179    /// recent failures moves the rate promptly (P-responsive-degradation) rather than being diluted
180    /// by a long success history.
181    fn alpha(&self) -> f64 {
182        (1.0 / (self.samples as f64 + 1.0)).max(0.25)
183    }
184
185    /// Fold a success (`ok = true`) or failure (`ok = false`) into the rate. A `hard` failure counts
186    /// as several failure weights (SPEC §3.4) so a verification-failing source drops sharply.
187    pub fn observe(&mut self, ok: bool, hard: bool) {
188        let target = if ok { 1.0 } else { 0.0 };
189        // A hard failure applies extra failure pressure: fold the 0.0 target multiple times.
190        let repeats = if !ok && hard { 3 } else { 1 };
191        for _ in 0..repeats {
192            match self.rate {
193                None => self.rate = Some(target),
194                Some(prev) => {
195                    let a = self.alpha();
196                    self.rate = Some((1.0 - a) * prev + a * target);
197                }
198            }
199            self.samples = self.samples.saturating_add(1);
200        }
201        if !ok && hard {
202            self.hard_failures = self.hard_failures.saturating_add(1);
203        }
204    }
205
206    /// Seed a **prior** reliability for a cold peer without counting it as measured (SPEC §3.3, §3.5).
207    pub fn seed_prior(&mut self, value: f64) {
208        if self.samples == 0 {
209            self.rate = Some(value.clamp(0.0, 1.0));
210        }
211    }
212}
213
214impl Default for Reliability {
215    fn default() -> Self {
216        Reliability::new()
217    }
218}
219
220/// The learned per-peer quality model (SPEC §3.1). Refined only from measured outcomes.
221#[derive(Debug, Clone, Copy, PartialEq)]
222pub struct PeerQuality {
223    /// Achievable throughput (bytes/sec), learned (SPEC §3.2).
224    pub throughput: Estimate,
225    /// Round-trip / time-to-first-byte latency (ms), learned (SPEC §3.2).
226    pub rtt: Estimate,
227    /// Success probability in `[0,1]`, learned (SPEC §3.4).
228    pub reliability: Reliability,
229    /// Count of measured outcomes folded in (confidence, SPEC §3.1).
230    pub samples: u64,
231    /// Ranges currently assigned to this peer (live in-flight, not learned — SPEC §3.1, §5.3).
232    pub in_flight: u32,
233}
234
235impl PeerQuality {
236    /// A cold quality model: no samples, unknown throughput/RTT/reliability (SPEC §3.5). Neither
237    /// optimistic nor pessimistic — *uncertain*.
238    pub const fn cold() -> Self {
239        PeerQuality {
240            throughput: Estimate::new(),
241            rtt: Estimate::new(),
242            reliability: Reliability::new(),
243            samples: 0,
244            in_flight: 0,
245        }
246    }
247
248    /// Whether this peer is cold (no measured outcomes yet), qualifying for exploration (SPEC §3.5,
249    /// §4.4-E).
250    pub fn is_cold(&self) -> bool {
251        self.samples == 0
252    }
253
254    /// A confidence weight in `[0,1]` — rises with samples, so a well-measured peer's point estimate
255    /// is trusted more than a barely-sampled one (SPEC §3.2, §4.4-E). A saturating curve: a handful
256    /// of samples already gives meaningful confidence, many samples approach full confidence.
257    pub fn confidence(&self) -> f64 {
258        let n = self.samples as f64;
259        n / (n + 4.0)
260    }
261
262    /// Fold one measured throughput sample (bytes/sec) into the model.
263    pub fn observe_throughput(&mut self, bps: f64) {
264        self.throughput.observe(bps);
265    }
266
267    /// Fold one measured RTT sample (ms) into the model.
268    pub fn observe_rtt(&mut self, ms: f64) {
269        self.rtt.observe(ms);
270    }
271
272    /// Fold a success/failure into the reliability model (`hard` = a verification failure, SPEC §3.4).
273    pub fn observe_result(&mut self, ok: bool, hard: bool) {
274        self.reliability.observe(ok, hard);
275    }
276
277    /// Increment the count of measured outcomes (confidence).
278    pub fn bump_samples(&mut self) {
279        self.samples = self.samples.saturating_add(1);
280    }
281}
282
283impl Default for PeerQuality {
284    fn default() -> Self {
285        PeerQuality::cold()
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    /// P-monotone-convergence: a stationary stream converges toward the true value and stays near it.
294    #[test]
295    fn estimate_converges_under_stationary_stream() {
296        let mut e = Estimate::new();
297        for _ in 0..50 {
298            e.observe(1000.0);
299        }
300        let v = e.value().unwrap();
301        assert!((v - 1000.0).abs() < 1.0, "converged to ~1000, got {v}");
302        assert!(
303            e.relative_volatility() < 0.01,
304            "steady stream => low volatility"
305        );
306    }
307
308    /// P-recency + P-responsive-degradation: after the true value drops, the estimate follows within a
309    /// bounded number of samples and does NOT stay pinned near the old high.
310    #[test]
311    fn estimate_responds_to_degradation_within_bounded_samples() {
312        let mut e = Estimate::new();
313        for _ in 0..30 {
314            e.observe(1000.0);
315        }
316        // Now the peer degrades to 100 and stays there.
317        for _ in 0..15 {
318            e.observe(100.0);
319        }
320        let v = e.value().unwrap();
321        assert!(
322            v < 300.0,
323            "estimate must follow the drop toward 100 within a bounded window, got {v}"
324        );
325    }
326
327    /// P-volatility-tracking: a volatile peer's estimate has a larger effective alpha (moves faster)
328    /// than a steady peer's, so a swinging peer's stale readings fall off sooner.
329    #[test]
330    fn volatile_peer_decays_faster_than_steady_peer() {
331        // Steady peer warmed to a stable mean.
332        let mut steady = Estimate::new();
333        for _ in 0..30 {
334            steady.observe(500.0);
335        }
336        // Volatile peer swinging around a similar mean.
337        let mut volatile = Estimate::new();
338        for i in 0..30 {
339            volatile.observe(if i % 2 == 0 { 100.0 } else { 900.0 });
340        }
341        assert!(
342            volatile.relative_volatility() > steady.relative_volatility(),
343            "swinging stream must register higher volatility"
344        );
345        // Both now receive the same big new reading; the volatile one must move MORE toward it.
346        let steady_before = steady.value().unwrap();
347        let volatile_before = volatile.value().unwrap();
348        steady.observe(2000.0);
349        volatile.observe(2000.0);
350        let steady_move = steady.value().unwrap() - steady_before;
351        let volatile_move = volatile.value().unwrap() - volatile_before;
352        assert!(
353            volatile_move > steady_move,
354            "volatile peer must react more to a new reading (adaptive decay): steady {steady_move}, volatile {volatile_move}"
355        );
356    }
357
358    #[test]
359    fn prior_is_overridden_by_first_measurement() {
360        let mut e = Estimate::new();
361        e.seed_prior(50.0);
362        assert_eq!(e.value(), Some(50.0));
363        assert_eq!(e.samples(), 0, "a prior is not a measured sample");
364        e.observe(1000.0);
365        assert_eq!(
366            e.value(),
367            Some(1000.0),
368            "first measurement overrides the prior"
369        );
370        assert_eq!(e.samples(), 1);
371    }
372
373    #[test]
374    fn reliability_falls_on_failure_and_rises_on_recovery() {
375        let mut r = Reliability::new();
376        for _ in 0..10 {
377            r.observe(true, false);
378        }
379        assert!(r.rate().unwrap() > 0.9);
380        for _ in 0..10 {
381            r.observe(false, false);
382        }
383        assert!(
384            r.rate().unwrap() < 0.3,
385            "reliability must fall on a failure run"
386        );
387        for _ in 0..10 {
388            r.observe(true, false);
389        }
390        assert!(r.rate().unwrap() > 0.7, "reliability must recover");
391    }
392
393    #[test]
394    fn hard_failure_penalizes_more_than_soft_failure() {
395        let mut soft = Reliability::new();
396        let mut hard = Reliability::new();
397        for _ in 0..5 {
398            soft.observe(true, false);
399            hard.observe(true, false);
400        }
401        soft.observe(false, false);
402        hard.observe(false, true);
403        assert!(
404            hard.rate().unwrap() < soft.rate().unwrap(),
405            "a hard (verification) failure must drop reliability more than a soft one"
406        );
407        assert_eq!(hard.hard_failures(), 1);
408    }
409
410    #[test]
411    fn cold_quality_is_uncertain_and_confidence_grows() {
412        let mut q = PeerQuality::cold();
413        assert!(q.is_cold());
414        assert_eq!(q.confidence(), 0.0);
415        for _ in 0..4 {
416            q.observe_throughput(500.0);
417            q.bump_samples();
418        }
419        assert!(!q.is_cold());
420        assert!(
421            (q.confidence() - 0.5).abs() < 1e-9,
422            "4 samples => 4/(4+4)=0.5"
423        );
424    }
425}