Skip to main content

commonware_utils/
sampling.rs

1//! Random sampling with weights.
2
3#[cfg(not(feature = "std"))]
4use alloc::vec::Vec;
5use rand::{Rng, RngExt as _};
6
7/// A collection of weights at fixed indices supporting proportional and uniform draws and updates.
8///
9/// Construction takes `O(n)` time and space. Sampling and changing a weight take `O(log n)` time.
10/// Zero-weight entries are never sampled. Draws use only integer arithmetic, so a fixed sequence
11/// of weights and random input is reproducible across platforms.
12///
13/// # Examples
14///
15/// ```
16/// use commonware_utils::sampling::WeightedIndex;
17/// use rand::{SeedableRng, rngs::StdRng};
18///
19/// let mut rng = StdRng::seed_from_u64(0);
20/// let mut weights = WeightedIndex::new([0, 5]);
21/// assert_eq!(weights.sample(&mut rng), Some(1));
22/// assert_eq!(weights.update(1, |weight| weight * 2), 5);
23/// assert_eq!(weights.weight(1), 10);
24/// weights.set(1, 0);
25/// assert_eq!(weights.sample(&mut rng), None);
26/// ```
27pub struct WeightedIndex {
28    /// Weights in index order.
29    weights: Vec<u64>,
30    /// Fenwick tree in which entry `i` holds the sum of `weights[i & (i + 1)..=i]`.
31    tree: Vec<u128>,
32    /// Number of positive weights in each Fenwick entry's range.
33    counts: Vec<usize>,
34    /// Sum of all weights.
35    total: u128,
36    /// Number of positive weights.
37    positive: usize,
38}
39
40impl WeightedIndex {
41    /// Builds an index over `weights`, preserving their order.
42    pub fn new(weights: impl IntoIterator<Item = u64>) -> Self {
43        let weights: Vec<_> = weights.into_iter().collect();
44        let mut tree: Vec<_> = weights.iter().copied().map(u128::from).collect();
45        let mut counts: Vec<_> = weights
46            .iter()
47            .map(|&weight| usize::from(weight != 0))
48            .collect();
49        let total = tree.iter().sum();
50        let positive = counts.iter().sum();
51
52        // Each entry's range lies inside its parent's range, so one forward pass builds the tree.
53        for index in 0..tree.len() {
54            let parent = index | (index + 1);
55            if parent < tree.len() {
56                tree[parent] += tree[index];
57                counts[parent] += counts[index];
58            }
59        }
60        Self {
61            weights,
62            tree,
63            counts,
64            total,
65            positive,
66        }
67    }
68
69    /// Returns the sum of all weights.
70    pub const fn total(&self) -> u128 {
71        self.total
72    }
73
74    /// Returns the weight at `index`.
75    ///
76    /// # Panics
77    ///
78    /// Panics if `index` is out of bounds.
79    pub fn weight(&self, index: usize) -> u64 {
80        self.weights[index]
81    }
82
83    /// Replaces the weight at `index`, returning its previous value.
84    ///
85    /// # Panics
86    ///
87    /// Panics if `index` is out of bounds.
88    pub fn set(&mut self, index: usize, weight: u64) -> u64 {
89        self.update(index, |_| weight)
90    }
91
92    /// Applies `f` to the weight at `index`, returning its previous value.
93    /// Returning the same weight skips the update.
94    ///
95    /// # Panics
96    ///
97    /// Panics if `index` is out of bounds, before invoking `f`. If `f` panics, `self` is unchanged.
98    pub fn update(&mut self, index: usize, f: impl FnOnce(u64) -> u64) -> u64 {
99        // Evaluate the callback before changing any state. Unchanged weights need no tree walk.
100        let entry = &mut self.weights[index];
101        let previous = *entry;
102        let weight = f(previous);
103        if weight == previous {
104            return previous;
105        }
106        *entry = weight;
107
108        // Every Fenwick entry whose range contains `index` trades the old weight for the new one.
109        let old = u128::from(previous);
110        let new = u128::from(weight);
111        let was_positive = usize::from(previous != 0);
112        let is_positive = usize::from(weight != 0);
113        self.total = self.total - old + new;
114        self.positive = self.positive - was_positive + is_positive;
115        let mut node = index;
116        while node < self.tree.len() {
117            self.tree[node] = self.tree[node] - old + new;
118            if was_positive != is_positive {
119                self.counts[node] = self.counts[node] - was_positive + is_positive;
120            }
121            node |= node + 1;
122        }
123        previous
124    }
125
126    /// Draws an index with probability proportional to its weight.
127    ///
128    /// Returns `None` without consuming randomness when the total weight is zero.
129    pub fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Option<usize> {
130        (self.total != 0).then(|| self.locate::<false>(rng.random_range(0..self.total)))
131    }
132
133    /// Draws uniformly from the indices with positive weight.
134    ///
135    /// Returns `None` without consuming randomness when all weights are zero.
136    pub fn sample_uniform<R: Rng + ?Sized>(&self, rng: &mut R) -> Option<usize> {
137        (self.positive != 0)
138            .then(|| self.locate::<true>(rng.random_range(0..self.positive as u128)))
139    }
140
141    /// Starts a sequence of draws that can choose each positive-weight index at most once.
142    ///
143    /// Dropping the sequence restores all weights, including during unwinding. Construction takes
144    /// constant time without allocating. Each draw takes amortized `O(log n)` time, and `k` draws
145    /// use `O(k)` space. Stopping after the first draw leaves the index unchanged and needs no
146    /// restoration.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use commonware_utils::sampling::WeightedIndex;
152    /// use rand::{SeedableRng, rngs::StdRng};
153    ///
154    /// let mut rng = StdRng::seed_from_u64(0);
155    /// let mut weights = WeightedIndex::new([0, 5, 3]);
156    /// {
157    ///     let mut draws = weights.without_replacement();
158    ///     let first = draws.sample(&mut rng).unwrap();
159    ///     let second = draws.sample_uniform(&mut rng).unwrap();
160    ///     assert_ne!(first, second);
161    ///     assert!(draws.is_empty());
162    /// }
163    /// assert_eq!(weights.total(), 8);
164    /// ```
165    pub const fn without_replacement(&mut self) -> WithoutReplacement<'_> {
166        WithoutReplacement {
167            pool: Pool::Borrowed {
168                index: self,
169                excluded: Vec::new(),
170            },
171            previous: None,
172        }
173    }
174
175    /// Consumes this index for draws that can choose each positive-weight index at most once.
176    ///
177    /// Construction takes constant time without allocating. Each draw takes `O(log n)` time and
178    /// constant extra space, because a consumed index is never restored.
179    pub const fn into_without_replacement(self) -> WithoutReplacement<'static> {
180        WithoutReplacement {
181            pool: Pool::Owned(self),
182            previous: None,
183        }
184    }
185
186    fn locate<const UNIFORM: bool>(&self, mut draw: u128) -> usize {
187        // Descend to the first index whose cumulative weight or positive count exceeds the draw.
188        let mut index = 0;
189        let mut step = 1usize << self.tree.len().ilog2();
190        while step != 0 {
191            let next = index + step;
192            if next <= self.tree.len() {
193                let sum = if UNIFORM {
194                    self.counts[next - 1] as u128
195                } else {
196                    self.tree[next - 1]
197                };
198                if sum <= draw {
199                    draw -= sum;
200                    index = next;
201                }
202            }
203            step >>= 1;
204        }
205        index
206    }
207}
208
209/// Storage for a without-replacement sequence.
210enum Pool<'a> {
211    /// A borrowed index and the weights to restore when the sequence ends.
212    Borrowed {
213        index: &'a mut WeightedIndex,
214        excluded: Vec<(usize, u64)>,
215    },
216    /// An index discarded when the sequence ends.
217    Owned(WeightedIndex),
218}
219
220/// Draws from a [`WeightedIndex`] without repeating an index.
221///
222/// Weighted and uniform draws share the same exclusions. Dropping a sequence over a borrowed index
223/// restores its original weights in `O(k log n)` time, where `k` is the number of draws after the
224/// first. A sequence that owns its index discards it.
225pub struct WithoutReplacement<'a> {
226    /// Index and any weights needed for restoration.
227    pool: Pool<'a>,
228    /// Last draw, excluded only if another draw is requested.
229    previous: Option<usize>,
230}
231
232impl WithoutReplacement<'_> {
233    /// Returns whether every positive-weight index has been drawn.
234    pub fn is_empty(&self) -> bool {
235        let index = match &self.pool {
236            Pool::Borrowed { index, .. } => &**index,
237            Pool::Owned(index) => index,
238        };
239        index.positive == usize::from(self.previous.is_some())
240    }
241
242    /// Draws proportionally to the remaining weights, excluding all previous draws.
243    ///
244    /// Returns `None` without consuming randomness when no indices remain.
245    pub fn sample<R: Rng + ?Sized>(&mut self, rng: &mut R) -> Option<usize> {
246        self.draw::<false, R>(rng)
247    }
248
249    /// Draws uniformly from the remaining positive-weight indices.
250    ///
251    /// Returns `None` without consuming randomness when no indices remain.
252    pub fn sample_uniform<R: Rng + ?Sized>(&mut self, rng: &mut R) -> Option<usize> {
253        self.draw::<true, R>(rng)
254    }
255
256    fn draw<const UNIFORM: bool, R: Rng + ?Sized>(&mut self, rng: &mut R) -> Option<usize> {
257        if self.is_empty() {
258            return None;
259        }
260        let (index, excluded) = match &mut self.pool {
261            Pool::Borrowed { index, excluded } => (&mut **index, Some(excluded)),
262            Pool::Owned(index) => (index, None),
263        };
264
265        // Delay mutation and allocation until a later draw needs the previous entry excluded.
266        if let Some(previous) = self.previous {
267            if let Some(excluded) = excluded {
268                excluded.push((previous, index.weight(previous)));
269            }
270            self.previous = None;
271            index.set(previous, 0);
272        }
273        self.previous = if UNIFORM {
274            index.sample_uniform(rng)
275        } else {
276            index.sample(rng)
277        };
278        self.previous
279    }
280}
281
282impl Drop for WithoutReplacement<'_> {
283    fn drop(&mut self) {
284        if let Pool::Borrowed { index, excluded } = &mut self.pool {
285            for &(position, weight) in excluded.iter() {
286                index.set(position, weight);
287            }
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::TestRng;
296    use std::panic::{AssertUnwindSafe, catch_unwind};
297
298    /// Number of deterministic draws used for distribution checks.
299    const DRAWS: usize = 2_000;
300
301    /// Asserts that `index` holds `weights` and that every weighted interval and uniform rank
302    /// locates its own position.
303    fn assert_intervals(index: &WeightedIndex, weights: &[u64]) {
304        assert_eq!(index.weights, weights);
305        let mut start = 0;
306        let mut positive = 0;
307        for (position, &weight) in weights.iter().enumerate() {
308            let end = start + u128::from(weight);
309            if weight != 0 {
310                assert_eq!(index.locate::<false>(start), position);
311                assert_eq!(index.locate::<false>(end - 1), position);
312                assert_eq!(index.locate::<true>(positive as u128), position);
313                positive += 1;
314            }
315            start = end;
316        }
317        assert_eq!(index.total(), start);
318        assert_eq!(index.positive, positive);
319    }
320
321    #[test]
322    fn weighted_index_updates_match_intervals() {
323        let mut rng = TestRng::new(0);
324        for len in [
325            0, 1, 2, 3, 4, 7, 8, 9, 15, 16, 17, 63, 64, 65, 127, 128, 129,
326        ] {
327            let mut weights: Vec<_> = (0..len).map(|i| [0, 1, u64::MAX][i % 3]).collect();
328            let mut index = WeightedIndex::new(weights.iter().copied());
329            assert_intervals(&index, &weights);
330            for _ in 0..64 {
331                if weights.is_empty() {
332                    break;
333                }
334                let position = rng.random_range(0..weights.len());
335                let weight = [0, 1, u64::MAX, rng.random()][rng.random_range(0..4)];
336                assert_eq!(index.set(position, weight), weights[position]);
337                weights[position] = weight;
338                assert_intervals(&index, &weights);
339            }
340        }
341    }
342
343    #[test]
344    fn weighted_index_update_uses_current_weight() {
345        // Derive a replacement from the current weight and observe one callback invocation.
346        let mut index = WeightedIndex::new([0, 7, u64::MAX]);
347        let mut calls = 0;
348        let previous = index.update(1, |weight| {
349            calls += 1;
350            weight * 2
351        });
352        assert_eq!(previous, 7);
353        assert_eq!(calls, 1);
354        assert_intervals(&index, &[0, 14, u64::MAX]);
355
356        // Identity updates preserve every interval, including zero and maximum weights.
357        for (position, weight) in [0, 14, u64::MAX].into_iter().enumerate() {
358            assert_eq!(index.update(position, core::convert::identity), weight);
359        }
360        assert_intervals(&index, &[0, 14, u64::MAX]);
361
362        // Moving entries between zero and positive weight must update the cumulative sums.
363        assert_eq!(index.update(2, |_| 0), u64::MAX);
364        assert_eq!(index.update(0, |weight| weight + 1), 0);
365        assert_intervals(&index, &[1, 14, 0]);
366    }
367
368    #[test]
369    fn weighted_index_update_panics_preserve_index() {
370        // Bounds checks must reject invalid positions before invoking the callback.
371        for weights in [vec![], vec![0, 7, u64::MAX]] {
372            let mut index = WeightedIndex::new(weights.iter().copied());
373            for position in [weights.len(), usize::MAX] {
374                let mut called = false;
375                let result = catch_unwind(AssertUnwindSafe(|| {
376                    index.update(position, |_| {
377                        called = true;
378                        1
379                    });
380                }));
381                assert!(result.is_err());
382                assert!(!called);
383                assert_intervals(&index, &weights);
384            }
385        }
386
387        // A callback panic must leave both weights and cumulative sums intact.
388        let mut index = WeightedIndex::new([0, 7, u64::MAX]);
389        let result = catch_unwind(AssertUnwindSafe(|| {
390            index.update(1, |_| panic!("callback must not return"));
391        }));
392        assert!(result.is_err());
393        assert_intervals(&index, &[0, 7, u64::MAX]);
394    }
395
396    #[test]
397    fn weighted_index_zero_total_does_not_draw() {
398        let mut rng = TestRng::new(0);
399        let mut reference = TestRng::new(0);
400        for weights in [vec![], vec![0], vec![0; 65]] {
401            let mut index = WeightedIndex::new(weights);
402            assert_eq!(index.sample(&mut rng), None);
403            assert_eq!(index.sample_uniform(&mut rng), None);
404            let mut draws = index.without_replacement();
405            assert!(draws.is_empty());
406            assert_eq!(draws.sample(&mut rng), None);
407            assert_eq!(draws.sample_uniform(&mut rng), None);
408        }
409        assert_eq!(rng.random::<[u64; 4]>(), reference.random::<[u64; 4]>());
410        let mut index = WeightedIndex::new([0, 7, 0]);
411        assert_eq!(index.sample(&mut rng), Some(1));
412        index.set(1, 0);
413        assert_eq!(index.sample(&mut rng), None);
414    }
415
416    #[test]
417    fn uniform_draws_match_unit_weights() {
418        for weights in [vec![0, 1, 0], vec![u64::MAX, 0, 1, 7], vec![9; 65]] {
419            let index = WeightedIndex::new(weights.iter().copied());
420            let uniform = WeightedIndex::new(weights.iter().map(|&weight| u64::from(weight != 0)));
421            let mut rng = TestRng::new(0);
422            let mut reference = TestRng::new(0);
423            for _ in 0..DRAWS {
424                assert_eq!(
425                    index.sample_uniform(&mut rng),
426                    uniform.sample(&mut reference)
427                );
428            }
429            assert_eq!(rng.random::<[u64; 4]>(), reference.random::<[u64; 4]>());
430        }
431    }
432
433    /// Checks a sequence against independently excluded weighted and unit-weight indices.
434    /// `mode` 0 draws by weight, 1 draws uniformly, and 2 alternates, starting by weight.
435    fn assert_without_replacement(
436        mut draws: WithoutReplacement<'_>,
437        weights: &[u64],
438        stop_after: usize,
439        mode: usize,
440    ) {
441        let mut weighted = WeightedIndex::new(weights.iter().copied());
442        let mut uniform = WeightedIndex::new(weights.iter().map(|&weight| u64::from(weight != 0)));
443        let mut rng = TestRng::new(0);
444        let mut reference = TestRng::new(0);
445        for step in 0..stop_after {
446            assert!(!draws.is_empty());
447            let (actual, expected) = if mode == 1 || (mode == 2 && step % 2 == 1) {
448                (
449                    draws.sample_uniform(&mut rng),
450                    uniform.sample(&mut reference),
451                )
452            } else {
453                (draws.sample(&mut rng), weighted.sample(&mut reference))
454            };
455            assert_eq!(actual, expected);
456            let selected = expected.unwrap();
457            weighted.set(selected, 0);
458            uniform.set(selected, 0);
459        }
460        assert_eq!(draws.is_empty(), weighted.total() == 0);
461        if draws.is_empty() {
462            assert_eq!(draws.sample(&mut rng), None);
463            assert_eq!(draws.sample_uniform(&mut rng), None);
464        }
465        assert_eq!(rng.random::<[u64; 4]>(), reference.random::<[u64; 4]>());
466    }
467
468    #[test]
469    fn without_replacement_matches_exclusion_and_restores_weights() {
470        for weights in [
471            vec![],
472            vec![0, 0],
473            vec![0, 1, 0],
474            vec![u64::MAX, 1, 0, 7, u64::MAX],
475            vec![1; 65],
476        ] {
477            let positive = weights.iter().filter(|&&weight| weight != 0).count();
478            for stop_after in 0..=positive {
479                for mode in 0..3 {
480                    let mut index = WeightedIndex::new(weights.iter().copied());
481                    assert_without_replacement(
482                        index.without_replacement(),
483                        &weights,
484                        stop_after,
485                        mode,
486                    );
487                    assert_intervals(&index, &weights);
488                    assert_without_replacement(
489                        index.into_without_replacement(),
490                        &weights,
491                        stop_after,
492                        mode,
493                    );
494                }
495            }
496        }
497    }
498
499    #[test]
500    fn without_replacement_restores_weights_on_unwind() {
501        let weights = [u64::MAX, 1, 0, 7];
502        let mut index = WeightedIndex::new(weights);
503        let result = catch_unwind(AssertUnwindSafe(|| {
504            let mut draws = index.without_replacement();
505            let mut rng = TestRng::new(0);
506            assert!(draws.sample(&mut rng).is_some());
507            assert!(draws.sample_uniform(&mut rng).is_some());
508            panic!("unwind with an unfinished selection");
509        }));
510        assert!(result.is_err());
511        assert_intervals(&index, &weights);
512    }
513
514    /// Counts how often each index is sampled over `DRAWS` draws, one seed per draw.
515    fn sample_counts<const N: usize>(weights: [u64; N]) -> [usize; N] {
516        let index = WeightedIndex::new(weights);
517        let mut counts = [0; N];
518        for seed in 0..DRAWS as u64 {
519            counts[index.sample(&mut TestRng::new(seed)).unwrap()] += 1;
520        }
521        counts
522    }
523
524    #[test]
525    fn weighted_index_prefers_heavier_weights() {
526        let counts = sample_counts([0, 9, 1, 0]);
527        assert_eq!(counts[0], 0);
528        assert_eq!(counts[3], 0);
529        assert!(
530            (DRAWS * 85 / 100..DRAWS * 95 / 100).contains(&counts[1]),
531            "{counts:?}"
532        );
533    }
534
535    #[test]
536    fn weighted_index_equal_weights_are_uniform() {
537        let counts = sample_counts([7, 7, 7]);
538        for count in counts {
539            assert!((DRAWS / 4..DRAWS * 5 / 12).contains(&count), "{counts:?}");
540        }
541    }
542}