Skip to main content

ipfrs_network/
churn_manager.rs

1//! Peer churn tracking and stability scoring for DHT and routing decisions.
2//!
3//! This module provides [`PeerChurnManager`] which tracks peer join/leave events,
4//! computes churn rate over sliding windows, and maintains stability scores to
5//! inform DHT and routing decisions.
6
7use std::collections::HashMap;
8
9/// An event representing a peer joining or leaving the network.
10#[derive(Clone, Debug, PartialEq)]
11pub enum ChurnEvent {
12    /// A peer joined at the given tick.
13    Joined {
14        /// The peer's identifier.
15        peer_id: String,
16        /// The logical clock tick when the peer joined.
17        tick: u64,
18    },
19    /// A peer left at the given tick.
20    Left {
21        /// The peer's identifier.
22        peer_id: String,
23        /// The logical clock tick when the peer left.
24        tick: u64,
25    },
26}
27
28impl ChurnEvent {
29    /// Returns the tick associated with this event.
30    pub fn tick(&self) -> u64 {
31        match self {
32            ChurnEvent::Joined { tick, .. } => *tick,
33            ChurnEvent::Left { tick, .. } => *tick,
34        }
35    }
36
37    /// Returns the peer_id associated with this event.
38    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/// Records the lifetime of a single peer in the network.
47#[derive(Clone, Debug, PartialEq)]
48pub struct PeerLifetime {
49    /// The peer's unique identifier.
50    pub peer_id: String,
51    /// The tick at which this peer joined.
52    pub joined_tick: u64,
53    /// The tick at which this peer left, or `None` if still online.
54    pub left_tick: Option<u64>,
55}
56
57impl PeerLifetime {
58    /// Returns how long this peer has been (or was) connected.
59    ///
60    /// If the peer is still online, `current_tick` is used as the end boundary.
61    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    /// Returns `true` if the peer is currently online (has not left).
68    pub fn is_online(&self) -> bool {
69        self.left_tick.is_none()
70    }
71}
72
73/// A sliding time window used to compute churn rate.
74#[derive(Clone, Debug, PartialEq)]
75pub struct ChurnWindow {
76    /// The inclusive start tick of this window.
77    pub tick_start: u64,
78    /// The inclusive end tick of this window.
79    pub tick_end: u64,
80    /// Number of join events within the window.
81    pub joins: u32,
82    /// Number of leave events within the window.
83    pub leaves: u32,
84}
85
86impl ChurnWindow {
87    /// Computes the churn rate as `(joins + leaves) / window_width`.
88    ///
89    /// Returns `0.0` for zero-width windows.
90    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/// Configuration for the [`PeerChurnManager`].
101#[derive(Clone, Debug, PartialEq)]
102pub struct ChurnManagerConfig {
103    /// Width of the sliding window in ticks (default: 100).
104    pub window_size_ticks: u64,
105    /// Churn rate below this threshold is considered stable (default: 0.1).
106    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/// Aggregated statistics for the churn manager at a given tick.
119#[derive(Clone, Debug, PartialEq)]
120pub struct ChurnStats {
121    /// Total number of join events recorded since creation.
122    pub total_joins: u64,
123    /// Total number of leave events recorded since creation.
124    pub total_leaves: u64,
125    /// Number of peers currently online.
126    pub current_online: usize,
127    /// Mean lifetime of all peers (online peers measured to `current_tick`).
128    pub avg_lifetime_ticks: f64,
129    /// Churn rate from the most recent sliding window.
130    pub churn_rate: f64,
131    /// Whether the churn rate is below the stability threshold.
132    pub is_stable: bool,
133}
134
135/// Manages peer join/leave events, computes churn rate over sliding windows,
136/// and provides stability scores for DHT and routing decisions.
137pub struct PeerChurnManager {
138    /// Per-peer lifetime records, keyed by peer ID.
139    pub lifetimes: HashMap<String, PeerLifetime>,
140    /// All events recorded in chronological order.
141    pub events: Vec<ChurnEvent>,
142    /// Configuration governing window size and stability threshold.
143    pub config: ChurnManagerConfig,
144}
145
146impl PeerChurnManager {
147    /// Creates a new [`PeerChurnManager`] with the given configuration.
148    pub fn new(config: ChurnManagerConfig) -> Self {
149        Self {
150            lifetimes: HashMap::new(),
151            events: Vec::new(),
152            config,
153        }
154    }
155
156    /// Records a churn event and updates internal state accordingly.
157    ///
158    /// - `Joined`: Creates (or replaces, if the peer was previously offline) a
159    ///   [`PeerLifetime`] record.
160    /// - `Left`: If the peer is currently online, marks the peer as offline by
161    ///   setting `left_tick`. If the peer is unknown or already offline, this is
162    ///   a no-op beyond storing the event.
163    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    /// Builds the current sliding window ending at `current_tick`.
185    ///
186    /// The window spans `[current_tick - window_size_ticks, current_tick]`
187    /// (saturating at 0), and counts join and leave events within that range.
188    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    /// Computes a stability score in `[0.0, 1.0]`.
214    ///
215    /// `1.0` means no churn; `0.0` means maximum churn (rate ≥ 1.0).
216    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    /// Returns the peer IDs of all currently online peers, sorted alphabetically.
223    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    /// Returns a reference to the [`PeerLifetime`] for a given peer ID, if any.
235    pub fn peer_lifetime(&self, peer_id: &str) -> Option<&PeerLifetime> {
236        self.lifetimes.get(peer_id)
237    }
238
239    /// Computes aggregated [`ChurnStats`] at the given tick.
240    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    // ── ChurnEvent helpers ────────────────────────────────────────────────────
288
289    #[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    // ── PeerLifetime ──────────────────────────────────────────────────────────
326
327    #[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    // ── ChurnWindow ───────────────────────────────────────────────────────────
378
379    #[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        // (3+2) / (9-0+1) = 5/10 = 0.5
388        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    // ── ChurnManagerConfig ────────────────────────────────────────────────────
414
415    #[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    // ── record_event: Joined ──────────────────────────────────────────────────
423
424    #[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        // Peer rejoins
458        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    // ── record_event: Left ────────────────────────────────────────────────────
468
469    #[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        // No join first — Left for unknown peer should not panic or insert an entry
489        mgr.record_event(ChurnEvent::Left {
490            peer_id: "ghost".into(),
491            tick: 5,
492        });
493        assert!(mgr.peer_lifetime("ghost").is_none());
494        // Event is still stored
495        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        // Second leave should not change left_tick
510        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    // ── current_window ────────────────────────────────────────────────────────
519
520    #[test]
521    fn test_current_window_counts_joins_and_leaves() {
522        let mut mgr = default_manager();
523        // Events inside window [900, 1000]
524        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        // Event outside window (before start)
537        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        // window_size_ticks=100, so window = [0, 100]
562        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); // 50 - 100 saturates to 0
572    }
573
574    // ── stability_score ───────────────────────────────────────────────────────
575
576    #[test]
577    fn test_stability_score_no_events() {
578        let mgr = default_manager();
579        // No events → churn_rate = 0 → stability = 1.0
580        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        // 4 events in a 2-tick window → rate = 4/2 = 2.0, clamped to 1.0 → score = 0.0
590        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        // 5 events in window [0, 9] (width=10) → rate = 5/10 = 0.5 → score = 0.5
618        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    // ── online_peers ──────────────────────────────────────────────────────────
629
630    #[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    // ── peer_lifetime ─────────────────────────────────────────────────────────
685
686    #[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    // ── stats ─────────────────────────────────────────────────────────────────
703
704    #[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        // peer_a: online from 0, left at 10 → duration at tick=20 is 10
747        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        // peer_b: online from 10, still online → duration at tick=20 is 10
756        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        // No events → churn_rate = 0 < 0.5 → stable
772        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        // 5 joins in [0..9] window → rate = 5/10 = 0.5 > 0.1 → not stable
784        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}