1use std::collections::HashMap;
8
9#[derive(Clone, Debug, PartialEq)]
11pub enum ChurnEvent {
12 Joined {
14 peer_id: String,
16 tick: u64,
18 },
19 Left {
21 peer_id: String,
23 tick: u64,
25 },
26}
27
28impl ChurnEvent {
29 pub fn tick(&self) -> u64 {
31 match self {
32 ChurnEvent::Joined { tick, .. } => *tick,
33 ChurnEvent::Left { tick, .. } => *tick,
34 }
35 }
36
37 pub fn peer_id(&self) -> &str {
39 match self {
40 ChurnEvent::Joined { peer_id, .. } => peer_id,
41 ChurnEvent::Left { peer_id, .. } => peer_id,
42 }
43 }
44}
45
46#[derive(Clone, Debug, PartialEq)]
48pub struct PeerLifetime {
49 pub peer_id: String,
51 pub joined_tick: u64,
53 pub left_tick: Option<u64>,
55}
56
57impl PeerLifetime {
58 pub fn duration(&self, current_tick: u64) -> u64 {
62 self.left_tick
63 .unwrap_or(current_tick)
64 .saturating_sub(self.joined_tick)
65 }
66
67 pub fn is_online(&self) -> bool {
69 self.left_tick.is_none()
70 }
71}
72
73#[derive(Clone, Debug, PartialEq)]
75pub struct ChurnWindow {
76 pub tick_start: u64,
78 pub tick_end: u64,
80 pub joins: u32,
82 pub leaves: u32,
84}
85
86impl ChurnWindow {
87 pub fn churn_rate(&self) -> f64 {
91 if self.tick_end == self.tick_start {
92 return 0.0;
93 }
94 let events = (self.joins as f64) + (self.leaves as f64);
95 let width = (self.tick_end - self.tick_start + 1) as f64;
96 events / width
97 }
98}
99
100#[derive(Clone, Debug, PartialEq)]
102pub struct ChurnManagerConfig {
103 pub window_size_ticks: u64,
105 pub stability_threshold: f64,
107}
108
109impl Default for ChurnManagerConfig {
110 fn default() -> Self {
111 Self {
112 window_size_ticks: 100,
113 stability_threshold: 0.1,
114 }
115 }
116}
117
118#[derive(Clone, Debug, PartialEq)]
120pub struct ChurnStats {
121 pub total_joins: u64,
123 pub total_leaves: u64,
125 pub current_online: usize,
127 pub avg_lifetime_ticks: f64,
129 pub churn_rate: f64,
131 pub is_stable: bool,
133}
134
135pub struct PeerChurnManager {
138 pub lifetimes: HashMap<String, PeerLifetime>,
140 pub events: Vec<ChurnEvent>,
142 pub config: ChurnManagerConfig,
144}
145
146impl PeerChurnManager {
147 pub fn new(config: ChurnManagerConfig) -> Self {
149 Self {
150 lifetimes: HashMap::new(),
151 events: Vec::new(),
152 config,
153 }
154 }
155
156 pub fn record_event(&mut self, event: ChurnEvent) {
164 match &event {
165 ChurnEvent::Joined { peer_id, tick } => {
166 let lifetime = PeerLifetime {
167 peer_id: peer_id.clone(),
168 joined_tick: *tick,
169 left_tick: None,
170 };
171 self.lifetimes.insert(peer_id.clone(), lifetime);
172 }
173 ChurnEvent::Left { peer_id, tick } => {
174 if let Some(lifetime) = self.lifetimes.get_mut(peer_id) {
175 if lifetime.is_online() {
176 lifetime.left_tick = Some(*tick);
177 }
178 }
179 }
180 }
181 self.events.push(event);
182 }
183
184 pub fn current_window(&self, current_tick: u64) -> ChurnWindow {
189 let tick_start = current_tick.saturating_sub(self.config.window_size_ticks);
190 let tick_end = current_tick;
191
192 let mut joins: u32 = 0;
193 let mut leaves: u32 = 0;
194
195 for event in &self.events {
196 let t = event.tick();
197 if t >= tick_start && t <= tick_end {
198 match event {
199 ChurnEvent::Joined { .. } => joins += 1,
200 ChurnEvent::Left { .. } => leaves += 1,
201 }
202 }
203 }
204
205 ChurnWindow {
206 tick_start,
207 tick_end,
208 joins,
209 leaves,
210 }
211 }
212
213 pub fn stability_score(&self, current_tick: u64) -> f64 {
217 let window = self.current_window(current_tick);
218 let rate = window.churn_rate();
219 1.0 - rate.min(1.0)
220 }
221
222 pub fn online_peers(&self) -> Vec<&str> {
224 let mut peers: Vec<&str> = self
225 .lifetimes
226 .values()
227 .filter(|l| l.is_online())
228 .map(|l| l.peer_id.as_str())
229 .collect();
230 peers.sort_unstable();
231 peers
232 }
233
234 pub fn peer_lifetime(&self, peer_id: &str) -> Option<&PeerLifetime> {
236 self.lifetimes.get(peer_id)
237 }
238
239 pub fn stats(&self, current_tick: u64) -> ChurnStats {
241 let mut total_joins: u64 = 0;
242 let mut total_leaves: u64 = 0;
243
244 for event in &self.events {
245 match event {
246 ChurnEvent::Joined { .. } => total_joins += 1,
247 ChurnEvent::Left { .. } => total_leaves += 1,
248 }
249 }
250
251 let current_online = self.lifetimes.values().filter(|l| l.is_online()).count();
252
253 let avg_lifetime_ticks = if self.lifetimes.is_empty() {
254 0.0
255 } else {
256 let total: u64 = self
257 .lifetimes
258 .values()
259 .map(|l| l.duration(current_tick))
260 .sum();
261 total as f64 / self.lifetimes.len() as f64
262 };
263
264 let window = self.current_window(current_tick);
265 let churn_rate = window.churn_rate();
266 let is_stable = churn_rate < self.config.stability_threshold;
267
268 ChurnStats {
269 total_joins,
270 total_leaves,
271 current_online,
272 avg_lifetime_ticks,
273 churn_rate,
274 is_stable,
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn default_manager() -> PeerChurnManager {
284 PeerChurnManager::new(ChurnManagerConfig::default())
285 }
286
287 #[test]
290 fn test_churn_event_tick_joined() {
291 let e = ChurnEvent::Joined {
292 peer_id: "peer1".into(),
293 tick: 42,
294 };
295 assert_eq!(e.tick(), 42);
296 }
297
298 #[test]
299 fn test_churn_event_tick_left() {
300 let e = ChurnEvent::Left {
301 peer_id: "peer1".into(),
302 tick: 77,
303 };
304 assert_eq!(e.tick(), 77);
305 }
306
307 #[test]
308 fn test_churn_event_peer_id() {
309 let e = ChurnEvent::Joined {
310 peer_id: "alpha".into(),
311 tick: 1,
312 };
313 assert_eq!(e.peer_id(), "alpha");
314 }
315
316 #[test]
317 fn test_churn_event_clone_and_eq() {
318 let e = ChurnEvent::Left {
319 peer_id: "x".into(),
320 tick: 5,
321 };
322 assert_eq!(e.clone(), e);
323 }
324
325 #[test]
328 fn test_peer_lifetime_is_online_when_left_tick_none() {
329 let l = PeerLifetime {
330 peer_id: "p".into(),
331 joined_tick: 10,
332 left_tick: None,
333 };
334 assert!(l.is_online());
335 }
336
337 #[test]
338 fn test_peer_lifetime_is_not_online_when_left_tick_set() {
339 let l = PeerLifetime {
340 peer_id: "p".into(),
341 joined_tick: 10,
342 left_tick: Some(20),
343 };
344 assert!(!l.is_online());
345 }
346
347 #[test]
348 fn test_peer_lifetime_duration_online() {
349 let l = PeerLifetime {
350 peer_id: "p".into(),
351 joined_tick: 5,
352 left_tick: None,
353 };
354 assert_eq!(l.duration(15), 10);
355 }
356
357 #[test]
358 fn test_peer_lifetime_duration_offline() {
359 let l = PeerLifetime {
360 peer_id: "p".into(),
361 joined_tick: 5,
362 left_tick: Some(12),
363 };
364 assert_eq!(l.duration(999), 7);
365 }
366
367 #[test]
368 fn test_peer_lifetime_duration_zero_when_same_tick() {
369 let l = PeerLifetime {
370 peer_id: "p".into(),
371 joined_tick: 10,
372 left_tick: Some(10),
373 };
374 assert_eq!(l.duration(10), 0);
375 }
376
377 #[test]
380 fn test_churn_window_churn_rate_basic() {
381 let w = ChurnWindow {
382 tick_start: 0,
383 tick_end: 9,
384 joins: 3,
385 leaves: 2,
386 };
387 assert!((w.churn_rate() - 0.5).abs() < f64::EPSILON);
389 }
390
391 #[test]
392 fn test_churn_window_churn_rate_zero_width() {
393 let w = ChurnWindow {
394 tick_start: 5,
395 tick_end: 5,
396 joins: 10,
397 leaves: 10,
398 };
399 assert_eq!(w.churn_rate(), 0.0);
400 }
401
402 #[test]
403 fn test_churn_window_churn_rate_no_events() {
404 let w = ChurnWindow {
405 tick_start: 0,
406 tick_end: 99,
407 joins: 0,
408 leaves: 0,
409 };
410 assert_eq!(w.churn_rate(), 0.0);
411 }
412
413 #[test]
416 fn test_churn_manager_config_default() {
417 let cfg = ChurnManagerConfig::default();
418 assert_eq!(cfg.window_size_ticks, 100);
419 assert!((cfg.stability_threshold - 0.1).abs() < f64::EPSILON);
420 }
421
422 #[test]
425 fn test_record_event_joined_creates_lifetime() {
426 let mut mgr = default_manager();
427 mgr.record_event(ChurnEvent::Joined {
428 peer_id: "peer1".into(),
429 tick: 10,
430 });
431 let l = mgr.peer_lifetime("peer1").expect("lifetime should exist");
432 assert_eq!(l.joined_tick, 10);
433 assert!(l.is_online());
434 }
435
436 #[test]
437 fn test_record_event_joined_stores_event() {
438 let mut mgr = default_manager();
439 mgr.record_event(ChurnEvent::Joined {
440 peer_id: "peer1".into(),
441 tick: 1,
442 });
443 assert_eq!(mgr.events.len(), 1);
444 }
445
446 #[test]
447 fn test_record_event_joined_replaces_offline_peer() {
448 let mut mgr = default_manager();
449 mgr.record_event(ChurnEvent::Joined {
450 peer_id: "peer1".into(),
451 tick: 5,
452 });
453 mgr.record_event(ChurnEvent::Left {
454 peer_id: "peer1".into(),
455 tick: 10,
456 });
457 mgr.record_event(ChurnEvent::Joined {
459 peer_id: "peer1".into(),
460 tick: 20,
461 });
462 let l = mgr.peer_lifetime("peer1").expect("lifetime should exist");
463 assert_eq!(l.joined_tick, 20);
464 assert!(l.is_online());
465 }
466
467 #[test]
470 fn test_record_event_left_sets_left_tick() {
471 let mut mgr = default_manager();
472 mgr.record_event(ChurnEvent::Joined {
473 peer_id: "peer1".into(),
474 tick: 0,
475 });
476 mgr.record_event(ChurnEvent::Left {
477 peer_id: "peer1".into(),
478 tick: 50,
479 });
480 let l = mgr.peer_lifetime("peer1").expect("lifetime should exist");
481 assert_eq!(l.left_tick, Some(50));
482 assert!(!l.is_online());
483 }
484
485 #[test]
486 fn test_record_event_left_unknown_peer_is_noop() {
487 let mut mgr = default_manager();
488 mgr.record_event(ChurnEvent::Left {
490 peer_id: "ghost".into(),
491 tick: 5,
492 });
493 assert!(mgr.peer_lifetime("ghost").is_none());
494 assert_eq!(mgr.events.len(), 1);
496 }
497
498 #[test]
499 fn test_record_event_left_already_offline_noop() {
500 let mut mgr = default_manager();
501 mgr.record_event(ChurnEvent::Joined {
502 peer_id: "p".into(),
503 tick: 0,
504 });
505 mgr.record_event(ChurnEvent::Left {
506 peer_id: "p".into(),
507 tick: 10,
508 });
509 mgr.record_event(ChurnEvent::Left {
511 peer_id: "p".into(),
512 tick: 99,
513 });
514 let l = mgr.peer_lifetime("p").expect("lifetime should exist");
515 assert_eq!(l.left_tick, Some(10));
516 }
517
518 #[test]
521 fn test_current_window_counts_joins_and_leaves() {
522 let mut mgr = default_manager();
523 mgr.record_event(ChurnEvent::Joined {
525 peer_id: "a".into(),
526 tick: 950,
527 });
528 mgr.record_event(ChurnEvent::Joined {
529 peer_id: "b".into(),
530 tick: 960,
531 });
532 mgr.record_event(ChurnEvent::Left {
533 peer_id: "a".into(),
534 tick: 970,
535 });
536 mgr.record_event(ChurnEvent::Joined {
538 peer_id: "c".into(),
539 tick: 899,
540 });
541
542 let w = mgr.current_window(1000);
543 assert_eq!(w.joins, 2);
544 assert_eq!(w.leaves, 1);
545 assert_eq!(w.tick_start, 900);
546 assert_eq!(w.tick_end, 1000);
547 }
548
549 #[test]
550 fn test_current_window_includes_boundaries() {
551 let mut mgr = default_manager();
552 mgr.record_event(ChurnEvent::Joined {
553 peer_id: "x".into(),
554 tick: 0,
555 });
556 mgr.record_event(ChurnEvent::Left {
557 peer_id: "x".into(),
558 tick: 100,
559 });
560
561 let w = mgr.current_window(100);
563 assert_eq!(w.joins, 1);
564 assert_eq!(w.leaves, 1);
565 }
566
567 #[test]
568 fn test_current_window_saturates_at_zero() {
569 let mgr = default_manager();
570 let w = mgr.current_window(50);
571 assert_eq!(w.tick_start, 0); }
573
574 #[test]
577 fn test_stability_score_no_events() {
578 let mgr = default_manager();
579 assert!((mgr.stability_score(100) - 1.0).abs() < f64::EPSILON);
581 }
582
583 #[test]
584 fn test_stability_score_high_churn_clamped_to_zero() {
585 let mut mgr = PeerChurnManager::new(ChurnManagerConfig {
586 window_size_ticks: 1,
587 stability_threshold: 0.1,
588 });
589 mgr.record_event(ChurnEvent::Joined {
591 peer_id: "a".into(),
592 tick: 0,
593 });
594 mgr.record_event(ChurnEvent::Left {
595 peer_id: "a".into(),
596 tick: 0,
597 });
598 mgr.record_event(ChurnEvent::Joined {
599 peer_id: "b".into(),
600 tick: 1,
601 });
602 mgr.record_event(ChurnEvent::Left {
603 peer_id: "b".into(),
604 tick: 1,
605 });
606 let score = mgr.stability_score(1);
607 assert!((score - 0.0).abs() < f64::EPSILON);
608 }
609
610 #[test]
611 fn test_stability_score_partial() {
612 let cfg = ChurnManagerConfig {
613 window_size_ticks: 9,
614 stability_threshold: 0.1,
615 };
616 let mut mgr = PeerChurnManager::new(cfg);
617 for i in 0u64..5 {
619 mgr.record_event(ChurnEvent::Joined {
620 peer_id: format!("p{i}"),
621 tick: i,
622 });
623 }
624 let score = mgr.stability_score(9);
625 assert!((score - 0.5).abs() < 1e-10);
626 }
627
628 #[test]
631 fn test_online_peers_sorted_alphabetically() {
632 let mut mgr = default_manager();
633 mgr.record_event(ChurnEvent::Joined {
634 peer_id: "charlie".into(),
635 tick: 1,
636 });
637 mgr.record_event(ChurnEvent::Joined {
638 peer_id: "alice".into(),
639 tick: 2,
640 });
641 mgr.record_event(ChurnEvent::Joined {
642 peer_id: "bob".into(),
643 tick: 3,
644 });
645
646 let peers = mgr.online_peers();
647 assert_eq!(peers, vec!["alice", "bob", "charlie"]);
648 }
649
650 #[test]
651 fn test_online_peers_excludes_offline() {
652 let mut mgr = default_manager();
653 mgr.record_event(ChurnEvent::Joined {
654 peer_id: "alice".into(),
655 tick: 0,
656 });
657 mgr.record_event(ChurnEvent::Joined {
658 peer_id: "bob".into(),
659 tick: 1,
660 });
661 mgr.record_event(ChurnEvent::Left {
662 peer_id: "alice".into(),
663 tick: 5,
664 });
665
666 let peers = mgr.online_peers();
667 assert_eq!(peers, vec!["bob"]);
668 }
669
670 #[test]
671 fn test_online_peers_empty_when_all_left() {
672 let mut mgr = default_manager();
673 mgr.record_event(ChurnEvent::Joined {
674 peer_id: "x".into(),
675 tick: 0,
676 });
677 mgr.record_event(ChurnEvent::Left {
678 peer_id: "x".into(),
679 tick: 1,
680 });
681 assert!(mgr.online_peers().is_empty());
682 }
683
684 #[test]
687 fn test_peer_lifetime_returns_some() {
688 let mut mgr = default_manager();
689 mgr.record_event(ChurnEvent::Joined {
690 peer_id: "z".into(),
691 tick: 7,
692 });
693 assert!(mgr.peer_lifetime("z").is_some());
694 }
695
696 #[test]
697 fn test_peer_lifetime_returns_none_for_unknown() {
698 let mgr = default_manager();
699 assert!(mgr.peer_lifetime("nobody").is_none());
700 }
701
702 #[test]
705 fn test_stats_current_online() {
706 let mut mgr = default_manager();
707 mgr.record_event(ChurnEvent::Joined {
708 peer_id: "a".into(),
709 tick: 0,
710 });
711 mgr.record_event(ChurnEvent::Joined {
712 peer_id: "b".into(),
713 tick: 0,
714 });
715 mgr.record_event(ChurnEvent::Left {
716 peer_id: "a".into(),
717 tick: 5,
718 });
719 let s = mgr.stats(10);
720 assert_eq!(s.current_online, 1);
721 }
722
723 #[test]
724 fn test_stats_total_joins_and_leaves() {
725 let mut mgr = default_manager();
726 mgr.record_event(ChurnEvent::Joined {
727 peer_id: "a".into(),
728 tick: 0,
729 });
730 mgr.record_event(ChurnEvent::Joined {
731 peer_id: "b".into(),
732 tick: 1,
733 });
734 mgr.record_event(ChurnEvent::Left {
735 peer_id: "a".into(),
736 tick: 5,
737 });
738 let s = mgr.stats(10);
739 assert_eq!(s.total_joins, 2);
740 assert_eq!(s.total_leaves, 1);
741 }
742
743 #[test]
744 fn test_stats_avg_lifetime_ticks() {
745 let mut mgr = default_manager();
746 mgr.record_event(ChurnEvent::Joined {
748 peer_id: "a".into(),
749 tick: 0,
750 });
751 mgr.record_event(ChurnEvent::Left {
752 peer_id: "a".into(),
753 tick: 10,
754 });
755 mgr.record_event(ChurnEvent::Joined {
757 peer_id: "b".into(),
758 tick: 10,
759 });
760 let s = mgr.stats(20);
761 assert!((s.avg_lifetime_ticks - 10.0).abs() < f64::EPSILON);
762 }
763
764 #[test]
765 fn test_stats_is_stable_true() {
766 let cfg = ChurnManagerConfig {
767 window_size_ticks: 100,
768 stability_threshold: 0.5,
769 };
770 let mgr = PeerChurnManager::new(cfg);
771 let s = mgr.stats(100);
773 assert!(s.is_stable);
774 }
775
776 #[test]
777 fn test_stats_is_stable_false() {
778 let cfg = ChurnManagerConfig {
779 window_size_ticks: 9,
780 stability_threshold: 0.1,
781 };
782 let mut mgr = PeerChurnManager::new(cfg);
783 for i in 0u64..5 {
785 mgr.record_event(ChurnEvent::Joined {
786 peer_id: format!("p{i}"),
787 tick: i,
788 });
789 }
790 let s = mgr.stats(9);
791 assert!(!s.is_stable);
792 }
793}