Skip to main content

decdn_reputation/
local.rs

1//! Local reputation scoring (ADR 008 §3 with the §14a scope reductions).
2//!
3//! Folds delivery outcomes into a per-peer EWMA score in `[0.0, 1.0]`, and
4//! decays idle scores back toward neutral over time (ADR 008 §Score Decay) so
5//! a peer that stops being used drifts back to unopinionated rather than
6//! holding a stale high or low score. In-memory only; persistence is deferred
7//! per ADR 008 §14a.3.
8
9use iroh::PublicKey as NodeId;
10use std::collections::HashMap;
11use std::sync::{Arc, RwLock};
12use std::time::Duration;
13use thiserror::Error;
14
15const DEFAULT_ALPHA: f64 = 0.1;
16const DEFAULT_INITIAL_SCORE: f64 = 0.5;
17const DEFAULT_REFERENCE_BPS: u64 = 1024 * 1024 * 1024; // 1 GiB/s log reference (~1.0)
18const DEFAULT_SPEED_WEIGHT: f64 = 0.4;
19const DEFAULT_CORRECTNESS_WEIGHT: f64 = 0.4;
20const DEFAULT_REACHABILITY_WEIGHT: f64 = 0.2;
21/// Default decay half-life: 3 days. Sub-weekly on purpose — a transiently
22/// dinged node re-enters selection in days, not weeks, and no node coasts on
23/// stale reputation, both of which widen the serving set (ADR 008 §Score Decay).
24const DEFAULT_DECAY_HALF_LIFE_SECS: u64 = 3 * 24 * 3600; // 259_200
25
26/// Seconds in a week — used only to bound the eviction idle window (ADR 008
27/// §Score Decay).
28const SECONDS_PER_WEEK: f64 = 7.0 * 24.0 * 3600.0;
29
30/// Number of independent score shards. Peers are spread across shards by the
31/// first byte of their [`NodeId`] (uniformly distributed for an Ed25519 key), so
32/// a `score()` read on the selection hot path locks one shard, and the periodic
33/// `evict()` sweep walks shards one at a time instead of holding a single global
34/// write lock across an O(peers) scan. A concurrent miss then contends only with
35/// the ~1/N of peers sharing its shard, not the whole peer set.
36const SHARD_COUNT: usize = 16;
37
38/// Eviction candidates must be within this band of neutral (ADR 008
39/// §Score Decay: converge within 0.05 of neutral).
40const EVICT_NEUTRAL_BAND: f64 = 0.05;
41/// …and idle for longer than this many weeks. 26 weeks (~½ year) is far
42/// longer than the ~10 days a score now needs to reach the neutral band, so
43/// eviction only drops entries already reading neutral.
44const EVICT_IDLE_WEEKS: f64 = 26.0;
45// ADR 008 §14a excludes per-report clamping from the local scoring rule. The
46// clamp logic is kept so callers can opt into §8's ±0.05 cap by overriding this
47// field, but the default is `1.0` — a no-op cap given EWMA delta cannot exceed
48// 1.0 when prev, sample ∈ [0,1] and alpha ∈ [0,1]. The production node wiring
49// opts in via [`LocalReputationConfig::with_max_delta_per_update`] +
50// [`LOCAL_SCORE_MAX_DELTA_PER_REPORT`]; the library default stays a no-op.
51const DEFAULT_MAX_DELTA_PER_UPDATE: f64 = 1.0;
52
53/// ADR 008 §8 per-report score-movement cap (±0.05). The library default is a
54/// no-op cap of `1.0`; the node opts into this value via
55/// [`LocalReputationConfig::with_max_delta_per_update`] so a single bad
56/// interaction cannot over-penalize an otherwise good peer.
57pub const LOCAL_SCORE_MAX_DELTA_PER_REPORT: f64 = 0.05;
58
59/// Validation errors when constructing a [`LocalReputation`].
60#[derive(Debug, Error, PartialEq)]
61pub enum ConfigError {
62    /// A config field that must sit in `[0.0, 1.0]` is outside it, infinite,
63    /// or NaN.
64    #[error("{field} must be a finite number in [0.0, 1.0], got {value}")]
65    OutOfUnitInterval {
66        /// Name of the offending [`LocalReputationConfig`] field.
67        field: &'static str,
68        /// The value that was rejected.
69        value: f64,
70    },
71    /// `reference_bps` is `0`, which would make the log speed curve undefined.
72    #[error("reference_bps must be > 0")]
73    ZeroReferenceBps,
74    /// The three component weights do not add up to `1.0`, so the blended
75    /// score would not stay in `[0.0, 1.0]`.
76    #[error(
77        "weights must sum to 1.0; got speed={speed} + correctness={correctness} \
78         + reachability={reachability} = {sum}"
79    )]
80    WeightsDoNotSumToOne {
81        /// The configured speed weight.
82        speed: f64,
83        /// The configured correctness weight.
84        correctness: f64,
85        /// The configured reachability weight.
86        reachability: f64,
87        /// What the three actually add up to.
88        sum: f64,
89    },
90}
91
92/// Outcome of a single delivery interaction with a peer.
93///
94/// Encodes the three outcome classes relevant to ADR 008 §3 scoring. Speed
95/// contributes only when the peer actually delivered bytes; the
96/// [`Outcome::Unreachable`] and [`Outcome::Corruption`] variants implicitly
97/// score speed as 0.
98///
99/// `Delivered { bytes: 0, .. }` is treated as a successful but zero-rate
100/// delivery — callers should prefer [`Outcome::Corruption`] when a peer
101/// agreed to serve and then sent nothing useful, since `Delivered` still
102/// rewards the correctness and reachability components at zero throughput.
103#[non_exhaustive]
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Outcome {
106    /// Dial or handshake to the peer failed.
107    Unreachable,
108    /// Peer responded but BLAKE3 verification of the bytes failed.
109    Corruption,
110    /// Peer delivered correctly verified bytes over the wire.
111    Delivered {
112        /// Bytes that verified against the blob hash.
113        bytes: u64,
114        /// Wall-clock time the delivery took; with `bytes`, the throughput
115        /// the speed component scores.
116        elapsed: Duration,
117    },
118    /// Peer self-attested *this node's own* region yet the observed probe
119    /// latency exceeded the ADR 030 ceiling — the canonical region-spoofing
120    /// signal
121    /// ([ADR 030 §Soft mitigation](../../../adr/030-node-region-self-attestation.md)).
122    /// Scored as a fully negative sample (like [`Outcome::Unreachable`]); it is a
123    /// local-only observation.
124    RegionLatencyMismatch,
125}
126
127/// Tunable parameters for the local EWMA score.
128///
129/// Defaults follow ADR 008 §3 (α=0.1, initial=0.5, weights 40/40/20).
130/// Validation runs at [`LocalReputation::new`]; constructing an invalid
131/// config in isolation is allowed but installing it returns
132/// [`ConfigError`].
133///
134/// `max_delta_per_update` defaults to `1.0` (no clamping) per ADR 008 §14a;
135/// operators can set it to `0.05` to enforce §8's per-report cap without
136/// code changes.
137#[non_exhaustive]
138#[derive(Debug, Clone)]
139pub struct LocalReputationConfig {
140    /// EWMA smoothing factor: the weight given to the newest sample. Larger
141    /// reacts faster and forgets faster.
142    pub alpha: f64,
143    /// Score a peer starts at, before any interaction is recorded.
144    pub initial_score: f64,
145    /// Reference throughput scoring ~1.0 under the log speed curve (was the
146    /// flat saturation baseline).
147    pub reference_bps: u64,
148    /// Weight of the throughput component. The three weights must sum to
149    /// `1.0` or [`LocalReputation::new`] rejects the config.
150    pub speed_weight: f64,
151    /// Weight of the hash-verification component.
152    pub correctness_weight: f64,
153    /// Weight of the dial-success component.
154    pub reachability_weight: f64,
155    /// Largest score movement one report may cause. `1.0` disables the clamp;
156    /// see [`LOCAL_SCORE_MAX_DELTA_PER_REPORT`].
157    pub max_delta_per_update: f64,
158    /// Half-life (seconds) of idle-score decay toward neutral (ADR 008 §Score
159    /// Decay). Each half-life halves the distance to neutral. `0` disables decay.
160    pub decay_half_life_secs: u64,
161}
162
163impl Default for LocalReputationConfig {
164    fn default() -> Self {
165        Self {
166            alpha: DEFAULT_ALPHA,
167            initial_score: DEFAULT_INITIAL_SCORE,
168            reference_bps: DEFAULT_REFERENCE_BPS,
169            speed_weight: DEFAULT_SPEED_WEIGHT,
170            correctness_weight: DEFAULT_CORRECTNESS_WEIGHT,
171            reachability_weight: DEFAULT_REACHABILITY_WEIGHT,
172            max_delta_per_update: DEFAULT_MAX_DELTA_PER_UPDATE,
173            decay_half_life_secs: DEFAULT_DECAY_HALF_LIFE_SECS,
174        }
175    }
176}
177
178impl LocalReputationConfig {
179    /// Set the per-report score-movement cap and return the config.
180    ///
181    /// The struct is `#[non_exhaustive]`, so downstream crates cannot use a
182    /// struct-update literal to override a single field; this builder is how the
183    /// node wiring opts into [`LOCAL_SCORE_MAX_DELTA_PER_REPORT`]. The value is
184    /// validated (finite, `[0.0, 1.0]`) at [`LocalReputation::new`], not here.
185    #[must_use]
186    pub const fn with_max_delta_per_update(mut self, cap: f64) -> Self {
187        self.max_delta_per_update = cap;
188        self
189    }
190}
191
192/// A source of wall-clock time in whole seconds since the Unix epoch, driving
193/// idle-score decay (ADR 008 §Score Decay). Injected so tests can advance time
194/// deterministically instead of sleeping.
195pub trait Clock: std::fmt::Debug + Send + Sync {
196    /// Current time as seconds since the Unix epoch.
197    fn now_secs(&self) -> u64;
198}
199
200/// Production clock reading the system wall clock.
201///
202/// A clock before the Unix epoch (or a `duration_since` error) reads as `0`.
203/// Since `decay` saturates elapsed time at 0, a `0` read is never *after* a
204/// stored `last_update_secs`, so it yields no decay rather than corrupting a
205/// score — the conservative outcome for a host whose clock is not a real
206/// deployment.
207#[derive(Debug, Default, Clone, Copy)]
208pub struct SystemClock;
209
210impl Clock for SystemClock {
211    fn now_secs(&self) -> u64 {
212        std::time::SystemTime::now()
213            .duration_since(std::time::UNIX_EPOCH)
214            .map_or(0, |d| d.as_secs())
215    }
216}
217
218/// A stored per-peer score plus the wall-clock second it was last written, so
219/// reads can lazily decay it toward neutral (ADR 008 §Score Decay).
220#[derive(Debug, Clone, Copy)]
221struct Entry {
222    score: f64,
223    last_update_secs: u64,
224}
225
226/// In-memory store of per-peer EWMA reputation scores keyed by [`NodeId`].
227///
228/// Cheap to share across tasks via [`std::sync::Arc`]; reads use a
229/// read-lock, writes a write-lock. Idle scores decay toward neutral lazily at
230/// read time (ADR 008 §Score Decay). No persistence and no network
231/// aggregation per ADR 008 §14a.
232///
233/// The peer map is split into `SHARD_COUNT` independently-locked shards so
234/// per-candidate reads during ranking and the periodic maintenance sweep do not
235/// serialize on one global lock.
236#[derive(Debug)]
237pub struct LocalReputation {
238    config: LocalReputationConfig,
239    clock: Arc<dyn Clock>,
240    scores: Vec<RwLock<HashMap<NodeId, Entry>>>,
241}
242
243impl LocalReputation {
244    /// Build a new score store backed by the system clock, validating the config.
245    ///
246    /// Validation rejects non-finite or out-of-range parameters so a single
247    /// bad config write cannot poison every future score with NaN.
248    pub fn new(config: LocalReputationConfig) -> Result<Self, ConfigError> {
249        Self::with_clock(config, Arc::new(SystemClock))
250    }
251
252    /// Build a new score store driven by an explicit [`Clock`], validating the
253    /// config. Tests inject a controllable clock to exercise decay without
254    /// sleeping; production uses [`Self::new`] (the system clock).
255    pub fn with_clock(
256        config: LocalReputationConfig,
257        clock: Arc<dyn Clock>,
258    ) -> Result<Self, ConfigError> {
259        validate(&config)?;
260        Ok(Self {
261            config,
262            clock,
263            scores: (0..SHARD_COUNT)
264                .map(|_| RwLock::new(HashMap::new()))
265                .collect(),
266        })
267    }
268
269    /// The shard holding `peer`'s entry, chosen by the first key byte. Both
270    /// lookups are `.first()`/`.get()` rather than indexing, so the anti-panic
271    /// `indexing_slicing` lint has nothing to flag; `None` is unreachable (a key
272    /// is 32 bytes and the index is taken modulo the shard count, which is the
273    /// vector's length) but lets callers fall back to a neutral answer.
274    fn shard_for(&self, peer: &NodeId) -> Option<&RwLock<HashMap<NodeId, Entry>>> {
275        let first = *peer.as_bytes().first()?;
276        self.scores.get(usize::from(first) % SHARD_COUNT)
277    }
278
279    /// Fold an outcome into the peer's score and return the new value.
280    ///
281    /// The stored score is first decayed to now (ADR 008 §Score Decay), then
282    /// EWMA-folded with this interaction's sample. The returned value matches a
283    /// subsequent [`Self::score`] call taken at the same instant so callers
284    /// logging both can avoid a re-lock.
285    pub fn record(&self, peer: NodeId, outcome: Outcome) -> f64 {
286        let sample = self.interaction_score(outcome);
287        let now = self.clock.now_secs();
288        let Some(shard) = self.shard_for(&peer) else {
289            return sample;
290        };
291        // Poison recovery: the only writer is this method, and the map entry
292        // is mutated only after all arithmetic. A panic earlier in the
293        // function leaves the map structurally intact.
294        let mut guard = shard
295            .write()
296            .unwrap_or_else(std::sync::PoisonError::into_inner);
297        let entry = guard.entry(peer).or_insert(Entry {
298            score: self.config.initial_score,
299            last_update_secs: now,
300        });
301        // Clamp the effective "now" so a backward clock read never rewinds
302        // `last_update_secs`, which would make a later `score()` apply extra
303        // decay. `decay` also saturates elapsed at 0; this keeps the stored
304        // timestamp monotonic too.
305        let effective_now = now.max(entry.last_update_secs);
306        let decayed = self.decay(entry.score, entry.last_update_secs, effective_now);
307        let next = self.fold(decayed, sample);
308        entry.score = next;
309        entry.last_update_secs = effective_now;
310        next
311    }
312
313    /// Current score for `peer`, decayed toward neutral for idle time (ADR 008
314    /// §Score Decay), or [`LocalReputationConfig::initial_score`] if unseen.
315    pub fn score(&self, peer: NodeId) -> f64 {
316        let now = self.clock.now_secs();
317        let Some(shard) = self.shard_for(&peer) else {
318            return self.config.initial_score;
319        };
320        // Poison recovery: read-only path; cannot itself corrupt state.
321        let guard = shard
322            .read()
323            .unwrap_or_else(std::sync::PoisonError::into_inner);
324        guard.get(&peer).map_or(self.config.initial_score, |e| {
325            self.decay(e.score, e.last_update_secs, now)
326        })
327    }
328
329    /// Snapshot of all observed `(peer, score)` pairs, each decayed to now
330    /// (ADR 008 §Score Decay). Allocates.
331    pub fn snapshot(&self) -> Vec<(NodeId, f64)> {
332        let now = self.clock.now_secs();
333        let mut out = Vec::new();
334        // Poison recovery: read-only path; same reasoning as `score`.
335        for shard in &self.scores {
336            let guard = shard
337                .read()
338                .unwrap_or_else(std::sync::PoisonError::into_inner);
339            out.extend(
340                guard
341                    .iter()
342                    .map(|(k, e)| (*k, self.decay(e.score, e.last_update_secs, now))),
343            );
344        }
345        out
346    }
347
348    /// Drop entries whose decayed score has converged within `EVICT_NEUTRAL_BAND`
349    /// (0.05) of neutral AND that have been idle for more than `EVICT_IDLE_WEEKS`
350    /// (26), so the map stays bounded under peer churn without
351    /// discarding any opinion that still reads as non-neutral. A re-seen peer
352    /// simply re-inserts at neutral, so eviction is information-lossless for
353    /// anything that has already decayed to neutral. Best-effort maintenance —
354    /// call periodically; correctness never depends on it.
355    pub fn evict(&self) {
356        let now = self.clock.now_secs();
357        let neutral = self.config.initial_score;
358        // Sweep shard by shard: each write lock is held only across its own
359        // shard's retain, so a maintenance pass never blocks a `score()` read on
360        // a different shard.
361        for shard in &self.scores {
362            let mut guard = shard
363                .write()
364                .unwrap_or_else(std::sync::PoisonError::into_inner);
365            guard.retain(|_, e| {
366                let decayed = self.decay(e.score, e.last_update_secs, now);
367                #[allow(clippy::cast_precision_loss)]
368                let idle_weeks = now.saturating_sub(e.last_update_secs) as f64 / SECONDS_PER_WEEK;
369                let near_neutral = (decayed - neutral).abs() <= EVICT_NEUTRAL_BAND;
370                !(near_neutral && idle_weeks > EVICT_IDLE_WEEKS)
371            });
372        }
373    }
374
375    /// Closed-form half-life decay toward neutral (ADR 008 §Score Decay):
376    /// `neutral + (score - neutral) * 0.5^(elapsed_secs / half_life_secs)`.
377    /// A `0` half-life disables decay; a backward clock yields no decay
378    /// (elapsed saturates at 0).
379    fn decay(&self, score: f64, last_update_secs: u64, now_secs: u64) -> f64 {
380        let elapsed_secs = now_secs.saturating_sub(last_update_secs);
381        let hl = self.config.decay_half_life_secs;
382        if elapsed_secs == 0 || hl == 0 {
383            return score;
384        }
385        #[allow(clippy::cast_precision_loss)]
386        let half_lives = elapsed_secs as f64 / hl as f64;
387        let neutral = self.config.initial_score;
388        neutral + (score - neutral) * 0.5_f64.powf(half_lives)
389    }
390
391    fn interaction_score(&self, outcome: Outcome) -> f64 {
392        let w = crate::interaction::InteractionWeights {
393            speed: self.config.speed_weight,
394            correctness: self.config.correctness_weight,
395            reachability: self.config.reachability_weight,
396        };
397        match outcome {
398            // Fully negative sample (speed 0, correctness 0, reachability 0):
399            // `Unreachable` — the peer could not be reached; and
400            // `RegionLatencyMismatch` — the ADR 030 latency-vs-claim penalty,
401            // which taxes a same-region claim contradicted by measured RTT.
402            Outcome::Unreachable | Outcome::RegionLatencyMismatch => 0.0,
403            // reachable but the bytes failed verification: only reachability
404            Outcome::Corruption => crate::interaction::interaction_score(w, 0.0, 0.0, 1.0),
405            // correctly delivered: full correctness + reachability, scaled speed
406            Outcome::Delivered { bytes, elapsed } => {
407                let speed = speed_score(bytes, elapsed, self.config.reference_bps);
408                crate::interaction::interaction_score(w, speed, 1.0, 1.0)
409            }
410        }
411    }
412
413    fn fold(&self, prev: f64, sample: f64) -> f64 {
414        let next = (1.0 - self.config.alpha) * prev + self.config.alpha * sample;
415        let delta = (next - prev).clamp(
416            -self.config.max_delta_per_update,
417            self.config.max_delta_per_update,
418        );
419        (prev + delta).clamp(0.0, 1.0)
420    }
421}
422
423// Thin wrapper over the shared formula so local and network paths cannot
424// drift (see [`crate::interaction`]).
425fn speed_score(bytes: u64, elapsed: Duration, reference_bps: u64) -> f64 {
426    crate::interaction::speed_score_from_transfer(bytes, elapsed, reference_bps)
427}
428
429fn validate(c: &LocalReputationConfig) -> Result<(), ConfigError> {
430    require_unit_interval(c.alpha, "alpha")?;
431    require_unit_interval(c.initial_score, "initial_score")?;
432    require_unit_interval(c.speed_weight, "speed_weight")?;
433    require_unit_interval(c.correctness_weight, "correctness_weight")?;
434    require_unit_interval(c.reachability_weight, "reachability_weight")?;
435    require_unit_interval(c.max_delta_per_update, "max_delta_per_update")?;
436    if c.reference_bps == 0 {
437        return Err(ConfigError::ZeroReferenceBps);
438    }
439    let sum = c.speed_weight + c.correctness_weight + c.reachability_weight;
440    if (sum - 1.0).abs() > 1e-9 {
441        return Err(ConfigError::WeightsDoNotSumToOne {
442            speed: c.speed_weight,
443            correctness: c.correctness_weight,
444            reachability: c.reachability_weight,
445            sum,
446        });
447    }
448    Ok(())
449}
450
451fn require_unit_interval(value: f64, field: &'static str) -> Result<(), ConfigError> {
452    if value.is_finite() && (0.0..=1.0).contains(&value) {
453        Ok(())
454    } else {
455        Err(ConfigError::OutOfUnitInterval { field, value })
456    }
457}
458
459#[cfg(test)]
460mod tests;