1use std::collections::HashMap;
30
31pub const DEFAULT_STALE_THRESHOLD_SECS: u64 = 600;
37
38pub const DEFAULT_TARGET_BUCKET_FILL: usize = 8;
40
41pub const DEFAULT_K_BUCKET_CAPACITY: usize = 20;
43
44const MAX_PINGS_PER_BUCKET: usize = 3;
46
47const EVICT_MULTIPLIER: u64 = 2;
49
50#[derive(Debug, Clone, PartialEq)]
56pub enum BucketHealth {
57 Healthy,
59 Sparse {
61 fill_ratio: f64,
63 },
64 Stale {
66 stale_count: usize,
68 },
69 Saturated,
71}
72
73#[derive(Debug, Clone)]
79pub struct RoutingEntry {
80 pub peer_id: String,
82 pub bucket_index: u8,
84 pub last_seen_secs: u64,
86 pub latency_ms: f64,
88 pub is_responsive: bool,
90}
91
92impl RoutingEntry {
93 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#[derive(Debug, Clone)]
117pub struct BucketAnalysis {
118 pub bucket_index: u8,
120 pub entry_count: usize,
122 pub responsive_count: usize,
124 pub avg_latency_ms: f64,
126 pub health: BucketHealth,
128 pub oldest_entry_age_secs: u64,
132}
133
134#[derive(Debug, Clone, PartialEq)]
140pub enum RoutingRecommendation {
141 RefreshBucket(u8),
143 EvictPeer {
145 bucket: u8,
147 peer_id: String,
149 },
150 PingPeer {
152 bucket: u8,
154 peer_id: String,
156 },
157 NoAction,
159}
160
161impl RoutingRecommendation {
162 pub fn is_actionable(&self) -> bool {
165 !matches!(self, Self::NoAction)
166 }
167}
168
169#[derive(Debug, Clone)]
175pub struct OptimizationReport {
176 pub analyzed_at_secs: u64,
178 pub total_entries: usize,
180 pub healthy_buckets: usize,
182 pub sparse_buckets: usize,
184 pub stale_buckets: usize,
186 pub recommendations: Vec<RoutingRecommendation>,
188}
189
190impl OptimizationReport {
191 pub fn has_issues(&self) -> bool {
194 self.recommendations.iter().any(|r| r.is_actionable())
195 }
196}
197
198#[derive(Debug, Clone)]
218pub struct DhtRoutingOptimizer {
219 pub stale_threshold_secs: u64,
221 pub target_bucket_fill: usize,
223 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 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 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 let responsive_count = entries.iter().filter(|e| e.is_responsive).count();
268
269 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 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 let stale_count = entries.iter().filter(|e| !e.is_responsive).count();
294
295 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 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 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 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 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 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 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 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#[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 #[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 #[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 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); } 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), ];
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 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 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), make_entry("peer-y", 2, -100, 10.0, true), ];
563 let analysis = opt.analyze_bucket(&entries, NOW);
564 assert_eq!(analysis.oldest_entry_age_secs, 500);
565 }
566
567 #[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 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 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 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 let healthy: Vec<RoutingEntry> = (0..10)
639 .map(|i| make_entry(&format!("h-{i}"), 0, 0, 5.0, true))
640 .collect();
641 let sparse: Vec<RoutingEntry> = (0..2)
643 .map(|i| make_entry(&format!("s-{i}"), 1, 0, 5.0, true))
644 .collect();
645 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 let mut entries: Vec<RoutingEntry> = (0..6)
663 .map(|i| make_entry(&format!("bad-{i}"), 5, -100, 5.0, false))
664 .collect();
665 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 #[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), ];
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 #[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 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), make_entry("peer-4", 2, 0, 5.0, false), ];
715 let score = opt.coverage_score(&entries);
716 assert!((score - 2.0 / 256.0).abs() < 1e-12);
718 }
719
720 #[test]
723 fn test_has_issues_true_when_recommendations_exist() {
724 let opt = DhtRoutingOptimizer::new(600, 8);
725 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}