1use std::collections::HashMap;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RttSample {
29 pub peer_id: String,
31 pub rtt_micros: u64,
33 pub sampled_at_tick: u64,
35}
36
37#[derive(Debug, Clone)]
39pub struct TimeoutEstimate {
40 pub peer_id: String,
42 pub srtt_micros: f64,
44 pub rttvar_micros: f64,
46 pub timeout_micros: u64,
48 pub sample_count: u32,
50}
51
52#[derive(Debug, Clone)]
54pub struct AdaptiveTimeoutConfig {
55 pub alpha: f64,
57 pub beta: f64,
59 pub min_timeout_micros: u64,
61 pub max_timeout_micros: u64,
63}
64
65impl Default for AdaptiveTimeoutConfig {
66 fn default() -> Self {
67 Self {
68 alpha: 0.125,
69 beta: 0.25,
70 min_timeout_micros: 1_000,
71 max_timeout_micros: 30_000_000,
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct TimeoutStats {
79 pub total_peers: usize,
81 pub total_samples: u64,
83 pub avg_timeout_micros: f64,
85}
86
87#[derive(Debug)]
92pub struct PeerAdaptiveTimeout {
93 pub estimates: HashMap<String, TimeoutEstimate>,
95 pub config: AdaptiveTimeoutConfig,
97 pub total_samples: u64,
99}
100
101impl PeerAdaptiveTimeout {
102 pub fn new(config: AdaptiveTimeoutConfig) -> Self {
104 Self {
105 estimates: HashMap::new(),
106 config,
107 total_samples: 0,
108 }
109 }
110
111 pub fn record_sample(&mut self, sample: RttSample) {
127 let rtt = sample.rtt_micros as f64;
128 let alpha = self.config.alpha;
129 let beta = self.config.beta;
130 let min_t = self.config.min_timeout_micros;
131 let max_t = self.config.max_timeout_micros;
132
133 let estimate = self
134 .estimates
135 .entry(sample.peer_id.clone())
136 .or_insert_with(|| TimeoutEstimate {
137 peer_id: sample.peer_id.clone(),
138 srtt_micros: 0.0,
139 rttvar_micros: 0.0,
140 timeout_micros: 0,
141 sample_count: 0,
142 });
143
144 if estimate.sample_count == 0 {
145 estimate.srtt_micros = rtt;
147 estimate.rttvar_micros = rtt / 2.0;
148 } else {
149 let diff = (estimate.srtt_micros - rtt).abs();
151 estimate.rttvar_micros = (1.0 - beta) * estimate.rttvar_micros + beta * diff;
152 estimate.srtt_micros = (1.0 - alpha) * estimate.srtt_micros + alpha * rtt;
153 }
154
155 let raw_timeout = estimate.srtt_micros + 4.0 * estimate.rttvar_micros;
156 let clamped = raw_timeout.max(min_t as f64).min(max_t as f64) as u64;
157 estimate.timeout_micros = clamped.clamp(min_t, max_t);
158
159 estimate.sample_count += 1;
160 self.total_samples += 1;
161 }
162
163 pub fn timeout_for(&self, peer_id: &str) -> Option<u64> {
165 self.estimates.get(peer_id).map(|e| e.timeout_micros)
166 }
167
168 pub fn timeout_or_default(&self, peer_id: &str) -> u64 {
171 self.timeout_for(peer_id)
172 .unwrap_or(self.config.max_timeout_micros)
173 }
174
175 pub fn remove_peer(&mut self, peer_id: &str) -> bool {
179 self.estimates.remove(peer_id).is_some()
180 }
181
182 pub fn stats(&self) -> TimeoutStats {
184 let total_peers = self.estimates.len();
185 let avg_timeout_micros = if total_peers == 0 {
186 0.0
187 } else {
188 let sum: f64 = self
189 .estimates
190 .values()
191 .map(|e| e.timeout_micros as f64)
192 .sum();
193 sum / total_peers as f64
194 };
195 TimeoutStats {
196 total_peers,
197 total_samples: self.total_samples,
198 avg_timeout_micros,
199 }
200 }
201}
202
203#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn default_pat() -> PeerAdaptiveTimeout {
212 PeerAdaptiveTimeout::new(AdaptiveTimeoutConfig::default())
213 }
214
215 fn sample(peer: &str, rtt: u64) -> RttSample {
216 RttSample {
217 peer_id: peer.to_string(),
218 rtt_micros: rtt,
219 sampled_at_tick: 0,
220 }
221 }
222
223 #[test]
226 fn test_first_sample_srtt() {
227 let mut pat = default_pat();
228 pat.record_sample(sample("peer-a", 10_000));
229 let est = pat
230 .estimates
231 .get("peer-a")
232 .expect("test: peer-a estimate should exist");
233 assert!(
234 (est.srtt_micros - 10_000.0).abs() < f64::EPSILON,
235 "SRTT should equal first RTT"
236 );
237 }
238
239 #[test]
240 fn test_first_sample_rttvar() {
241 let mut pat = default_pat();
242 pat.record_sample(sample("peer-a", 10_000));
243 let est = pat
244 .estimates
245 .get("peer-a")
246 .expect("test: peer-a estimate should exist");
247 assert!(
248 (est.rttvar_micros - 5_000.0).abs() < f64::EPSILON,
249 "RTTVAR should be RTT/2 on first sample"
250 );
251 }
252
253 #[test]
254 fn test_first_sample_timeout_formula() {
255 let mut pat = default_pat();
256 pat.record_sample(sample("peer-a", 10_000));
257 let est = pat
258 .estimates
259 .get("peer-a")
260 .expect("test: peer-a estimate should exist");
261 assert_eq!(
263 est.timeout_micros, 30_000,
264 "First-sample timeout = srtt + 4*rttvar"
265 );
266 }
267
268 #[test]
271 fn test_second_sample_srtt_ewma() {
272 let mut pat = default_pat();
273 pat.record_sample(sample("peer-a", 8_000));
274 pat.record_sample(sample("peer-a", 12_000));
275 let est = pat
276 .estimates
277 .get("peer-a")
278 .expect("test: peer-a estimate should exist");
279 let expected_srtt = (1.0 - 0.125_f64) * 8_000.0 + 0.125 * 12_000.0;
281 assert!(
282 (est.srtt_micros - expected_srtt).abs() < 1.0,
283 "SRTT EWMA mismatch: got {}, expected {}",
284 est.srtt_micros,
285 expected_srtt
286 );
287 }
288
289 #[test]
292 fn test_timeout_formula_after_two_samples() {
293 let mut pat = default_pat();
294 pat.record_sample(sample("peer-a", 8_000));
295 pat.record_sample(sample("peer-a", 12_000));
296 let est = pat
297 .estimates
298 .get("peer-a")
299 .expect("test: peer-a estimate should exist");
300 let expected = (est.srtt_micros + 4.0 * est.rttvar_micros) as u64;
301 assert_eq!(
302 est.timeout_micros, expected,
303 "timeout_micros should equal srtt + 4*rttvar"
304 );
305 }
306
307 #[test]
310 fn test_min_timeout_clamping() {
311 let config = AdaptiveTimeoutConfig {
312 min_timeout_micros: 50_000,
313 ..Default::default()
314 };
315 let mut pat = PeerAdaptiveTimeout::new(config);
316 pat.record_sample(sample("peer-a", 1));
318 let t = pat
319 .timeout_for("peer-a")
320 .expect("test: peer-a timeout should exist after sample");
321 assert_eq!(t, 50_000, "Timeout should be clamped to min_timeout_micros");
322 }
323
324 #[test]
327 fn test_max_timeout_clamping() {
328 let config = AdaptiveTimeoutConfig {
329 max_timeout_micros: 5_000,
330 ..Default::default()
331 };
332 let mut pat = PeerAdaptiveTimeout::new(config);
333 pat.record_sample(sample("peer-a", 1_000_000));
335 let t = pat
336 .timeout_for("peer-a")
337 .expect("test: peer-a timeout should exist after sample");
338 assert_eq!(t, 5_000, "Timeout should be clamped to max_timeout_micros");
339 }
340
341 #[test]
344 fn test_remove_peer_existing() {
345 let mut pat = default_pat();
346 pat.record_sample(sample("peer-a", 1_000));
347 assert!(
348 pat.remove_peer("peer-a"),
349 "Should return true for known peer"
350 );
351 assert!(
352 !pat.estimates.contains_key("peer-a"),
353 "Peer should be gone after removal"
354 );
355 }
356
357 #[test]
360 fn test_remove_peer_unknown() {
361 let mut pat = default_pat();
362 assert!(
363 !pat.remove_peer("ghost"),
364 "Should return false for unknown peer"
365 );
366 }
367
368 #[test]
371 fn test_timeout_or_default_unknown() {
372 let pat = default_pat();
373 assert_eq!(
374 pat.timeout_or_default("nobody"),
375 pat.config.max_timeout_micros,
376 "Unknown peer should receive max timeout"
377 );
378 }
379
380 #[test]
383 fn test_timeout_for_unknown() {
384 let pat = default_pat();
385 assert!(pat.timeout_for("nobody").is_none());
386 }
387
388 #[test]
391 fn test_stats_avg_timeout() {
392 let mut pat = default_pat();
393 pat.record_sample(sample("peer-a", 10_000));
394 pat.record_sample(sample("peer-b", 20_000));
395 let stats = pat.stats();
396 let ta = pat.estimates["peer-a"].timeout_micros as f64;
397 let tb = pat.estimates["peer-b"].timeout_micros as f64;
398 let expected_avg = (ta + tb) / 2.0;
399 assert!(
400 (stats.avg_timeout_micros - expected_avg).abs() < 1.0,
401 "avg_timeout_micros mismatch"
402 );
403 }
404
405 #[test]
408 fn test_stats_total_peers() {
409 let mut pat = default_pat();
410 pat.record_sample(sample("peer-a", 5_000));
411 pat.record_sample(sample("peer-b", 5_000));
412 assert_eq!(pat.stats().total_peers, 2);
413 }
414
415 #[test]
418 fn test_stats_total_samples() {
419 let mut pat = default_pat();
420 pat.record_sample(sample("peer-a", 1_000));
421 pat.record_sample(sample("peer-a", 2_000));
422 pat.record_sample(sample("peer-b", 3_000));
423 assert_eq!(pat.stats().total_samples, 3);
424 }
425
426 #[test]
429 fn test_multiple_peers_independent() {
430 let mut pat = default_pat();
431 pat.record_sample(sample("peer-a", 1_000));
432 pat.record_sample(sample("peer-b", 100_000));
433 let ta = pat
434 .timeout_for("peer-a")
435 .expect("test: peer-a timeout should exist");
436 let tb = pat
437 .timeout_for("peer-b")
438 .expect("test: peer-b timeout should exist");
439 assert!(
440 ta < tb,
441 "Peer with lower RTT should have lower timeout: {} vs {}",
442 ta,
443 tb
444 );
445 }
446
447 #[test]
450 fn test_sample_count_increments() {
451 let mut pat = default_pat();
452 for i in 0..5u64 {
453 pat.record_sample(sample("peer-a", 1_000 * (i + 1)));
454 }
455 assert_eq!(pat.estimates["peer-a"].sample_count, 5);
456 }
457
458 #[test]
461 fn test_total_samples_across_peers() {
462 let mut pat = default_pat();
463 pat.record_sample(sample("peer-a", 1_000));
464 pat.record_sample(sample("peer-b", 2_000));
465 pat.record_sample(sample("peer-c", 3_000));
466 assert_eq!(pat.total_samples, 3);
467 }
468
469 #[test]
472 fn test_stable_rtt_converges() {
473 let mut pat = default_pat();
474 let stable_rtt = 20_000u64;
475 for _ in 0..200 {
477 pat.record_sample(sample("peer-a", stable_rtt));
478 }
479 let est = pat
480 .estimates
481 .get("peer-a")
482 .expect("test: peer-a estimate should exist after convergence");
483 let srtt_err = (est.srtt_micros - stable_rtt as f64).abs();
484 assert!(
485 srtt_err < 1.0,
486 "SRTT should converge to stable RTT; error={srtt_err}"
487 );
488 assert!(
489 est.rttvar_micros < 10.0,
490 "RTTVAR should converge to near-zero; got {}",
491 est.rttvar_micros
492 );
493 assert!(
494 est.timeout_micros < stable_rtt + 100,
495 "Timeout should converge near RTT; got {}",
496 est.timeout_micros
497 );
498 }
499
500 #[test]
503 fn test_custom_alpha_beta() {
504 let config = AdaptiveTimeoutConfig {
505 alpha: 0.5,
506 beta: 0.5,
507 ..Default::default()
508 };
509 let mut pat = PeerAdaptiveTimeout::new(config);
510 pat.record_sample(sample("peer-a", 10_000));
511 pat.record_sample(sample("peer-a", 20_000));
512 let est = pat
513 .estimates
514 .get("peer-a")
515 .expect("test: peer-a estimate should exist");
516 assert!((est.srtt_micros - 15_000.0).abs() < 1.0);
518 }
519
520 #[test]
523 fn test_remove_then_readd_resets_state() {
524 let mut pat = default_pat();
525 for _ in 0..10 {
526 pat.record_sample(sample("peer-a", 5_000));
527 }
528 pat.remove_peer("peer-a");
529 pat.record_sample(sample("peer-a", 100_000));
530 let est = pat
531 .estimates
532 .get("peer-a")
533 .expect("test: peer-a estimate should exist after re-add");
534 assert_eq!(est.sample_count, 1);
536 assert!((est.srtt_micros - 100_000.0).abs() < 1.0);
537 assert!((est.rttvar_micros - 50_000.0).abs() < 1.0);
538 }
539
540 #[test]
543 fn test_empty_stats() {
544 let pat = default_pat();
545 let stats = pat.stats();
546 assert_eq!(stats.total_peers, 0);
547 assert_eq!(stats.total_samples, 0);
548 assert!((stats.avg_timeout_micros - 0.0).abs() < f64::EPSILON);
549 }
550
551 #[test]
554 fn test_timeout_or_default_known() {
555 let mut pat = default_pat();
556 pat.record_sample(sample("peer-a", 5_000));
557 let expected = pat
558 .timeout_for("peer-a")
559 .expect("test: peer-a timeout should exist");
560 assert_eq!(pat.timeout_or_default("peer-a"), expected);
561 }
562
563 #[test]
566 fn test_total_samples_monotone() {
567 let mut pat = default_pat();
568 for i in 0..10u64 {
569 pat.record_sample(sample("peer-a", 1_000 * (i + 1)));
570 assert_eq!(pat.total_samples, i + 1);
571 }
572 }
573
574 #[test]
577 fn test_high_variance_rttvar() {
578 let mut pat = default_pat();
579 pat.record_sample(sample("peer-a", 1_000));
580 pat.record_sample(sample("peer-a", 100_000));
581 let est = pat
582 .estimates
583 .get("peer-a")
584 .expect("test: peer-a estimate should exist");
585 assert!(
587 est.rttvar_micros > 10_000.0,
588 "RTTVAR should reflect high variance; got {}",
589 est.rttvar_micros
590 );
591 }
592
593 #[test]
596 fn test_min_floor_tiny_rtt() {
597 let mut pat = default_pat(); pat.record_sample(sample("peer-a", 1)); assert!(
600 pat.timeout_for("peer-a")
601 .expect("test: peer-a timeout should be present")
602 >= 1_000,
603 "Timeout must not fall below min"
604 );
605 }
606
607 #[test]
610 fn test_timeout_never_below_min_over_many_samples() {
611 let mut pat = default_pat();
612 for i in 0..50u64 {
613 pat.record_sample(sample("peer-a", i + 1));
614 }
615 let t = pat
616 .timeout_for("peer-a")
617 .expect("test: peer-a timeout should be present");
618 assert!(t >= pat.config.min_timeout_micros);
619 }
620
621 #[test]
624 fn test_timeout_never_above_max_over_many_samples() {
625 let config = AdaptiveTimeoutConfig {
626 max_timeout_micros: 10_000,
627 ..Default::default()
628 };
629 let mut pat = PeerAdaptiveTimeout::new(config.clone());
630 for i in 0..50u64 {
631 pat.record_sample(sample("peer-a", 1_000_000 * (i + 1)));
632 }
633 let t = pat
634 .timeout_for("peer-a")
635 .expect("test: peer-a timeout should exist");
636 assert!(
637 t <= config.max_timeout_micros,
638 "Timeout must not exceed max; got {t}"
639 );
640 }
641
642 #[test]
645 fn test_stats_after_remove() {
646 let mut pat = default_pat();
647 pat.record_sample(sample("peer-a", 5_000));
648 pat.record_sample(sample("peer-b", 5_000));
649 pat.remove_peer("peer-a");
650 let stats = pat.stats();
651 assert_eq!(stats.total_peers, 1, "Only one peer should remain");
652 assert_eq!(stats.total_samples, 2, "total_samples is cumulative");
654 }
655
656 #[test]
659 fn test_rttvar_update_uses_prev_srtt() {
660 let mut pat = default_pat();
661 pat.record_sample(sample("peer-a", 8_000));
662 pat.record_sample(sample("peer-a", 12_000));
664 let est = pat
665 .estimates
666 .get("peer-a")
667 .expect("test: peer-a estimate should exist");
668 let expected_rttvar = (1.0 - 0.25_f64) * 4_000.0 + 0.25 * (8_000.0 - 12_000.0_f64).abs();
671 assert!(
672 (est.rttvar_micros - expected_rttvar).abs() < 1.0,
673 "RTTVAR mismatch: {} vs {}",
674 est.rttvar_micros,
675 expected_rttvar
676 );
677 }
678}