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}