Skip to main content

irontide_session/
choker.rs

1use std::net::SocketAddr;
2
3use serde::{Deserialize, Serialize};
4
5// ---------------------------------------------------------------------------
6// Algorithm enums
7// ---------------------------------------------------------------------------
8
9/// Choking algorithm used when we are seeding.
10#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum SeedChokingAlgorithm {
13    /// Unchoke peers we upload to fastest.
14    #[default]
15    FastestUpload,
16    /// Round-robin through all interested peers.
17    RoundRobin,
18    /// Prefer leechers over seeds (anti-leech).
19    AntiLeech,
20}
21
22/// Top-level choking algorithm variant.
23#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ChokingAlgorithm {
26    /// Fixed number of unchoke slots (libtorrent default).
27    #[default]
28    FixedSlots,
29    /// Rate-based unchoking (auto-adjusts slots).
30    RateBased,
31}
32
33// ---------------------------------------------------------------------------
34// PeerInfo
35// ---------------------------------------------------------------------------
36
37/// Information about a peer used by the choking algorithm.
38#[derive(Debug, Clone)]
39pub(crate) struct PeerInfo {
40    pub addr: SocketAddr,
41    /// Bytes/sec they are uploading TO us.
42    pub download_rate: u64,
43    /// Bytes/sec we are uploading TO them.
44    pub upload_rate: u64,
45    /// Peer is interested in our data.
46    pub interested: bool,
47    /// BEP 21: peer declared upload-only status.
48    pub upload_only: bool,
49    /// Whether this peer is a seed (has all pieces).
50    pub is_seed: bool,
51}
52
53// ---------------------------------------------------------------------------
54// ChokeDecision
55// ---------------------------------------------------------------------------
56
57/// Result of a choking decision.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub(crate) struct ChokeDecision {
60    /// Peers that should be unchoked.
61    pub to_unchoke: Vec<SocketAddr>,
62    /// Peers that should be choked.
63    pub to_choke: Vec<SocketAddr>,
64}
65
66// ---------------------------------------------------------------------------
67// ChokerStrategy trait
68// ---------------------------------------------------------------------------
69
70/// Trait for pluggable choking strategies.
71pub(crate) trait ChokerStrategy: Send + Sync {
72    /// Given the current peer list, decide who to unchoke/choke.
73    fn decide(
74        &mut self,
75        peers: &[PeerInfo],
76        unchoke_slots: usize,
77        seed_mode: bool,
78    ) -> ChokeDecision;
79
80    /// Rotate the optimistic unchoke peer.
81    fn rotate_optimistic(&mut self, peers: &[PeerInfo]);
82
83    /// Observe current throughput for rate-based slot adjustment.
84    /// No-op by default (fixed-slots strategies ignore this).
85    fn observe_throughput(&mut self, _throughput: u64) {}
86
87    /// Return the dynamically computed slot count, if this strategy manages its own.
88    /// Returns `None` by default (fixed-slots strategies defer to the `Choker`'s `unchoke_slots`).
89    #[allow(dead_code)] // Part of the trait API; called by RateBasedStrategy but not dispatched externally yet.
90    fn dynamic_slots(&self) -> Option<usize> {
91        None
92    }
93}
94
95// ---------------------------------------------------------------------------
96// FixedSlotsStrategy
97// ---------------------------------------------------------------------------
98
99/// Fixed-slots choking strategy.
100///
101/// Leech mode: tit-for-tat (sort by download rate descending).
102/// Seed mode: configurable via [`SeedChokingAlgorithm`].
103pub(crate) struct FixedSlotsStrategy {
104    optimistic_peer: Option<SocketAddr>,
105    seed_algorithm: SeedChokingAlgorithm,
106    round_robin_offset: usize,
107}
108
109impl FixedSlotsStrategy {
110    pub fn new(seed_algorithm: SeedChokingAlgorithm) -> Self {
111        Self {
112            optimistic_peer: None,
113            seed_algorithm,
114            round_robin_offset: 0,
115        }
116    }
117
118    /// Select an optimistic unchoke peer.
119    ///
120    /// If the current optimistic peer is interested and not in the regular set,
121    /// keep it. Otherwise pick the first interested peer not in the regular set.
122    fn select_optimistic(
123        &self,
124        interested: &[&PeerInfo],
125        regular_unchokes: &[SocketAddr],
126    ) -> Option<SocketAddr> {
127        // Keep existing optimistic peer if it qualifies.
128        if let Some(opt) = self.optimistic_peer {
129            let still_interested = interested.iter().any(|p| p.addr == opt && !p.upload_only);
130            let already_regular = regular_unchokes.contains(&opt);
131            if still_interested && !already_regular {
132                return Some(opt);
133            }
134        }
135
136        // Pick first interested peer not already in regular unchokes (exclude upload-only).
137        interested
138            .iter()
139            .find(|p| !regular_unchokes.contains(&p.addr) && !p.upload_only)
140            .map(|p| p.addr)
141    }
142
143    /// Sort interested peers according to the seed-mode algorithm.
144    fn sort_seed_mode(&mut self, interested: &mut [&PeerInfo], unchoke_slots: usize) {
145        match self.seed_algorithm {
146            SeedChokingAlgorithm::FastestUpload => {
147                interested.sort_by_key(|p| std::cmp::Reverse(p.upload_rate));
148            }
149            SeedChokingAlgorithm::RoundRobin => {
150                // Sort by addr for a deterministic order, then rotate.
151                interested.sort_by_key(|p| p.addr);
152                if !interested.is_empty() {
153                    let offset = self.round_robin_offset % interested.len();
154                    interested.rotate_left(offset);
155                    self.round_robin_offset =
156                        self.round_robin_offset.wrapping_add(unchoke_slots) % interested.len();
157                }
158            }
159            SeedChokingAlgorithm::AntiLeech => {
160                // Non-seed peers first (sorted by upload rate desc), then seeds.
161                interested.sort_by(|a, b| match (a.is_seed, b.is_seed) {
162                    (false, true) => std::cmp::Ordering::Less,
163                    (true, false) => std::cmp::Ordering::Greater,
164                    _ => b.upload_rate.cmp(&a.upload_rate),
165                });
166            }
167        }
168    }
169}
170
171impl ChokerStrategy for FixedSlotsStrategy {
172    fn decide(
173        &mut self,
174        peers: &[PeerInfo],
175        unchoke_slots: usize,
176        seed_mode: bool,
177    ) -> ChokeDecision {
178        let all_addrs: Vec<SocketAddr> = peers.iter().map(|p| p.addr).collect();
179
180        // Interested peers sorted by rate descending.
181        let mut interested: Vec<&PeerInfo> = peers.iter().filter(|p| p.interested).collect();
182        if seed_mode {
183            self.sort_seed_mode(&mut interested, unchoke_slots);
184        } else {
185            // Leech mode: unchoke peers that upload to us fastest (tit-for-tat)
186            interested.sort_by_key(|p| std::cmp::Reverse(p.download_rate));
187        }
188
189        // Regular unchokes: top N.
190        let regular_count = unchoke_slots.min(interested.len());
191        let regular_unchokes: Vec<SocketAddr> =
192            interested[..regular_count].iter().map(|p| p.addr).collect();
193
194        // Optimistic unchoke selection.
195        let optimistic = self.select_optimistic(&interested, &regular_unchokes);
196        self.optimistic_peer = optimistic;
197
198        // Build the final unchoke set.
199        let mut to_unchoke = regular_unchokes;
200        if let Some(opt) = optimistic
201            && !to_unchoke.contains(&opt)
202        {
203            to_unchoke.push(opt);
204        }
205
206        // Everyone not in to_unchoke gets choked.
207        let to_choke: Vec<SocketAddr> = all_addrs
208            .into_iter()
209            .filter(|a| !to_unchoke.contains(a))
210            .collect();
211
212        ChokeDecision {
213            to_unchoke,
214            to_choke,
215        }
216    }
217
218    fn rotate_optimistic(&mut self, peers: &[PeerInfo]) {
219        let mut interested: Vec<&PeerInfo> = peers
220            .iter()
221            .filter(|p| p.interested && !p.upload_only)
222            .collect();
223        // Sort ascending by download rate so the first non-optimistic is picked.
224        interested.sort_by_key(|p| p.download_rate);
225
226        self.optimistic_peer = interested
227            .iter()
228            .find(|p| Some(p.addr) != self.optimistic_peer)
229            .map(|p| p.addr);
230    }
231}
232
233// ---------------------------------------------------------------------------
234// RateBasedStrategy
235// ---------------------------------------------------------------------------
236
237/// Rate-based choking strategy that dynamically adjusts unchoke slots
238/// based on observed throughput.
239///
240/// When throughput increases, adds slots to utilize available bandwidth.
241/// When throughput drops significantly (>10%), removes slots to reduce
242/// overhead. Respects configured min/max bounds and upload rate limits.
243pub(crate) struct RateBasedStrategy {
244    /// Underlying fixed-slots strategy for peer ranking and optimistic unchoke.
245    inner: FixedSlotsStrategy,
246    /// Current number of dynamically-adjusted unchoke slots.
247    dynamic_slots: usize,
248    /// Previous throughput observation (bytes/sec).
249    prev_throughput: u64,
250    /// Upload rate limit (bytes/sec). 0 means unlimited.
251    upload_rate_limit: u64,
252    /// Minimum number of unchoke slots.
253    min_slots: usize,
254    /// Maximum number of unchoke slots.
255    max_slots: usize,
256}
257
258impl RateBasedStrategy {
259    pub fn new(
260        seed_algorithm: SeedChokingAlgorithm,
261        upload_rate_limit: u64,
262        min_slots: usize,
263        max_slots: usize,
264    ) -> Self {
265        Self {
266            inner: FixedSlotsStrategy::new(seed_algorithm),
267            dynamic_slots: min_slots.max(2),
268            prev_throughput: 0,
269            upload_rate_limit,
270            min_slots,
271            max_slots,
272        }
273    }
274
275    /// Observe current aggregate throughput and adjust slot count.
276    fn observe_throughput_inner(&mut self, throughput: u64) {
277        // First observation with throughput > 0: just set baseline.
278        if self.prev_throughput == 0 && throughput > 0 {
279            self.prev_throughput = throughput;
280            return;
281        }
282
283        if throughput > self.prev_throughput {
284            // Throughput increased — consider adding a slot.
285            // But not if we're already at capacity (>90% of upload rate limit).
286            let at_capacity =
287                self.upload_rate_limit > 0 && throughput > self.upload_rate_limit * 90 / 100;
288
289            if !at_capacity && self.dynamic_slots < self.max_slots {
290                self.dynamic_slots += 1;
291            }
292        } else if self.prev_throughput > 0 {
293            // Check for >10% drop.
294            let threshold = self.prev_throughput * 90 / 100;
295            if throughput < threshold && self.dynamic_slots > self.min_slots {
296                self.dynamic_slots -= 1;
297            }
298        }
299
300        // Check for headroom: if we have spare capacity, try adding a slot.
301        if self.upload_rate_limit > 0
302            && throughput < self.upload_rate_limit
303            && self.dynamic_slots > 0
304        {
305            let per_slot_avg = throughput / self.dynamic_slots as u64;
306            if per_slot_avg > 0 {
307                let headroom = self.upload_rate_limit - throughput;
308                if headroom > per_slot_avg && self.dynamic_slots < self.max_slots {
309                    self.dynamic_slots += 1;
310                }
311            }
312        }
313
314        self.prev_throughput = throughput;
315    }
316
317    /// Return the current dynamically-adjusted slot count.
318    #[cfg(test)]
319    pub fn current_slots(&self) -> usize {
320        self.dynamic_slots
321    }
322}
323
324impl ChokerStrategy for RateBasedStrategy {
325    fn decide(
326        &mut self,
327        peers: &[PeerInfo],
328        _unchoke_slots: usize,
329        seed_mode: bool,
330    ) -> ChokeDecision {
331        // Ignore external unchoke_slots — use our dynamic count.
332        self.inner.decide(peers, self.dynamic_slots, seed_mode)
333    }
334
335    fn rotate_optimistic(&mut self, peers: &[PeerInfo]) {
336        self.inner.rotate_optimistic(peers);
337    }
338
339    fn observe_throughput(&mut self, throughput: u64) {
340        self.observe_throughput_inner(throughput);
341    }
342
343    fn dynamic_slots(&self) -> Option<usize> {
344        Some(self.dynamic_slots)
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Choker (dispatcher)
350// ---------------------------------------------------------------------------
351
352/// Choking algorithm dispatcher.
353///
354/// Delegates to a pluggable [`ChokerStrategy`] implementation. Backward
355/// compatible: `Choker::new(n)` creates a fixed-slots strategy with
356/// `FastestUpload` seed algorithm (same behavior as the original `Choker`).
357pub(crate) struct Choker {
358    strategy: Box<dyn ChokerStrategy>,
359    unchoke_slots: usize,
360    seed_mode: bool,
361    #[allow(dead_code)] // Read via #[cfg(test)] accessor.
362    choking_algorithm: ChokingAlgorithm,
363}
364
365impl Choker {
366    /// Create a choker with the given number of regular unchoke slots.
367    ///
368    /// Uses [`FixedSlotsStrategy`] with [`SeedChokingAlgorithm::FastestUpload`],
369    /// matching the behavior of the original monolithic `Choker`.
370    #[cfg(test)]
371    pub fn new(unchoke_slots: usize) -> Self {
372        Self {
373            strategy: Box::new(FixedSlotsStrategy::new(SeedChokingAlgorithm::FastestUpload)),
374            unchoke_slots,
375            seed_mode: false,
376            choking_algorithm: ChokingAlgorithm::FixedSlots,
377        }
378    }
379
380    /// Create a choker with explicit algorithm configuration.
381    pub fn with_algorithms(
382        unchoke_slots: usize,
383        seed_algorithm: SeedChokingAlgorithm,
384        choking_algorithm: ChokingAlgorithm,
385        upload_rate_limit: u64,
386        min_slots: usize,
387        max_slots: usize,
388    ) -> Self {
389        let strategy: Box<dyn ChokerStrategy> = match choking_algorithm {
390            ChokingAlgorithm::FixedSlots => Box::new(FixedSlotsStrategy::new(seed_algorithm)),
391            ChokingAlgorithm::RateBased => Box::new(RateBasedStrategy::new(
392                seed_algorithm,
393                upload_rate_limit,
394                min_slots,
395                max_slots,
396            )),
397        };
398
399        Self {
400            strategy,
401            unchoke_slots,
402            seed_mode: false,
403            choking_algorithm,
404        }
405    }
406
407    pub fn set_seed_mode(&mut self, seed_mode: bool) {
408        self.seed_mode = seed_mode;
409    }
410
411    /// Update the number of regular unchoke slots (used by auto upload slot tuner).
412    pub fn set_unchoke_slots(&mut self, n: usize) {
413        self.unchoke_slots = n;
414    }
415
416    /// Return the current number of regular unchoke slots.
417    #[allow(dead_code)] // Used by make_stats() in a later task.
418    pub fn unchoke_slots(&self) -> usize {
419        self.unchoke_slots
420    }
421
422    /// Observe current aggregate throughput for rate-based slot adjustment.
423    pub fn observe_throughput(&mut self, throughput: u64) {
424        self.strategy.observe_throughput(throughput);
425    }
426
427    /// Return the choking algorithm variant.
428    #[cfg(test)]
429    pub fn choking_algorithm(&self) -> ChokingAlgorithm {
430        self.choking_algorithm
431    }
432
433    /// Decide which peers to unchoke and choke.
434    pub fn decide(&mut self, peers: &[PeerInfo]) -> ChokeDecision {
435        self.strategy
436            .decide(peers, self.unchoke_slots, self.seed_mode)
437    }
438
439    /// Pick a new optimistic peer from interested peers.
440    pub fn rotate_optimistic(&mut self, peers: &[PeerInfo]) {
441        self.strategy.rotate_optimistic(peers);
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn addr(port: u16) -> SocketAddr {
450        format!("127.0.0.1:{port}").parse().unwrap()
451    }
452
453    fn peer(port: u16, download_rate: u64, interested: bool) -> PeerInfo {
454        PeerInfo {
455            addr: addr(port),
456            download_rate,
457            upload_rate: 0,
458            interested,
459            upload_only: false,
460            is_seed: false,
461        }
462    }
463
464    fn seed_peer(port: u16, upload_rate: u64, interested: bool) -> PeerInfo {
465        PeerInfo {
466            addr: addr(port),
467            download_rate: 0,
468            upload_rate,
469            interested,
470            upload_only: false,
471            is_seed: false,
472        }
473    }
474
475    // -----------------------------------------------------------------------
476    // Existing Choker tests (unchanged logic, updated PeerInfo construction)
477    // -----------------------------------------------------------------------
478
479    #[test]
480    fn unchoke_top_n() {
481        let mut choker = Choker::new(4);
482        let peers = vec![
483            peer(6881, 100, true),
484            peer(6882, 500, true),
485            peer(6883, 300, true),
486            peer(6884, 200, true),
487            peer(6885, 400, true),
488            peer(6886, 50, true),
489        ];
490
491        let decision = choker.decide(&peers);
492
493        // Top 4 by rate: 500, 400, 300, 200 (ports 6882, 6885, 6883, 6884).
494        assert!(decision.to_unchoke.contains(&addr(6882)));
495        assert!(decision.to_unchoke.contains(&addr(6885)));
496        assert!(decision.to_unchoke.contains(&addr(6883)));
497        assert!(decision.to_unchoke.contains(&addr(6884)));
498
499        // Optimistic should add one more from the remaining interested peers,
500        // so total unchoked should be 5 (4 regular + 1 optimistic).
501        assert_eq!(decision.to_unchoke.len(), 5);
502
503        // The remaining peer is choked.
504        assert_eq!(decision.to_choke.len(), 1);
505    }
506
507    #[test]
508    fn optimistic_rotation() {
509        let mut choker = Choker::new(4);
510        let peers = vec![
511            peer(6881, 500, true),
512            peer(6882, 400, true),
513            peer(6883, 300, true),
514            peer(6884, 200, true),
515            peer(6885, 100, true),
516            peer(6886, 50, true),
517        ];
518
519        let decision = choker.decide(&peers);
520
521        // After decide(), there should be an optimistic peer among the unchoked.
522        // The top 4 are 6881..6884. Optimistic is from remaining interested.
523        assert_eq!(decision.to_unchoke.len(), 5);
524
525        // The optimistic peer is one of the two not in the top-4 regular set.
526        let regular = [addr(6881), addr(6882), addr(6883), addr(6884)];
527        let opt: Vec<_> = decision
528            .to_unchoke
529            .iter()
530            .filter(|a| !regular.contains(a))
531            .copied()
532            .collect();
533        assert_eq!(opt.len(), 1);
534        let first_opt = opt[0];
535        assert!(first_opt == addr(6885) || first_opt == addr(6886));
536
537        // After rotation, the optimistic peer should change.
538        choker.rotate_optimistic(&peers);
539        // Run decide again to reflect the new optimistic.
540        let decision2 = choker.decide(&peers);
541        let opt2: Vec<_> = decision2
542            .to_unchoke
543            .iter()
544            .filter(|a| !regular.contains(a))
545            .copied()
546            .collect();
547        assert_eq!(opt2.len(), 1);
548        assert_ne!(opt2[0], first_opt);
549    }
550
551    #[test]
552    fn fewer_peers_than_slots() {
553        let mut choker = Choker::new(4);
554        let peers = vec![peer(6881, 100, true), peer(6882, 200, true)];
555
556        let decision = choker.decide(&peers);
557
558        // Both interested peers should be unchoked.
559        assert!(decision.to_unchoke.contains(&addr(6881)));
560        assert!(decision.to_unchoke.contains(&addr(6882)));
561        assert_eq!(decision.to_unchoke.len(), 2);
562        assert!(decision.to_choke.is_empty());
563    }
564
565    #[test]
566    fn no_interested_peers() {
567        let mut choker = Choker::new(4);
568        let peers = vec![
569            peer(6881, 100, false),
570            peer(6882, 200, false),
571            peer(6883, 300, false),
572        ];
573
574        let decision = choker.decide(&peers);
575
576        assert!(decision.to_unchoke.is_empty());
577        // All peers should be in to_choke.
578        assert_eq!(decision.to_choke.len(), 3);
579        assert!(decision.to_choke.contains(&addr(6881)));
580        assert!(decision.to_choke.contains(&addr(6882)));
581        assert!(decision.to_choke.contains(&addr(6883)));
582    }
583
584    #[test]
585    fn choke_below_threshold() {
586        let mut choker = Choker::new(2);
587        let peers = vec![
588            peer(6881, 500, true),
589            peer(6882, 400, true),
590            peer(6883, 100, true),
591            peer(6884, 50, true),
592            peer(6885, 200, false), // not interested
593        ];
594
595        let decision = choker.decide(&peers);
596
597        // Regular unchokes: top 2 = ports 6881 (500), 6882 (400).
598        assert!(decision.to_unchoke.contains(&addr(6881)));
599        assert!(decision.to_unchoke.contains(&addr(6882)));
600
601        // Optimistic adds one more interested peer (6883 or 6884).
602        assert_eq!(decision.to_unchoke.len(), 3);
603
604        // The non-unchoked peers should be in to_choke.
605        // That's 2 from the remaining: one interested + the uninterested peer.
606        assert_eq!(decision.to_choke.len(), 2);
607        // Uninterested peer is always choked.
608        assert!(decision.to_choke.contains(&addr(6885)));
609    }
610
611    #[test]
612    fn set_unchoke_slots_changes_capacity() {
613        let mut choker = Choker::new(2);
614        let peers = vec![
615            peer(6881, 500, true),
616            peer(6882, 400, true),
617            peer(6883, 300, true),
618            peer(6884, 200, true),
619            peer(6885, 100, true),
620        ];
621
622        let decision = choker.decide(&peers);
623        // 2 regular + 1 optimistic = 3 unchoked
624        assert_eq!(decision.to_unchoke.len(), 3);
625
626        // Increase slots to 4
627        choker.set_unchoke_slots(4);
628        let decision = choker.decide(&peers);
629        // 4 regular + 1 optimistic = 5 unchoked
630        assert_eq!(decision.to_unchoke.len(), 5);
631    }
632
633    // M224: G2-choker — confirm `set_unchoke_slots(n)` caps the regular
634    // unchoke set at `n`. This is the precise contract
635    // `Settings.max_uploads_per_torrent` relies on when propagated through
636    // `handle_update_settings`.
637    #[test]
638    fn set_unchoke_slots_caps_regular_set_at_m224_value() {
639        // 10 interested peers with strictly decreasing download rates so
640        // tit-for-tat ordering is unambiguous.
641        let peers: Vec<PeerInfo> = (0u16..10)
642            .map(|i| peer(6881 + i, 1_000 - u64::from(i) * 100, true))
643            .collect();
644
645        // Cap = 1 → exactly 1 regular + 1 optimistic = 2 unchoked.
646        let mut choker = Choker::new(1);
647        let decision = choker.decide(&peers);
648        assert_eq!(
649            decision.to_unchoke.len(),
650            2,
651            "cap=1 must yield 1 regular + 1 optimistic"
652        );
653
654        // Bump cap to 3 → 3 regular + 1 optimistic = 4 unchoked.
655        choker.set_unchoke_slots(3);
656        let decision = choker.decide(&peers);
657        assert_eq!(
658            decision.to_unchoke.len(),
659            4,
660            "cap=3 must yield 3 regular + 1 optimistic"
661        );
662
663        // Bump cap to 8 → 8 regular + 1 optimistic = 9 unchoked.
664        choker.set_unchoke_slots(8);
665        let decision = choker.decide(&peers);
666        assert_eq!(
667            decision.to_unchoke.len(),
668            9,
669            "cap=8 must yield 8 regular + 1 optimistic"
670        );
671    }
672
673    #[test]
674    fn seed_mode_unchokes_by_upload_rate() {
675        let mut choker = Choker::new(2);
676        choker.set_seed_mode(true);
677
678        // In seed mode, peers are ranked by upload_rate (how fast we upload TO them)
679        let peers = vec![
680            seed_peer(6881, 100, true),
681            seed_peer(6882, 500, true),
682            seed_peer(6883, 300, true),
683            seed_peer(6884, 200, true),
684        ];
685
686        let decision = choker.decide(&peers);
687
688        // Top 2 by upload rate: 500 (6882), 300 (6883)
689        assert!(decision.to_unchoke.contains(&addr(6882)));
690        assert!(decision.to_unchoke.contains(&addr(6883)));
691
692        // Plus one optimistic
693        assert_eq!(decision.to_unchoke.len(), 3);
694    }
695
696    #[test]
697    fn upload_only_excluded_from_optimistic() {
698        let mut choker = Choker::new(2);
699        let peers = vec![
700            peer(6881, 500, true),
701            peer(6882, 400, true),
702            // These two are interested but upload-only — should never get optimistic slot
703            PeerInfo {
704                addr: addr(6883),
705                download_rate: 100,
706                upload_rate: 0,
707                interested: true,
708                upload_only: true,
709                is_seed: false,
710            },
711            PeerInfo {
712                addr: addr(6884),
713                download_rate: 50,
714                upload_rate: 0,
715                interested: true,
716                upload_only: true,
717                is_seed: false,
718            },
719        ];
720
721        let decision = choker.decide(&peers);
722
723        // Regular unchokes: 6881, 6882 (top 2 by rate)
724        // No optimistic: both remaining interested peers are upload-only
725        assert_eq!(decision.to_unchoke.len(), 2);
726        assert!(decision.to_unchoke.contains(&addr(6881)));
727        assert!(decision.to_unchoke.contains(&addr(6882)));
728
729        // Upload-only peers are choked
730        assert!(decision.to_choke.contains(&addr(6883)));
731        assert!(decision.to_choke.contains(&addr(6884)));
732    }
733
734    #[test]
735    fn upload_only_still_regular_unchoked() {
736        // In seed mode, upload-only peers can earn regular unchoke by upload rate
737        let mut choker = Choker::new(2);
738        choker.set_seed_mode(true);
739
740        let peers = vec![
741            PeerInfo {
742                addr: addr(6881),
743                download_rate: 0,
744                upload_rate: 500,
745                interested: true,
746                upload_only: true, // upload-only but high upload rate
747                is_seed: false,
748            },
749            seed_peer(6882, 300, true),
750            seed_peer(6883, 100, true),
751        ];
752
753        let decision = choker.decide(&peers);
754
755        // Upload-only peer at 6881 has highest upload rate, should be in regular unchokes
756        assert!(decision.to_unchoke.contains(&addr(6881)));
757        assert!(decision.to_unchoke.contains(&addr(6882)));
758    }
759
760    // -----------------------------------------------------------------------
761    // New tests: algorithm enums
762    // -----------------------------------------------------------------------
763
764    #[test]
765    fn seed_choking_algorithm_default() {
766        assert_eq!(
767            SeedChokingAlgorithm::default(),
768            SeedChokingAlgorithm::FastestUpload
769        );
770    }
771
772    #[test]
773    fn choking_algorithm_default() {
774        assert_eq!(ChokingAlgorithm::default(), ChokingAlgorithm::FixedSlots);
775    }
776
777    #[test]
778    fn seed_choking_algorithm_serde_round_trip() {
779        for variant in [
780            SeedChokingAlgorithm::FastestUpload,
781            SeedChokingAlgorithm::RoundRobin,
782            SeedChokingAlgorithm::AntiLeech,
783        ] {
784            let json = serde_json::to_string(&variant).unwrap();
785            let decoded: SeedChokingAlgorithm = serde_json::from_str(&json).unwrap();
786            assert_eq!(decoded, variant);
787        }
788    }
789
790    #[test]
791    fn choking_algorithm_serde_round_trip() {
792        for variant in [ChokingAlgorithm::FixedSlots, ChokingAlgorithm::RateBased] {
793            let json = serde_json::to_string(&variant).unwrap();
794            let decoded: ChokingAlgorithm = serde_json::from_str(&json).unwrap();
795            assert_eq!(decoded, variant);
796        }
797    }
798
799    // -----------------------------------------------------------------------
800    // New tests: FixedSlotsStrategy
801    // -----------------------------------------------------------------------
802
803    #[test]
804    fn fixed_slots_strategy_leech_mode() {
805        let mut strategy = FixedSlotsStrategy::new(SeedChokingAlgorithm::FastestUpload);
806        let peers = vec![
807            peer(6881, 100, true),
808            peer(6882, 500, true),
809            peer(6883, 300, true),
810            peer(6884, 200, true),
811            peer(6885, 400, true),
812            peer(6886, 50, true),
813        ];
814
815        let decision = strategy.decide(&peers, 4, false);
816
817        // Top 4 by download_rate: 500 (6882), 400 (6885), 300 (6883), 200 (6884).
818        assert!(decision.to_unchoke.contains(&addr(6882)));
819        assert!(decision.to_unchoke.contains(&addr(6885)));
820        assert!(decision.to_unchoke.contains(&addr(6883)));
821        assert!(decision.to_unchoke.contains(&addr(6884)));
822
823        // 4 regular + 1 optimistic = 5
824        assert_eq!(decision.to_unchoke.len(), 5);
825        assert_eq!(decision.to_choke.len(), 1);
826    }
827
828    #[test]
829    fn fixed_slots_round_robin_rotates() {
830        let mut strategy = FixedSlotsStrategy::new(SeedChokingAlgorithm::RoundRobin);
831        // 5 interested peers, 2 unchoke slots — should rotate through them.
832        let peers = vec![
833            seed_peer(6881, 100, true),
834            seed_peer(6882, 200, true),
835            seed_peer(6883, 300, true),
836            seed_peer(6884, 400, true),
837            seed_peer(6885, 500, true),
838        ];
839
840        let d1 = strategy.decide(&peers, 2, true);
841        let d2 = strategy.decide(&peers, 2, true);
842
843        // The two rounds should select different regular unchoke sets because
844        // the offset advances by unchoke_slots each round.
845        // Extract the first two from each (regular unchokes before optimistic).
846        // We just need to verify they are not identical sets.
847        let set1 = d1.to_unchoke;
848        let set2 = d2.to_unchoke;
849        assert_ne!(set1, set2, "round-robin should rotate unchoke set");
850    }
851
852    #[test]
853    fn fixed_slots_anti_leech_prefers_non_seeds() {
854        let mut strategy = FixedSlotsStrategy::new(SeedChokingAlgorithm::AntiLeech);
855        let peers = vec![
856            // Two seed peers (is_seed = true)
857            PeerInfo {
858                addr: addr(6881),
859                download_rate: 0,
860                upload_rate: 500,
861                interested: true,
862                upload_only: false,
863                is_seed: true,
864            },
865            PeerInfo {
866                addr: addr(6882),
867                download_rate: 0,
868                upload_rate: 400,
869                interested: true,
870                upload_only: false,
871                is_seed: true,
872            },
873            // Two leecher peers (is_seed = false) with lower upload rates
874            PeerInfo {
875                addr: addr(6883),
876                download_rate: 0,
877                upload_rate: 100,
878                interested: true,
879                upload_only: false,
880                is_seed: false,
881            },
882            PeerInfo {
883                addr: addr(6884),
884                download_rate: 0,
885                upload_rate: 50,
886                interested: true,
887                upload_only: false,
888                is_seed: false,
889            },
890        ];
891
892        // 2 slots: anti-leech should prefer the 2 non-seed peers over seeds.
893        let decision = strategy.decide(&peers, 2, true);
894
895        // The regular unchokes should be the two leechers (non-seeds).
896        assert!(
897            decision.to_unchoke.contains(&addr(6883)),
898            "non-seed peer 6883 should be unchoked"
899        );
900        assert!(
901            decision.to_unchoke.contains(&addr(6884)),
902            "non-seed peer 6884 should be unchoked"
903        );
904    }
905
906    // -----------------------------------------------------------------------
907    // New tests: RateBasedStrategy
908    // -----------------------------------------------------------------------
909
910    #[test]
911    fn rate_based_starts_at_min_slots() {
912        let strategy = RateBasedStrategy::new(
913            SeedChokingAlgorithm::FastestUpload,
914            0,  // unlimited
915            3,  // min_slots
916            10, // max_slots
917        );
918        // min_slots=3, max(3,2)=3
919        assert_eq!(strategy.current_slots(), 3);
920
921        // If min_slots < 2, should start at 2.
922        let strategy2 = RateBasedStrategy::new(
923            SeedChokingAlgorithm::FastestUpload,
924            0,
925            1, // min_slots=1
926            10,
927        );
928        assert_eq!(strategy2.current_slots(), 2);
929    }
930
931    #[test]
932    fn rate_based_increases_slots_on_throughput_increase() {
933        let mut strategy = RateBasedStrategy::new(
934            SeedChokingAlgorithm::FastestUpload,
935            0,  // unlimited
936            2,  // min_slots
937            10, // max_slots
938        );
939        assert_eq!(strategy.current_slots(), 2);
940
941        // First observation sets baseline.
942        strategy.observe_throughput_inner(1000);
943        assert_eq!(strategy.current_slots(), 2);
944
945        // Throughput increased — should add a slot.
946        strategy.observe_throughput_inner(1500);
947        assert_eq!(strategy.current_slots(), 3);
948    }
949
950    #[test]
951    fn rate_based_decreases_slots_on_throughput_drop() {
952        let mut strategy = RateBasedStrategy::new(
953            SeedChokingAlgorithm::FastestUpload,
954            0,  // unlimited
955            2,  // min_slots
956            10, // max_slots
957        );
958
959        // Build up to 4 slots.
960        strategy.observe_throughput_inner(1000);
961        strategy.observe_throughput_inner(2000);
962        assert_eq!(strategy.current_slots(), 3);
963        strategy.observe_throughput_inner(3000);
964        assert_eq!(strategy.current_slots(), 4);
965
966        // Drop >10%: 3000 -> 2000 (33% drop).
967        strategy.observe_throughput_inner(2000);
968        assert_eq!(strategy.current_slots(), 3);
969    }
970
971    #[test]
972    fn rate_based_respects_min_max() {
973        let mut strategy = RateBasedStrategy::new(
974            SeedChokingAlgorithm::FastestUpload,
975            0, // unlimited
976            2, // min_slots
977            3, // max_slots = 3
978        );
979
980        // Build up.
981        strategy.observe_throughput_inner(1000);
982        strategy.observe_throughput_inner(2000);
983        assert_eq!(strategy.current_slots(), 3);
984
985        // Try to exceed max — should stay at 3.
986        strategy.observe_throughput_inner(5000);
987        assert_eq!(strategy.current_slots(), 3);
988
989        // Drop to bring down.
990        strategy.observe_throughput_inner(1000);
991        assert_eq!(strategy.current_slots(), 2);
992
993        // Drop more — should not go below min.
994        strategy.observe_throughput_inner(100);
995        assert_eq!(strategy.current_slots(), 2);
996    }
997
998    #[test]
999    fn rate_based_does_not_add_at_capacity() {
1000        let mut strategy = RateBasedStrategy::new(
1001            SeedChokingAlgorithm::FastestUpload,
1002            10_000, // 10 KB/s limit
1003            2,
1004            10,
1005        );
1006
1007        // Baseline.
1008        strategy.observe_throughput_inner(5_000);
1009        assert_eq!(strategy.current_slots(), 2);
1010
1011        // Throughput increased but >90% of capacity (9500/10000 = 95%).
1012        strategy.observe_throughput_inner(9_500);
1013        // Should NOT add slot because we're at capacity.
1014        // However, we might still be at 2 because throughput increase check
1015        // fails the at_capacity guard. The headroom check also shouldn't fire
1016        // because headroom (500) < per_slot_avg (4750).
1017        assert_eq!(strategy.current_slots(), 2);
1018    }
1019
1020    #[test]
1021    fn rate_based_ignores_external_unchoke_slots() {
1022        let mut strategy = RateBasedStrategy::new(SeedChokingAlgorithm::FastestUpload, 0, 2, 10);
1023
1024        let peers = vec![
1025            peer(6881, 500, true),
1026            peer(6882, 400, true),
1027            peer(6883, 300, true),
1028            peer(6884, 200, true),
1029            peer(6885, 100, true),
1030        ];
1031
1032        // dynamic_slots = 2 (min). Pass unchoke_slots=10 externally — should be ignored.
1033        let decision = strategy.decide(&peers, 10, false);
1034
1035        // Should use dynamic_slots=2, not the external 10.
1036        // 2 regular + 1 optimistic = 3 unchoked.
1037        assert_eq!(decision.to_unchoke.len(), 3);
1038    }
1039
1040    // -----------------------------------------------------------------------
1041    // New tests: Choker dispatcher
1042    // -----------------------------------------------------------------------
1043
1044    #[test]
1045    fn choker_with_round_robin() {
1046        let mut choker = Choker::with_algorithms(
1047            2,
1048            SeedChokingAlgorithm::RoundRobin,
1049            ChokingAlgorithm::FixedSlots,
1050            0,
1051            2,
1052            10,
1053        );
1054        choker.set_seed_mode(true);
1055
1056        let peers = vec![
1057            seed_peer(6881, 100, true),
1058            seed_peer(6882, 200, true),
1059            seed_peer(6883, 300, true),
1060            seed_peer(6884, 400, true),
1061            seed_peer(6885, 500, true),
1062        ];
1063
1064        let d1 = choker.decide(&peers);
1065        let d2 = choker.decide(&peers);
1066
1067        // Round-robin should produce different unchoke sets on successive calls.
1068        let set1 = d1.to_unchoke;
1069        let set2 = d2.to_unchoke;
1070        assert_ne!(set1, set2, "round-robin dispatcher should rotate");
1071    }
1072
1073    #[test]
1074    fn choker_with_rate_based() {
1075        let mut choker = Choker::with_algorithms(
1076            4, // this should be ignored by rate-based
1077            SeedChokingAlgorithm::FastestUpload,
1078            ChokingAlgorithm::RateBased,
1079            0,
1080            2,
1081            10,
1082        );
1083        assert_eq!(choker.choking_algorithm(), ChokingAlgorithm::RateBased);
1084
1085        let peers = vec![
1086            peer(6881, 500, true),
1087            peer(6882, 400, true),
1088            peer(6883, 300, true),
1089            peer(6884, 200, true),
1090            peer(6885, 100, true),
1091        ];
1092
1093        // Rate-based starts at min_slots=2, so 2 regular + 1 optimistic = 3.
1094        let decision = choker.decide(&peers);
1095        assert_eq!(decision.to_unchoke.len(), 3);
1096
1097        // Feed throughput to increase slots.
1098        choker.observe_throughput(1000);
1099        choker.observe_throughput(2000);
1100        // Now dynamic_slots should be 3 (increased).
1101        let decision = choker.decide(&peers);
1102        assert_eq!(decision.to_unchoke.len(), 4); // 3 regular + 1 optimistic
1103    }
1104
1105    #[test]
1106    fn choker_unchoke_slots_getter() {
1107        let mut choker = Choker::new(4);
1108        assert_eq!(choker.unchoke_slots(), 4);
1109
1110        choker.set_unchoke_slots(7);
1111        assert_eq!(choker.unchoke_slots(), 7);
1112
1113        choker.set_unchoke_slots(0);
1114        assert_eq!(choker.unchoke_slots(), 0);
1115    }
1116
1117    #[test]
1118    fn choker_new_is_backward_compatible() {
1119        let mut choker = Choker::new(4);
1120        assert_eq!(choker.choking_algorithm(), ChokingAlgorithm::FixedSlots);
1121
1122        let peers = vec![
1123            peer(6881, 500, true),
1124            peer(6882, 400, true),
1125            peer(6883, 300, true),
1126            peer(6884, 200, true),
1127            peer(6885, 100, true),
1128            peer(6886, 50, true),
1129        ];
1130
1131        let decision = choker.decide(&peers);
1132
1133        // Same behavior as old Choker: top 4 + 1 optimistic = 5.
1134        assert_eq!(decision.to_unchoke.len(), 5);
1135        assert!(decision.to_unchoke.contains(&addr(6882)));
1136        assert!(decision.to_unchoke.contains(&addr(6885)));
1137        assert!(decision.to_unchoke.contains(&addr(6883)));
1138        assert!(decision.to_unchoke.contains(&addr(6884)));
1139
1140        // set_seed_mode and set_unchoke_slots still work.
1141        choker.set_seed_mode(true);
1142        choker.set_unchoke_slots(2);
1143        let decision = choker.decide(&peers);
1144        assert_eq!(decision.to_unchoke.len(), 3); // 2 regular + 1 optimistic
1145    }
1146}