Skip to main content

ipfrs_network/
dht_optimizer.rs

1//! DHT Routing Table Optimizer for Kademlia bucket health analysis.
2//!
3//! Periodically analyzes the Kademlia routing table to identify bucket
4//! imbalances, dead peers, and suboptimal coverage, then emits remediation
5//! suggestions via [`RoutingRecommendation`].
6//!
7//! ## Design
8//!
9//! The optimizer is a pure analytical component — it does **not** mutate any
10//! state.  Callers feed it a snapshot of their routing table entries and
11//! receive an [`OptimizationReport`] containing per-bucket analyses and a
12//! prioritised list of actions to take.
13//!
14//! ### Health classification (per bucket)
15//!
16//! | Condition | Classification |
17//! |---|---|
18//! | `entry_count > k` (default k=20) | `Saturated` |
19//! | any non-responsive entry | `Stale` |
20//! | `responsive_count < target_fill` | `Sparse` |
21//! | otherwise | `Healthy` |
22//!
23//! ### Recommendation priority
24//!
25//! 1. For each stale bucket: up to 3 `PingPeer` recommendations (or
26//!    `EvictPeer` if the entry has not been seen for > 2× stale threshold).
27//! 2. For each sparse bucket: one `RefreshBucket` recommendation.
28
29use std::collections::HashMap;
30
31// ────────────────────────────────────────────────────────────────────────────
32// Constants
33// ────────────────────────────────────────────────────────────────────────────
34
35/// Default number of seconds before a peer is considered stale (10 minutes).
36pub const DEFAULT_STALE_THRESHOLD_SECS: u64 = 600;
37
38/// Default target number of responsive peers per bucket.
39pub const DEFAULT_TARGET_BUCKET_FILL: usize = 8;
40
41/// Kademlia k-bucket capacity.
42pub const DEFAULT_K_BUCKET_CAPACITY: usize = 20;
43
44/// Maximum ping recommendations emitted per bucket per analysis cycle.
45const MAX_PINGS_PER_BUCKET: usize = 3;
46
47/// Multiplier applied to `stale_threshold_secs` to decide eviction vs ping.
48const EVICT_MULTIPLIER: u64 = 2;
49
50// ────────────────────────────────────────────────────────────────────────────
51// BucketHealth
52// ────────────────────────────────────────────────────────────────────────────
53
54/// Health classification for a single Kademlia k-bucket.
55#[derive(Debug, Clone, PartialEq)]
56pub enum BucketHealth {
57    /// Bucket is at or above the target fill ratio.
58    Healthy,
59    /// Bucket has fewer responsive peers than the target.
60    Sparse {
61        /// Fraction of target fill that is currently met (`responsive / target`).
62        fill_ratio: f64,
63    },
64    /// Bucket contains one or more non-responsive (stale) peers.
65    Stale {
66        /// Number of non-responsive entries.
67        stale_count: usize,
68    },
69    /// Bucket exceeds the k-bucket capacity (k = 20 by default).
70    Saturated,
71}
72
73// ────────────────────────────────────────────────────────────────────────────
74// RoutingEntry
75// ────────────────────────────────────────────────────────────────────────────
76
77/// A single peer entry in the Kademlia routing table snapshot.
78#[derive(Debug, Clone)]
79pub struct RoutingEntry {
80    /// Peer identifier (e.g. libp2p PeerId as a string).
81    pub peer_id: String,
82    /// XOR-distance bit-prefix index (0 = closest bucket).
83    pub bucket_index: u8,
84    /// Unix timestamp (seconds) of the last successful response from this peer.
85    pub last_seen_secs: u64,
86    /// Observed round-trip latency in milliseconds.
87    pub latency_ms: f64,
88    /// Whether the peer responded to the most recent liveness probe.
89    pub is_responsive: bool,
90}
91
92impl RoutingEntry {
93    /// Convenience constructor.
94    pub fn new(
95        peer_id: impl Into<String>,
96        bucket_index: u8,
97        last_seen_secs: u64,
98        latency_ms: f64,
99        is_responsive: bool,
100    ) -> Self {
101        Self {
102            peer_id: peer_id.into(),
103            bucket_index,
104            last_seen_secs,
105            latency_ms,
106            is_responsive,
107        }
108    }
109}
110
111// ────────────────────────────────────────────────────────────────────────────
112// BucketAnalysis
113// ────────────────────────────────────────────────────────────────────────────
114
115/// Per-bucket analysis result produced by [`DhtRoutingOptimizer::analyze_bucket`].
116#[derive(Debug, Clone)]
117pub struct BucketAnalysis {
118    /// Index of the analyzed bucket.
119    pub bucket_index: u8,
120    /// Total number of entries in this bucket.
121    pub entry_count: usize,
122    /// Number of entries that are currently responsive.
123    pub responsive_count: usize,
124    /// Average latency (ms) across responsive entries; `0.0` when none.
125    pub avg_latency_ms: f64,
126    /// Health classification for this bucket.
127    pub health: BucketHealth,
128    /// Age in seconds of the oldest entry: `now_secs - min(last_seen_secs)`.
129    ///
130    /// Returns `0` for an empty bucket.
131    pub oldest_entry_age_secs: u64,
132}
133
134// ────────────────────────────────────────────────────────────────────────────
135// RoutingRecommendation
136// ────────────────────────────────────────────────────────────────────────────
137
138/// Remediation action recommended by the optimizer.
139#[derive(Debug, Clone, PartialEq)]
140pub enum RoutingRecommendation {
141    /// Trigger a Kademlia lookup to populate a sparse bucket.
142    RefreshBucket(u8),
143    /// Remove a stale / confirmed-unresponsive peer from the routing table.
144    EvictPeer {
145        /// Bucket containing the peer.
146        bucket: u8,
147        /// Peer to evict.
148        peer_id: String,
149    },
150    /// Send a liveness ping before deciding whether to evict.
151    PingPeer {
152        /// Bucket containing the peer.
153        bucket: u8,
154        /// Peer to ping.
155        peer_id: String,
156    },
157    /// No action required.
158    NoAction,
159}
160
161impl RoutingRecommendation {
162    /// Returns `true` if this recommendation requires an action to be taken
163    /// (i.e. is not [`RoutingRecommendation::NoAction`]).
164    pub fn is_actionable(&self) -> bool {
165        !matches!(self, Self::NoAction)
166    }
167}
168
169// ────────────────────────────────────────────────────────────────────────────
170// OptimizationReport
171// ────────────────────────────────────────────────────────────────────────────
172
173/// Summary report produced by [`DhtRoutingOptimizer::optimize`].
174#[derive(Debug, Clone)]
175pub struct OptimizationReport {
176    /// Unix timestamp (seconds) at which the analysis was performed.
177    pub analyzed_at_secs: u64,
178    /// Total number of routing entries across all buckets.
179    pub total_entries: usize,
180    /// Number of buckets classified as [`BucketHealth::Healthy`].
181    pub healthy_buckets: usize,
182    /// Number of buckets classified as [`BucketHealth::Sparse`].
183    pub sparse_buckets: usize,
184    /// Number of buckets classified as [`BucketHealth::Stale`].
185    pub stale_buckets: usize,
186    /// Ordered list of recommended actions.
187    pub recommendations: Vec<RoutingRecommendation>,
188}
189
190impl OptimizationReport {
191    /// Returns `true` if the report contains at least one actionable
192    /// recommendation (anything other than [`RoutingRecommendation::NoAction`]).
193    pub fn has_issues(&self) -> bool {
194        self.recommendations.iter().any(|r| r.is_actionable())
195    }
196}
197
198// ────────────────────────────────────────────────────────────────────────────
199// DhtRoutingOptimizer
200// ────────────────────────────────────────────────────────────────────────────
201
202/// Analyzes Kademlia routing table snapshots and emits remediation suggestions.
203///
204/// # Example
205///
206/// ```rust
207/// use ipfrs_network::dht_optimizer::{DhtRoutingOptimizer, RoutingEntry};
208///
209/// let optimizer = DhtRoutingOptimizer::new(600, 8);
210/// let entries = vec![
211///     RoutingEntry::new("peer-1", 5, 1_700_000_000, 12.0, true),
212///     RoutingEntry::new("peer-2", 5, 1_700_000_010, 18.5, true),
213/// ];
214/// let report = optimizer.optimize(&entries, 1_700_000_600);
215/// assert!(!report.has_issues());
216/// ```
217#[derive(Debug, Clone)]
218pub struct DhtRoutingOptimizer {
219    /// Seconds of inactivity before a peer is considered stale.
220    pub stale_threshold_secs: u64,
221    /// Target number of responsive peers per bucket.
222    pub target_bucket_fill: usize,
223    /// Maximum number of entries allowed per bucket (Kademlia k).
224    pub k_bucket_capacity: usize,
225}
226
227impl Default for DhtRoutingOptimizer {
228    fn default() -> Self {
229        Self::new(DEFAULT_STALE_THRESHOLD_SECS, DEFAULT_TARGET_BUCKET_FILL)
230    }
231}
232
233impl DhtRoutingOptimizer {
234    /// Creates a new optimizer with the given thresholds.
235    ///
236    /// # Arguments
237    ///
238    /// * `stale_threshold_secs` – seconds without a response before a peer is
239    ///   labelled stale (default: [`DEFAULT_STALE_THRESHOLD_SECS`]).
240    /// * `target_bucket_fill` – desired number of responsive entries per bucket
241    ///   (default: [`DEFAULT_TARGET_BUCKET_FILL`]).
242    pub fn new(stale_threshold_secs: u64, target_bucket_fill: usize) -> Self {
243        Self {
244            stale_threshold_secs,
245            target_bucket_fill,
246            k_bucket_capacity: DEFAULT_K_BUCKET_CAPACITY,
247        }
248    }
249
250    /// Analyzes a slice of entries that all belong to the **same** bucket.
251    ///
252    /// The `bucket_index` field of the returned [`BucketAnalysis`] is taken
253    /// from the first entry; callers should ensure all entries share the same
254    /// bucket index (as guaranteed by [`optimize`][Self::optimize]).
255    ///
256    /// # Health classification order
257    ///
258    /// 1. **Saturated** — `entry_count > k_bucket_capacity`
259    /// 2. **Stale** — any non-responsive entries present
260    /// 3. **Sparse** — `responsive_count < target_bucket_fill`
261    /// 4. **Healthy** — otherwise
262    pub fn analyze_bucket(&self, entries: &[RoutingEntry], now_secs: u64) -> BucketAnalysis {
263        let bucket_index = entries.first().map(|e| e.bucket_index).unwrap_or(0);
264        let entry_count = entries.len();
265
266        // Responsiveness counts
267        let responsive_count = entries.iter().filter(|e| e.is_responsive).count();
268
269        // Average latency from responsive entries only
270        let avg_latency_ms = {
271            let responsive_latencies: Vec<f64> = entries
272                .iter()
273                .filter(|e| e.is_responsive)
274                .map(|e| e.latency_ms)
275                .collect();
276            if responsive_latencies.is_empty() {
277                0.0
278            } else {
279                let sum: f64 = responsive_latencies.iter().sum();
280                sum / responsive_latencies.len() as f64
281            }
282        };
283
284        // Age of oldest entry
285        let oldest_entry_age_secs = if entries.is_empty() {
286            0
287        } else {
288            let min_last_seen = entries.iter().map(|e| e.last_seen_secs).min().unwrap_or(0);
289            now_secs.saturating_sub(min_last_seen)
290        };
291
292        // Stale count: non-responsive entries
293        let stale_count = entries.iter().filter(|e| !e.is_responsive).count();
294
295        // Health classification — precedence: Saturated > Stale > Sparse > Healthy
296        let health = if entry_count > self.k_bucket_capacity {
297            BucketHealth::Saturated
298        } else if stale_count > 0 {
299            BucketHealth::Stale { stale_count }
300        } else if responsive_count < self.target_bucket_fill {
301            let fill_ratio = if self.target_bucket_fill == 0 {
302                1.0
303            } else {
304                responsive_count as f64 / self.target_bucket_fill as f64
305            };
306            BucketHealth::Sparse { fill_ratio }
307        } else {
308            BucketHealth::Healthy
309        };
310
311        BucketAnalysis {
312            bucket_index,
313            entry_count,
314            responsive_count,
315            avg_latency_ms,
316            health,
317            oldest_entry_age_secs,
318        }
319    }
320
321    /// Analyzes all routing entries and returns a full [`OptimizationReport`].
322    ///
323    /// # Algorithm
324    ///
325    /// 1. Group entries by `bucket_index`.
326    /// 2. Run [`analyze_bucket`][Self::analyze_bucket] on each group.
327    /// 3. For each bucket produce recommendations:
328    ///    - **Stale** entries: emit `PingPeer` (or `EvictPeer` if age >
329    ///      2× `stale_threshold_secs`), capped at
330    ///      `MAX_PINGS_PER_BUCKET` per bucket.
331    ///    - **Sparse** bucket: emit `RefreshBucket`.
332    /// 4. Collate bucket-health counts and return the report.
333    pub fn optimize(&self, all_entries: &[RoutingEntry], now_secs: u64) -> OptimizationReport {
334        if all_entries.is_empty() {
335            return OptimizationReport {
336                analyzed_at_secs: now_secs,
337                total_entries: 0,
338                healthy_buckets: 0,
339                sparse_buckets: 0,
340                stale_buckets: 0,
341                recommendations: vec![],
342            };
343        }
344
345        // Group entries by bucket index
346        let mut buckets: HashMap<u8, Vec<&RoutingEntry>> = HashMap::new();
347        for entry in all_entries {
348            buckets.entry(entry.bucket_index).or_default().push(entry);
349        }
350
351        let mut recommendations: Vec<RoutingRecommendation> = Vec::new();
352        let mut healthy_buckets = 0usize;
353        let mut sparse_buckets = 0usize;
354        let mut stale_buckets = 0usize;
355
356        // Sort bucket indices for deterministic output
357        let mut bucket_indices: Vec<u8> = buckets.keys().copied().collect();
358        bucket_indices.sort_unstable();
359
360        for idx in bucket_indices {
361            let entries: Vec<RoutingEntry> = buckets[&idx].iter().map(|e| (*e).clone()).collect();
362
363            let analysis = self.analyze_bucket(&entries, now_secs);
364
365            match &analysis.health {
366                BucketHealth::Healthy | BucketHealth::Saturated => {
367                    healthy_buckets += 1;
368                }
369                BucketHealth::Sparse { .. } => {
370                    sparse_buckets += 1;
371                    recommendations.push(RoutingRecommendation::RefreshBucket(idx));
372                }
373                BucketHealth::Stale { .. } => {
374                    stale_buckets += 1;
375
376                    // Emit up to MAX_PINGS_PER_BUCKET recommendations for
377                    // non-responsive entries in this bucket.
378                    let evict_threshold = self.stale_threshold_secs * EVICT_MULTIPLIER;
379                    let mut ping_count = 0usize;
380
381                    for entry in entries
382                        .iter()
383                        .filter(|e| !e.is_responsive)
384                        .take(MAX_PINGS_PER_BUCKET)
385                    {
386                        let age = now_secs.saturating_sub(entry.last_seen_secs);
387                        if age > evict_threshold {
388                            recommendations.push(RoutingRecommendation::EvictPeer {
389                                bucket: idx,
390                                peer_id: entry.peer_id.clone(),
391                            });
392                        } else {
393                            recommendations.push(RoutingRecommendation::PingPeer {
394                                bucket: idx,
395                                peer_id: entry.peer_id.clone(),
396                            });
397                            ping_count += 1;
398                            if ping_count >= MAX_PINGS_PER_BUCKET {
399                                break;
400                            }
401                        }
402                    }
403                }
404            }
405        }
406
407        OptimizationReport {
408            analyzed_at_secs: now_secs,
409            total_entries: all_entries.len(),
410            healthy_buckets,
411            sparse_buckets,
412            stale_buckets,
413            recommendations,
414        }
415    }
416
417    /// Returns the `n` responsive peers with the **highest** latency, sorted
418    /// descending.  These are candidates for replacement by lower-latency
419    /// peers discovered during bucket refresh.
420    pub fn top_latency_peers<'a>(
421        &self,
422        entries: &'a [RoutingEntry],
423        n: usize,
424    ) -> Vec<&'a RoutingEntry> {
425        let mut responsive: Vec<&'a RoutingEntry> =
426            entries.iter().filter(|e| e.is_responsive).collect();
427
428        // Sort descending by latency (NaN-safe: treat NaN as very large)
429        responsive.sort_by(|a, b| {
430            b.latency_ms
431                .partial_cmp(&a.latency_ms)
432                .unwrap_or(std::cmp::Ordering::Equal)
433        });
434
435        responsive.into_iter().take(n).collect()
436    }
437
438    /// Returns the fraction of the 256 possible Kademlia bucket indices that
439    /// have at least one responsive entry.
440    ///
441    /// Returns `0.0` for an empty entry list.
442    pub fn coverage_score(&self, entries: &[RoutingEntry]) -> f64 {
443        if entries.is_empty() {
444            return 0.0;
445        }
446
447        let covered: std::collections::HashSet<u8> = entries
448            .iter()
449            .filter(|e| e.is_responsive)
450            .map(|e| e.bucket_index)
451            .collect();
452
453        covered.len() as f64 / 256.0
454    }
455}
456
457// ────────────────────────────────────────────────────────────────────────────
458// Tests
459// ────────────────────────────────────────────────────────────────────────────
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    const NOW: u64 = 1_700_000_000;
466
467    fn make_entry(
468        peer_id: &str,
469        bucket: u8,
470        last_seen_offset: i64,
471        latency_ms: f64,
472        responsive: bool,
473    ) -> RoutingEntry {
474        let last_seen = (NOW as i64 + last_seen_offset) as u64;
475        RoutingEntry::new(peer_id, bucket, last_seen, latency_ms, responsive)
476    }
477
478    // ── Constructor ──────────────────────────────────────────────────────────
479
480    #[test]
481    fn test_new_with_custom_params() {
482        let opt = DhtRoutingOptimizer::new(300, 5);
483        assert_eq!(opt.stale_threshold_secs, 300);
484        assert_eq!(opt.target_bucket_fill, 5);
485        assert_eq!(opt.k_bucket_capacity, DEFAULT_K_BUCKET_CAPACITY);
486    }
487
488    // ── analyze_bucket ───────────────────────────────────────────────────────
489
490    #[test]
491    fn test_analyze_bucket_all_healthy() {
492        let opt = DhtRoutingOptimizer::new(600, 3);
493        let entries: Vec<RoutingEntry> = (0..5)
494            .map(|i| make_entry(&format!("peer-{i}"), 7, 0, 10.0, true))
495            .collect();
496        let analysis = opt.analyze_bucket(&entries, NOW);
497        assert_eq!(analysis.entry_count, 5);
498        assert_eq!(analysis.responsive_count, 5);
499        assert_eq!(analysis.health, BucketHealth::Healthy);
500        assert!((analysis.avg_latency_ms - 10.0).abs() < f64::EPSILON);
501    }
502
503    #[test]
504    fn test_analyze_bucket_sparse() {
505        let opt = DhtRoutingOptimizer::new(600, 8);
506        // Only 2 responsive out of target 8 → Sparse
507        let entries: Vec<RoutingEntry> = (0..2)
508            .map(|i| make_entry(&format!("peer-{i}"), 3, 0, 5.0, true))
509            .collect();
510        let analysis = opt.analyze_bucket(&entries, NOW);
511        assert_eq!(analysis.responsive_count, 2);
512        if let BucketHealth::Sparse { fill_ratio } = analysis.health {
513            assert!((fill_ratio - 0.25).abs() < 1e-9); // 2/8
514        } else {
515            panic!("expected Sparse, got {:?}", analysis.health);
516        }
517    }
518
519    #[test]
520    fn test_analyze_bucket_stale() {
521        let opt = DhtRoutingOptimizer::new(600, 2);
522        let entries = vec![
523            make_entry("peer-a", 1, 0, 10.0, true),
524            make_entry("peer-b", 1, 0, 10.0, false), // non-responsive
525        ];
526        let analysis = opt.analyze_bucket(&entries, NOW);
527        assert!(matches!(
528            analysis.health,
529            BucketHealth::Stale { stale_count: 1 }
530        ));
531    }
532
533    #[test]
534    fn test_analyze_bucket_saturated() {
535        let opt = DhtRoutingOptimizer::new(600, 8);
536        // 21 entries > k=20 → Saturated
537        let entries: Vec<RoutingEntry> = (0..21)
538            .map(|i| make_entry(&format!("peer-{i}"), 9, 0, 5.0, true))
539            .collect();
540        let analysis = opt.analyze_bucket(&entries, NOW);
541        assert_eq!(analysis.health, BucketHealth::Saturated);
542    }
543
544    #[test]
545    fn test_analyze_bucket_empty() {
546        let opt = DhtRoutingOptimizer::default();
547        let analysis = opt.analyze_bucket(&[], NOW);
548        assert_eq!(analysis.entry_count, 0);
549        assert_eq!(analysis.responsive_count, 0);
550        assert_eq!(analysis.oldest_entry_age_secs, 0);
551        assert_eq!(analysis.avg_latency_ms, 0.0);
552        // Empty → responsive_count (0) < target_fill (8) → Sparse
553        assert!(matches!(analysis.health, BucketHealth::Sparse { .. }));
554    }
555
556    #[test]
557    fn test_analyze_bucket_oldest_entry_age() {
558        let opt = DhtRoutingOptimizer::default();
559        let entries = vec![
560            make_entry("peer-x", 2, -500, 10.0, true), // 500 s ago
561            make_entry("peer-y", 2, -100, 10.0, true), // 100 s ago
562        ];
563        let analysis = opt.analyze_bucket(&entries, NOW);
564        assert_eq!(analysis.oldest_entry_age_secs, 500);
565    }
566
567    // ── optimize ─────────────────────────────────────────────────────────────
568
569    #[test]
570    fn test_optimize_empty_returns_empty_report() {
571        let opt = DhtRoutingOptimizer::default();
572        let report = opt.optimize(&[], NOW);
573        assert_eq!(report.total_entries, 0);
574        assert_eq!(report.recommendations.len(), 0);
575        assert!(!report.has_issues());
576    }
577
578    #[test]
579    fn test_optimize_single_healthy_bucket_no_recommendations() {
580        let opt = DhtRoutingOptimizer::new(600, 3);
581        let entries: Vec<RoutingEntry> = (0..5)
582            .map(|i| make_entry(&format!("peer-{i}"), 4, 0, 8.0, true))
583            .collect();
584        let report = opt.optimize(&entries, NOW);
585        assert!(!report.has_issues());
586        assert_eq!(report.healthy_buckets, 1);
587    }
588
589    #[test]
590    fn test_optimize_sparse_bucket_refresh_recommendation() {
591        let opt = DhtRoutingOptimizer::new(600, 8);
592        // Only 2 responsive peers in bucket 10 → Sparse → RefreshBucket(10)
593        let entries: Vec<RoutingEntry> = (0..2)
594            .map(|i| make_entry(&format!("peer-{i}"), 10, 0, 5.0, true))
595            .collect();
596        let report = opt.optimize(&entries, NOW);
597        assert!(report.has_issues());
598        assert!(report
599            .recommendations
600            .contains(&RoutingRecommendation::RefreshBucket(10)));
601        assert_eq!(report.sparse_buckets, 1);
602    }
603
604    #[test]
605    fn test_optimize_stale_peer_ping_recommendation() {
606        let opt = DhtRoutingOptimizer::new(600, 1);
607        // One responsive + one non-responsive (age = 400 s < 2×600) → PingPeer
608        let entries = vec![
609            make_entry("peer-ok", 6, 0, 5.0, true),
610            make_entry("peer-bad", 6, -400, 5.0, false),
611        ];
612        let report = opt.optimize(&entries, NOW);
613        let has_ping = report.recommendations.iter().any(|r| {
614            matches!(r, RoutingRecommendation::PingPeer { peer_id, .. } if peer_id == "peer-bad")
615        });
616        assert!(has_ping, "expected PingPeer for peer-bad");
617    }
618
619    #[test]
620    fn test_optimize_very_old_peer_evict_recommendation() {
621        let opt = DhtRoutingOptimizer::new(600, 1);
622        // Non-responsive peer not seen for 2000 s > 2×600 → EvictPeer
623        let entries = vec![
624            make_entry("peer-ok", 6, 0, 5.0, true),
625            make_entry("peer-old", 6, -2000, 5.0, false),
626        ];
627        let report = opt.optimize(&entries, NOW);
628        let has_evict = report.recommendations.iter().any(|r| {
629            matches!(r, RoutingRecommendation::EvictPeer { peer_id, .. } if peer_id == "peer-old")
630        });
631        assert!(has_evict, "expected EvictPeer for peer-old");
632    }
633
634    #[test]
635    fn test_optimize_counts_bucket_health_correctly() {
636        let opt = DhtRoutingOptimizer::new(600, 8);
637        // Bucket 0: 10 responsive → Healthy
638        let healthy: Vec<RoutingEntry> = (0..10)
639            .map(|i| make_entry(&format!("h-{i}"), 0, 0, 5.0, true))
640            .collect();
641        // Bucket 1: 2 responsive → Sparse
642        let sparse: Vec<RoutingEntry> = (0..2)
643            .map(|i| make_entry(&format!("s-{i}"), 1, 0, 5.0, true))
644            .collect();
645        // Bucket 2: 1 non-responsive + 5 responsive → Stale
646        let mut stale: Vec<RoutingEntry> = (0..5)
647            .map(|i| make_entry(&format!("st-{i}"), 2, 0, 5.0, true))
648            .collect();
649        stale.push(make_entry("stale-peer", 2, -100, 5.0, false));
650
651        let all: Vec<RoutingEntry> = [healthy, sparse, stale].concat();
652        let report = opt.optimize(&all, NOW);
653        assert_eq!(report.healthy_buckets, 1);
654        assert_eq!(report.sparse_buckets, 1);
655        assert_eq!(report.stale_buckets, 1);
656    }
657
658    #[test]
659    fn test_optimize_at_most_3_ping_recommendations_per_bucket() {
660        let opt = DhtRoutingOptimizer::new(600, 1);
661        // 6 non-responsive peers in same bucket, age within threshold
662        let mut entries: Vec<RoutingEntry> = (0..6)
663            .map(|i| make_entry(&format!("bad-{i}"), 5, -100, 5.0, false))
664            .collect();
665        // Add one responsive so the bucket is classified as Stale
666        entries.push(make_entry("ok", 5, 0, 5.0, true));
667
668        let report = opt.optimize(&entries, NOW);
669        let ping_count = report
670            .recommendations
671            .iter()
672            .filter(|r| matches!(r, RoutingRecommendation::PingPeer { .. }))
673            .count();
674        assert!(
675            ping_count <= MAX_PINGS_PER_BUCKET,
676            "expected ≤ {MAX_PINGS_PER_BUCKET} pings, got {ping_count}"
677        );
678    }
679
680    // ── top_latency_peers ────────────────────────────────────────────────────
681
682    #[test]
683    fn test_top_latency_peers_sorted_descending() {
684        let opt = DhtRoutingOptimizer::default();
685        let entries = vec![
686            make_entry("peer-a", 0, 0, 10.0, true),
687            make_entry("peer-b", 0, 0, 50.0, true),
688            make_entry("peer-c", 0, 0, 30.0, true),
689            make_entry("peer-d", 0, 0, 5.0, false), // non-responsive, excluded
690        ];
691        let top = opt.top_latency_peers(&entries, 2);
692        assert_eq!(top.len(), 2);
693        assert_eq!(top[0].peer_id, "peer-b");
694        assert_eq!(top[1].peer_id, "peer-c");
695    }
696
697    // ── coverage_score ───────────────────────────────────────────────────────
698
699    #[test]
700    fn test_coverage_score_zero_for_empty() {
701        let opt = DhtRoutingOptimizer::default();
702        assert_eq!(opt.coverage_score(&[]), 0.0);
703    }
704
705    #[test]
706    fn test_coverage_score_fractional_for_partial_coverage() {
707        let opt = DhtRoutingOptimizer::default();
708        // 2 distinct responsive bucket indices out of 256
709        let entries = vec![
710            make_entry("peer-1", 0, 0, 5.0, true),
711            make_entry("peer-2", 1, 0, 5.0, true),
712            make_entry("peer-3", 1, 0, 5.0, true), // duplicate bucket
713            make_entry("peer-4", 2, 0, 5.0, false), // non-responsive, not counted
714        ];
715        let score = opt.coverage_score(&entries);
716        // 2 responsive unique buckets / 256
717        assert!((score - 2.0 / 256.0).abs() < 1e-12);
718    }
719
720    // ── has_issues ───────────────────────────────────────────────────────────
721
722    #[test]
723    fn test_has_issues_true_when_recommendations_exist() {
724        let opt = DhtRoutingOptimizer::new(600, 8);
725        // Only 1 responsive peer → Sparse → RefreshBucket
726        let entries = vec![make_entry("peer-1", 0, 0, 5.0, true)];
727        let report = opt.optimize(&entries, NOW);
728        assert!(report.has_issues());
729    }
730}