hopr_utils/statistics/
weighted.rs1use rand::RngExt;
2
3pub struct WeightedCollection<T> {
20 items: Vec<(T, f64)>,
21 total_weight: f64,
23}
24
25impl<T> From<Vec<(T, f64)>> for WeightedCollection<T> {
26 fn from(items: Vec<(T, f64)>) -> Self {
27 let total_weight: f64 = items.iter().map(|(_, w)| w.max(0.0)).sum();
28 Self { items, total_weight }
29 }
30}
31
32impl<T> WeightedCollection<T> {
33 pub fn new(items: Vec<(T, f64)>) -> Self {
35 Self::from(items)
36 }
37
38 pub fn is_empty(&self) -> bool {
40 self.items.is_empty()
41 }
42
43 pub fn len(&self) -> usize {
45 self.items.len()
46 }
47
48 pub fn iter(&self) -> impl Iterator<Item = &(T, f64)> {
50 self.items.iter()
51 }
52
53 pub fn total_weight(&self) -> f64 {
55 self.total_weight
56 }
57
58 pub fn probability_of(&self, weight: f64) -> f64 {
64 if self.total_weight > 0.0 && weight > 0.0 {
65 weight / self.total_weight
66 } else {
67 0.0
68 }
69 }
70}
71
72impl<T> WeightedCollection<T> {
73 pub fn pick_index(&self) -> Option<usize> {
78 if self.total_weight <= 0.0 {
79 return None;
80 }
81
82 if self.items.len() == 1 {
83 return Some(0);
84 }
85
86 let mut rng = rand::rng();
87 let r = rng.random_range(0.0..self.total_weight);
88 let mut cumulative = 0.0;
89 for (i, (_, weight)) in self.items.iter().enumerate() {
90 cumulative += weight.max(0.0);
91 if r < cumulative {
92 return Some(i);
93 }
94 }
95
96 self.items.iter().rposition(|(_, weight)| *weight > 0.0)
98 }
99}
100
101impl<T> WeightedCollection<T> {
102 pub fn pick_ref(&self) -> Option<&T> {
107 self.pick_index().map(|i| &self.items[i].0)
108 }
109}
110
111impl<T: Clone> WeightedCollection<T> {
112 pub fn pick_one(&self) -> Option<T> {
116 self.pick_ref().cloned()
117 }
118}
119
120impl<T> WeightedCollection<T> {
121 pub fn into_shuffled(self) -> Vec<T> {
128 let mut rng = rand::rng();
129
130 let mut keyed = self.items;
132 for (_, weight) in keyed.iter_mut() {
133 *weight = if *weight > 0.0 {
134 let u: f64 = rng.random_range(f64::EPSILON..1.0);
135 u.powf(1.0 / *weight)
136 } else {
137 0.0
138 };
139 }
140
141 keyed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
142 keyed.into_iter().map(|(item, _)| item).collect()
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn pick_one_returns_none_for_empty_collection() {
152 let wc: WeightedCollection<&str> = WeightedCollection::from(vec![]);
153 assert!(wc.pick_one().is_none());
154 }
155
156 #[test]
157 fn pick_one_returns_sole_item_with_positive_weight() {
158 let wc = WeightedCollection::from(vec![("only", 1.0)]);
159 assert_eq!(wc.pick_one(), Some("only"));
160 }
161
162 #[test]
163 fn pick_one_returns_none_for_sole_item_with_non_positive_weight() {
164 let wc = WeightedCollection::from(vec![("only", 0.0)]);
165 assert!(wc.pick_one().is_none());
166 let wc = WeightedCollection::from(vec![("only", -1.0)]);
167 assert!(wc.pick_one().is_none());
168 }
169
170 #[test]
171 fn pick_one_favors_higher_weight() {
172 let wc = WeightedCollection::from(vec![("low", 0.01), ("high", 100.0)]);
173 let mut high_count = 0;
174 let trials = 1000;
175 for _ in 0..trials {
176 if wc.pick_one() == Some("high") {
177 high_count += 1;
178 }
179 }
180 assert!(
181 high_count > trials * 9 / 10,
182 "high-weight item should be picked >90% of the time, was {high_count}/{trials}"
183 );
184 }
185
186 #[test]
187 fn pick_one_returns_none_for_all_non_positive_weights() {
188 let wc = WeightedCollection::from(vec![("a", 0.0), ("b", -1.0)]);
189 assert!(wc.pick_one().is_none());
190 }
191
192 #[test]
193 fn pick_index_returns_valid_index() {
194 let wc = WeightedCollection::from(vec![("a", 1.0), ("b", 2.0), ("c", 3.0)]);
195 for _ in 0..100 {
196 let idx = wc.pick_index().expect("should pick an index");
197 assert!(idx < 3);
198 }
199 }
200
201 #[test]
202 fn pick_index_returns_none_for_non_positive_weights() {
203 let wc = WeightedCollection::from(vec![("a", 0.0), ("b", -5.0)]);
204 assert!(wc.pick_index().is_none());
205 }
206
207 #[test]
208 fn shuffled_preserves_all_items() {
209 let items: Vec<(u32, f64)> = (0..10).map(|i| (i, (i as f64 + 1.0) * 0.1)).collect();
210 let shuffled = WeightedCollection::from(items).into_shuffled();
211 assert_eq!(shuffled.len(), 10);
212 let mut sorted = shuffled.clone();
213 sorted.sort();
214 assert_eq!(sorted, (0..10).collect::<Vec<_>>());
215 }
216
217 #[test]
218 fn shuffled_favors_higher_weight_items() {
219 let items = vec![("low", 0.1), ("high", 10.0)];
220 let mut high_first_count = 0;
221 let trials = 1000;
222 for _ in 0..trials {
223 let shuffled = WeightedCollection::from(items.clone()).into_shuffled();
224 if shuffled[0] == "high" {
225 high_first_count += 1;
226 }
227 }
228 assert!(
229 high_first_count > trials * 8 / 10,
230 "high-weight item should appear first >80% of the time, was {high_first_count}/{trials}"
231 );
232 }
233
234 #[test]
235 fn shuffled_empty_collection_returns_empty() {
236 let wc: WeightedCollection<&str> = WeightedCollection::from(vec![]);
237 assert!(wc.into_shuffled().is_empty());
238 }
239}