1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#![allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
reason = "M175: hill-climbing slot tuner — rates and slot counts bounded by realistic peer counts"
)]
//! Automatic upload slot tuning via hill-climbing.
//!
//! Observes aggregate upload throughput per unchoke interval and adjusts the
//! number of unchoke slots to maximize throughput.
/// Automatic upload slot tuner using hill-climbing optimization.
///
/// Every unchoke interval (10s), `observe(throughput)` is called with the total
/// upload bytes for that interval. The tuner adjusts the slot count:
/// - If throughput improved: continue in the same direction
/// - If throughput decreased: reverse direction
/// - Clamp to `[min_slots, max_slots]`
#[allow(dead_code)] // consumed by torrent module (wired in later tasks)
pub(crate) struct SlotTuner {
slots: usize,
min_slots: usize,
max_slots: usize,
prev_throughput: u64,
direction: i8, // +1 = increasing, -1 = decreasing
enabled: bool,
}
#[allow(dead_code)]
impl SlotTuner {
/// Create a new tuner starting at `initial` slots, bounded by `[min, max]`.
pub fn new(initial: usize, min: usize, max: usize) -> Self {
Self {
slots: initial.clamp(min, max),
min_slots: min,
max_slots: max,
prev_throughput: 0,
direction: 1, // start by trying to increase
enabled: true,
}
}
/// Create a disabled tuner that always returns a fixed slot count.
pub fn disabled(slots: usize) -> Self {
Self {
slots,
min_slots: slots,
max_slots: slots,
prev_throughput: 0,
direction: 0,
enabled: false,
}
}
/// Current number of unchoke slots.
pub fn current_slots(&self) -> usize {
self.slots
}
/// Observe upload throughput for the latest interval and adjust slots.
///
/// Call this once per unchoke interval with the total bytes uploaded.
pub fn observe(&mut self, throughput: u64) {
if !self.enabled {
return;
}
// First observation: just record baseline, don't adjust
if self.prev_throughput == 0 && throughput > 0 {
self.prev_throughput = throughput;
return;
}
// Compare to previous interval
if throughput > self.prev_throughput {
// Throughput improved: continue in same direction
self.apply_direction();
} else if throughput < self.prev_throughput {
// Throughput decreased: reverse direction
self.direction = -self.direction;
self.apply_direction();
}
// If throughput == prev_throughput: hold steady (do nothing)
self.prev_throughput = throughput;
}
fn apply_direction(&mut self) {
let new_slots = self.slots as i64 + i64::from(self.direction);
self.slots = (new_slots as usize).clamp(self.min_slots, self.max_slots);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_starts_at_initial_slots() {
let tuner = SlotTuner::new(4, 2, 20);
assert_eq!(tuner.current_slots(), 4);
}
#[test]
fn increases_slots_when_throughput_improves() {
let mut tuner = SlotTuner::new(4, 2, 20);
tuner.observe(100_000); // first observation (baseline)
tuner.observe(120_000); // throughput improved → increase slots
assert_eq!(tuner.current_slots(), 5);
}
#[test]
fn decreases_slots_when_throughput_drops() {
let mut tuner = SlotTuner::new(4, 2, 20);
tuner.observe(100_000); // baseline
tuner.observe(120_000); // improved → 5 slots, direction = +1
assert_eq!(tuner.current_slots(), 5);
tuner.observe(90_000); // dropped → reverse direction to -1, apply → 4
assert_eq!(tuner.current_slots(), 4);
}
#[test]
fn respects_min_max_bounds() {
let mut tuner = SlotTuner::new(2, 2, 3);
// Can't go below min even with decreasing throughput
tuner.observe(100_000);
tuner.observe(50_000); // would decrease, but direction reverses and applies
assert!(tuner.current_slots() >= 2);
// Fill up to max
let mut tuner = SlotTuner::new(3, 2, 3);
tuner.observe(100_000);
tuner.observe(200_000); // increase
assert!(tuner.current_slots() <= 3);
}
#[test]
fn disabled_returns_fixed_slots() {
let mut tuner = SlotTuner::disabled(4);
assert_eq!(tuner.current_slots(), 4);
tuner.observe(100_000);
tuner.observe(200_000);
assert_eq!(tuner.current_slots(), 4); // unchanged
}
#[test]
fn stagnant_throughput_holds_steady() {
let mut tuner = SlotTuner::new(4, 2, 20);
tuner.observe(100_000);
tuner.observe(100_000); // same → no change
assert_eq!(tuner.current_slots(), 4);
}
}