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](https://github.com/decdn/decdn/blob/main/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;