1use crate::PeerId;
22use parking_lot::RwLock;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::sync::Arc;
26use std::time::{Instant, SystemTime, UNIX_EPOCH};
27use tracing::info;
28
29pub const DEFAULT_NEUTRAL_TRUST: f64 = 0.5;
31
32const MIN_TRUST_SCORE: f64 = 0.0;
34
35const MAX_TRUST_SCORE: f64 = 1.0;
37
38const EMA_WEIGHT: f64 = 0.124;
43
44const DECAY_LAMBDA: f64 = 1.394e-5;
55
56#[derive(Debug, Clone)]
58struct PeerTrust {
59 score: f64,
61 last_updated: Instant,
63}
64
65impl PeerTrust {
66 fn new() -> Self {
67 Self {
68 score: DEFAULT_NEUTRAL_TRUST,
69 last_updated: Instant::now(),
70 }
71 }
72
73 fn apply_decay(&mut self) {
78 let elapsed_secs = self.last_updated.elapsed().as_secs_f64();
79 self.apply_decay_secs(elapsed_secs);
80 }
81
82 fn apply_decay_secs(&mut self, elapsed_secs: f64) {
87 if elapsed_secs > 0.0 {
88 let decay_factor = (-DECAY_LAMBDA * elapsed_secs).exp();
89 self.score =
90 DEFAULT_NEUTRAL_TRUST + (self.score - DEFAULT_NEUTRAL_TRUST) * decay_factor;
91 self.score = self.score.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE);
92 self.last_updated = Instant::now();
93 }
94 }
95
96 fn record_weighted(&mut self, observation: f64, weight: f64) -> Option<(f64, f64)> {
103 if !weight.is_finite() || weight <= 0.0 {
104 return None;
105 }
106 self.apply_decay();
107 let previous_score = self.score;
108 let alpha_w = 1.0 - (1.0 - EMA_WEIGHT).powf(weight);
109 self.score = (1.0 - alpha_w) * self.score + alpha_w * observation;
110 self.score = self.score.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE);
111 self.last_updated = Instant::now();
112 if (self.score - previous_score).abs() > f64::EPSILON {
113 Some((previous_score, self.score))
114 } else {
115 None
116 }
117 }
118
119 #[allow(dead_code)] fn record(&mut self, observation: f64) {
122 let _ = self.record_weighted(observation, 1.0);
123 }
124
125 fn decayed_score(&self) -> f64 {
127 Self::decay_score(self.score, self.last_updated.elapsed().as_secs_f64())
128 }
129
130 fn decay_score(score: f64, elapsed_secs: f64) -> f64 {
132 if elapsed_secs > 0.0 {
133 let decay_factor = (-DECAY_LAMBDA * elapsed_secs).exp();
134 let decayed = DEFAULT_NEUTRAL_TRUST + (score - DEFAULT_NEUTRAL_TRUST) * decay_factor;
135 decayed.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE)
136 } else {
137 score
138 }
139 }
140}
141
142const SUCCESS_OBSERVATION: f64 = 1.0;
144
145const FAILURE_OBSERVATION: f64 = 0.0;
147
148#[derive(Debug, Clone, Copy)]
150pub enum NodeStatisticsUpdate {
151 CorrectResponse,
153 FailedResponse,
155}
156
157impl NodeStatisticsUpdate {
158 const fn observation(self) -> f64 {
159 match self {
160 Self::CorrectResponse => SUCCESS_OBSERVATION,
161 Self::FailedResponse => FAILURE_OBSERVATION,
162 }
163 }
164
165 const fn as_str(self) -> &'static str {
166 match self {
167 Self::CorrectResponse => "correct_response",
168 Self::FailedResponse => "failed_response",
169 }
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct TrustSnapshot {
176 pub peers: HashMap<PeerId, TrustRecord>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct TrustRecord {
184 pub score: f64,
186 pub last_updated_epoch_secs: u64,
188}
189
190#[derive(Debug)]
198pub struct TrustEngine {
199 peers: Arc<RwLock<HashMap<PeerId, PeerTrust>>>,
201}
202
203impl TrustEngine {
204 pub fn new() -> Self {
206 Self {
207 peers: Arc::new(RwLock::new(HashMap::new())),
208 }
209 }
210
211 pub fn update_node_stats(&self, node_id: &PeerId, update: NodeStatisticsUpdate) {
213 self.update_node_stats_weighted(node_id, update, 1.0);
214 }
215
216 pub(crate) fn update_node_stats_with_reason(
218 &self,
219 node_id: &PeerId,
220 update: NodeStatisticsUpdate,
221 reason: &'static str,
222 ) {
223 self.update_node_stats_weighted_with_reason(node_id, update, 1.0, reason);
224 }
225
226 pub fn update_node_stats_weighted(
232 &self,
233 node_id: &PeerId,
234 update: NodeStatisticsUpdate,
235 weight: f64,
236 ) {
237 self.update_node_stats_weighted_with_reason(node_id, update, weight, update.as_str());
238 }
239
240 pub(crate) fn update_node_stats_weighted_with_reason(
242 &self,
243 node_id: &PeerId,
244 update: NodeStatisticsUpdate,
245 weight: f64,
246 reason: &'static str,
247 ) {
248 let score_change = {
249 let mut peers = self.peers.write();
250 let entry = peers.entry(*node_id).or_insert_with(PeerTrust::new);
251
252 entry.record_weighted(update.observation(), weight)
253 };
254
255 if let Some((previous_score, current_score)) = score_change {
256 info!(
257 peer_id = %node_id.to_hex(),
258 reason = %reason,
259 update = %update.as_str(),
260 previous_score,
261 current_score,
262 delta = current_score - previous_score,
263 weight,
264 "peer trust score changed"
265 );
266 }
267 }
268
269 pub fn score(&self, node_id: &PeerId) -> f64 {
278 let peers = self.peers.read();
279 peers
280 .get(node_id)
281 .map(|p| p.decayed_score())
282 .unwrap_or(DEFAULT_NEUTRAL_TRUST)
283 }
284
285 pub fn remove_node(&self, node_id: &PeerId) {
287 let mut peers = self.peers.write();
288 peers.remove(node_id);
289 }
290
291 pub fn export_snapshot(&self) -> TrustSnapshot {
296 let peers_guard = self.peers.read();
297 let now_epoch = SystemTime::now()
298 .duration_since(UNIX_EPOCH)
299 .map(|d| d.as_secs())
300 .unwrap_or(0);
301
302 let peers = peers_guard
303 .iter()
304 .map(|(peer_id, peer_trust)| {
305 let record = TrustRecord {
306 score: peer_trust.decayed_score(),
307 last_updated_epoch_secs: now_epoch,
308 };
309 (*peer_id, record)
310 })
311 .collect();
312
313 TrustSnapshot { peers }
314 }
315
316 pub fn import_snapshot(&self, snapshot: &TrustSnapshot) {
323 let mut peers_guard = self.peers.write();
324
325 for (peer_id, record) in &snapshot.peers {
326 let score = if record.score.is_finite() {
329 record.score.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE)
330 } else {
331 DEFAULT_NEUTRAL_TRUST
332 };
333 let peer_trust = PeerTrust {
334 score,
335 last_updated: Instant::now(),
336 };
337 peers_guard.insert(*peer_id, peer_trust);
338 }
339 }
340
341 #[cfg(test)]
347 pub async fn simulate_elapsed(&self, node_id: &PeerId, elapsed: std::time::Duration) {
348 let mut peers = self.peers.write();
349 if let Some(trust) = peers.get_mut(node_id) {
350 trust.apply_decay_secs(elapsed.as_secs_f64());
351 }
352 }
353}
354
355impl Default for TrustEngine {
356 fn default() -> Self {
357 Self::new()
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[tokio::test]
366 async fn test_unknown_peer_returns_neutral() {
367 let engine = TrustEngine::new();
368 let peer = PeerId::random();
369 assert!((engine.score(&peer) - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON);
370 }
371
372 #[tokio::test]
373 async fn test_successes_increase_score() {
374 let engine = TrustEngine::new();
375 let peer = PeerId::random();
376
377 for _ in 0..50 {
378 engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
379 }
380
381 let score = engine.score(&peer);
382 assert!(
383 score > DEFAULT_NEUTRAL_TRUST,
384 "Score {score} should be above neutral"
385 );
386 assert!(score <= MAX_TRUST_SCORE, "Score {score} should be <= max");
387 }
388
389 #[tokio::test]
390 async fn test_failures_decrease_score() {
391 let engine = TrustEngine::new();
392 let peer = PeerId::random();
393
394 for _ in 0..50 {
395 engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
396 }
397
398 let score = engine.score(&peer);
399 assert!(
400 score < DEFAULT_NEUTRAL_TRUST,
401 "Score {score} should be below neutral"
402 );
403 assert!(score >= MIN_TRUST_SCORE, "Score {score} should be >= min");
404 }
405
406 #[tokio::test]
407 async fn test_scores_clamped_to_bounds() {
408 let engine = TrustEngine::new();
409 let peer = PeerId::random();
410
411 for _ in 0..1000 {
413 engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
414 }
415 let score = engine.score(&peer);
416 assert!(score >= MIN_TRUST_SCORE, "Score {score} below min");
417 assert!(score <= MAX_TRUST_SCORE, "Score {score} above max");
418
419 for _ in 0..2000 {
421 engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
422 }
423 let score = engine.score(&peer);
424 assert!(score >= MIN_TRUST_SCORE, "Score {score} below min");
425 assert!(score <= MAX_TRUST_SCORE, "Score {score} above max");
426 }
427
428 #[tokio::test]
429 async fn test_remove_node_resets_to_neutral() {
430 let engine = TrustEngine::new();
431 let peer = PeerId::random();
432
433 engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
434 assert!(engine.score(&peer) < DEFAULT_NEUTRAL_TRUST);
435
436 engine.remove_node(&peer);
437 assert!((engine.score(&peer) - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON);
438 }
439
440 #[tokio::test]
441 async fn test_ema_blends_observations() {
442 let engine = TrustEngine::new();
443 let peer = PeerId::random();
444
445 engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
447 let after_fail = engine.score(&peer);
448 assert!(after_fail < DEFAULT_NEUTRAL_TRUST);
449
450 engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
452 let after_success = engine.score(&peer);
453 assert!(after_success > after_fail, "Success should increase score");
454 }
455
456 #[test]
461 fn test_worst_score_recovers_after_1_day() {
462 let one_day_secs = (24 * 3600) as f64;
463 let score = PeerTrust::decay_score(MIN_TRUST_SCORE, one_day_secs);
464
465 assert!(
466 score >= 0.35,
467 "After 1 day, score {score} should be >= swap threshold 0.35",
468 );
469 }
470
471 #[test]
473 fn test_worst_score_still_below_threshold_before_1_day() {
474 let twenty_two_hours = (22 * 3600) as f64;
475 let score = PeerTrust::decay_score(MIN_TRUST_SCORE, twenty_two_hours);
476
477 assert!(
478 score < 0.35,
479 "Before 1 day, score {score} should still be < swap threshold 0.35",
480 );
481 }
482
483 #[test]
484 fn test_decay_from_high_score_moves_down() {
485 let one_week_secs = (7 * 24 * 3600) as f64;
486 let score = PeerTrust::decay_score(0.95, one_week_secs);
487
488 assert!(score < 0.95, "Score should have decayed from 0.95");
489 assert!(
490 score > DEFAULT_NEUTRAL_TRUST,
491 "Score should still be above neutral after 1 week"
492 );
493 }
494
495 #[test]
496 fn test_decay_from_low_score_moves_up() {
497 let one_week_secs = (7 * 24 * 3600) as f64;
498 let score = PeerTrust::decay_score(0.1, one_week_secs);
499
500 assert!(score > 0.1, "Low score should decay upward toward neutral");
501 }
502
503 #[tokio::test]
504 async fn test_export_import_roundtrip() {
505 let engine = TrustEngine::new();
506 let peer1 = PeerId::random();
507 let peer2 = PeerId::random();
508
509 for _ in 0..20 {
511 engine.update_node_stats(&peer1, NodeStatisticsUpdate::CorrectResponse);
512 }
513 for _ in 0..10 {
514 engine.update_node_stats(&peer2, NodeStatisticsUpdate::FailedResponse);
515 }
516
517 let score1_before = engine.score(&peer1);
518 let score2_before = engine.score(&peer2);
519
520 let snapshot = engine.export_snapshot();
522 assert_eq!(snapshot.peers.len(), 2);
523
524 let engine2 = TrustEngine::new();
526 engine2.import_snapshot(&snapshot);
527
528 let score1_after = engine2.score(&peer1);
529 let score2_after = engine2.score(&peer2);
530
531 assert!(
533 (score1_before - score1_after).abs() < 0.01,
534 "peer1 score drifted: before={score1_before}, after={score1_after}"
535 );
536 assert!(
537 (score2_before - score2_after).abs() < 0.01,
538 "peer2 score drifted: before={score2_before}, after={score2_after}"
539 );
540 }
541
542 #[tokio::test]
543 async fn test_import_preserves_scores_without_decay() {
544 let peer = PeerId::random();
547 let one_day_secs: u64 = 86_400;
548 let one_day_ago = SystemTime::now()
549 .duration_since(UNIX_EPOCH)
550 .unwrap()
551 .as_secs()
552 - one_day_secs;
553
554 let snapshot = TrustSnapshot {
555 peers: HashMap::from([(
556 peer,
557 TrustRecord {
558 score: 0.9,
559 last_updated_epoch_secs: one_day_ago,
560 },
561 )]),
562 };
563
564 let engine = TrustEngine::new();
565 engine.import_snapshot(&snapshot);
566
567 let score = engine.score(&peer);
568 assert!(
570 (score - 0.9).abs() < 0.01,
571 "Score {score} should be ~0.9 (no offline decay)"
572 );
573 }
574
575 #[tokio::test]
576 async fn test_import_nan_score_falls_back_to_neutral() {
577 let peer = PeerId::random();
578 let snapshot = TrustSnapshot {
579 peers: HashMap::from([(
580 peer,
581 TrustRecord {
582 score: f64::NAN,
583 last_updated_epoch_secs: 1_000_000,
584 },
585 )]),
586 };
587
588 let engine = TrustEngine::new();
589 engine.import_snapshot(&snapshot);
590
591 let score = engine.score(&peer);
592 assert!(
593 score.is_finite(),
594 "NaN score should have been replaced with a finite value"
595 );
596 assert!(
597 (score - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON,
598 "NaN score should fall back to neutral, got {score}"
599 );
600 }
601
602 #[tokio::test]
603 async fn test_import_infinity_score_falls_back_to_neutral() {
604 let peer = PeerId::random();
605 let snapshot = TrustSnapshot {
606 peers: HashMap::from([(
607 peer,
608 TrustRecord {
609 score: f64::INFINITY,
610 last_updated_epoch_secs: 1_000_000,
611 },
612 )]),
613 };
614
615 let engine = TrustEngine::new();
616 engine.import_snapshot(&snapshot);
617
618 let score = engine.score(&peer);
619 assert!(
620 score.is_finite(),
621 "Infinity score should have been replaced with a finite value"
622 );
623 assert!(
624 (score - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON,
625 "Infinity score should fall back to neutral, got {score}"
626 );
627 }
628
629 #[tokio::test]
635 async fn test_negative_weight_is_noop() {
636 let engine = TrustEngine::new();
637 let peer = PeerId::random();
638
639 let before = engine.score(&peer);
640
641 engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, -5.0);
643 let after_negative = engine.score(&peer);
644 assert!(
645 (before - after_negative).abs() < f64::EPSILON,
646 "negative weight should be a no-op: before={before}, after={after_negative}"
647 );
648
649 engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::CorrectResponse, -1.0);
651 let after_negative_success = engine.score(&peer);
652 assert!(
653 (before - after_negative_success).abs() < f64::EPSILON,
654 "negative weight success should be a no-op: before={before}, after={after_negative_success}"
655 );
656
657 engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 1.0);
659 let after_valid = engine.score(&peer);
660 assert!(
661 after_valid < before,
662 "valid weight-1 failure should reduce score: before={before}, after={after_valid}"
663 );
664 }
665
666 #[tokio::test]
668 async fn test_weighted_ema_larger_impact() {
669 let engine = TrustEngine::new();
670 let peer_a = PeerId::random();
671 let peer_b = PeerId::random();
672
673 engine.update_node_stats_weighted(&peer_a, NodeStatisticsUpdate::FailedResponse, 1.0);
675 let score_a = engine.score(&peer_a);
676
677 engine.update_node_stats_weighted(&peer_b, NodeStatisticsUpdate::FailedResponse, 5.0);
679 let score_b = engine.score(&peer_b);
680
681 assert!(
682 score_b < score_a,
683 "weight-5 failure ({score_b}) should produce lower score than weight-1 ({score_a})"
684 );
685 }
686
687 #[tokio::test]
689 async fn test_unit_weight_equivalence() {
690 let engine1 = TrustEngine::new();
691 let engine2 = TrustEngine::new();
692 let peer = PeerId::random();
693
694 engine1.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
695 engine2.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 1.0);
696
697 let diff = (engine1.score(&peer) - engine2.score(&peer)).abs();
698 assert!(
699 diff < 1e-10,
700 "unit-weight paths should be equivalent, diff={diff}"
701 );
702 }
703
704 #[tokio::test]
715 async fn test_consumer_penalty_degrades_below_swap_threshold() {
716 const SWAP_THRESHOLD: f64 = 0.35;
718
719 let engine = TrustEngine::new();
720 let peer = PeerId::random();
721
722 let failure_count = 10;
724 for _ in 0..failure_count {
725 engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 3.0);
726 }
727
728 let score = engine.score(&peer);
729 assert!(
730 score < SWAP_THRESHOLD,
731 "after {failure_count} weight-3 failures, score {score} should be below swap threshold {SWAP_THRESHOLD}"
732 );
733 }
734
735 #[tokio::test]
742 async fn test_consumer_and_internal_events_combine() {
743 let engine = TrustEngine::new();
744 let peer = PeerId::random();
745
746 engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
748 let after_success = engine.score(&peer);
749 assert!(
750 after_success > DEFAULT_NEUTRAL_TRUST,
751 "single success should raise above neutral"
752 );
753
754 engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 3.0);
756 let after_failure = engine.score(&peer);
757
758 assert!(
759 after_failure < after_success,
760 "weight-3 failure ({after_failure}) should outweigh weight-1 success ({after_success})"
761 );
762 assert!(
763 after_failure < DEFAULT_NEUTRAL_TRUST,
764 "net effect ({after_failure}) should be below neutral ({DEFAULT_NEUTRAL_TRUST})"
765 );
766 }
767
768 #[tokio::test]
775 async fn test_trust_query_reflects_all_event_sources() {
776 let engine = TrustEngine::new();
777 let peer = PeerId::random();
778
779 engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
781 engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::CorrectResponse, 2.0);
782 engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
783
784 let score = engine.score(&peer);
786 assert!(
789 score > DEFAULT_NEUTRAL_TRUST,
790 "combined score {score} should be above neutral (net positive events)"
791 );
792 }
793
794 #[tokio::test]
802 async fn test_time_decay_applies_to_consumer_events() {
803 let engine = TrustEngine::new();
804 let peer = PeerId::random();
805
806 engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 3.0);
808 let after_failure = engine.score(&peer);
809 assert!(
810 after_failure < DEFAULT_NEUTRAL_TRUST,
811 "after failure, score {after_failure} should be below neutral"
812 );
813
814 let two_days = std::time::Duration::from_secs(2 * 24 * 3600);
816 engine.simulate_elapsed(&peer, two_days).await;
817
818 let after_decay = engine.score(&peer);
819 assert!(
820 after_decay > after_failure,
821 "score should decay toward neutral: {after_failure} -> {after_decay}"
822 );
823 let distance_from_neutral = (after_decay - DEFAULT_NEUTRAL_TRUST).abs();
825 assert!(
826 distance_from_neutral < 0.2,
827 "after 2 days, score {after_decay} should be near neutral (distance {distance_from_neutral})"
828 );
829 }
830
831 #[tokio::test]
838 async fn test_consumer_rewards_restore_trust_protection() {
839 const TRUST_PROTECTION_THRESHOLD: f64 = 0.7;
841
842 let engine = TrustEngine::new();
843 let peer = PeerId::random();
844
845 for _ in 0..5 {
847 engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
848 }
849 let low_score = engine.score(&peer);
850 assert!(
851 low_score < TRUST_PROTECTION_THRESHOLD,
852 "peer should start below trust protection: {low_score}"
853 );
854
855 let success_rounds = 30;
857 for _ in 0..success_rounds {
858 engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::CorrectResponse, 3.0);
859 }
860 let restored_score = engine.score(&peer);
861 assert!(
862 restored_score >= TRUST_PROTECTION_THRESHOLD,
863 "after {success_rounds} weight-3 successes, score {restored_score} should be >= {TRUST_PROTECTION_THRESHOLD}"
864 );
865 }
866}