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#[cfg(test)]
246thread_local! {
247    /// Test-only, PER-THREAD call counter for [`score_peer`] (#179 LOW finding: `select`/`rebalance`
248    /// must score each pooled peer at most ONCE per call, not twice via a separate
249    /// `proven_score_bounds` pass). Never read or incremented outside `cfg(test)` — zero cost in
250    /// production.
251    ///
252    /// Per-thread is what makes a call-count assertion sound: `select`/`rebalance` score entirely on
253    /// the calling thread, while `cargo test` runs tests on many threads in parallel. A process-global
254    /// counter would mix in every concurrently-running scoring test's calls, which no lock held by
255    /// only the measuring test can prevent.
256    static SCORE_PEER_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
257}
258
259/// This thread's cumulative [`score_peer`] call count. Measure a *delta* across the operation under
260/// test, never an absolute value — earlier calls on the same thread also count.
261#[cfg(test)]
262pub(crate) fn score_peer_calls() -> u64 {
263    SCORE_PEER_CALLS.with(std::cell::Cell::get)
264}
265
266/// Compute a peer's **effective score** and its exploratory flag against the learned models
267/// (SPEC §4.4). Higher score = preferred. This is a pure function of the entry's measured quality +
268/// the learned saturation/relay models — no self-reported input can raise it (SPEC §9.2).
269///
270/// The score encodes the joint objective:
271/// - base value = measured throughput × reliability (convergence to fast/reliable, SPEC §4.4-A);
272/// - a **tail-risk discount** for volatility, low reliability, and near-saturation (P99 orientation,
273///   SPEC §4.4-C) — a peer likely to leave a range straggling is down-weighted for the tail;
274/// - a **learned relayed penalty** for a relayed path (SPEC §4.2);
275/// - a **hard floor** for a verification-failing source: driven below any cold peer (SPEC §9.4);
276/// - a **cold exploration bonus** bounded so cold peers get tried but do not crowd out proven fast
277///   peers (SPEC §4.4-E).
278pub fn score_peer(
279    entry: &PeerEntry,
280    saturation: &SaturationModel,
281    relay: &RelayModel,
282    exploration_bonus: f64,
283) -> ScoredPeer {
284    #[cfg(test)]
285    SCORE_PEER_CALLS.with(|c| c.set(c.get() + 1));
286    let q = &entry.quality;
287    let class = PeerClass::of(entry.connection_class);
288    let sat_point = saturation.saturation_point(class);
289    let headroom = sat_point.saturating_sub(q.in_flight);
290    let tie_break = tie_break_salt(entry);
291
292    // --- Cold peer: exploratory, scored just above the worst proven peer so it gets tried but does
293    //     not displace a measured fast peer for the bulk of a transfer (SPEC §4.4-E, §3.5).
294    if q.is_cold() {
295        return ScoredPeer {
296            effective_score: exploration_bonus,
297            headroom: headroom.max(1),
298            exploratory: true,
299            tie_break,
300        };
301    }
302
303    // --- Hard-source floor: a verification-failing peer is worse than an unknown one (SPEC §9.4).
304    if is_bad_source(q) {
305        return ScoredPeer {
306            effective_score: -1.0e12 + tie_break as f64 * 1e-6,
307            headroom: headroom.max(1),
308            exploratory: false,
309            tie_break,
310        };
311    }
312
313    let tput = q.throughput.value().unwrap_or(0.0);
314    let rel = q.reliability.rate().unwrap_or(0.5);
315    let conf = q.confidence();
316
317    // Base value: measured throughput weighted by reliability. Observed capacity only (SPEC §9.3).
318    let base = tput * rel;
319
320    // Tail-risk discount (P99 orientation, SPEC §4.4-C): volatility (unpredictable tail), unreliability
321    // (straggle/refetch risk), and near-saturation (queuing tail) each shave the score. A high-median
322    // but heavy-tailed peer is down-weighted for tail-sensitive assignment.
323    let volatility = q.throughput.relative_volatility();
324    let saturation_pressure = if sat_point == 0 {
325        1.0
326    } else {
327        (q.in_flight as f64 / sat_point as f64).clamp(0.0, 1.0)
328    };
329    let tail_discount =
330        (1.0 - 0.5 * volatility) * (0.5 + 0.5 * rel) * (1.0 - 0.4 * saturation_pressure);
331
332    // Confidence blends the measured value toward a neutral baseline for barely-sampled peers, so a
333    // single lucky sample does not vault an under-measured peer above a well-measured one (SPEC §3.2).
334    let confident_value = base * (0.3 + 0.7 * conf);
335
336    // Learned relayed penalty (SPEC §4.2), subordinate to the measured value above.
337    let relay_factor = if class.is_relayed() {
338        relay.penalty()
339    } else {
340        1.0
341    };
342
343    let effective_score = confident_value * tail_discount * relay_factor;
344
345    ScoredPeer {
346        effective_score,
347        headroom,
348        exploratory: false,
349        tie_break,
350    }
351}
352
353/// Whether a peer is a bad/hostile source that must sink below cold peers (SPEC §9.4): it has served
354/// verification-failing ranges AND its measured reliability is poor.
355fn is_bad_source(q: &PeerQuality) -> bool {
356    q.reliability.hard_failures() > 0 && q.reliability.rate().unwrap_or(1.0) < 0.5
357}
358
359/// A stable per-peer tie-break salt derived from the `peer_id` bytes — deterministic ordering for
360/// equal scores (SPEC §4.4-F) without needing entropy.
361fn tie_break_salt(entry: &PeerEntry) -> u64 {
362    let b = entry.peer_id.as_bytes();
363    let mut acc = 0u64;
364    for &x in b.iter().take(8) {
365        acc = (acc << 8) | x as u64;
366    }
367    acc
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::types::Provenance;
374    use dig_nat::PeerId;
375
376    fn entry(b: u8) -> PeerEntry {
377        PeerEntry::cold(PeerId::from_bytes([b; 32]), Provenance::Dht, 0)
378    }
379
380    #[test]
381    fn peer_class_maps_relayed_vs_direct() {
382        assert_eq!(
383            PeerClass::of(Some(TraversalKind::Relayed)),
384            PeerClass::RelayedPath
385        );
386        assert_eq!(
387            PeerClass::of(Some(TraversalKind::Direct)),
388            PeerClass::DirectPath
389        );
390        assert_eq!(
391            PeerClass::of(Some(TraversalKind::HolePunch)),
392            PeerClass::DirectPath
393        );
394        assert_eq!(PeerClass::of(None), PeerClass::Unknown);
395    }
396
397    #[test]
398    fn faster_reliable_peer_scores_higher() {
399        let sat = SaturationModel::default();
400        let relay = RelayModel::default();
401
402        let mut fast = entry(1);
403        for _ in 0..10 {
404            fast.quality.observe_throughput(1000.0);
405            fast.quality.observe_result(true, false);
406            fast.quality.bump_samples();
407        }
408        let mut slow = entry(2);
409        for _ in 0..10 {
410            slow.quality.observe_throughput(100.0);
411            slow.quality.observe_result(true, false);
412            slow.quality.bump_samples();
413        }
414        let sf = score_peer(&fast, &sat, &relay, 0.0);
415        let ss = score_peer(&slow, &sat, &relay, 0.0);
416        assert!(sf.effective_score > ss.effective_score);
417    }
418
419    #[test]
420    fn cold_peer_is_exploratory_and_bounded() {
421        let sat = SaturationModel::default();
422        let relay = RelayModel::default();
423        let cold = entry(1);
424        let s = score_peer(&cold, &sat, &relay, 50.0);
425        assert!(s.exploratory);
426        assert_eq!(s.effective_score, 50.0);
427        assert!(s.headroom >= 1);
428    }
429
430    #[test]
431    fn bad_source_sinks_below_cold_peers() {
432        let sat = SaturationModel::default();
433        let relay = RelayModel::default();
434        let mut bad = entry(1);
435        // Warm it, then hammer verification failures.
436        for _ in 0..5 {
437            bad.quality.observe_throughput(1000.0);
438            bad.quality.observe_result(true, false);
439            bad.quality.bump_samples();
440        }
441        for _ in 0..8 {
442            bad.quality.observe_result(false, true);
443            bad.quality.bump_samples();
444        }
445        let sbad = score_peer(&bad, &sat, &relay, 0.0);
446        let cold = entry(2);
447        let scold = score_peer(&cold, &sat, &relay, 10.0);
448        assert!(
449            sbad.effective_score < scold.effective_score,
450            "a verification-failing source must rank below a cold peer"
451        );
452    }
453
454    #[test]
455    fn saturation_model_lowers_point_on_oversubscription_degradation() {
456        let mut m = SaturationModel::default();
457        // At concurrency 2, great throughput.
458        for _ in 0..5 {
459            m.observe(PeerClass::DirectPath, 2, 1000.0);
460        }
461        let p_before = m.saturation_point(PeerClass::DirectPath);
462        // At higher concurrency 8, throughput collapses => saturation is below 8.
463        for _ in 0..8 {
464            m.observe(PeerClass::DirectPath, 8, 200.0);
465        }
466        let p_after = m.saturation_point(PeerClass::DirectPath);
467        assert!(
468            p_after <= p_before,
469            "oversubscription with degraded throughput must not raise the saturation point (before {p_before}, after {p_after})"
470        );
471        assert!(p_after >= 1);
472    }
473
474    #[test]
475    fn relay_penalty_shrinks_when_relayed_measures_as_well_as_direct() {
476        let mut m = RelayModel::default();
477        for _ in 0..10 {
478            m.observe(false, 1000.0);
479            m.observe(true, 950.0);
480        }
481        assert!(
482            m.penalty() > 0.9,
483            "near-parity relayed links => small penalty"
484        );
485
486        let mut m2 = RelayModel::default();
487        for _ in 0..10 {
488            m2.observe(false, 1000.0);
489            m2.observe(true, 300.0);
490        }
491        assert!(
492            m2.penalty() < 0.5,
493            "much-worse relayed links => large penalty"
494        );
495    }
496}