Skip to main content

sum_segment_tree/
lib.rs

1//! A fixed-capacity sum tree for weighted sampling, the structure used in
2//! prioritized experience replay.
3//!
4//! Priorities live in the leaves of a complete binary tree packed into a flat
5//! array. Every internal node stores the sum of its two children, so the total
6//! weight and "find the leaf at cumulative weight `s`" are both
7//! `O(log capacity)`. Writes are a ring buffer: once full, the oldest leaf is
8//! overwritten.
9
10/// A binary sum tree holding exactly `capacity` leaves.
11///
12/// The backing tree is padded up to a power of two internally; the extra
13/// padding leaves stay at zero weight and are never returned by
14/// [`SumTree::get`]. The exposed [`SumTree::capacity`] is exactly what you
15/// requested.
16#[derive(Clone, Debug)]
17pub struct SumTree {
18    // 1-indexed heap of length `2 * size`. `nodes[1]` is the root (total).
19    // Leaf `i` lives at `nodes[size + i]`.
20    nodes: Vec<f32>,
21    // Number of leaf slots in the padded tree, a power of two >= capacity.
22    size: usize,
23    capacity: usize,
24    write: usize,
25    len: usize,
26}
27
28impl SumTree {
29    /// Create a tree that holds exactly `capacity` leaves (at least one).
30    pub fn new(capacity: usize) -> Self {
31        let capacity = capacity.max(1);
32        let size = capacity.next_power_of_two();
33        SumTree {
34            nodes: vec![0.0; 2 * size],
35            size,
36            capacity,
37            write: 0,
38            len: 0,
39        }
40    }
41
42    /// Number of leaves the tree can hold.
43    pub fn capacity(&self) -> usize {
44        self.capacity
45    }
46
47    /// Number of leaves currently written.
48    pub fn len(&self) -> usize {
49        self.len
50    }
51
52    pub fn is_empty(&self) -> bool {
53        self.len == 0
54    }
55
56    pub fn is_full(&self) -> bool {
57        self.len == self.capacity
58    }
59
60    /// Sum of every priority in the tree.
61    #[inline]
62    pub fn total(&self) -> f32 {
63        self.nodes[1]
64    }
65
66    /// Read the priority stored at `index`.
67    #[inline]
68    pub fn priority(&self, index: usize) -> f32 {
69        assert!(index < self.capacity, "index {index} out of bounds");
70        self.nodes[self.size + index]
71    }
72
73    /// Set the priority at `index` and repair the sums up to the root.
74    ///
75    /// Ancestors are adjusted by the delta rather than recomputed from both
76    /// children, halving the memory traffic per level. Over very many updates
77    /// this can accumulate floating-point drift; call [`SumTree::rebuild`] to
78    /// reset it.
79    #[inline]
80    pub fn update(&mut self, index: usize, priority: f32) {
81        assert!(index < self.capacity, "index {index} out of bounds");
82        assert!(priority >= 0.0, "priority must be non-negative");
83        let nodes = &mut self.nodes;
84        // `nodes.len()` is a power of two, so `idx & mask` is a no-op for our
85        // in-range indices while proving `idx < len` to the compiler, which
86        // drops the bounds checks without any `unsafe`.
87        let mask = nodes.len() - 1;
88        let mut i = index + self.size;
89        let delta = priority - nodes[i & mask];
90        nodes[i & mask] = priority;
91        while i > 1 {
92            i >>= 1;
93            nodes[i & mask] += delta;
94        }
95    }
96
97    /// Recompute every internal sum from the leaves, clearing any drift left by
98    /// repeated [`SumTree::update`] calls.
99    pub fn rebuild(&mut self) {
100        for i in (1..self.size).rev() {
101            self.nodes[i] = self.nodes[2 * i] + self.nodes[2 * i + 1];
102        }
103    }
104
105    /// Append a priority at the next ring position, overwriting the oldest leaf
106    /// when full. Returns the leaf index that was written.
107    #[inline]
108    pub fn push(&mut self, priority: f32) -> usize {
109        let index = self.write;
110        self.update(index, priority);
111        self.write = (self.write + 1) % self.capacity;
112        if self.len < self.capacity {
113            self.len += 1;
114        }
115        index
116    }
117
118    /// Find the leaf whose cumulative-weight interval contains `s`, returning
119    /// its index and priority. `s` is clamped to `[0, total]`. Returns `None`
120    /// when the tree is empty or its total weight is zero.
121    ///
122    /// The returned index is always in `[0, len)`, so it is safe to use it in a
123    /// side buffer sized to [`SumTree::capacity`].
124    #[inline]
125    pub fn get(&self, s: f32) -> Option<(usize, f32)> {
126        let total = self.nodes[1];
127        if self.len == 0 || total <= 0.0 {
128            return None;
129        }
130
131        let mut s = if s.is_nan() { 0.0 } else { s.clamp(0.0, total) };
132        let size = self.size;
133        let nodes = self.nodes.as_slice();
134
135        // `nodes.len()` is a power of two, so `idx & mask` is a no-op for our
136        // in-range indices while proving `idx < len` to the compiler, which
137        // elides the per-access bounds checks without any `unsafe`.
138        let mask = nodes.len() - 1;
139        let mut i = 1;
140
141        // Branchless descent: the comparison drives the child index and the
142        // subtraction directly, which avoids a per-level mispredicted branch.
143        while i < size {
144            let left = 2 * i;
145            let left_sum = nodes[left & mask];
146            let go_right = (s > left_sum) as usize;
147            s -= left_sum * go_right as f32;
148            i = left + go_right;
149        }
150
151        // Clamp to the last written slot. In exact arithmetic the descent always
152        // lands on a written leaf, but this keeps the returned index in
153        // `[0, len)` even if floating-point drift nudges a boundary sample past
154        // the filled region.
155        let leaf = (i - size).min(self.len - 1);
156        Some((leaf, nodes[(size + leaf) & mask]))
157    }
158}