Skip to main content

hopr_utils/statistics/
weighted.rs

1use rand::RngExt;
2
3/// A collection of items with associated weights for probabilistic selection.
4///
5/// Weights must be positive (`> 0.0`); items with non-positive weights are
6/// treated as having zero probability for [`pick_one`](Self::pick_one) /
7/// [`pick_index`](Self::pick_index) and are placed at the end of the shuffled
8/// output in [`into_shuffled`](Self::into_shuffled).
9///
10/// # Examples
11///
12/// ```rust
13/// use hopr_utils::statistics::WeightedCollection;
14///
15/// let wc = WeightedCollection::from(vec![("rare", 0.1), ("common", 10.0)]);
16/// let picked = wc.pick_one().expect("non-empty collection");
17/// assert!(picked == "rare" || picked == "common");
18/// ```
19pub struct WeightedCollection<T> {
20    items: Vec<(T, f64)>,
21    /// Pre-computed sum of positive weights (cached to avoid recomputing on every pick).
22    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    /// Create a collection from pre-weighted items.
34    pub fn new(items: Vec<(T, f64)>) -> Self {
35        Self::from(items)
36    }
37
38    /// Returns `true` if the collection contains no items.
39    pub fn is_empty(&self) -> bool {
40        self.items.is_empty()
41    }
42
43    /// Returns the number of items in the collection.
44    pub fn len(&self) -> usize {
45        self.items.len()
46    }
47
48    /// Iterates over `(item, weight)` pairs.
49    pub fn iter(&self) -> impl Iterator<Item = &(T, f64)> {
50        self.items.iter()
51    }
52
53    /// Returns the sum of positive weights in this collection.
54    pub fn total_weight(&self) -> f64 {
55        self.total_weight
56    }
57
58    /// Returns the sampling probability that an entry carrying `weight` would
59    /// receive in this collection — i.e. `weight.max(0.0) / total_positive_weight`.
60    ///
61    /// Returns `0.0` if `weight <= 0.0` or if the collection holds no positive-weight
62    /// entries (empty or fully non-positive collection).
63    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    /// Returns the index of a randomly selected item, weighted by probability
74    /// proportional to its weight.
75    ///
76    /// Returns `None` if the collection is empty or all weights are non-positive.
77    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        // Floating-point edge case: return the last positive-weight item.
97        self.items.iter().rposition(|(_, weight)| *weight > 0.0)
98    }
99}
100
101impl<T> WeightedCollection<T> {
102    /// Pick a reference to one item at random, with probability proportional
103    /// to its weight.
104    ///
105    /// Returns `None` if the collection is empty or all weights are non-positive.
106    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    /// Pick one item at random, with probability proportional to its weight.
113    ///
114    /// Returns `None` if the collection is empty or all weights are non-positive.
115    pub fn pick_one(&self) -> Option<T> {
116        self.pick_ref().cloned()
117    }
118}
119
120impl<T> WeightedCollection<T> {
121    /// Consume the collection and return items in a weighted random permutation.
122    ///
123    /// Uses the Efraimidis–Spirakis algorithm: each item is assigned a key
124    /// `random()^(1/weight)` and the items are sorted by descending key.
125    /// Higher-weight items appear earlier with higher probability, but all
126    /// items retain a nonzero chance of appearing at any position.
127    pub fn into_shuffled(self) -> Vec<T> {
128        let mut rng = rand::rng();
129
130        // Reuse the existing Vec — replace weights with Efraimidis–Spirakis keys in-place.
131        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}