ipfrs_network/peer_health.rs
1//! Peer Health Monitor
2//!
3//! Tracks health scores for peers based on recent ping successes, message delivery
4//! rates, and protocol compliance, with automatic degradation over time.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use ipfrs_network::peer_health::{PeerHealthMonitor, MonitorConfig, HealthSample};
10//!
11//! let config = MonitorConfig::default();
12//! let mut monitor = PeerHealthMonitor::new(config);
13//!
14//! let sample = HealthSample {
15//! timestamp_secs: 1_000_000,
16//! ping_rtt_ms: Some(12.5),
17//! messages_delivered: 100,
18//! messages_failed: 0,
19//! };
20//! monitor.record_sample("peer-1", sample);
21//!
22//! if let Some(score) = monitor.score("peer-1") {
23//! println!("Health score: {:.2}", score.score);
24//! }
25//! ```
26
27use std::collections::HashMap;
28
29// ---------------------------------------------------------------------------
30// HealthStatus
31// ---------------------------------------------------------------------------
32
33/// The health classification of a peer.
34#[derive(Clone, Debug, PartialEq)]
35pub enum HealthStatus {
36 /// Peer is operating normally (score >= healthy_threshold).
37 Healthy,
38 /// Peer is experiencing issues (degraded_threshold <= score < healthy_threshold).
39 Degraded { reason: String },
40 /// Peer is not functioning correctly (score < degraded_threshold).
41 Unhealthy { reason: String },
42 /// No data has been collected yet.
43 Unknown,
44}
45
46// ---------------------------------------------------------------------------
47// HealthSample
48// ---------------------------------------------------------------------------
49
50/// A single observation recorded for a peer at a point in time.
51#[derive(Clone, Debug)]
52pub struct HealthSample {
53 /// Unix timestamp (seconds) when this sample was recorded.
54 pub timestamp_secs: u64,
55 /// Round-trip time in milliseconds, or `None` if the ping failed.
56 pub ping_rtt_ms: Option<f64>,
57 /// Number of messages that were successfully delivered in this window.
58 pub messages_delivered: u64,
59 /// Number of messages that failed delivery in this window.
60 pub messages_failed: u64,
61}
62
63impl HealthSample {
64 /// Fraction of messages that were successfully delivered.
65 ///
66 /// Returns `delivered / (delivered + failed).max(1)`, so it is always in
67 /// the range `[0.0, 1.0]`.
68 pub fn delivery_rate(&self) -> f64 {
69 let total = (self.messages_delivered + self.messages_failed).max(1);
70 self.messages_delivered as f64 / total as f64
71 }
72
73 /// Returns `true` when the ping completed successfully (RTT is known).
74 pub fn ping_ok(&self) -> bool {
75 self.ping_rtt_ms.is_some()
76 }
77}
78
79// ---------------------------------------------------------------------------
80// HealthScore
81// ---------------------------------------------------------------------------
82
83/// Derived health information for a peer.
84#[derive(Clone, Debug)]
85pub struct HealthScore {
86 /// Composite health score in `[0.0, 1.0]`; 0.0 = dead, 1.0 = perfect.
87 pub score: f64,
88 /// Human-readable classification of the current score.
89 pub status: HealthStatus,
90 /// Total number of samples that have been incorporated.
91 pub sample_count: usize,
92 /// Unix timestamp (seconds) of the most recent update.
93 pub last_updated_secs: u64,
94}
95
96impl HealthScore {
97 /// Returns `true` when the score can be acted upon: the status is known
98 /// *and* at least 3 samples have been recorded.
99 pub fn is_actionable(&self) -> bool {
100 self.status != HealthStatus::Unknown && self.sample_count >= 3
101 }
102}
103
104impl Default for HealthScore {
105 fn default() -> Self {
106 Self {
107 score: 0.0,
108 status: HealthStatus::Unknown,
109 sample_count: 0,
110 last_updated_secs: 0,
111 }
112 }
113}
114
115// ---------------------------------------------------------------------------
116// MonitorConfig
117// ---------------------------------------------------------------------------
118
119/// Configuration for [`PeerHealthMonitor`].
120#[derive(Clone, Debug)]
121pub struct MonitorConfig {
122 /// Score at or above which a peer is considered `Healthy` (default 0.8).
123 pub healthy_threshold: f64,
124 /// Score at or above which a peer is considered `Degraded` (default 0.5).
125 pub degraded_threshold: f64,
126 /// Per-sample decay factor applied when the sample window is not fully
127 /// saturated (default 0.95: `score *= 0.95^missing_samples`).
128 pub decay_rate: f64,
129 /// Fraction of the raw score contributed by ping success (default 0.4).
130 pub ping_weight: f64,
131 /// Fraction of the raw score contributed by delivery rate (default 0.6).
132 pub delivery_weight: f64,
133 /// Maximum number of most-recent samples to retain per peer (default 10).
134 pub window_samples: usize,
135}
136
137impl Default for MonitorConfig {
138 fn default() -> Self {
139 Self {
140 healthy_threshold: 0.8,
141 degraded_threshold: 0.5,
142 decay_rate: 0.95,
143 ping_weight: 0.4,
144 delivery_weight: 0.6,
145 window_samples: 10,
146 }
147 }
148}
149
150// ---------------------------------------------------------------------------
151// PeerHealthMonitor
152// ---------------------------------------------------------------------------
153
154/// Tracks health scores for a set of peers.
155///
156/// For each peer the monitor maintains a sliding window of [`HealthSample`]s
157/// and derives a composite [`HealthScore`] every time a new sample is
158/// recorded.
159pub struct PeerHealthMonitor {
160 /// `peer_id -> (samples, score)`
161 peers: HashMap<String, (Vec<HealthSample>, HealthScore)>,
162 /// Configuration controlling thresholds, weights, and window size.
163 pub config: MonitorConfig,
164}
165
166impl PeerHealthMonitor {
167 /// Create a new monitor with the supplied configuration.
168 pub fn new(config: MonitorConfig) -> Self {
169 Self {
170 peers: HashMap::new(),
171 config,
172 }
173 }
174
175 // -----------------------------------------------------------------------
176 // Mutation
177 // -----------------------------------------------------------------------
178
179 /// Record a new health observation for `peer_id`.
180 ///
181 /// The sample is appended to the peer's sliding window (oldest entry
182 /// discarded when the window is full) and the [`HealthScore`] is
183 /// recomputed immediately.
184 pub fn record_sample(&mut self, peer_id: &str, sample: HealthSample) {
185 let timestamp = sample.timestamp_secs;
186 let entry = self
187 .peers
188 .entry(peer_id.to_owned())
189 .or_insert_with(|| (Vec::new(), HealthScore::default()));
190
191 // Maintain sliding window.
192 entry.0.push(sample);
193 if entry.0.len() > self.config.window_samples {
194 let excess = entry.0.len() - self.config.window_samples;
195 entry.0.drain(..excess);
196 }
197
198 let samples = &entry.0;
199 let n = samples.len();
200
201 // --- ping component ---------------------------------------------------
202 let ping_ok_count = samples.iter().filter(|s| s.ping_ok()).count();
203 let ping_component = (ping_ok_count as f64 / n as f64) * self.config.ping_weight;
204
205 // --- delivery component -----------------------------------------------
206 let avg_delivery: f64 = samples.iter().map(|s| s.delivery_rate()).sum::<f64>() / n as f64;
207 let delivery_component = avg_delivery * self.config.delivery_weight;
208
209 // --- raw score --------------------------------------------------------
210 let raw_score = ping_component + delivery_component;
211
212 // --- decay ------------------------------------------------------------
213 // Apply decay for each "missing" sample slot so that a small window is
214 // penalised relative to a fully-saturated window.
215 let missing = self.config.window_samples.saturating_sub(n);
216 let decay_factor = self.config.decay_rate.powi(missing as i32);
217 let decayed = (raw_score * decay_factor).clamp(0.0, 1.0);
218
219 // --- status -----------------------------------------------------------
220 let status = if decayed >= self.config.healthy_threshold {
221 HealthStatus::Healthy
222 } else if decayed >= self.config.degraded_threshold {
223 HealthStatus::Degraded {
224 reason: format!(
225 "score {:.3} below healthy threshold {:.3}",
226 decayed, self.config.healthy_threshold
227 ),
228 }
229 } else {
230 HealthStatus::Unhealthy {
231 reason: format!(
232 "score {:.3} below degraded threshold {:.3}",
233 decayed, self.config.degraded_threshold
234 ),
235 }
236 };
237
238 // --- update -----------------------------------------------------------
239 entry.1 = HealthScore {
240 score: decayed,
241 status,
242 sample_count: n,
243 last_updated_secs: timestamp,
244 };
245 }
246
247 /// Remove all data for `peer_id`. Returns `true` if the peer existed.
248 pub fn remove_peer(&mut self, peer_id: &str) -> bool {
249 self.peers.remove(peer_id).is_some()
250 }
251
252 // -----------------------------------------------------------------------
253 // Query
254 // -----------------------------------------------------------------------
255
256 /// Return the current [`HealthScore`] for `peer_id`, or `None` if unknown.
257 pub fn score(&self, peer_id: &str) -> Option<&HealthScore> {
258 self.peers.get(peer_id).map(|(_, score)| score)
259 }
260
261 /// Return the IDs of all peers currently classified as [`HealthStatus::Healthy`].
262 pub fn healthy_peers(&self) -> Vec<&str> {
263 self.peers
264 .iter()
265 .filter_map(|(id, (_, score))| {
266 if score.status == HealthStatus::Healthy {
267 Some(id.as_str())
268 } else {
269 None
270 }
271 })
272 .collect()
273 }
274
275 /// Return the IDs of all peers currently classified as [`HealthStatus::Unhealthy`].
276 pub fn unhealthy_peers(&self) -> Vec<&str> {
277 self.peers
278 .iter()
279 .filter_map(|(id, (_, score))| {
280 if matches!(score.status, HealthStatus::Unhealthy { .. }) {
281 Some(id.as_str())
282 } else {
283 None
284 }
285 })
286 .collect()
287 }
288
289 /// Return the IDs of all peers currently classified as [`HealthStatus::Degraded`].
290 pub fn degraded_peers(&self) -> Vec<&str> {
291 self.peers
292 .iter()
293 .filter_map(|(id, (_, score))| {
294 if matches!(score.status, HealthStatus::Degraded { .. }) {
295 Some(id.as_str())
296 } else {
297 None
298 }
299 })
300 .collect()
301 }
302
303 /// Return the top-`n` peers by score, highest first.
304 ///
305 /// Each entry is `(peer_id, score)`.
306 pub fn top_peers(&self, n: usize) -> Vec<(&str, f64)> {
307 let mut pairs: Vec<(&str, f64)> = self
308 .peers
309 .iter()
310 .map(|(id, (_, score))| (id.as_str(), score.score))
311 .collect();
312
313 // Sort descending; use total_cmp for NaN-safety.
314 pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
315 pairs.truncate(n);
316 pairs
317 }
318}
319
320// ---------------------------------------------------------------------------
321// Tests
322// ---------------------------------------------------------------------------
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 // ------------------------------------------------------------------
329 // Helpers
330 // ------------------------------------------------------------------
331
332 fn good_sample(ts: u64) -> HealthSample {
333 HealthSample {
334 timestamp_secs: ts,
335 ping_rtt_ms: Some(10.0),
336 messages_delivered: 100,
337 messages_failed: 0,
338 }
339 }
340
341 fn bad_sample(ts: u64) -> HealthSample {
342 HealthSample {
343 timestamp_secs: ts,
344 ping_rtt_ms: None,
345 messages_delivered: 0,
346 messages_failed: 100,
347 }
348 }
349
350 fn mixed_sample(ts: u64) -> HealthSample {
351 HealthSample {
352 timestamp_secs: ts,
353 ping_rtt_ms: Some(50.0),
354 messages_delivered: 50,
355 messages_failed: 50,
356 }
357 }
358
359 // ------------------------------------------------------------------
360 // 1. new() produces an empty monitor
361 // ------------------------------------------------------------------
362 #[test]
363 fn test_new_empty() {
364 let monitor = PeerHealthMonitor::new(MonitorConfig::default());
365 assert!(monitor.peers.is_empty());
366 }
367
368 // ------------------------------------------------------------------
369 // 2. record_sample creates a peer entry
370 // ------------------------------------------------------------------
371 #[test]
372 fn test_record_sample_creates_entry() {
373 let mut monitor = PeerHealthMonitor::new(MonitorConfig::default());
374 monitor.record_sample("peer-A", good_sample(1000));
375 assert!(monitor.score("peer-A").is_some());
376 }
377
378 // ------------------------------------------------------------------
379 // 3. delivery_rate: all delivered → 1.0
380 // ------------------------------------------------------------------
381 #[test]
382 fn test_delivery_rate_all_delivered() {
383 let s = HealthSample {
384 timestamp_secs: 0,
385 ping_rtt_ms: None,
386 messages_delivered: 42,
387 messages_failed: 0,
388 };
389 assert!((s.delivery_rate() - 1.0).abs() < f64::EPSILON);
390 }
391
392 // ------------------------------------------------------------------
393 // 4. delivery_rate: all failed → 0.0
394 // ------------------------------------------------------------------
395 #[test]
396 fn test_delivery_rate_all_failed() {
397 let s = HealthSample {
398 timestamp_secs: 0,
399 ping_rtt_ms: None,
400 messages_delivered: 0,
401 messages_failed: 99,
402 };
403 assert!(s.delivery_rate() < f64::EPSILON);
404 }
405
406 // ------------------------------------------------------------------
407 // 5. ping_ok: Some vs None
408 // ------------------------------------------------------------------
409 #[test]
410 fn test_ping_ok() {
411 let ok = HealthSample {
412 timestamp_secs: 0,
413 ping_rtt_ms: Some(5.0),
414 messages_delivered: 0,
415 messages_failed: 0,
416 };
417 let fail = HealthSample {
418 timestamp_secs: 0,
419 ping_rtt_ms: None,
420 messages_delivered: 0,
421 messages_failed: 0,
422 };
423 assert!(ok.ping_ok());
424 assert!(!fail.ping_ok());
425 }
426
427 // ------------------------------------------------------------------
428 // 6. record_sample: healthy after enough good samples
429 // ------------------------------------------------------------------
430 #[test]
431 fn test_record_sample_healthy() {
432 let config = MonitorConfig {
433 window_samples: 5,
434 ..Default::default()
435 };
436 let mut monitor = PeerHealthMonitor::new(config);
437 for i in 0..5u64 {
438 monitor.record_sample("peer-X", good_sample(i));
439 }
440 let score = monitor.score("peer-X").expect("entry must exist");
441 assert_eq!(score.status, HealthStatus::Healthy);
442 assert!(score.score >= 0.8);
443 }
444
445 // ------------------------------------------------------------------
446 // 7. record_sample: unhealthy after enough bad samples
447 // ------------------------------------------------------------------
448 #[test]
449 fn test_record_sample_unhealthy() {
450 let config = MonitorConfig {
451 window_samples: 5,
452 ..Default::default()
453 };
454 let mut monitor = PeerHealthMonitor::new(config);
455 for i in 0..5u64 {
456 monitor.record_sample("peer-Y", bad_sample(i));
457 }
458 let score = monitor.score("peer-Y").expect("entry must exist");
459 assert!(matches!(score.status, HealthStatus::Unhealthy { .. }));
460 assert!(score.score < 0.5);
461 }
462
463 // ------------------------------------------------------------------
464 // 8. record_sample: degraded with mixed samples
465 // ------------------------------------------------------------------
466 #[test]
467 fn test_record_sample_degraded() {
468 let config = MonitorConfig {
469 window_samples: 10,
470 ..Default::default()
471 };
472 let mut monitor = PeerHealthMonitor::new(config);
473 // 5 samples with 50% ping success and 50% delivery → raw ~0.5,
474 // but decayed because only 5 of 10 slots filled.
475 for i in 0..5u64 {
476 monitor.record_sample("peer-Z", mixed_sample(i));
477 }
478 let score = monitor.score("peer-Z").expect("entry must exist");
479 // Score should be somewhere in the degraded range or unhealthy due to decay.
480 assert!(
481 score.score < 0.8,
482 "score should not be healthy: {}",
483 score.score
484 );
485 }
486
487 // ------------------------------------------------------------------
488 // 9. score: None for unknown peer
489 // ------------------------------------------------------------------
490 #[test]
491 fn test_score_none_unknown_peer() {
492 let monitor = PeerHealthMonitor::new(MonitorConfig::default());
493 assert!(monitor.score("no-such-peer").is_none());
494 }
495
496 // ------------------------------------------------------------------
497 // 10. score: sample_count increments
498 // ------------------------------------------------------------------
499 #[test]
500 fn test_sample_count_increments() {
501 let mut monitor = PeerHealthMonitor::new(MonitorConfig::default());
502 monitor.record_sample("peer-C", good_sample(1));
503 assert_eq!(
504 monitor
505 .score("peer-C")
506 .expect("test: peer-C score should exist")
507 .sample_count,
508 1
509 );
510 monitor.record_sample("peer-C", good_sample(2));
511 assert_eq!(
512 monitor
513 .score("peer-C")
514 .expect("test: peer-C score should exist")
515 .sample_count,
516 2
517 );
518 monitor.record_sample("peer-C", good_sample(3));
519 assert_eq!(
520 monitor
521 .score("peer-C")
522 .expect("test: peer-C score should exist")
523 .sample_count,
524 3
525 );
526 }
527
528 // ------------------------------------------------------------------
529 // 11. healthy_peers filtered correctly
530 // ------------------------------------------------------------------
531 #[test]
532 fn test_healthy_peers_filtered() {
533 let config = MonitorConfig {
534 window_samples: 5,
535 ..Default::default()
536 };
537 let mut monitor = PeerHealthMonitor::new(config);
538 for i in 0..5u64 {
539 monitor.record_sample("good-peer", good_sample(i));
540 monitor.record_sample("bad-peer", bad_sample(i));
541 }
542 let healthy = monitor.healthy_peers();
543 assert!(healthy.contains(&"good-peer"), "good-peer must be healthy");
544 assert!(
545 !healthy.contains(&"bad-peer"),
546 "bad-peer must not be healthy"
547 );
548 }
549
550 // ------------------------------------------------------------------
551 // 12. unhealthy_peers filtered correctly
552 // ------------------------------------------------------------------
553 #[test]
554 fn test_unhealthy_peers_filtered() {
555 let config = MonitorConfig {
556 window_samples: 5,
557 ..Default::default()
558 };
559 let mut monitor = PeerHealthMonitor::new(config);
560 for i in 0..5u64 {
561 monitor.record_sample("good-peer", good_sample(i));
562 monitor.record_sample("bad-peer", bad_sample(i));
563 }
564 let unhealthy = monitor.unhealthy_peers();
565 assert!(
566 unhealthy.contains(&"bad-peer"),
567 "bad-peer must be unhealthy"
568 );
569 assert!(
570 !unhealthy.contains(&"good-peer"),
571 "good-peer must not be unhealthy"
572 );
573 }
574
575 // ------------------------------------------------------------------
576 // 13. degraded_peers filtered correctly
577 // ------------------------------------------------------------------
578 #[test]
579 fn test_degraded_peers_filtered() {
580 let config = MonitorConfig {
581 healthy_threshold: 0.8,
582 degraded_threshold: 0.5,
583 window_samples: 5,
584 decay_rate: 1.0, // no decay so score is purely from samples
585 ping_weight: 0.4,
586 delivery_weight: 0.6,
587 };
588 let mut monitor = PeerHealthMonitor::new(config);
589
590 // Construct a sample that produces ~0.65 raw score (no decay):
591 // ping_ok=true → ping_component = 0.4
592 // delivery_rate = 40/100 = 0.4 → delivery_component = 0.24
593 // raw ≈ 0.64 → Degraded
594 let degraded_sample = HealthSample {
595 timestamp_secs: 1,
596 ping_rtt_ms: Some(30.0),
597 messages_delivered: 40,
598 messages_failed: 60,
599 };
600 for i in 0..5u64 {
601 let mut s = degraded_sample.clone();
602 s.timestamp_secs = i;
603 monitor.record_sample("mid-peer", s);
604 }
605
606 let degraded = monitor.degraded_peers();
607 assert!(
608 degraded.contains(&"mid-peer"),
609 "mid-peer should be degraded, got status: {:?}",
610 monitor.score("mid-peer").map(|s| &s.status)
611 );
612 }
613
614 // ------------------------------------------------------------------
615 // 14. remove_peer: true when peer existed, false otherwise
616 // ------------------------------------------------------------------
617 #[test]
618 fn test_remove_peer() {
619 let mut monitor = PeerHealthMonitor::new(MonitorConfig::default());
620 monitor.record_sample("to-remove", good_sample(1));
621 assert!(monitor.remove_peer("to-remove"));
622 assert!(!monitor.remove_peer("to-remove"));
623 assert!(!monitor.remove_peer("never-existed"));
624 }
625
626 // ------------------------------------------------------------------
627 // 15. top_peers sorted descending
628 // ------------------------------------------------------------------
629 #[test]
630 fn test_top_peers_sorted_descending() {
631 let config = MonitorConfig {
632 window_samples: 5,
633 ..Default::default()
634 };
635 let mut monitor = PeerHealthMonitor::new(config);
636 for i in 0..5u64 {
637 monitor.record_sample("alpha", good_sample(i));
638 monitor.record_sample("beta", bad_sample(i));
639 }
640 let top = monitor.top_peers(2);
641 assert_eq!(top.len(), 2);
642 // First entry must have a higher or equal score than the second.
643 assert!(
644 top[0].1 >= top[1].1,
645 "expected descending order, got {:?}",
646 top
647 );
648 assert_eq!(top[0].0, "alpha", "alpha should rank first");
649 }
650
651 // ------------------------------------------------------------------
652 // 16. is_actionable: false when sample_count < 3
653 // ------------------------------------------------------------------
654 #[test]
655 fn test_is_actionable_false_below_three_samples() {
656 let config = MonitorConfig {
657 window_samples: 5,
658 ..Default::default()
659 };
660 let mut monitor = PeerHealthMonitor::new(config);
661 monitor.record_sample("peer-act", good_sample(1));
662 assert!(!monitor
663 .score("peer-act")
664 .expect("test: peer-act score should exist")
665 .is_actionable());
666 monitor.record_sample("peer-act", good_sample(2));
667 assert!(!monitor
668 .score("peer-act")
669 .expect("test: peer-act score should exist")
670 .is_actionable());
671 monitor.record_sample("peer-act", good_sample(3));
672 // Now sample_count == 3 and status should be non-Unknown.
673 assert!(monitor
674 .score("peer-act")
675 .expect("test: peer-act score should exist")
676 .is_actionable());
677 }
678
679 // ------------------------------------------------------------------
680 // 17. window_samples cap: keeps only last N
681 // ------------------------------------------------------------------
682 #[test]
683 fn test_window_samples_cap() {
684 let config = MonitorConfig {
685 window_samples: 3,
686 ..Default::default()
687 };
688 let mut monitor = PeerHealthMonitor::new(config);
689 // Insert 5 samples; the window should hold at most 3.
690 for i in 0..5u64 {
691 monitor.record_sample("peer-win", good_sample(i));
692 }
693 let score = monitor
694 .score("peer-win")
695 .expect("test: peer-win score should exist");
696 assert_eq!(
697 score.sample_count, 3,
698 "sample_count should be capped at window_samples"
699 );
700 }
701}