bp_core/network/
reputation.rs

1//! Peer reputation system — R0..R4 tiers computed from QoS history.
2//!
3//! ## Tiers
4//!
5//! | Tier | Name       | Criteria                                                         |
6//! |------|------------|------------------------------------------------------------------|
7//! | R0   | Quarantine | fault_score ≥ FAULT_BLACKLISTED; isolated from trusted peers     |
8//! | R1   | Fledgling  | Node age < FLEDGLING_DAYS (7 days in the network)               |
9//! | R2   | Reliable   | uptime_ratio ≥ 0.95 over 30 d; pos_pass_rate ≥ 0.98             |
10//! | R3   | Trusted    | uptime_ratio ≥ 0.99 over 90 d; pos_pass_rate ≥ 0.995            |
11//! | R4   | Pillar     | uptime_ratio ≥ 0.999 over 365 d; pos_pass_rate ≥ 0.999          |
12//!
13//! ## Reputation score
14//!
15//! A continuous `reputation_score: i64` underlies the tier computation.
16//! Score changes:
17//!
18//! | Event                         | Delta         |
19//! |-------------------------------|---------------|
20//! | PoS challenge passed          | +PASS_INC     |
21//! | PoS challenge failed          | −FAIL_DEC     |
22//! | 24 h verified uptime          | +UPTIME_INC   |
23//! | `bp pause --eta` respected    | 0             |
24//! | `bp pause --eta` overrun      | −LATE_PENALTY |
25//! | Eviction without notice       | score reset→0, R0 lock for 30 days |
26//!
27//! ## Placement preference
28//!
29//! When selecting Pouch peers for fragment distribution (`PutFile`), the
30//! scheduler **prefers** peers whose reputation tier is ≥ the sender's
31//! own tier. R0 peers only receive fragments from other R0 senders.
32
33use crate::network::qos::FAULT_BLACKLISTED;
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36
37// ── Constants ─────────────────────────────────────────────────────────────────
38
39/// Days since first-seen after which a node graduates from Fledgling (R1).
40pub const FLEDGLING_DAYS: u64 = 7;
41
42/// Minimum uptime ratio (0–1) required for R2.
43pub const R2_UPTIME: f64 = 0.95;
44/// Minimum PoS pass rate required for R2.
45pub const R2_POS_RATE: f64 = 0.98;
46/// Observation window for R2 criteria (days).
47pub const R2_WINDOW_DAYS: u64 = 30;
48
49/// Minimum uptime ratio required for R3.
50pub const R3_UPTIME: f64 = 0.99;
51/// Minimum PoS pass rate required for R3.
52pub const R3_POS_RATE: f64 = 0.995;
53/// Observation window for R3 criteria (days).
54pub const R3_WINDOW_DAYS: u64 = 90;
55
56/// Minimum uptime ratio required for R4.
57pub const R4_UPTIME: f64 = 0.999;
58/// Minimum PoS pass rate required for R4.
59pub const R4_POS_RATE: f64 = 0.999;
60/// Observation window for R4 criteria (days).
61pub const R4_WINDOW_DAYS: u64 = 365;
62
63/// Score increment per passed PoS challenge.
64pub const PASS_INC: i64 = 2;
65/// Score decrement per failed PoS challenge.
66pub const FAIL_DEC: i64 = 10;
67/// Score increment per 24 h of verified uptime.
68pub const UPTIME_INC: i64 = 1;
69/// Penalty for overrunning a `bp pause --eta` deadline.
70pub const LATE_PENALTY: i64 = 5;
71
72// ── ReputationTier ────────────────────────────────────────────────────────────
73
74/// Discrete reputation tier for a Pouch peer.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
76#[serde(rename_all = "UPPERCASE")]
77pub enum ReputationTier {
78    /// Quarantined — isolated from trusted peers, may be evicted.
79    R0,
80    /// Fledgling — new node, < 7 days in the network.
81    R1,
82    /// Reliable — sustained uptime and PoS pass rate over 30 days.
83    R2,
84    /// Trusted — high reliability over 90 days.
85    R3,
86    /// Pillar — exemplary reliability over 365 days.
87    R4,
88}
89
90impl ReputationTier {
91    /// Human-readable name.
92    pub fn name(self) -> &'static str {
93        match self {
94            Self::R0 => "Quarantine",
95            Self::R1 => "Fledgling",
96            Self::R2 => "Reliable",
97            Self::R3 => "Trusted",
98            Self::R4 => "Pillar",
99        }
100    }
101
102    /// Target QoS recovery probability `Ph` for this reputation tier.
103    ///
104    /// Used as the `ph` argument to
105    /// [`crate::coding::params::compute_network_storage_factor`] when
106    /// computing the effective available storage of a Pouch node:
107    ///
108    /// ```text
109    /// effective_available = (bid − used) × k/N
110    /// ```
111    ///
112    /// where `k/N` is the maximum coding rate that the current network
113    /// of N Pouch peers can sustain while guaranteeing at least `Ph`
114    /// probability of full file recovery.
115    ///
116    /// | Tier | Ph    | Rationale                                     |
117    /// |------|-------|-----------------------------------------------|
118    /// | R0   | 0.00  | Quarantined — no availability guaranteed      |
119    /// | R1   | 0.70  | Fledgling   — lenient, network still forming  |
120    /// | R2   | 0.85  | Reliable    — solid sustained network         |
121    /// | R3   | 0.95  | Trusted     — high-reliability threshold      |
122    /// | R4   | 0.999 | Pillar      — near-certain recovery           |
123    pub fn qos_target_ph(self) -> f64 {
124        match self {
125            Self::R0 => 0.0,
126            Self::R1 => 0.70,
127            Self::R2 => 0.85,
128            Self::R3 => 0.95,
129            Self::R4 => 0.999,
130        }
131    }
132
133    /// Returns `true` if this tier is trusted enough to receive fragments
134    /// from senders with `sender_tier`.
135    ///
136    /// Rule: a Pouch is eligible for a sender's fragments if
137    /// `pouch_tier >= sender_tier || sender_tier == R0`.
138    pub fn is_eligible_for(self, sender_tier: ReputationTier) -> bool {
139        if sender_tier == ReputationTier::R0 {
140            self == ReputationTier::R0
141        } else {
142            self >= sender_tier
143        }
144    }
145}
146
147impl std::fmt::Display for ReputationTier {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(f, "{}", self.name())
150    }
151}
152
153// ── ReputationRecord ──────────────────────────────────────────────────────────
154
155/// Per-peer reputation state persisted for the daemon's lifetime.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ReputationRecord {
158    /// libp2p PeerId (base58).
159    pub peer_id: String,
160
161    /// Continuous score used for tier promotion / demotion.
162    pub reputation_score: i64,
163
164    /// Current computed tier.
165    pub tier: ReputationTier,
166
167    /// Unix timestamp (seconds) when this peer was first seen.
168    pub first_seen_at: u64,
169
170    /// Unix timestamp of the last score update.
171    pub last_updated_at: u64,
172
173    /// If set, the peer is R0-locked until this Unix timestamp.
174    pub r0_lock_until: Option<u64>,
175
176    /// Cumulative uptime seconds observed.
177    pub uptime_seconds: u64,
178
179    /// Total PoS challenges sent to this peer.
180    pub pos_challenges_sent: u64,
181
182    /// PoS challenges that this peer answered successfully.
183    pub pos_challenges_passed: u64,
184}
185
186impl ReputationRecord {
187    /// Create a brand-new reputation record for a newly discovered peer.
188    pub fn new(peer_id: impl Into<String>, now_secs: u64) -> Self {
189        Self {
190            peer_id: peer_id.into(),
191            reputation_score: 0,
192            tier: ReputationTier::R1,
193            first_seen_at: now_secs,
194            last_updated_at: now_secs,
195            r0_lock_until: None,
196            uptime_seconds: 0,
197            pos_challenges_sent: 0,
198            pos_challenges_passed: 0,
199        }
200    }
201
202    /// PoS challenge outcome — update score and challenge counters.
203    pub fn record_pos_challenge(&mut self, passed: bool, fault_score: u8, now_secs: u64) {
204        self.pos_challenges_sent += 1;
205        if passed {
206            self.pos_challenges_passed += 1;
207            self.reputation_score += PASS_INC;
208        } else {
209            self.reputation_score = (self.reputation_score - FAIL_DEC).max(0);
210        }
211        self.last_updated_at = now_secs;
212        self.recompute_tier(fault_score, now_secs);
213    }
214
215    /// Credit verified uptime.  Call once per continuous 24 h window.
216    pub fn credit_uptime_day(&mut self, fault_score: u8, now_secs: u64) {
217        self.uptime_seconds += 86_400;
218        self.reputation_score += UPTIME_INC;
219        self.last_updated_at = now_secs;
220        self.recompute_tier(fault_score, now_secs);
221    }
222
223    /// Apply the late-pause penalty (pause ETA overrun).
224    pub fn penalise_late_pause(&mut self, fault_score: u8, now_secs: u64) {
225        self.reputation_score = (self.reputation_score - LATE_PENALTY).max(0);
226        self.last_updated_at = now_secs;
227        self.recompute_tier(fault_score, now_secs);
228    }
229
230    /// Forced eviction without notice — reset score and lock into R0 for 30 days.
231    pub fn evict_without_notice(&mut self, now_secs: u64) {
232        self.reputation_score = 0;
233        self.tier = ReputationTier::R0;
234        self.r0_lock_until = Some(now_secs + 30 * 86_400);
235        self.last_updated_at = now_secs;
236    }
237
238    /// Recompute the tier from current stats.
239    ///
240    /// Call after every mutation that changes score, uptime, or PoS counters.
241    pub fn recompute_tier(&mut self, fault_score: u8, now_secs: u64) {
242        // R0 lock still active?
243        if let Some(lock_until) = self.r0_lock_until {
244            if now_secs < lock_until {
245                self.tier = ReputationTier::R0;
246                return;
247            }
248            // Lock expired — clear it and re-evaluate
249            self.r0_lock_until = None;
250        }
251
252        // Quarantine: fault_score maxed out
253        if fault_score >= FAULT_BLACKLISTED {
254            self.tier = ReputationTier::R0;
255            return;
256        }
257
258        let age_days = (now_secs.saturating_sub(self.first_seen_at)) / 86_400;
259        let pos_rate = if self.pos_challenges_sent == 0 {
260            1.0_f64
261        } else {
262            self.pos_challenges_passed as f64 / self.pos_challenges_sent as f64
263        };
264        // Uptime ratio is computed over the node's full age.
265        let uptime_ratio = if age_days == 0 {
266            1.0_f64
267        } else {
268            self.uptime_seconds as f64 / (age_days as f64 * 86_400.0)
269        };
270
271        self.tier = if age_days >= R4_WINDOW_DAYS
272            && uptime_ratio >= R4_UPTIME
273            && pos_rate >= R4_POS_RATE
274        {
275            ReputationTier::R4
276        } else if age_days >= R3_WINDOW_DAYS && uptime_ratio >= R3_UPTIME && pos_rate >= R3_POS_RATE
277        {
278            ReputationTier::R3
279        } else if age_days >= R2_WINDOW_DAYS && uptime_ratio >= R2_UPTIME && pos_rate >= R2_POS_RATE
280        {
281            ReputationTier::R2
282        } else if age_days < FLEDGLING_DAYS {
283            ReputationTier::R1
284        } else {
285            // Past fledgling window but not yet meeting R2 criteria
286            ReputationTier::R1
287        };
288    }
289
290    /// PoS pass rate in `[0.0, 1.0]`.
291    pub fn pos_pass_rate(&self) -> f64 {
292        if self.pos_challenges_sent == 0 {
293            1.0
294        } else {
295            self.pos_challenges_passed as f64 / self.pos_challenges_sent as f64
296        }
297    }
298}
299
300// ── ReputationStore ───────────────────────────────────────────────────────────
301
302/// In-memory registry of per-peer [`ReputationRecord`]s.
303///
304/// Stored in `DaemonState` behind an `RwLock`.
305#[derive(Debug, Default, Serialize, Deserialize)]
306pub struct ReputationStore {
307    records: HashMap<String, ReputationRecord>,
308}
309
310impl ReputationStore {
311    pub fn new() -> Self {
312        Self::default()
313    }
314
315    /// Return or create a record for `peer_id`.
316    pub fn get_or_create(&mut self, peer_id: &str, now_secs: u64) -> &mut ReputationRecord {
317        self.records
318            .entry(peer_id.to_string())
319            .or_insert_with(|| ReputationRecord::new(peer_id, now_secs))
320    }
321
322    /// Look up an existing record (read-only).
323    pub fn get(&self, peer_id: &str) -> Option<&ReputationRecord> {
324        self.records.get(peer_id)
325    }
326
327    /// Current tier for `peer_id` (default: R1 for unknown peers).
328    pub fn tier(&self, peer_id: &str) -> ReputationTier {
329        self.records
330            .get(peer_id)
331            .map(|r| r.tier)
332            .unwrap_or(ReputationTier::R1)
333    }
334
335    /// All records, for inspection / gossip.
336    pub fn all(&self) -> impl Iterator<Item = &ReputationRecord> {
337        self.records.values()
338    }
339}
340
341// ── Tests ─────────────────────────────────────────────────────────────────────
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    const NOW: u64 = 1_710_000_000; // arbitrary fixed timestamp
348
349    #[test]
350    fn new_peer_is_fledgling() {
351        let r = ReputationRecord::new("peer1", NOW);
352        assert_eq!(r.tier, ReputationTier::R1);
353    }
354
355    #[test]
356    fn blacklisted_fault_score_forces_r0() {
357        let mut r = ReputationRecord::new("peer1", NOW);
358        // Simulate many uptime days so it would otherwise be R2+
359        r.uptime_seconds = 31 * 86_400;
360        r.first_seen_at = NOW - 31 * 86_400;
361        r.pos_challenges_sent = 100;
362        r.pos_challenges_passed = 99;
363        r.recompute_tier(100 /* FAULT_BLACKLISTED */, NOW);
364        assert_eq!(r.tier, ReputationTier::R0);
365    }
366
367    #[test]
368    fn eviction_sets_r0_lock_30_days() {
369        let mut r = ReputationRecord::new("peer1", NOW);
370        r.evict_without_notice(NOW);
371        assert_eq!(r.tier, ReputationTier::R0);
372        assert_eq!(r.r0_lock_until, Some(NOW + 30 * 86_400));
373        assert_eq!(r.reputation_score, 0);
374    }
375
376    #[test]
377    fn r0_lock_expires() {
378        let mut r = ReputationRecord::new("peer1", NOW - 40 * 86_400);
379        r.evict_without_notice(NOW - 40 * 86_400);
380        // Re-evaluate after lock expired and enough uptime
381        let later = NOW;
382        r.uptime_seconds = 31 * 86_400;
383        r.pos_challenges_sent = 100;
384        r.pos_challenges_passed = 99;
385        r.recompute_tier(0, later);
386        // Lock expired (was 30 days, now 40 days later) — should promote
387        assert_ne!(r.tier, ReputationTier::R0);
388    }
389
390    #[test]
391    fn pos_pass_increments_score() {
392        let mut r = ReputationRecord::new("peer1", NOW);
393        let initial = r.reputation_score;
394        r.record_pos_challenge(true, 0, NOW);
395        assert_eq!(r.reputation_score, initial + PASS_INC);
396    }
397
398    #[test]
399    fn pos_fail_decrements_score_clamped_at_zero() {
400        let mut r = ReputationRecord::new("peer1", NOW);
401        r.reputation_score = 3;
402        r.record_pos_challenge(false, 0, NOW);
403        assert_eq!(r.reputation_score, 0); // clamped, not negative
404    }
405
406    #[test]
407    fn is_eligible_for_placement() {
408        // R0 pouch only receives R0 sender fragments
409        assert!(ReputationTier::R0.is_eligible_for(ReputationTier::R0));
410        assert!(!ReputationTier::R0.is_eligible_for(ReputationTier::R1));
411
412        // R2 pouch accepts R1 and R2 senders (>= sender_tier)
413        assert!(ReputationTier::R2.is_eligible_for(ReputationTier::R1));
414        assert!(ReputationTier::R2.is_eligible_for(ReputationTier::R2));
415        assert!(!ReputationTier::R2.is_eligible_for(ReputationTier::R3));
416
417        // R4 accepts everyone (>= R0, R1, ..., R4)
418        assert!(ReputationTier::R4.is_eligible_for(ReputationTier::R2));
419        assert!(ReputationTier::R4.is_eligible_for(ReputationTier::R4));
420    }
421
422    #[test]
423    fn store_tier_unknown_peer_defaults_r1() {
424        let store = ReputationStore::new();
425        assert_eq!(store.tier("unknown_peer"), ReputationTier::R1);
426    }
427
428    #[test]
429    fn tier_display() {
430        assert_eq!(ReputationTier::R0.to_string(), "Quarantine");
431        assert_eq!(ReputationTier::R4.to_string(), "Pillar");
432    }
433}