Skip to main content

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}