dig_peer_selector/scoring.rs
1//! The autonomous scoring function (SPEC.md §4): learned saturation per class, an adaptive relayed
2//! penalty, volatility-driven decay (in [`crate::quality`]), the min-P99 + anti-thundering-herd
3//! objective, and the ranked-subset output — satisfying invariants A–G (SPEC §4.4).
4//!
5//! # What is learned here (no baked constants, no user knobs — SPEC §1.5, §4)
6//!
7//! - **Saturation point per peer class** ([`SaturationModel`], SPEC §4.1). From the observed relation
8//! between a peer's `in_flight` at dispatch and the throughput the resulting outcomes measured, the
9//! model learns the concurrency beyond which a class's per-range throughput stops rising. A new peer
10//! of a known class inherits the class prior, then specializes.
11//! - **Adaptive relayed penalty** ([`RelayModel`], SPEC §4.2). Learned from the *measured* throughput
12//! of relayed vs non-relayed transfers — it shrinks where relayed links measure nearly as well as
13//! direct and grows where they measure much worse. Subordinate to per-peer measured quality.
14//! - **Volatility-driven decay** — lives in the [`crate::quality::Estimate`] (its alpha is derived
15//! from observed volatility, SPEC §3.2/§4.3); the scorer reads the resulting estimate + its
16//! volatility as a tail-risk signal.
17//!
18//! # The objective (SPEC §4.4)
19//!
20//! Jointly **minimize P99 request latency** (make the slowest ranges faster, not just the median) and
21//! **avoid thundering-herd** collapse on a fast peer. The scorer produces a per-peer *effective score*
22//! that (a) rewards measured throughput × reliability, (b) is discounted for tail risk (volatility,
23//! low reliability, near-saturation), (c) is penalized for a relayed path by the learned amount, and
24//! (d) floors a verification-failing (bad/hostile) source below cold peers. It then dispatches by
25//! filling each peer only up to its learned saturation headroom (anti-herd) and spilling to the next.
26
27use dig_nat::TraversalKind;
28
29use crate::quality::PeerQuality;
30use crate::registry::PeerEntry;
31
32/// A coarse **peer class** — the equivalence bucket the selector learns saturation behavior *per*
33/// (SPEC §1.3, §4.1). NOT the raw connection class: peers reached by a similar path share a
34/// saturation prior, so a new peer of a known class inherits a sane concurrency prior.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum PeerClass {
37 /// A direct peer-to-peer data path (Direct / mapped / hole-punched — the relay carries no bytes).
38 DirectPath,
39 /// A relayed path — the relay carries every byte (SPEC §1.3 relay-last invariant).
40 RelayedPath,
41 /// Class unknown (no connection class observed yet).
42 Unknown,
43}
44
45impl PeerClass {
46 /// The coarse class for a `dig-nat` connection class (SPEC §4.1). Every non-relayed tier is a
47 /// peer-to-peer data path; only `Relayed` means the relay carries the bytes.
48 pub fn of(class: Option<TraversalKind>) -> Self {
49 match class {
50 Some(TraversalKind::Relayed) => PeerClass::RelayedPath,
51 Some(_) => PeerClass::DirectPath,
52 None => PeerClass::Unknown,
53 }
54 }
55
56 /// Whether this is a relayed path (attracts the learned relayed penalty, SPEC §4.2).
57 pub fn is_relayed(self) -> bool {
58 matches!(self, PeerClass::RelayedPath)
59 }
60}
61
62/// The learned saturation model per [`PeerClass`] (SPEC §4.1). Tracks, per class, the concurrency at
63/// which measured per-range throughput stops rising — so the scorer caps a peer's concurrent ranges
64/// at its learned headroom and spreads the excess (anti-thundering-herd, SPEC §4.4-B).
65#[derive(Debug, Clone)]
66pub struct SaturationModel {
67 /// Per-class learned saturation point (max useful concurrent ranges). Learned, adapts (SPEC §4.1).
68 direct: SaturationEstimate,
69 relayed: SaturationEstimate,
70 unknown: SaturationEstimate,
71}
72
73impl Default for SaturationModel {
74 fn default() -> Self {
75 SaturationModel {
76 // Class priors: a fresh, unmeasured class starts with a modest concurrency so a peer is
77 // tried with a little parallelism, then the point is learned from measured outcomes.
78 // These are ESTIMATOR SEEDS (learned away by data), not user knobs.
79 direct: SaturationEstimate::seeded(4.0),
80 relayed: SaturationEstimate::seeded(2.0),
81 unknown: SaturationEstimate::seeded(3.0),
82 }
83 }
84}
85
86impl SaturationModel {
87 fn slot(&self, class: PeerClass) -> &SaturationEstimate {
88 match class {
89 PeerClass::DirectPath => &self.direct,
90 PeerClass::RelayedPath => &self.relayed,
91 PeerClass::Unknown => &self.unknown,
92 }
93 }
94
95 fn slot_mut(&mut self, class: PeerClass) -> &mut SaturationEstimate {
96 match class {
97 PeerClass::DirectPath => &mut self.direct,
98 PeerClass::RelayedPath => &mut self.relayed,
99 PeerClass::Unknown => &mut self.unknown,
100 }
101 }
102
103 /// The learned saturation point (max useful concurrent ranges) for a class — always `>= 1`.
104 pub fn saturation_point(&self, class: PeerClass) -> u32 {
105 self.slot(class).point()
106 }
107
108 /// Fold an observation: at dispatch a peer of `class` had `in_flight_at_dispatch` ranges in
109 /// flight and the resulting transfer measured `throughput_bps`. The model raises the class's
110 /// saturation point when more concurrency still yields throughput, and lowers it when higher
111 /// concurrency coincides with degraded per-range throughput (SPEC §4.1).
112 pub fn observe(&mut self, class: PeerClass, in_flight_at_dispatch: u32, throughput_bps: f64) {
113 self.slot_mut(class)
114 .observe(in_flight_at_dispatch, throughput_bps);
115 }
116}
117
118/// Per-class saturation estimator: learns the concurrency at which per-range throughput peaks.
119///
120/// It keeps the best `(concurrency, throughput)` seen and, when a *higher* concurrency measures a
121/// clearly-lower per-range throughput, concludes saturation sits at the lower concurrency; when a
122/// higher concurrency still improves throughput, it lifts the point. The point drifts (EWMA) so it
123/// adapts if the class's capacity changes (SPEC §4.1 "must adapt").
124#[derive(Debug, Clone)]
125struct SaturationEstimate {
126 /// The current learned saturation point (fractional; exposed rounded, `>= 1`).
127 point: f64,
128 /// The best per-range throughput seen so far (for detecting a degradation with more concurrency).
129 best_throughput: f64,
130 /// The concurrency at which `best_throughput` was seen.
131 best_concurrency: f64,
132 samples: u64,
133}
134
135impl SaturationEstimate {
136 fn seeded(point: f64) -> Self {
137 SaturationEstimate {
138 point,
139 best_throughput: 0.0,
140 best_concurrency: 1.0,
141 samples: 0,
142 }
143 }
144
145 fn point(&self) -> u32 {
146 (self.point.round() as i64).max(1) as u32
147 }
148
149 fn observe(&mut self, in_flight_at_dispatch: u32, throughput_bps: f64) {
150 let conc = (in_flight_at_dispatch.max(1)) as f64;
151 self.samples = self.samples.saturating_add(1);
152 if throughput_bps <= 0.0 {
153 return;
154 }
155 // Adaptive learning rate: converge fast early, then drift slowly (adapts to capacity change).
156 let alpha = (1.0 / (self.samples as f64)).max(0.1);
157 if throughput_bps >= self.best_throughput {
158 // Better throughput at this concurrency: raise the point toward this concurrency (there
159 // is still headroom), and remember the new best.
160 self.best_throughput = throughput_bps;
161 self.best_concurrency = conc;
162 let target = conc.max(self.point);
163 self.point = (1.0 - alpha) * self.point + alpha * target;
164 } else if conc > self.best_concurrency && throughput_bps < 0.8 * self.best_throughput {
165 // More concurrency but clearly-worse throughput => past saturation: pull the point back
166 // toward the best-known concurrency (SPEC §4.1 degrade-on-oversubscription).
167 let target = self.best_concurrency;
168 self.point = (1.0 - alpha) * self.point + alpha * target;
169 }
170 // Keep the point sane.
171 self.point = self.point.clamp(1.0, 64.0);
172 }
173}
174
175/// The learned, adaptive relayed penalty (SPEC §4.2). A multiplicative factor in `(0, 1]` applied to a
176/// relayed peer's score, learned from how relayed transfers *measure* against non-relayed ones — it
177/// shrinks toward 1.0 when relayed links measure nearly as well, grows (toward a floor) when they
178/// measure much worse. Subordinate to per-peer measured quality: a relayed peer that measures fast is
179/// barely penalized; a relayed peer that measures slow is de-prioritized by both its own low estimate
180/// AND this factor.
181#[derive(Debug, Clone, Default)]
182pub struct RelayModel {
183 /// EWMA mean measured throughput of relayed transfers.
184 relayed_mean: Option<f64>,
185 /// EWMA mean measured throughput of non-relayed transfers.
186 direct_mean: Option<f64>,
187 relayed_samples: u64,
188 direct_samples: u64,
189}
190
191impl RelayModel {
192 /// Fold one measured transfer into the relay model, tagged by whether its path was relayed.
193 pub fn observe(&mut self, relayed: bool, throughput_bps: f64) {
194 if throughput_bps <= 0.0 {
195 return;
196 }
197 if relayed {
198 self.relayed_samples += 1;
199 let a = (1.0 / self.relayed_samples as f64).max(0.1);
200 self.relayed_mean = Some(match self.relayed_mean {
201 None => throughput_bps,
202 Some(m) => (1.0 - a) * m + a * throughput_bps,
203 });
204 } else {
205 self.direct_samples += 1;
206 let a = (1.0 / self.direct_samples as f64).max(0.1);
207 self.direct_mean = Some(match self.direct_mean {
208 None => throughput_bps,
209 Some(m) => (1.0 - a) * m + a * throughput_bps,
210 });
211 }
212 }
213
214 /// The current learned relayed-penalty factor in `[floor, 1.0]`. `1.0` = no penalty (relayed
215 /// measures as well as direct or better); smaller = relayed measures worse. Before enough data on
216 /// both sides, returns a mild default penalty (relayed links *often* cost more — SPEC §4.2) that
217 /// is quickly replaced by the measured ratio.
218 pub fn penalty(&self) -> f64 {
219 const FLOOR: f64 = 0.25; // never zero out a relayed peer purely on the class prior.
220 match (self.relayed_mean, self.direct_mean) {
221 (Some(r), Some(d)) if d > 0.0 => (r / d).clamp(FLOOR, 1.0),
222 // Insufficient paired data: a mild prior penalty, learned away once both sides measure.
223 _ => 0.85,
224 }
225 }
226}
227
228/// A per-peer scored candidate: the effective score + the concurrency headroom + whether it is a cold
229/// exploratory pick. The engine ranks by `effective_score` (desc) and dispatches by `headroom`.
230#[derive(Debug, Clone, Copy)]
231pub struct ScoredPeer {
232 /// The peer's registry index-free identity is carried by the engine; here we hold the score parts.
233 /// The higher, the better (SPEC §4.4-A).
234 pub effective_score: f64,
235 /// The recommended concurrent-range headroom for this peer (its learned saturation point minus
236 /// its current in-flight, `>= 0`; a selected peer gets `>= 1`). Anti-herd (SPEC §4.4-B, §4.5).
237 pub headroom: u32,
238 /// Whether this is a cold exploratory pick (SPEC §4.4-E).
239 pub exploratory: bool,
240 /// A stable tie-break salt (derived from the peer id) so equal scores order deterministically
241 /// (SPEC §4.4-F) unless the engine's seeded RNG chooses among cold peers.
242 pub tie_break: u64,
243}
244
245/// Test-only call counter for [`score_peer`] (#179 LOW finding: `select`/`rebalance` must score each
246/// pooled peer at most ONCE per call, not twice via a separate `proven_score_bounds` pass). Never read
247/// or incremented outside `cfg(test)` — zero cost in production.
248///
249/// This is process-global, so `cargo test`'s default parallel test threads all increment the same
250/// counter — a test measuring it MUST serialize via [`SCORE_PEER_CALLS_LOCK`] and read a *delta*
251/// across the measured operation, never an absolute value, or concurrent tests calling `score_peer`
252/// (any scoring test in this crate) will make the count flaky.
253#[cfg(test)]
254pub(crate) static SCORE_PEER_CALLS: std::sync::atomic::AtomicU64 =
255 std::sync::atomic::AtomicU64::new(0);
256
257/// Serializes access to [`SCORE_PEER_CALLS`] across test threads so a delta measurement is not
258/// polluted by a concurrently-running test's `score_peer` calls.
259#[cfg(test)]
260pub(crate) static SCORE_PEER_CALLS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
261
262/// Compute a peer's **effective score** and its exploratory flag against the learned models
263/// (SPEC §4.4). Higher score = preferred. This is a pure function of the entry's measured quality +
264/// the learned saturation/relay models — no self-reported input can raise it (SPEC §9.2).
265///
266/// The score encodes the joint objective:
267/// - base value = measured throughput × reliability (convergence to fast/reliable, SPEC §4.4-A);
268/// - a **tail-risk discount** for volatility, low reliability, and near-saturation (P99 orientation,
269/// SPEC §4.4-C) — a peer likely to leave a range straggling is down-weighted for the tail;
270/// - a **learned relayed penalty** for a relayed path (SPEC §4.2);
271/// - a **hard floor** for a verification-failing source: driven below any cold peer (SPEC §9.4);
272/// - a **cold exploration bonus** bounded so cold peers get tried but do not crowd out proven fast
273/// peers (SPEC §4.4-E).
274pub fn score_peer(
275 entry: &PeerEntry,
276 saturation: &SaturationModel,
277 relay: &RelayModel,
278 exploration_bonus: f64,
279) -> ScoredPeer {
280 #[cfg(test)]
281 SCORE_PEER_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
282 let q = &entry.quality;
283 let class = PeerClass::of(entry.connection_class);
284 let sat_point = saturation.saturation_point(class);
285 let headroom = sat_point.saturating_sub(q.in_flight);
286 let tie_break = tie_break_salt(entry);
287
288 // --- Cold peer: exploratory, scored just above the worst proven peer so it gets tried but does
289 // not displace a measured fast peer for the bulk of a transfer (SPEC §4.4-E, §3.5).
290 if q.is_cold() {
291 return ScoredPeer {
292 effective_score: exploration_bonus,
293 headroom: headroom.max(1),
294 exploratory: true,
295 tie_break,
296 };
297 }
298
299 // --- Hard-source floor: a verification-failing peer is worse than an unknown one (SPEC §9.4).
300 if is_bad_source(q) {
301 return ScoredPeer {
302 effective_score: -1.0e12 + tie_break as f64 * 1e-6,
303 headroom: headroom.max(1),
304 exploratory: false,
305 tie_break,
306 };
307 }
308
309 let tput = q.throughput.value().unwrap_or(0.0);
310 let rel = q.reliability.rate().unwrap_or(0.5);
311 let conf = q.confidence();
312
313 // Base value: measured throughput weighted by reliability. Observed capacity only (SPEC §9.3).
314 let base = tput * rel;
315
316 // Tail-risk discount (P99 orientation, SPEC §4.4-C): volatility (unpredictable tail), unreliability
317 // (straggle/refetch risk), and near-saturation (queuing tail) each shave the score. A high-median
318 // but heavy-tailed peer is down-weighted for tail-sensitive assignment.
319 let volatility = q.throughput.relative_volatility();
320 let saturation_pressure = if sat_point == 0 {
321 1.0
322 } else {
323 (q.in_flight as f64 / sat_point as f64).clamp(0.0, 1.0)
324 };
325 let tail_discount =
326 (1.0 - 0.5 * volatility) * (0.5 + 0.5 * rel) * (1.0 - 0.4 * saturation_pressure);
327
328 // Confidence blends the measured value toward a neutral baseline for barely-sampled peers, so a
329 // single lucky sample does not vault an under-measured peer above a well-measured one (SPEC §3.2).
330 let confident_value = base * (0.3 + 0.7 * conf);
331
332 // Learned relayed penalty (SPEC §4.2), subordinate to the measured value above.
333 let relay_factor = if class.is_relayed() {
334 relay.penalty()
335 } else {
336 1.0
337 };
338
339 let effective_score = confident_value * tail_discount * relay_factor;
340
341 ScoredPeer {
342 effective_score,
343 headroom,
344 exploratory: false,
345 tie_break,
346 }
347}
348
349/// Whether a peer is a bad/hostile source that must sink below cold peers (SPEC §9.4): it has served
350/// verification-failing ranges AND its measured reliability is poor.
351fn is_bad_source(q: &PeerQuality) -> bool {
352 q.reliability.hard_failures() > 0 && q.reliability.rate().unwrap_or(1.0) < 0.5
353}
354
355/// A stable per-peer tie-break salt derived from the `peer_id` bytes — deterministic ordering for
356/// equal scores (SPEC §4.4-F) without needing entropy.
357fn tie_break_salt(entry: &PeerEntry) -> u64 {
358 let b = entry.peer_id.as_bytes();
359 let mut acc = 0u64;
360 for &x in b.iter().take(8) {
361 acc = (acc << 8) | x as u64;
362 }
363 acc
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::types::Provenance;
370 use dig_nat::PeerId;
371
372 fn entry(b: u8) -> PeerEntry {
373 PeerEntry::cold(PeerId::from_bytes([b; 32]), Provenance::Dht, 0)
374 }
375
376 #[test]
377 fn peer_class_maps_relayed_vs_direct() {
378 assert_eq!(
379 PeerClass::of(Some(TraversalKind::Relayed)),
380 PeerClass::RelayedPath
381 );
382 assert_eq!(
383 PeerClass::of(Some(TraversalKind::Direct)),
384 PeerClass::DirectPath
385 );
386 assert_eq!(
387 PeerClass::of(Some(TraversalKind::HolePunch)),
388 PeerClass::DirectPath
389 );
390 assert_eq!(PeerClass::of(None), PeerClass::Unknown);
391 }
392
393 #[test]
394 fn faster_reliable_peer_scores_higher() {
395 let sat = SaturationModel::default();
396 let relay = RelayModel::default();
397
398 let mut fast = entry(1);
399 for _ in 0..10 {
400 fast.quality.observe_throughput(1000.0);
401 fast.quality.observe_result(true, false);
402 fast.quality.bump_samples();
403 }
404 let mut slow = entry(2);
405 for _ in 0..10 {
406 slow.quality.observe_throughput(100.0);
407 slow.quality.observe_result(true, false);
408 slow.quality.bump_samples();
409 }
410 let sf = score_peer(&fast, &sat, &relay, 0.0);
411 let ss = score_peer(&slow, &sat, &relay, 0.0);
412 assert!(sf.effective_score > ss.effective_score);
413 }
414
415 #[test]
416 fn cold_peer_is_exploratory_and_bounded() {
417 let sat = SaturationModel::default();
418 let relay = RelayModel::default();
419 let cold = entry(1);
420 let s = score_peer(&cold, &sat, &relay, 50.0);
421 assert!(s.exploratory);
422 assert_eq!(s.effective_score, 50.0);
423 assert!(s.headroom >= 1);
424 }
425
426 #[test]
427 fn bad_source_sinks_below_cold_peers() {
428 let sat = SaturationModel::default();
429 let relay = RelayModel::default();
430 let mut bad = entry(1);
431 // Warm it, then hammer verification failures.
432 for _ in 0..5 {
433 bad.quality.observe_throughput(1000.0);
434 bad.quality.observe_result(true, false);
435 bad.quality.bump_samples();
436 }
437 for _ in 0..8 {
438 bad.quality.observe_result(false, true);
439 bad.quality.bump_samples();
440 }
441 let sbad = score_peer(&bad, &sat, &relay, 0.0);
442 let cold = entry(2);
443 let scold = score_peer(&cold, &sat, &relay, 10.0);
444 assert!(
445 sbad.effective_score < scold.effective_score,
446 "a verification-failing source must rank below a cold peer"
447 );
448 }
449
450 #[test]
451 fn saturation_model_lowers_point_on_oversubscription_degradation() {
452 let mut m = SaturationModel::default();
453 // At concurrency 2, great throughput.
454 for _ in 0..5 {
455 m.observe(PeerClass::DirectPath, 2, 1000.0);
456 }
457 let p_before = m.saturation_point(PeerClass::DirectPath);
458 // At higher concurrency 8, throughput collapses => saturation is below 8.
459 for _ in 0..8 {
460 m.observe(PeerClass::DirectPath, 8, 200.0);
461 }
462 let p_after = m.saturation_point(PeerClass::DirectPath);
463 assert!(
464 p_after <= p_before,
465 "oversubscription with degraded throughput must not raise the saturation point (before {p_before}, after {p_after})"
466 );
467 assert!(p_after >= 1);
468 }
469
470 #[test]
471 fn relay_penalty_shrinks_when_relayed_measures_as_well_as_direct() {
472 let mut m = RelayModel::default();
473 for _ in 0..10 {
474 m.observe(false, 1000.0);
475 m.observe(true, 950.0);
476 }
477 assert!(
478 m.penalty() > 0.9,
479 "near-parity relayed links => small penalty"
480 );
481
482 let mut m2 = RelayModel::default();
483 for _ in 0..10 {
484 m2.observe(false, 1000.0);
485 m2.observe(true, 300.0);
486 }
487 assert!(
488 m2.penalty() < 0.5,
489 "much-worse relayed links => large penalty"
490 );
491 }
492}