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