Skip to main content

ipfrs_network/
churn_resilience.rs

1//! Adaptive Kademlia refresh under peer churn.
2//!
3//! This module implements a `ChurnResilienceManager` that tracks peer
4//! join/leave events in a rolling time window and adjusts Kademlia routing
5//! table refresh intervals based on observed churn rate.  High churn →
6//! shorter refresh interval; stable network → longer interval.
7
8use std::collections::VecDeque;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12
13use parking_lot::RwLock;
14
15// ---------------------------------------------------------------------------
16// Public types
17// ---------------------------------------------------------------------------
18
19/// A peer join/leave event.
20#[derive(Debug, Clone)]
21pub struct PeerChurnEvent {
22    /// Peer identifier (e.g. PeerId string).
23    pub peer_id: String,
24    /// Kind of churn event.
25    pub event_type: ChurnEventType,
26    /// Wall-clock timestamp in milliseconds since epoch.
27    pub timestamp_ms: u64,
28}
29
30/// Discriminator for churn event direction.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ChurnEventType {
33    /// Peer became reachable / connected.
34    Joined,
35    /// Peer disconnected gracefully.
36    Left,
37    /// Peer missed keep-alives and was evicted.
38    TimedOut,
39}
40
41/// Rolling-window churn metrics snapshot.
42#[derive(Debug, Clone)]
43pub struct ChurnMetrics {
44    /// Observation window length in milliseconds.
45    pub window_ms: u64,
46    /// Peers that joined within the window.
47    pub joins: usize,
48    /// Peers that left within the window.
49    pub leaves: usize,
50    /// `(joins + leaves) / window_seconds`
51    pub churn_rate: f64,
52    /// `1.0 / (1.0 + churn_rate)` — 1.0 = perfectly stable, 0.0 = max churn.
53    pub stability_score: f64,
54}
55
56// ---------------------------------------------------------------------------
57// Configuration
58// ---------------------------------------------------------------------------
59
60/// Configuration for adaptive refresh scheduling.
61#[derive(Debug, Clone)]
62pub struct AdaptiveRefreshConfig {
63    /// Base refresh interval when stable.  Default: 10 minutes.
64    pub base_interval_ms: u64,
65    /// Minimum refresh interval under extreme churn.  Default: 1 minute.
66    pub min_interval_ms: u64,
67    /// Maximum refresh interval when very stable.  Default: 30 minutes.
68    pub max_interval_ms: u64,
69    /// Churn-rate threshold above which we start shrinking the interval.
70    /// Default: 2.0 events per second.
71    pub high_churn_threshold: f64,
72    /// Rolling window size for churn measurement in milliseconds.
73    /// Default: 60 seconds.
74    pub window_ms: u64,
75    /// Maximum events to retain in rolling window (memory guard).
76    pub max_events: usize,
77}
78
79impl Default for AdaptiveRefreshConfig {
80    fn default() -> Self {
81        Self {
82            base_interval_ms: 10 * 60 * 1_000, // 10 min
83            min_interval_ms: 60 * 1_000,       // 1 min
84            max_interval_ms: 30 * 60 * 1_000,  // 30 min
85            high_churn_threshold: 2.0,
86            window_ms: 60 * 1_000, // 60 s
87            max_events: 10_000,
88        }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// PeerChurnTracker
94// ---------------------------------------------------------------------------
95
96/// Tracks peer join/leave events and computes adaptive refresh intervals.
97pub struct PeerChurnTracker {
98    events: RwLock<VecDeque<PeerChurnEvent>>,
99    config: AdaptiveRefreshConfig,
100    total_joins: AtomicU64,
101    total_leaves: AtomicU64,
102}
103
104impl PeerChurnTracker {
105    /// Create a new tracker with the given configuration.
106    pub fn new(config: AdaptiveRefreshConfig) -> Self {
107        Self {
108            events: RwLock::new(VecDeque::new()),
109            config,
110            total_joins: AtomicU64::new(0),
111            total_leaves: AtomicU64::new(0),
112        }
113    }
114
115    /// Record a peer join/leave event.
116    pub fn record_event(&self, event: PeerChurnEvent) {
117        match event.event_type {
118            ChurnEventType::Joined => {
119                self.total_joins.fetch_add(1, Ordering::Relaxed);
120            }
121            ChurnEventType::Left | ChurnEventType::TimedOut => {
122                self.total_leaves.fetch_add(1, Ordering::Relaxed);
123            }
124        }
125
126        let mut guard = self.events.write();
127        guard.push_back(event);
128
129        // Enforce hard cap so memory stays bounded even without periodic eviction.
130        while guard.len() > self.config.max_events {
131            guard.pop_front();
132        }
133    }
134
135    /// Evict events older than `now_ms - window_ms`.
136    ///
137    /// Returns the number of events evicted.
138    pub fn evict_old_events(&self, now_ms: u64) -> usize {
139        let cutoff = now_ms.saturating_sub(self.config.window_ms);
140        let mut guard = self.events.write();
141        let before = guard.len();
142        while let Some(front) = guard.front() {
143            if front.timestamp_ms < cutoff {
144                guard.pop_front();
145            } else {
146                break;
147            }
148        }
149        before - guard.len()
150    }
151
152    /// Compute churn metrics for the current rolling window.
153    pub fn metrics(&self, now_ms: u64) -> ChurnMetrics {
154        let cutoff = now_ms.saturating_sub(self.config.window_ms);
155        let guard = self.events.read();
156
157        let mut joins = 0usize;
158        let mut leaves = 0usize;
159
160        for ev in guard.iter() {
161            if ev.timestamp_ms >= cutoff {
162                match ev.event_type {
163                    ChurnEventType::Joined => joins += 1,
164                    ChurnEventType::Left | ChurnEventType::TimedOut => leaves += 1,
165                }
166            }
167        }
168
169        let window_secs = self.config.window_ms as f64 / 1_000.0;
170        let total_events = (joins + leaves) as f64;
171        let churn_rate = if window_secs > 0.0 {
172            total_events / window_secs
173        } else {
174            0.0
175        };
176        let stability_score = 1.0 / (1.0 + churn_rate);
177
178        ChurnMetrics {
179            window_ms: self.config.window_ms,
180            joins,
181            leaves,
182            churn_rate,
183            stability_score,
184        }
185    }
186
187    /// Compute recommended refresh interval based on current churn.
188    ///
189    /// Algorithm:
190    /// 1. If `churn_rate >= high_churn_threshold`:
191    ///    linearly interpolate from `base_interval_ms` down to `min_interval_ms`
192    ///    as `churn_rate` goes from threshold to `2 * threshold`.  Clamped to
193    ///    `min_interval_ms`.
194    /// 2. If `churn_rate < 0.1` (very stable):
195    ///    linearly interpolate from `base_interval_ms` up to `max_interval_ms`
196    ///    based on how far below 0.1 the rate is.
197    /// 3. Otherwise: `base_interval_ms`.
198    pub fn recommended_interval(&self, now_ms: u64) -> Duration {
199        let m = self.metrics(now_ms);
200        let rate = m.churn_rate;
201        let cfg = &self.config;
202
203        let interval_ms = if rate >= cfg.high_churn_threshold {
204            // Linear scale from base→min as rate goes threshold→2*threshold.
205            let t = ((rate - cfg.high_churn_threshold) / cfg.high_churn_threshold).clamp(0.0, 1.0);
206            let base = cfg.base_interval_ms as f64;
207            let min = cfg.min_interval_ms as f64;
208            let v = base + t * (min - base);
209            (v as u64).max(cfg.min_interval_ms)
210        } else if rate < 0.1 {
211            // Linear scale from base→max as rate goes from 0.1 down to 0.
212            let t = (1.0 - rate / 0.1).clamp(0.0, 1.0);
213            let base = cfg.base_interval_ms as f64;
214            let max = cfg.max_interval_ms as f64;
215            let v = base + t * (max - base);
216            (v as u64).min(cfg.max_interval_ms)
217        } else {
218            cfg.base_interval_ms
219        };
220
221        Duration::from_millis(interval_ms)
222    }
223
224    /// Number of events currently inside the rolling window.
225    pub fn window_event_count(&self, now_ms: u64) -> usize {
226        let cutoff = now_ms.saturating_sub(self.config.window_ms);
227        let guard = self.events.read();
228        guard.iter().filter(|e| e.timestamp_ms >= cutoff).count()
229    }
230
231    /// Total join events observed since tracker creation.
232    pub fn total_joins(&self) -> u64 {
233        self.total_joins.load(Ordering::Relaxed)
234    }
235
236    /// Total leave/timeout events observed since tracker creation.
237    pub fn total_leaves(&self) -> u64 {
238        self.total_leaves.load(Ordering::Relaxed)
239    }
240}
241
242// ---------------------------------------------------------------------------
243// AdaptiveRefreshScheduler
244// ---------------------------------------------------------------------------
245
246/// Coordinates Kademlia routing-table refresh using adaptive intervals.
247pub struct AdaptiveRefreshScheduler {
248    tracker: Arc<PeerChurnTracker>,
249    last_refresh_ms: AtomicU64,
250    refresh_count: AtomicU64,
251}
252
253impl AdaptiveRefreshScheduler {
254    /// Sentinel value stored in `last_refresh_ms` when no refresh has occurred.
255    const NEVER_REFRESHED: u64 = u64::MAX;
256
257    /// Create a new scheduler wrapped in an `Arc`.
258    pub fn new(config: AdaptiveRefreshConfig) -> Arc<Self> {
259        Arc::new(Self {
260            tracker: Arc::new(PeerChurnTracker::new(config)),
261            last_refresh_ms: AtomicU64::new(Self::NEVER_REFRESHED),
262            refresh_count: AtomicU64::new(0),
263        })
264    }
265
266    /// Record a peer event (delegates to the underlying tracker).
267    pub fn record_peer_event(&self, event: PeerChurnEvent) {
268        self.tracker.record_event(event);
269    }
270
271    /// Returns `true` when the current adaptive interval has elapsed since the
272    /// last recorded refresh.
273    pub fn is_refresh_due(&self, now_ms: u64) -> bool {
274        let last = self.last_refresh_ms.load(Ordering::Relaxed);
275        if last == Self::NEVER_REFRESHED {
276            // Never refreshed — immediately due.
277            return true;
278        }
279        let elapsed_ms = now_ms.saturating_sub(last);
280        let interval_ms = self.tracker.recommended_interval(now_ms).as_millis() as u64;
281        elapsed_ms >= interval_ms
282    }
283
284    /// Record that a refresh was performed at `now_ms`.
285    pub fn mark_refreshed(&self, now_ms: u64) {
286        self.last_refresh_ms.store(now_ms, Ordering::Relaxed);
287        self.refresh_count.fetch_add(1, Ordering::Relaxed);
288    }
289
290    /// Current recommended refresh interval.
291    pub fn current_interval(&self, now_ms: u64) -> Duration {
292        self.tracker.recommended_interval(now_ms)
293    }
294
295    /// Duration remaining until the next scheduled refresh.
296    ///
297    /// Returns `Duration::ZERO` if a refresh is already due.
298    pub fn time_until_refresh(&self, now_ms: u64) -> Duration {
299        let last = self.last_refresh_ms.load(Ordering::Relaxed);
300        if last == Self::NEVER_REFRESHED {
301            return Duration::ZERO;
302        }
303        let interval_ms = self.tracker.recommended_interval(now_ms).as_millis() as u64;
304        let elapsed_ms = now_ms.saturating_sub(last);
305        if elapsed_ms >= interval_ms {
306            Duration::ZERO
307        } else {
308            Duration::from_millis(interval_ms - elapsed_ms)
309        }
310    }
311
312    /// Total number of refreshes performed.
313    pub fn refresh_count(&self) -> u64 {
314        self.refresh_count.load(Ordering::Relaxed)
315    }
316
317    /// Current churn metrics (delegates to tracker).
318    pub fn churn_metrics(&self, now_ms: u64) -> ChurnMetrics {
319        self.tracker.metrics(now_ms)
320    }
321}
322
323// ---------------------------------------------------------------------------
324// ChurnResilienceManager
325// ---------------------------------------------------------------------------
326
327/// Full manager combining the churn tracker and refresh scheduler.
328pub struct ChurnResilienceManager {
329    /// The underlying adaptive refresh scheduler.
330    pub scheduler: Arc<AdaptiveRefreshScheduler>,
331}
332
333impl ChurnResilienceManager {
334    /// Create a new manager wrapped in an `Arc`.
335    pub fn new(config: AdaptiveRefreshConfig) -> Arc<Self> {
336        Arc::new(Self {
337            scheduler: AdaptiveRefreshScheduler::new(config),
338        })
339    }
340
341    /// Record that a peer joined at `now_ms`.
342    pub fn peer_joined(&self, peer_id: impl Into<String>, now_ms: u64) {
343        self.scheduler.record_peer_event(PeerChurnEvent {
344            peer_id: peer_id.into(),
345            event_type: ChurnEventType::Joined,
346            timestamp_ms: now_ms,
347        });
348    }
349
350    /// Record that a peer left gracefully at `now_ms`.
351    pub fn peer_left(&self, peer_id: impl Into<String>, now_ms: u64) {
352        self.scheduler.record_peer_event(PeerChurnEvent {
353            peer_id: peer_id.into(),
354            event_type: ChurnEventType::Left,
355            timestamp_ms: now_ms,
356        });
357    }
358
359    /// Record that a peer timed out at `now_ms`.
360    pub fn peer_timed_out(&self, peer_id: impl Into<String>, now_ms: u64) {
361        self.scheduler.record_peer_event(PeerChurnEvent {
362            peer_id: peer_id.into(),
363            event_type: ChurnEventType::TimedOut,
364            timestamp_ms: now_ms,
365        });
366    }
367
368    /// Returns `true` if a routing-table refresh is now due.
369    pub fn should_refresh(&self, now_ms: u64) -> bool {
370        self.scheduler.is_refresh_due(now_ms)
371    }
372
373    /// Mark that a refresh was performed at `now_ms`.
374    pub fn mark_refreshed(&self, now_ms: u64) {
375        self.scheduler.mark_refreshed(now_ms);
376    }
377
378    /// Current churn metrics.
379    pub fn metrics(&self, now_ms: u64) -> ChurnMetrics {
380        self.scheduler.churn_metrics(now_ms)
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Tests
386// ---------------------------------------------------------------------------
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    fn default_config() -> AdaptiveRefreshConfig {
393        AdaptiveRefreshConfig::default()
394    }
395
396    /// Helper: millisecond timestamp.
397    fn ts(secs: u64) -> u64 {
398        secs * 1_000
399    }
400
401    // ------------------------------------------------------------------
402    // PeerChurnTracker tests
403    // ------------------------------------------------------------------
404
405    #[test]
406    fn test_record_event_increments_totals() {
407        let tracker = PeerChurnTracker::new(default_config());
408        tracker.record_event(PeerChurnEvent {
409            peer_id: "p1".into(),
410            event_type: ChurnEventType::Joined,
411            timestamp_ms: ts(10),
412        });
413        tracker.record_event(PeerChurnEvent {
414            peer_id: "p2".into(),
415            event_type: ChurnEventType::Left,
416            timestamp_ms: ts(11),
417        });
418        tracker.record_event(PeerChurnEvent {
419            peer_id: "p3".into(),
420            event_type: ChurnEventType::TimedOut,
421            timestamp_ms: ts(12),
422        });
423        assert_eq!(tracker.total_joins(), 1);
424        assert_eq!(tracker.total_leaves(), 2);
425    }
426
427    #[test]
428    fn test_evict_old_events() {
429        let cfg = AdaptiveRefreshConfig {
430            window_ms: 60_000,
431            ..default_config()
432        };
433        let tracker = PeerChurnTracker::new(cfg);
434        // Two events at t=0 (well outside window), one at t=120s (inside window at t=180s).
435        tracker.record_event(PeerChurnEvent {
436            peer_id: "a".into(),
437            event_type: ChurnEventType::Joined,
438            timestamp_ms: 0,
439        });
440        tracker.record_event(PeerChurnEvent {
441            peer_id: "b".into(),
442            event_type: ChurnEventType::Left,
443            timestamp_ms: 0,
444        });
445        tracker.record_event(PeerChurnEvent {
446            peer_id: "c".into(),
447            event_type: ChurnEventType::Joined,
448            timestamp_ms: ts(120),
449        });
450
451        // At now=180s the cutoff is 120s, so "a" and "b" (ts=0) are evicted.
452        let evicted = tracker.evict_old_events(ts(180));
453        assert_eq!(evicted, 2);
454        assert_eq!(tracker.window_event_count(ts(180)), 1);
455    }
456
457    #[test]
458    fn test_metrics_empty_window() {
459        let tracker = PeerChurnTracker::new(default_config());
460        let m = tracker.metrics(ts(100));
461        assert_eq!(m.joins, 0);
462        assert_eq!(m.leaves, 0);
463        assert!((m.churn_rate - 0.0).abs() < f64::EPSILON);
464        // stability = 1 / (1 + 0) = 1.0
465        assert!((m.stability_score - 1.0).abs() < 1e-9);
466    }
467
468    #[test]
469    fn test_metrics_with_events() {
470        let cfg = AdaptiveRefreshConfig {
471            window_ms: 60_000,
472            ..default_config()
473        };
474        let tracker = PeerChurnTracker::new(cfg);
475
476        // 10 joins and 5 leaves, all within the window.
477        for i in 0..10u64 {
478            tracker.record_event(PeerChurnEvent {
479                peer_id: format!("join-{i}"),
480                event_type: ChurnEventType::Joined,
481                timestamp_ms: ts(50) + i,
482            });
483        }
484        for i in 0..5u64 {
485            tracker.record_event(PeerChurnEvent {
486                peer_id: format!("left-{i}"),
487                event_type: ChurnEventType::Left,
488                timestamp_ms: ts(55) + i,
489            });
490        }
491
492        let m = tracker.metrics(ts(100));
493        assert_eq!(m.joins, 10);
494        assert_eq!(m.leaves, 5);
495        // churn_rate = 15 / 60 = 0.25
496        assert!((m.churn_rate - 0.25).abs() < 1e-9);
497    }
498
499    #[test]
500    fn test_stability_score_stable() {
501        let tracker = PeerChurnTracker::new(default_config());
502        // Only 1 event in the last 60 s → very low churn.
503        tracker.record_event(PeerChurnEvent {
504            peer_id: "x".into(),
505            event_type: ChurnEventType::Joined,
506            timestamp_ms: ts(99),
507        });
508        let m = tracker.metrics(ts(100));
509        // score should be close to 1.0
510        assert!(
511            m.stability_score > 0.9,
512            "expected score > 0.9, got {}",
513            m.stability_score
514        );
515    }
516
517    #[test]
518    fn test_stability_score_high_churn() {
519        let cfg = AdaptiveRefreshConfig {
520            window_ms: 60_000,
521            ..default_config()
522        };
523        let tracker = PeerChurnTracker::new(cfg);
524        // Add many events → churn_rate should be high.
525        for i in 0..300u64 {
526            tracker.record_event(PeerChurnEvent {
527                peer_id: format!("p{i}"),
528                event_type: if i % 2 == 0 {
529                    ChurnEventType::Joined
530                } else {
531                    ChurnEventType::Left
532                },
533                timestamp_ms: ts(50) + i,
534            });
535        }
536        let m = tracker.metrics(ts(100));
537        // stability = 1/(1+5) = 0.167 → definitely < 0.5
538        assert!(
539            m.stability_score < 0.5,
540            "expected score < 0.5, got {}",
541            m.stability_score
542        );
543    }
544
545    #[test]
546    fn test_recommended_interval_stable() {
547        let cfg = AdaptiveRefreshConfig {
548            window_ms: 60_000,
549            base_interval_ms: 600_000,
550            max_interval_ms: 1_800_000,
551            ..default_config()
552        };
553        let tracker = PeerChurnTracker::new(cfg.clone());
554        // No events → churn_rate = 0 → interval should be max.
555        let interval = tracker.recommended_interval(ts(100));
556        assert!(
557            interval.as_millis() as u64 >= cfg.base_interval_ms,
558            "expected >= base, got {:?}",
559            interval
560        );
561    }
562
563    #[test]
564    fn test_recommended_interval_high_churn() {
565        let cfg = AdaptiveRefreshConfig {
566            window_ms: 10_000, // 10 s window for easy math
567            high_churn_threshold: 2.0,
568            base_interval_ms: 600_000,
569            min_interval_ms: 60_000,
570            max_interval_ms: 1_800_000,
571            max_events: 10_000,
572        };
573        let tracker = PeerChurnTracker::new(cfg.clone());
574
575        // Inject events that produce churn_rate >> threshold.
576        // 400 events in 10 s → rate = 40/s (well above 2*threshold=4).
577        for i in 0..400u64 {
578            tracker.record_event(PeerChurnEvent {
579                peer_id: format!("p{i}"),
580                event_type: ChurnEventType::Left,
581                timestamp_ms: ts(90) + i * 25, // spread within 10 s
582            });
583        }
584
585        let interval = tracker.recommended_interval(ts(100));
586        assert_eq!(
587            interval,
588            Duration::from_millis(cfg.min_interval_ms),
589            "expected min interval under extreme churn, got {:?}",
590            interval
591        );
592    }
593
594    // ------------------------------------------------------------------
595    // AdaptiveRefreshScheduler tests
596    // ------------------------------------------------------------------
597
598    #[test]
599    fn test_scheduler_refresh_due() {
600        let cfg = AdaptiveRefreshConfig {
601            base_interval_ms: 1_000, // 1 s base
602            min_interval_ms: 500,
603            max_interval_ms: 5_000,
604            ..default_config()
605        };
606        let scheduler = AdaptiveRefreshScheduler::new(cfg);
607        // Mark refresh at t=0.
608        scheduler.mark_refreshed(0);
609        // Before interval elapses.
610        assert!(!scheduler.is_refresh_due(500));
611        // After interval elapses.
612        assert!(scheduler.is_refresh_due(10_000));
613    }
614
615    #[test]
616    fn test_scheduler_not_due_after_refresh() {
617        let cfg = AdaptiveRefreshConfig {
618            base_interval_ms: 60_000,
619            min_interval_ms: 10_000,
620            max_interval_ms: 120_000,
621            ..default_config()
622        };
623        let scheduler = AdaptiveRefreshScheduler::new(cfg);
624        scheduler.mark_refreshed(ts(100));
625        // Immediately after refresh, should not be due.
626        assert!(!scheduler.is_refresh_due(ts(100) + 1));
627    }
628
629    #[test]
630    fn test_scheduler_refresh_count() {
631        let scheduler = AdaptiveRefreshScheduler::new(default_config());
632        assert_eq!(scheduler.refresh_count(), 0);
633        scheduler.mark_refreshed(ts(1));
634        scheduler.mark_refreshed(ts(20));
635        scheduler.mark_refreshed(ts(50));
636        assert_eq!(scheduler.refresh_count(), 3);
637    }
638
639    // ------------------------------------------------------------------
640    // ChurnResilienceManager tests
641    // ------------------------------------------------------------------
642
643    #[test]
644    fn test_churn_manager_peer_joined_left() {
645        let manager = ChurnResilienceManager::new(default_config());
646        manager.peer_joined("peer-1", ts(10));
647        manager.peer_left("peer-2", ts(11));
648        manager.peer_timed_out("peer-3", ts(12));
649
650        // Totals are maintained by the tracker.
651        let tracker = &manager.scheduler.tracker;
652        assert_eq!(tracker.total_joins(), 1);
653        assert_eq!(tracker.total_leaves(), 2);
654    }
655
656    #[test]
657    fn test_churn_manager_should_refresh() {
658        let cfg = AdaptiveRefreshConfig {
659            base_interval_ms: 1_000,
660            min_interval_ms: 200,
661            max_interval_ms: 5_000,
662            ..default_config()
663        };
664        let manager = ChurnResilienceManager::new(cfg);
665
666        // First call (never refreshed) → should refresh immediately.
667        assert!(manager.should_refresh(ts(0)));
668
669        // Mark refresh at t=0.
670        manager.mark_refreshed(ts(0));
671
672        // Should not be due immediately after.
673        assert!(!manager.should_refresh(ts(0) + 100));
674
675        // Should be due after enough time has elapsed.
676        assert!(manager.should_refresh(ts(0) + 10_000));
677    }
678
679    // ------------------------------------------------------------------
680    // Default config test
681    // ------------------------------------------------------------------
682
683    #[test]
684    fn test_default_config_values() {
685        let cfg = AdaptiveRefreshConfig::default();
686        assert_eq!(cfg.base_interval_ms, 10 * 60 * 1_000);
687        assert_eq!(cfg.min_interval_ms, 60 * 1_000);
688        assert_eq!(cfg.max_interval_ms, 30 * 60 * 1_000);
689        assert!((cfg.high_churn_threshold - 2.0).abs() < f64::EPSILON);
690        assert_eq!(cfg.window_ms, 60 * 1_000);
691        assert_eq!(cfg.max_events, 10_000);
692    }
693}