Skip to main content

ipfrs_network/
adaptive_peer_scheduler.rs

1//! Adaptive Peer Scheduler
2//!
3//! A dynamic peer request scheduler that adapts request rates based on peer
4//! performance, network conditions, and backpressure signals. The scheduler
5//! continuously recomputes per-peer request allocations by weighting together
6//! success rate and average latency, then applies a global backpressure factor
7//! to prevent overloading the local node or the network.
8//!
9//! # Design
10//!
11//! * Each registered peer has a [`PeerMetrics`] record that accumulates success
12//!   counts, failure counts and cumulative latency.
13//! * A [`ScheduleSlot`] is derived from those metrics and represents the number
14//!   of concurrent/next requests the scheduler is willing to dispatch to that
15//!   peer in one scheduling epoch.
16//! * [`BackpressureSignal`] allows callers to throttle the whole scheduler when
17//!   the network or the application layer is under pressure.
18//! * [`AdaptivePeerScheduler::recompute_schedule`] must be called periodically
19//!   (or after significant metric changes) to refresh the slots.
20
21use std::collections::HashMap;
22
23// ──────────────────────────────────────────────────────────────────────────────
24// Public types
25// ──────────────────────────────────────────────────────────────────────────────
26
27/// A single slot in the scheduling table for one peer.
28#[derive(Debug, Clone, PartialEq)]
29pub struct ScheduleSlot {
30    /// The peer this slot belongs to.
31    pub peer_id: String,
32    /// Number of requests that may be dispatched to this peer.
33    pub allocated_requests: u32,
34    /// Computed weight `[0.01, 1.0]` used to derive the allocation.
35    pub weight: f64,
36    /// Unix-epoch timestamp (ms) when this slot was last recomputed.
37    pub last_updated: u64,
38}
39
40/// Global backpressure signal reported by the application or transport layer.
41#[derive(Debug, Clone, PartialEq)]
42pub enum BackpressureSignal {
43    /// No backpressure — operate at full rate.
44    None,
45    /// Mild congestion — reduce rates by the given factor `(0, 1]`.
46    Mild {
47        /// Multiplicative factor applied to `base_requests_per_peer`.
48        factor: f64,
49    },
50    /// Severe congestion — heavily reduce rates.
51    Severe {
52        /// Multiplicative factor applied to `base_requests_per_peer`.
53        factor: f64,
54    },
55    /// System is overloaded — allocate zero requests to every peer.
56    Overloaded,
57}
58
59impl BackpressureSignal {
60    /// Returns the multiplicative factor for this signal.
61    ///
62    /// * `None`      → `1.0`
63    /// * `Mild`      → the stored factor
64    /// * `Severe`    → the stored factor
65    /// * `Overloaded`→ `0.0`
66    #[inline]
67    pub fn factor(&self) -> f64 {
68        match self {
69            Self::None => 1.0,
70            Self::Mild { factor } => *factor,
71            Self::Severe { factor } => *factor,
72            Self::Overloaded => 0.0,
73        }
74    }
75
76    /// Human-readable label used in [`SchedulerStats`].
77    pub fn label(&self) -> &'static str {
78        match self {
79            Self::None => "none",
80            Self::Mild { .. } => "mild",
81            Self::Severe { .. } => "severe",
82            Self::Overloaded => "overloaded",
83        }
84    }
85}
86
87/// Per-peer performance metrics accumulated over the lifetime of the peer
88/// registration (or since last eviction).
89#[derive(Debug, Clone, PartialEq)]
90pub struct PeerMetrics {
91    /// Unique peer identifier.
92    pub peer_id: String,
93    /// Number of successfully completed requests.
94    pub success_count: u64,
95    /// Number of failed requests.
96    pub failure_count: u64,
97    /// Sum of latencies (in ms) for all successful requests.
98    pub total_latency_ms: u64,
99    /// Unix-epoch timestamp (ms) of the last interaction with this peer.
100    pub last_seen: u64,
101    /// Number of failures in a row without any intervening success.
102    pub consecutive_failures: u32,
103}
104
105impl PeerMetrics {
106    /// Creates a fresh record for a peer, seeded with the given timestamp.
107    pub fn new(peer_id: String, now: u64) -> Self {
108        Self {
109            peer_id,
110            success_count: 0,
111            failure_count: 0,
112            total_latency_ms: 0,
113            last_seen: now,
114            consecutive_failures: 0,
115        }
116    }
117
118    /// Returns the success rate in `[0.0, 1.0]`.
119    ///
120    /// Returns `1.0` if neither successes nor failures have been recorded yet
121    /// (i.e. the peer is brand new and deserves optimistic treatment).
122    pub fn success_rate(&self) -> f64 {
123        let total = self.success_count + self.failure_count;
124        if total == 0 {
125            1.0
126        } else {
127            self.success_count as f64 / total as f64
128        }
129    }
130
131    /// Returns the average request latency in milliseconds.
132    ///
133    /// Returns [`f64::MAX`] when no successful requests have been recorded,
134    /// which causes the latency component of the weight to approach `0`.
135    pub fn avg_latency_ms(&self) -> f64 {
136        if self.success_count == 0 {
137            f64::MAX
138        } else {
139            self.total_latency_ms as f64 / self.success_count as f64
140        }
141    }
142}
143
144/// Tuning knobs for the scheduler.
145#[derive(Debug, Clone)]
146pub struct SchedulerConfig {
147    /// Maximum number of peers tracked simultaneously.
148    pub max_peers: usize,
149    /// Baseline requests per peer used as the multiplier in weight → allocation.
150    pub base_requests_per_peer: u32,
151    /// Hard cap on allocated requests per peer per epoch.
152    pub max_requests_per_peer: u32,
153    /// Minimum number of requests a live peer always receives (unless Overloaded).
154    pub min_requests_per_peer: u32,
155    /// When the computed success rate of a peer drops below this value the peer
156    /// is still kept but its weight is penalised via `failure_penalty`.
157    pub backpressure_threshold: f64,
158    /// Multiplicative penalty applied to a peer's weight when its success rate
159    /// is below `backpressure_threshold`.
160    pub failure_penalty: f64,
161    /// Relative importance of latency in the weight formula.
162    pub latency_weight: f64,
163    /// Relative importance of success rate in the weight formula.
164    pub success_weight: f64,
165}
166
167impl Default for SchedulerConfig {
168    fn default() -> Self {
169        Self {
170            max_peers: 50,
171            base_requests_per_peer: 10,
172            max_requests_per_peer: 100,
173            min_requests_per_peer: 1,
174            backpressure_threshold: 0.8,
175            failure_penalty: 0.5,
176            latency_weight: 0.3,
177            success_weight: 0.7,
178        }
179    }
180}
181
182/// Summary statistics snapshot emitted by [`AdaptivePeerScheduler::scheduler_stats`].
183#[derive(Debug, Clone)]
184pub struct SchedulerStats {
185    /// Number of peers currently registered.
186    pub registered_peers: usize,
187    /// Number of schedule slots with `allocated_requests > 0`.
188    pub active_slots: usize,
189    /// Total requests dispatched since construction.
190    pub total_dispatched: u64,
191    /// Total requests recorded as succeeded since construction.
192    pub total_succeeded: u64,
193    /// `total_succeeded / total_dispatched` (or `1.0` when `total_dispatched == 0`).
194    pub success_rate: f64,
195    /// Human-readable label of the current backpressure signal.
196    pub backpressure: String,
197}
198
199// ──────────────────────────────────────────────────────────────────────────────
200// AdaptivePeerScheduler
201// ──────────────────────────────────────────────────────────────────────────────
202
203/// A dynamic peer request scheduler that adapts request rates based on peer
204/// performance, network conditions, and backpressure signals.
205///
206/// # Usage
207///
208/// ```rust
209/// use ipfrs_network::{
210///     ApsSchedulerConfig, ApsBackpressureSignal, AdaptivePeerScheduler,
211/// };
212///
213/// let config = ApsSchedulerConfig::default();
214/// let mut sched = AdaptivePeerScheduler::new(config);
215///
216/// sched.register_peer("peer-1".to_string(), 0);
217/// sched.record_success("peer-1", 50, 100);
218/// sched.recompute_schedule(200);
219///
220/// if let Some(peer) = sched.next_peer() {
221///     println!("dispatch to {peer}");
222/// }
223/// ```
224#[derive(Debug)]
225pub struct AdaptivePeerScheduler {
226    /// Scheduler configuration.
227    pub config: SchedulerConfig,
228    /// Per-peer performance metrics.
229    pub peers: HashMap<String, PeerMetrics>,
230    /// Last computed schedule.
231    pub schedule: HashMap<String, ScheduleSlot>,
232    /// Current global backpressure signal.
233    pub global_backpressure: BackpressureSignal,
234    /// Monotonically increasing counter of dispatched requests.
235    pub total_requests_dispatched: u64,
236    /// Monotonically increasing counter of succeeded requests.
237    pub total_requests_succeeded: u64,
238}
239
240impl AdaptivePeerScheduler {
241    // ── Construction ────────────────────────────────────────────────────────
242
243    /// Creates a new scheduler with the supplied configuration.
244    pub fn new(config: SchedulerConfig) -> Self {
245        Self {
246            config,
247            peers: HashMap::new(),
248            schedule: HashMap::new(),
249            global_backpressure: BackpressureSignal::None,
250            total_requests_dispatched: 0,
251            total_requests_succeeded: 0,
252        }
253    }
254
255    // ── Peer management ─────────────────────────────────────────────────────
256
257    /// Registers a peer with the scheduler.
258    ///
259    /// If the peer is already known this is a no-op (existing metrics are
260    /// preserved). When the peer table is full the peer with the oldest
261    /// `last_seen` timestamp is evicted to make room.
262    pub fn register_peer(&mut self, peer_id: String, now: u64) {
263        if self.peers.contains_key(&peer_id) {
264            return;
265        }
266
267        // Evict the stalest peer when at capacity.
268        if self.peers.len() >= self.config.max_peers {
269            if let Some(oldest_id) = self.oldest_peer_id() {
270                self.peers.remove(&oldest_id);
271                self.schedule.remove(&oldest_id);
272            }
273        }
274
275        self.peers
276            .insert(peer_id.clone(), PeerMetrics::new(peer_id, now));
277    }
278
279    /// Removes a peer from both the metrics table and the schedule.
280    ///
281    /// Returns `true` if the peer was present and has been removed.
282    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
283        let removed = self.peers.remove(peer_id).is_some();
284        self.schedule.remove(peer_id);
285        removed
286    }
287
288    // ── Metric recording ────────────────────────────────────────────────────
289
290    /// Records a successful request completion for the given peer.
291    ///
292    /// Also increments the global `total_requests_succeeded` counter.
293    /// Silently ignores unknown peer IDs.
294    pub fn record_success(&mut self, peer_id: &str, latency_ms: u64, now: u64) {
295        if let Some(metrics) = self.peers.get_mut(peer_id) {
296            metrics.success_count = metrics.success_count.saturating_add(1);
297            metrics.total_latency_ms = metrics.total_latency_ms.saturating_add(latency_ms);
298            metrics.last_seen = now;
299            metrics.consecutive_failures = 0;
300            self.total_requests_succeeded = self.total_requests_succeeded.saturating_add(1);
301        }
302    }
303
304    /// Records a request failure for the given peer.
305    ///
306    /// Silently ignores unknown peer IDs.
307    pub fn record_failure(&mut self, peer_id: &str, now: u64) {
308        if let Some(metrics) = self.peers.get_mut(peer_id) {
309            metrics.failure_count = metrics.failure_count.saturating_add(1);
310            metrics.consecutive_failures = metrics.consecutive_failures.saturating_add(1);
311            metrics.last_seen = now;
312        }
313    }
314
315    // ── Backpressure ─────────────────────────────────────────────────────────
316
317    /// Updates the global backpressure signal.
318    ///
319    /// The new signal takes effect on the *next* call to
320    /// [`recompute_schedule`](Self::recompute_schedule).
321    pub fn set_backpressure(&mut self, signal: BackpressureSignal) {
322        self.global_backpressure = signal;
323    }
324
325    // ── Weight computation ───────────────────────────────────────────────────
326
327    /// Computes the scheduling weight for a single peer.
328    ///
329    /// Formula:
330    /// ```text
331    /// weight = success_weight  * success_rate
332    ///        + latency_weight  * (1 / (1 + avg_latency_ms / 1000))
333    /// ```
334    /// The result is clamped to `[0.01, 1.0]`.
335    ///
336    /// Additionally, if the peer's success rate is below
337    /// `config.backpressure_threshold`, the weight is further multiplied by
338    /// `config.failure_penalty`.
339    pub fn compute_weight(&self, metrics: &PeerMetrics) -> f64 {
340        let sr = metrics.success_rate();
341        let avg_lat = metrics.avg_latency_ms();
342
343        // Latency component: approaches 1 for very low latency, 0 for very high.
344        let lat_component = if avg_lat == f64::MAX {
345            0.0
346        } else {
347            1.0 / (1.0 + avg_lat / 1000.0)
348        };
349
350        let mut weight =
351            self.config.success_weight * sr + self.config.latency_weight * lat_component;
352
353        // Apply failure penalty when the peer is under-performing.
354        if sr < self.config.backpressure_threshold {
355            weight *= self.config.failure_penalty;
356        }
357
358        weight.clamp(0.01, 1.0)
359    }
360
361    // ── Schedule recomputation ───────────────────────────────────────────────
362
363    /// Recomputes the entire schedule based on current peer metrics and the
364    /// active backpressure signal.
365    ///
366    /// For each registered peer:
367    /// 1. The weight is computed via [`compute_weight`](Self::compute_weight).
368    /// 2. `allocated = clamp(round(weight × base × bp_factor), min, max)`.
369    ///    When backpressure is [`BackpressureSignal::Overloaded`] the allocation
370    ///    is forced to `0` regardless of the clamp range.
371    /// 3. The [`ScheduleSlot`] in `self.schedule` is updated (or inserted).
372    pub fn recompute_schedule(&mut self, now: u64) {
373        let bp_factor = self.global_backpressure.factor();
374        let base = self.config.base_requests_per_peer as f64;
375        let min_req = self.config.min_requests_per_peer;
376        let max_req = self.config.max_requests_per_peer;
377        let overloaded = matches!(self.global_backpressure, BackpressureSignal::Overloaded);
378
379        // Collect peer IDs + pre-computed weights to avoid borrow conflicts.
380        let peer_data: Vec<(String, f64)> = self
381            .peers
382            .values()
383            .map(|m| (m.peer_id.clone(), self.compute_weight(m)))
384            .collect();
385
386        for (peer_id, weight) in peer_data {
387            let allocated = if overloaded {
388                0u32
389            } else {
390                let raw = (weight * base * bp_factor).round() as u32;
391                raw.clamp(min_req, max_req)
392            };
393
394            let slot = self
395                .schedule
396                .entry(peer_id.clone())
397                .or_insert_with(|| ScheduleSlot {
398                    peer_id: peer_id.clone(),
399                    allocated_requests: 0,
400                    weight: 0.0,
401                    last_updated: now,
402                });
403            slot.allocated_requests = allocated;
404            slot.weight = weight;
405            slot.last_updated = now;
406        }
407
408        // Remove schedule slots whose peer has been evicted.
409        self.schedule
410            .retain(|pid, _| self.peers.contains_key(pid.as_str()));
411    }
412
413    // ── Dispatch helpers ─────────────────────────────────────────────────────
414
415    /// Returns the peer ID with the highest `allocated_requests` in the current
416    /// schedule (among peers with at least one allocated request).
417    ///
418    /// Returns `None` when every slot is at zero (or the schedule is empty).
419    ///
420    /// Also increments `total_requests_dispatched` when a peer is returned.
421    pub fn next_peer(&self) -> Option<&str> {
422        self.schedule
423            .values()
424            .filter(|s| s.allocated_requests > 0)
425            .max_by_key(|s| s.allocated_requests)
426            .map(|s| s.peer_id.as_str())
427    }
428
429    /// Increments the total dispatched counter. Callers should call this once
430    /// they have committed to sending a request to the peer returned by
431    /// [`next_peer`](Self::next_peer).
432    pub fn mark_dispatched(&mut self) {
433        self.total_requests_dispatched = self.total_requests_dispatched.saturating_add(1);
434    }
435
436    // ── Schedule inspection ──────────────────────────────────────────────────
437
438    /// Returns a snapshot of the schedule sorted by `allocated_requests`
439    /// descending.
440    ///
441    /// Each entry is `(peer_id, allocated_requests, weight)`.
442    pub fn peek_schedule(&self) -> Vec<(&str, u32, f64)> {
443        let mut entries: Vec<(&str, u32, f64)> = self
444            .schedule
445            .values()
446            .map(|s| (s.peer_id.as_str(), s.allocated_requests, s.weight))
447            .collect();
448        entries.sort_by_key(|b| std::cmp::Reverse(b.1));
449        entries
450    }
451
452    // ── Stale peer eviction ───────────────────────────────────────────────────
453
454    /// Removes all peers that have not been seen within the last `max_idle_ms`
455    /// milliseconds (i.e. where `now - last_seen > max_idle_ms`).
456    pub fn evict_stale_peers(&mut self, now: u64, max_idle_ms: u64) {
457        let stale: Vec<String> = self
458            .peers
459            .values()
460            .filter(|m| now.saturating_sub(m.last_seen) > max_idle_ms)
461            .map(|m| m.peer_id.clone())
462            .collect();
463
464        for pid in stale {
465            self.peers.remove(&pid);
466            self.schedule.remove(&pid);
467        }
468    }
469
470    // ── Statistics ────────────────────────────────────────────────────────────
471
472    /// Returns a statistics snapshot.
473    pub fn scheduler_stats(&self) -> SchedulerStats {
474        let active_slots = self
475            .schedule
476            .values()
477            .filter(|s| s.allocated_requests > 0)
478            .count();
479
480        let success_rate = if self.total_requests_dispatched == 0 {
481            1.0
482        } else {
483            self.total_requests_succeeded as f64 / self.total_requests_dispatched as f64
484        };
485
486        SchedulerStats {
487            registered_peers: self.peers.len(),
488            active_slots,
489            total_dispatched: self.total_requests_dispatched,
490            total_succeeded: self.total_requests_succeeded,
491            success_rate,
492            backpressure: self.global_backpressure.label().to_string(),
493        }
494    }
495
496    // ── Internal helpers ─────────────────────────────────────────────────────
497
498    /// Returns the ID of the peer with the smallest `last_seen` timestamp.
499    fn oldest_peer_id(&self) -> Option<String> {
500        self.peers
501            .values()
502            .min_by_key(|m| m.last_seen)
503            .map(|m| m.peer_id.clone())
504    }
505}
506
507// ──────────────────────────────────────────────────────────────────────────────
508// Tests
509// ──────────────────────────────────────────────────────────────────────────────
510
511#[cfg(test)]
512mod tests {
513    use super::{
514        AdaptivePeerScheduler, BackpressureSignal, PeerMetrics, ScheduleSlot, SchedulerConfig,
515    };
516
517    // ── Helpers ──────────────────────────────────────────────────────────────
518
519    fn default_scheduler() -> AdaptivePeerScheduler {
520        AdaptivePeerScheduler::new(SchedulerConfig::default())
521    }
522
523    fn scheduler_with_peer(peer_id: &str) -> AdaptivePeerScheduler {
524        let mut s = default_scheduler();
525        s.register_peer(peer_id.to_string(), 0);
526        s
527    }
528
529    // ── PeerMetrics unit tests ────────────────────────────────────────────────
530
531    #[test]
532    fn test_peer_metrics_success_rate_no_activity() {
533        let m = PeerMetrics::new("p1".to_string(), 0);
534        assert_eq!(m.success_rate(), 1.0, "brand-new peer should be 1.0");
535    }
536
537    #[test]
538    fn test_peer_metrics_success_rate_all_success() {
539        let mut m = PeerMetrics::new("p1".to_string(), 0);
540        m.success_count = 5;
541        assert_eq!(m.success_rate(), 1.0);
542    }
543
544    #[test]
545    fn test_peer_metrics_success_rate_mixed() {
546        let mut m = PeerMetrics::new("p1".to_string(), 0);
547        m.success_count = 3;
548        m.failure_count = 1;
549        assert!((m.success_rate() - 0.75).abs() < 1e-9);
550    }
551
552    #[test]
553    fn test_peer_metrics_success_rate_all_failure() {
554        let mut m = PeerMetrics::new("p1".to_string(), 0);
555        m.failure_count = 10;
556        assert_eq!(m.success_rate(), 0.0);
557    }
558
559    #[test]
560    fn test_peer_metrics_avg_latency_no_success() {
561        let m = PeerMetrics::new("p1".to_string(), 0);
562        assert_eq!(m.avg_latency_ms(), f64::MAX);
563    }
564
565    #[test]
566    fn test_peer_metrics_avg_latency_computed() {
567        let mut m = PeerMetrics::new("p1".to_string(), 0);
568        m.success_count = 4;
569        m.total_latency_ms = 200;
570        assert!((m.avg_latency_ms() - 50.0).abs() < 1e-9);
571    }
572
573    // ── SchedulerConfig defaults ──────────────────────────────────────────────
574
575    #[test]
576    fn test_config_defaults() {
577        let c = SchedulerConfig::default();
578        assert_eq!(c.max_peers, 50);
579        assert_eq!(c.base_requests_per_peer, 10);
580        assert_eq!(c.max_requests_per_peer, 100);
581        assert_eq!(c.min_requests_per_peer, 1);
582        assert!((c.backpressure_threshold - 0.8).abs() < 1e-9);
583        assert!((c.failure_penalty - 0.5).abs() < 1e-9);
584        assert!((c.latency_weight - 0.3).abs() < 1e-9);
585        assert!((c.success_weight - 0.7).abs() < 1e-9);
586    }
587
588    // ── BackpressureSignal ────────────────────────────────────────────────────
589
590    #[test]
591    fn test_backpressure_none_factor() {
592        assert_eq!(BackpressureSignal::None.factor(), 1.0);
593    }
594
595    #[test]
596    fn test_backpressure_mild_factor() {
597        let s = BackpressureSignal::Mild { factor: 0.6 };
598        assert!((s.factor() - 0.6).abs() < 1e-9);
599    }
600
601    #[test]
602    fn test_backpressure_severe_factor() {
603        let s = BackpressureSignal::Severe { factor: 0.2 };
604        assert!((s.factor() - 0.2).abs() < 1e-9);
605    }
606
607    #[test]
608    fn test_backpressure_overloaded_factor() {
609        assert_eq!(BackpressureSignal::Overloaded.factor(), 0.0);
610    }
611
612    #[test]
613    fn test_backpressure_labels() {
614        assert_eq!(BackpressureSignal::None.label(), "none");
615        assert_eq!(BackpressureSignal::Mild { factor: 0.5 }.label(), "mild");
616        assert_eq!(BackpressureSignal::Severe { factor: 0.1 }.label(), "severe");
617        assert_eq!(BackpressureSignal::Overloaded.label(), "overloaded");
618    }
619
620    // ── register_peer ─────────────────────────────────────────────────────────
621
622    #[test]
623    fn test_register_peer_basic() {
624        let mut s = default_scheduler();
625        s.register_peer("p1".to_string(), 100);
626        assert_eq!(s.peers.len(), 1);
627        assert!(s.peers.contains_key("p1"));
628    }
629
630    #[test]
631    fn test_register_peer_idempotent() {
632        let mut s = default_scheduler();
633        s.register_peer("p1".to_string(), 100);
634        s.register_peer("p1".to_string(), 200); // should be a no-op
635        assert_eq!(s.peers.len(), 1);
636        // Timestamp should remain at the first registration.
637        assert_eq!(s.peers["p1"].last_seen, 100);
638    }
639
640    #[test]
641    fn test_register_peer_evicts_oldest_when_full() {
642        let config = SchedulerConfig {
643            max_peers: 2,
644            ..SchedulerConfig::default()
645        };
646        let mut s = AdaptivePeerScheduler::new(config);
647        s.register_peer("p1".to_string(), 10);
648        s.register_peer("p2".to_string(), 20);
649        assert_eq!(s.peers.len(), 2);
650        // Adding a third should evict the oldest (p1, last_seen=10).
651        s.register_peer("p3".to_string(), 30);
652        assert_eq!(s.peers.len(), 2);
653        assert!(!s.peers.contains_key("p1"), "oldest peer must be evicted");
654        assert!(s.peers.contains_key("p3"));
655    }
656
657    // ── remove_peer ───────────────────────────────────────────────────────────
658
659    #[test]
660    fn test_remove_peer_known() {
661        let mut s = scheduler_with_peer("p1");
662        assert!(s.remove_peer("p1"));
663        assert!(s.peers.is_empty());
664    }
665
666    #[test]
667    fn test_remove_peer_unknown() {
668        let mut s = default_scheduler();
669        assert!(!s.remove_peer("ghost"));
670    }
671
672    #[test]
673    fn test_remove_peer_clears_schedule_slot() {
674        let mut s = scheduler_with_peer("p1");
675        s.recompute_schedule(0);
676        s.remove_peer("p1");
677        assert!(s.schedule.is_empty());
678    }
679
680    // ── record_success / record_failure ───────────────────────────────────────
681
682    #[test]
683    fn test_record_success_updates_metrics() {
684        let mut s = scheduler_with_peer("p1");
685        s.record_success("p1", 80, 500);
686        let m = &s.peers["p1"];
687        assert_eq!(m.success_count, 1);
688        assert_eq!(m.total_latency_ms, 80);
689        assert_eq!(m.last_seen, 500);
690        assert_eq!(m.consecutive_failures, 0);
691        assert_eq!(s.total_requests_succeeded, 1);
692    }
693
694    #[test]
695    fn test_record_success_ignores_unknown_peer() {
696        let mut s = default_scheduler();
697        s.record_success("ghost", 10, 1000); // must not panic
698        assert_eq!(s.total_requests_succeeded, 0);
699    }
700
701    #[test]
702    fn test_record_failure_updates_metrics() {
703        let mut s = scheduler_with_peer("p1");
704        s.record_failure("p1", 200);
705        let m = &s.peers["p1"];
706        assert_eq!(m.failure_count, 1);
707        assert_eq!(m.consecutive_failures, 1);
708        assert_eq!(m.last_seen, 200);
709    }
710
711    #[test]
712    fn test_record_failure_resets_after_success() {
713        let mut s = scheduler_with_peer("p1");
714        s.record_failure("p1", 100);
715        s.record_failure("p1", 200);
716        s.record_success("p1", 50, 300);
717        assert_eq!(s.peers["p1"].consecutive_failures, 0);
718    }
719
720    #[test]
721    fn test_record_failure_ignores_unknown_peer() {
722        let mut s = default_scheduler();
723        s.record_failure("ghost", 1000); // must not panic
724    }
725
726    // ── compute_weight ────────────────────────────────────────────────────────
727
728    #[test]
729    fn test_compute_weight_new_peer() {
730        let s = default_scheduler();
731        let m = PeerMetrics::new("p1".to_string(), 0);
732        // success_rate = 1.0; avg_latency = MAX → lat_component = 0
733        // weight = 0.7*1.0 + 0.3*0 = 0.7; no penalty; clamped → 0.7
734        let w = s.compute_weight(&m);
735        assert!((w - 0.7).abs() < 1e-9, "got {w}");
736    }
737
738    #[test]
739    fn test_compute_weight_perfect_fast_peer() {
740        let s = default_scheduler();
741        let mut m = PeerMetrics::new("p1".to_string(), 0);
742        m.success_count = 100;
743        m.total_latency_ms = 0; // 0 ms average latency
744                                // lat_component = 1/(1+0) = 1.0
745                                // weight = 0.7*1.0 + 0.3*1.0 = 1.0
746        let w = s.compute_weight(&m);
747        assert!((w - 1.0).abs() < 1e-9, "got {w}");
748    }
749
750    #[test]
751    fn test_compute_weight_clamped_below() {
752        let s = default_scheduler();
753        let mut m = PeerMetrics::new("p1".to_string(), 0);
754        m.failure_count = 1000; // success_rate ≈ 0
755                                // weight ≈ 0, penalty applied, but clamped to 0.01
756        let w = s.compute_weight(&m);
757        assert!(w >= 0.01, "weight must be at least 0.01, got {w}");
758    }
759
760    #[test]
761    fn test_compute_weight_failure_penalty_applied() {
762        let s = default_scheduler();
763        let mut m = PeerMetrics::new("p1".to_string(), 0);
764        // success_rate = 0.5, below threshold 0.8
765        m.success_count = 1;
766        m.failure_count = 1;
767        m.total_latency_ms = 1000; // 1 s avg latency
768        let w = s.compute_weight(&m);
769        // weight before penalty = 0.7*0.5 + 0.3*(1/(1+1)) = 0.35+0.15 = 0.5
770        // after penalty *= 0.5 → 0.25
771        assert!((w - 0.25).abs() < 1e-6, "got {w}");
772    }
773
774    // ── recompute_schedule ────────────────────────────────────────────────────
775
776    #[test]
777    fn test_recompute_schedule_creates_slots() {
778        let mut s = default_scheduler();
779        s.register_peer("p1".to_string(), 0);
780        s.register_peer("p2".to_string(), 0);
781        s.recompute_schedule(100);
782        assert_eq!(s.schedule.len(), 2);
783    }
784
785    #[test]
786    fn test_recompute_schedule_overloaded_zeroes_all() {
787        let mut s = scheduler_with_peer("p1");
788        s.record_success("p1", 10, 50);
789        s.set_backpressure(BackpressureSignal::Overloaded);
790        s.recompute_schedule(100);
791        let slot = &s.schedule["p1"];
792        assert_eq!(slot.allocated_requests, 0);
793    }
794
795    #[test]
796    fn test_recompute_schedule_mild_backpressure() {
797        let mut s = scheduler_with_peer("p1");
798        // Pump up success rate so we get solid weight.
799        for _ in 0..10 {
800            s.record_success("p1", 0, 100);
801        }
802        s.set_backpressure(BackpressureSignal::Mild { factor: 0.5 });
803        s.recompute_schedule(200);
804        let slot = &s.schedule["p1"];
805        // base=10 * factor=0.5 * weight=1.0 = 5
806        assert_eq!(
807            slot.allocated_requests, 5,
808            "got {}",
809            slot.allocated_requests
810        );
811    }
812
813    #[test]
814    fn test_recompute_schedule_no_backpressure_base_alloc() {
815        let mut s = scheduler_with_peer("p1");
816        for _ in 0..10 {
817            s.record_success("p1", 0, 100);
818        }
819        s.recompute_schedule(200);
820        let slot = &s.schedule["p1"];
821        // weight=1.0, bp=1.0, base=10 → 10
822        assert_eq!(slot.allocated_requests, 10);
823    }
824
825    #[test]
826    fn test_recompute_schedule_respects_min_alloc() {
827        let _s = scheduler_with_peer("p1");
828        // Don't pump any metrics; weight=0.7 (new peer)
829        // 0.7*10 = 7, which is > min=1 so min doesn't kick in naturally.
830        // Force min by reducing base.
831        let cfg = SchedulerConfig {
832            base_requests_per_peer: 0,
833            ..SchedulerConfig::default()
834        }; // raw = 0, clamp to min=1
835        let mut s2 = AdaptivePeerScheduler::new(cfg);
836        s2.register_peer("p1".to_string(), 0);
837        s2.recompute_schedule(0);
838        assert_eq!(s2.schedule["p1"].allocated_requests, 1);
839    }
840
841    #[test]
842    fn test_recompute_schedule_respects_max_alloc() {
843        let cfg = SchedulerConfig {
844            base_requests_per_peer: 1000,
845            max_requests_per_peer: 50,
846            ..SchedulerConfig::default()
847        };
848        let mut s = AdaptivePeerScheduler::new(cfg);
849        s.register_peer("p1".to_string(), 0);
850        for _ in 0..100 {
851            s.record_success("p1", 0, 100);
852        }
853        s.recompute_schedule(200);
854        assert!(s.schedule["p1"].allocated_requests <= 50);
855    }
856
857    // ── next_peer ─────────────────────────────────────────────────────────────
858
859    #[test]
860    fn test_next_peer_empty_schedule() {
861        let s = default_scheduler();
862        assert!(s.next_peer().is_none());
863    }
864
865    #[test]
866    fn test_next_peer_all_zero() {
867        let mut s = scheduler_with_peer("p1");
868        s.set_backpressure(BackpressureSignal::Overloaded);
869        s.recompute_schedule(0);
870        assert!(s.next_peer().is_none());
871    }
872
873    #[test]
874    fn test_next_peer_returns_highest_allocation() {
875        let mut s = default_scheduler();
876        s.register_peer("p1".to_string(), 0);
877        s.register_peer("p2".to_string(), 0);
878        // Give p2 a much better track record.
879        for _ in 0..20 {
880            s.record_success("p2", 0, 50);
881        }
882        s.record_failure("p1", 50);
883        s.record_failure("p1", 50);
884        s.recompute_schedule(100);
885        let best = s.next_peer().expect("should have a peer");
886        // p2 must have higher or equal allocation.
887        let p2_alloc = s.schedule["p2"].allocated_requests;
888        let p1_alloc = s.schedule["p1"].allocated_requests;
889        assert!(
890            p2_alloc >= p1_alloc,
891            "p2={p2_alloc} should >= p1={p1_alloc}"
892        );
893        // next_peer must be the one with the highest allocation.
894        assert_eq!(best, "p2");
895    }
896
897    // ── peek_schedule ─────────────────────────────────────────────────────────
898
899    #[test]
900    fn test_peek_schedule_sorted_desc() {
901        let mut s = default_scheduler();
902        for i in 1u32..=5 {
903            let id = format!("p{i}");
904            s.register_peer(id.clone(), 0);
905            for _ in 0..i {
906                s.record_success(&id, 0, 100);
907            }
908        }
909        s.recompute_schedule(200);
910        let view = s.peek_schedule();
911        // Check descending order.
912        for pair in view.windows(2) {
913            assert!(
914                pair[0].1 >= pair[1].1,
915                "not descending: {} < {}",
916                pair[0].1,
917                pair[1].1
918            );
919        }
920    }
921
922    #[test]
923    fn test_peek_schedule_empty() {
924        let s = default_scheduler();
925        assert!(s.peek_schedule().is_empty());
926    }
927
928    // ── evict_stale_peers ─────────────────────────────────────────────────────
929
930    #[test]
931    fn test_evict_stale_peers_removes_old() {
932        let mut s = default_scheduler();
933        s.register_peer("old".to_string(), 0);
934        s.register_peer("fresh".to_string(), 9000);
935        // now=10000, max_idle=5000 → old (10000-0=10000 > 5000) is stale
936        s.evict_stale_peers(10_000, 5_000);
937        assert!(!s.peers.contains_key("old"), "old peer must be evicted");
938        assert!(s.peers.contains_key("fresh"), "fresh peer must remain");
939    }
940
941    #[test]
942    fn test_evict_stale_peers_keeps_recent() {
943        let mut s = default_scheduler();
944        s.register_peer("p1".to_string(), 9500);
945        s.evict_stale_peers(10_000, 5_000);
946        assert!(s.peers.contains_key("p1"));
947    }
948
949    #[test]
950    fn test_evict_stale_peers_clears_schedule_slot() {
951        let mut s = default_scheduler();
952        s.register_peer("old".to_string(), 0);
953        s.recompute_schedule(100);
954        s.evict_stale_peers(10_000, 5_000);
955        assert!(!s.schedule.contains_key("old"));
956    }
957
958    #[test]
959    fn test_evict_stale_peers_exact_boundary() {
960        let mut s = default_scheduler();
961        // last_seen = 5000, now = 10000, max_idle = 5000 → 10000-5000 = 5000, NOT > 5000 → keep
962        s.register_peer("boundary".to_string(), 5000);
963        s.evict_stale_peers(10_000, 5_000);
964        assert!(
965            s.peers.contains_key("boundary"),
966            "exact boundary must be kept"
967        );
968    }
969
970    // ── scheduler_stats ───────────────────────────────────────────────────────
971
972    #[test]
973    fn test_scheduler_stats_empty() {
974        let s = default_scheduler();
975        let st = s.scheduler_stats();
976        assert_eq!(st.registered_peers, 0);
977        assert_eq!(st.active_slots, 0);
978        assert_eq!(st.total_dispatched, 0);
979        assert_eq!(st.total_succeeded, 0);
980        assert!((st.success_rate - 1.0).abs() < 1e-9);
981        assert_eq!(st.backpressure, "none");
982    }
983
984    #[test]
985    fn test_scheduler_stats_after_activity() {
986        let mut s = scheduler_with_peer("p1");
987        s.record_success("p1", 30, 100);
988        s.mark_dispatched();
989        s.recompute_schedule(200);
990        let st = s.scheduler_stats();
991        assert_eq!(st.registered_peers, 1);
992        assert_eq!(st.total_succeeded, 1);
993        assert_eq!(st.total_dispatched, 1);
994        assert!((st.success_rate - 1.0).abs() < 1e-9);
995        assert!(st.active_slots >= 1);
996    }
997
998    #[test]
999    fn test_scheduler_stats_backpressure_label() {
1000        let mut s = default_scheduler();
1001        s.set_backpressure(BackpressureSignal::Severe { factor: 0.1 });
1002        assert_eq!(s.scheduler_stats().backpressure, "severe");
1003    }
1004
1005    // ── mark_dispatched ───────────────────────────────────────────────────────
1006
1007    #[test]
1008    fn test_mark_dispatched_increments_counter() {
1009        let mut s = default_scheduler();
1010        assert_eq!(s.total_requests_dispatched, 0);
1011        s.mark_dispatched();
1012        s.mark_dispatched();
1013        assert_eq!(s.total_requests_dispatched, 2);
1014    }
1015
1016    // ── ScheduleSlot fields ───────────────────────────────────────────────────
1017
1018    #[test]
1019    fn test_schedule_slot_last_updated_set() {
1020        let mut s = scheduler_with_peer("p1");
1021        s.recompute_schedule(9999);
1022        assert_eq!(s.schedule["p1"].last_updated, 9999);
1023    }
1024
1025    #[test]
1026    fn test_schedule_slot_weight_set() {
1027        let mut s = scheduler_with_peer("p1");
1028        s.recompute_schedule(0);
1029        let w = s.schedule["p1"].weight;
1030        assert!((0.01..=1.0).contains(&w), "weight {w} out of range");
1031    }
1032
1033    // ── Eviction + schedule consistency ──────────────────────────────────────
1034
1035    #[test]
1036    fn test_recompute_removes_orphaned_schedule_slots() {
1037        let mut s = default_scheduler();
1038        s.register_peer("p1".to_string(), 0);
1039        s.recompute_schedule(0);
1040        // Manually add an orphaned slot (simulates a race or previous eviction).
1041        s.schedule.insert(
1042            "ghost".to_string(),
1043            ScheduleSlot {
1044                peer_id: "ghost".to_string(),
1045                allocated_requests: 5,
1046                weight: 0.5,
1047                last_updated: 0,
1048            },
1049        );
1050        s.recompute_schedule(100);
1051        assert!(
1052            !s.schedule.contains_key("ghost"),
1053            "orphaned slot must be removed"
1054        );
1055    }
1056
1057    // ── Saturation arithmetic ─────────────────────────────────────────────────
1058
1059    #[test]
1060    fn test_success_count_saturates() {
1061        let mut s = scheduler_with_peer("p1");
1062        s.peers.get_mut("p1").expect("must exist").success_count = u64::MAX;
1063        s.record_success("p1", 0, 0); // must not overflow
1064        assert_eq!(s.peers["p1"].success_count, u64::MAX);
1065    }
1066
1067    #[test]
1068    fn test_failure_count_saturates() {
1069        let mut s = scheduler_with_peer("p1");
1070        s.peers.get_mut("p1").expect("must exist").failure_count = u64::MAX;
1071        s.record_failure("p1", 0); // must not overflow
1072        assert_eq!(s.peers["p1"].failure_count, u64::MAX);
1073    }
1074
1075    // ── Multiple peers competitive allocation ─────────────────────────────────
1076
1077    #[test]
1078    fn test_competitive_allocation_higher_success_wins() {
1079        let mut s = default_scheduler();
1080        s.register_peer("good".to_string(), 0);
1081        s.register_peer("bad".to_string(), 0);
1082
1083        for _ in 0..100 {
1084            s.record_success("good", 10, 100);
1085        }
1086        for _ in 0..100 {
1087            s.record_failure("bad", 100);
1088        }
1089        s.recompute_schedule(200);
1090
1091        let good = s.schedule["good"].allocated_requests;
1092        let bad = s.schedule["bad"].allocated_requests;
1093        assert!(good > bad, "good={good} should > bad={bad}");
1094    }
1095
1096    #[test]
1097    fn test_severe_backpressure_reduces_allocation() {
1098        let mut s = scheduler_with_peer("p1");
1099        for _ in 0..10 {
1100            s.record_success("p1", 0, 100);
1101        }
1102        s.recompute_schedule(200);
1103        let full_alloc = s.schedule["p1"].allocated_requests;
1104
1105        s.set_backpressure(BackpressureSignal::Severe { factor: 0.1 });
1106        s.recompute_schedule(300);
1107        let reduced_alloc = s.schedule["p1"].allocated_requests;
1108
1109        assert!(
1110            reduced_alloc <= full_alloc,
1111            "severe bp should reduce: {reduced_alloc} <= {full_alloc}"
1112        );
1113    }
1114}