1use crate::network::qos::FAULT_BLACKLISTED;
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36
37pub const FLEDGLING_DAYS: u64 = 7;
41
42pub const R2_UPTIME: f64 = 0.95;
44pub const R2_POS_RATE: f64 = 0.98;
46pub const R2_WINDOW_DAYS: u64 = 30;
48
49pub const R3_UPTIME: f64 = 0.99;
51pub const R3_POS_RATE: f64 = 0.995;
53pub const R3_WINDOW_DAYS: u64 = 90;
55
56pub const R4_UPTIME: f64 = 0.999;
58pub const R4_POS_RATE: f64 = 0.999;
60pub const R4_WINDOW_DAYS: u64 = 365;
62
63pub const PASS_INC: i64 = 2;
65pub const FAIL_DEC: i64 = 10;
67pub const UPTIME_INC: i64 = 1;
69pub const LATE_PENALTY: i64 = 5;
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
76#[serde(rename_all = "UPPERCASE")]
77pub enum ReputationTier {
78 R0,
80 R1,
82 R2,
84 R3,
86 R4,
88}
89
90impl ReputationTier {
91 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ReputationRecord {
158 pub peer_id: String,
160
161 pub reputation_score: i64,
163
164 pub tier: ReputationTier,
166
167 pub first_seen_at: u64,
169
170 pub last_updated_at: u64,
172
173 pub r0_lock_until: Option<u64>,
175
176 pub uptime_seconds: u64,
178
179 pub pos_challenges_sent: u64,
181
182 pub pos_challenges_passed: u64,
184}
185
186impl ReputationRecord {
187 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 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 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 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 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 pub fn recompute_tier(&mut self, fault_score: u8, now_secs: u64) {
242 if let Some(lock_until) = self.r0_lock_until {
244 if now_secs < lock_until {
245 self.tier = ReputationTier::R0;
246 return;
247 }
248 self.r0_lock_until = None;
250 }
251
252 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 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 ReputationTier::R1
287 };
288 }
289
290 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#[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 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 pub fn get(&self, peer_id: &str) -> Option<&ReputationRecord> {
324 self.records.get(peer_id)
325 }
326
327 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 pub fn all(&self) -> impl Iterator<Item = &ReputationRecord> {
337 self.records.values()
338 }
339}
340
341#[cfg(test)]
344mod tests {
345 use super::*;
346
347 const NOW: u64 = 1_710_000_000; #[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 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 , 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 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 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); }
405
406 #[test]
407 fn is_eligible_for_placement() {
408 assert!(ReputationTier::R0.is_eligible_for(ReputationTier::R0));
410 assert!(!ReputationTier::R0.is_eligible_for(ReputationTier::R1));
411
412 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 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}