Skip to main content

thrust_rl/buffer/replay/
sum_tree.rs

1//! Sum-tree for prioritized experience replay.
2//!
3//! A sum-tree is a binary heap stored in a flat array where each internal
4//! node holds the sum of its two children. The root holds the total
5//! priority, which makes it cheap to sample a leaf proportional to its
6//! priority via cumulative-sum traversal:
7//!
8//! 1. Draw `p ∈ [0, total]` uniformly.
9//! 2. Walk from the root: if `p ≤ left`, descend left; else subtract `left`
10//!    from `p` and descend right.
11//! 3. The leaf reached is the chosen index.
12//!
13//! Both `update` and `find` are O(log N) where N is the number of leaves.
14//!
15//! # Layout
16//!
17//! For a tree with `capacity` leaves, the underlying `Vec<f32>` has length
18//! `2 * capacity - 1`:
19//!
20//! - Indices `0 .. capacity - 1` are internal nodes (the root is at 0).
21//! - Indices `capacity - 1 .. 2 * capacity - 1` are the leaves.
22//!
23//! For an internal node at index `i`, its children sit at `2*i + 1` (left)
24//! and `2*i + 2` (right). For a leaf at index `i`, its parent is at
25//! `(i - 1) / 2`.
26//!
27//! # References
28//!
29//! - Schaul et al., *Prioritized Experience Replay* ([ICLR 2016](https://arxiv.org/abs/1511.05952)).
30
31/// Sum-tree over `capacity` leaves backed by a flat `Vec<f32>`.
32///
33/// Used by [`super::PrioritizedReplayBuffer`] to draw transitions with
34/// probability proportional to their priority. The tree itself doesn't
35/// know about transitions — the buffer maintains the leaf-to-transition
36/// mapping separately.
37#[derive(Debug, Clone)]
38pub struct SumTree {
39    /// Number of leaves the tree can hold.
40    capacity: usize,
41    /// Flat node storage of length `2 * capacity - 1`. The root is at
42    /// index 0 and the leaves start at index `capacity - 1`.
43    nodes: Vec<f32>,
44}
45
46impl SumTree {
47    /// Create a new sum-tree with all priorities initialized to zero.
48    ///
49    /// # Arguments
50    /// * `capacity` - Number of leaves. Must be > 0.
51    ///
52    /// # Panics
53    /// Panics if `capacity == 0`.
54    pub fn new(capacity: usize) -> Self {
55        assert!(capacity > 0, "SumTree capacity must be > 0");
56        Self { capacity, nodes: vec![0.0; 2 * capacity - 1] }
57    }
58
59    /// Total priority (the root of the tree).
60    #[inline]
61    pub fn total(&self) -> f32 {
62        self.nodes[0]
63    }
64
65    /// Number of leaves.
66    #[inline]
67    pub fn capacity(&self) -> usize {
68        self.capacity
69    }
70
71    /// Index of the first leaf in the underlying `nodes` array.
72    #[inline]
73    fn leaf_offset(&self) -> usize {
74        self.capacity - 1
75    }
76
77    /// Maximum priority across all leaves.
78    ///
79    /// Used by [`super::PrioritizedReplayBuffer::push`] to assign a
80    /// freshly inserted transition a priority that guarantees it gets
81    /// sampled at least once.
82    pub fn max_leaf(&self) -> f32 {
83        let offset = self.leaf_offset();
84        let mut m: f32 = 0.0;
85        for &v in &self.nodes[offset..offset + self.capacity] {
86            if v > m {
87                m = v;
88            }
89        }
90        m
91    }
92
93    /// Read the priority of leaf `leaf_idx ∈ [0, capacity)`.
94    ///
95    /// # Panics
96    /// Panics if `leaf_idx >= capacity`.
97    pub fn leaf_priority(&self, leaf_idx: usize) -> f32 {
98        assert!(
99            leaf_idx < self.capacity,
100            "SumTree::leaf_priority: leaf_idx ({}) >= capacity ({})",
101            leaf_idx,
102            self.capacity
103        );
104        self.nodes[self.leaf_offset() + leaf_idx]
105    }
106
107    /// Set the priority of leaf `leaf_idx ∈ [0, capacity)` to `priority`,
108    /// propagating the delta up to the root.
109    ///
110    /// # Panics
111    /// Panics if `leaf_idx >= capacity` or `priority` is not finite or is
112    /// negative.
113    pub fn update(&mut self, leaf_idx: usize, priority: f32) {
114        assert!(
115            leaf_idx < self.capacity,
116            "SumTree::update: leaf_idx ({}) >= capacity ({})",
117            leaf_idx,
118            self.capacity
119        );
120        assert!(
121            priority.is_finite() && priority >= 0.0,
122            "SumTree::update: priority must be finite and non-negative, got {}",
123            priority
124        );
125
126        let node_idx = self.leaf_offset() + leaf_idx;
127        let delta = priority - self.nodes[node_idx];
128        self.nodes[node_idx] = priority;
129
130        // Walk up to the root, adding `delta` along the way.
131        let mut idx = node_idx;
132        while idx > 0 {
133            idx = (idx - 1) / 2;
134            self.nodes[idx] += delta;
135        }
136    }
137
138    /// Find the leaf whose cumulative-priority interval contains `p`.
139    ///
140    /// Returns `(leaf_idx, priority)`. The walk is deterministic: at each
141    /// internal node we descend left if `p ≤ left_child`, else we subtract
142    /// the left child's value from `p` and descend right.
143    ///
144    /// The caller is responsible for ensuring `p ∈ [0, total()]`; values
145    /// slightly above `total()` are clamped by the walk so that the
146    /// rightmost non-zero leaf is returned.
147    ///
148    /// # Panics
149    /// Panics if `total() == 0` (cannot sample from a zero-priority tree).
150    pub fn find(&self, mut p: f32) -> (usize, f32) {
151        let total = self.total();
152        assert!(total > 0.0, "SumTree::find: total priority is zero; nothing to sample from");
153
154        // Clamp `p` into the valid range. Tiny over-runs from float
155        // arithmetic on the boundary can otherwise direct the walk into
156        // an empty (zero-priority) right subtree.
157        if p < 0.0 {
158            p = 0.0;
159        }
160        if p > total {
161            p = total;
162        }
163
164        let mut idx = 0usize;
165        let leaf_start = self.leaf_offset();
166        while idx < leaf_start {
167            let left = 2 * idx + 1;
168            let right = left + 1;
169            let left_val = self.nodes[left];
170            if p <= left_val {
171                idx = left;
172            } else {
173                p -= left_val;
174                idx = right;
175            }
176        }
177        let leaf_idx = idx - leaf_start;
178        (leaf_idx, self.nodes[idx])
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_new_zero_total() {
188        let tree = SumTree::new(8);
189        assert_eq!(tree.capacity(), 8);
190        assert_eq!(tree.total(), 0.0);
191        assert_eq!(tree.max_leaf(), 0.0);
192    }
193
194    #[test]
195    fn test_update_propagates_to_root() {
196        let mut tree = SumTree::new(4);
197        tree.update(0, 1.0);
198        tree.update(1, 2.0);
199        tree.update(2, 3.0);
200        tree.update(3, 4.0);
201        assert!((tree.total() - 10.0).abs() < 1e-6);
202        assert!((tree.max_leaf() - 4.0).abs() < 1e-6);
203        assert!((tree.leaf_priority(0) - 1.0).abs() < 1e-6);
204        assert!((tree.leaf_priority(3) - 4.0).abs() < 1e-6);
205    }
206
207    #[test]
208    fn test_update_in_place_changes_total() {
209        let mut tree = SumTree::new(4);
210        tree.update(0, 1.0);
211        tree.update(1, 2.0);
212        tree.update(2, 3.0);
213        tree.update(3, 4.0);
214        // Overwrite leaf 1 from 2.0 → 5.0. Total should rise by 3.
215        tree.update(1, 5.0);
216        assert!((tree.total() - 13.0).abs() < 1e-6);
217        assert!((tree.leaf_priority(1) - 5.0).abs() < 1e-6);
218    }
219
220    #[test]
221    fn test_find_walks_to_correct_leaf() {
222        // Leaves: [1, 2, 3, 4]. Cumulative intervals (left-inclusive):
223        //   leaf 0: (0, 1]
224        //   leaf 1: (1, 3]
225        //   leaf 2: (3, 6]
226        //   leaf 3: (6, 10]
227        let mut tree = SumTree::new(4);
228        tree.update(0, 1.0);
229        tree.update(1, 2.0);
230        tree.update(2, 3.0);
231        tree.update(3, 4.0);
232
233        assert_eq!(tree.find(0.5).0, 0);
234        assert_eq!(tree.find(1.0).0, 0);
235        assert_eq!(tree.find(1.5).0, 1);
236        assert_eq!(tree.find(3.0).0, 1);
237        assert_eq!(tree.find(3.1).0, 2);
238        assert_eq!(tree.find(6.0).0, 2);
239        assert_eq!(tree.find(6.5).0, 3);
240        assert_eq!(tree.find(10.0).0, 3);
241    }
242
243    #[test]
244    fn test_find_skips_zero_priority_leaves() {
245        // Leaves: [0, 5, 0, 3]. Anywhere in (0, 5] must land on leaf 1;
246        // (5, 8] must land on leaf 3. Leaves 0 and 2 should never be hit.
247        let mut tree = SumTree::new(4);
248        tree.update(1, 5.0);
249        tree.update(3, 3.0);
250
251        let total = tree.total();
252        assert!((total - 8.0).abs() < 1e-6);
253
254        for p in [0.1, 1.0, 4.9, 5.0] {
255            assert_eq!(tree.find(p).0, 1, "p={} should hit leaf 1", p);
256        }
257        for p in [5.1, 6.0, 8.0] {
258            assert_eq!(tree.find(p).0, 3, "p={} should hit leaf 3", p);
259        }
260    }
261
262    #[test]
263    fn test_find_clamps_overshoot() {
264        // If the caller passes p slightly above total, the walk should
265        // still land on the rightmost non-zero leaf rather than wandering
266        // into a zero-priority leaf at the end.
267        let mut tree = SumTree::new(4);
268        tree.update(0, 1.0);
269        tree.update(1, 1.0);
270        let (leaf, _) = tree.find(2.0 + 1e-3);
271        assert!(leaf <= 1, "expected leaf ≤ 1 (rightmost non-zero), got {}", leaf);
272    }
273
274    #[test]
275    fn test_max_leaf_tracks_updates() {
276        let mut tree = SumTree::new(4);
277        assert_eq!(tree.max_leaf(), 0.0);
278        tree.update(0, 1.0);
279        tree.update(1, 2.5);
280        tree.update(2, 0.5);
281        assert!((tree.max_leaf() - 2.5).abs() < 1e-6);
282        tree.update(1, 0.1);
283        assert!((tree.max_leaf() - 1.0).abs() < 1e-6);
284    }
285
286    #[test]
287    #[should_panic(expected = "capacity must be > 0")]
288    fn test_zero_capacity_panics() {
289        let _ = SumTree::new(0);
290    }
291
292    #[test]
293    #[should_panic(expected = "leaf_idx")]
294    fn test_update_out_of_range_panics() {
295        let mut tree = SumTree::new(4);
296        tree.update(4, 1.0);
297    }
298
299    #[test]
300    #[should_panic(expected = "priority must be finite")]
301    fn test_update_nan_panics() {
302        let mut tree = SumTree::new(4);
303        tree.update(0, f32::NAN);
304    }
305
306    #[test]
307    #[should_panic(expected = "priority must be finite")]
308    fn test_update_negative_panics() {
309        let mut tree = SumTree::new(4);
310        tree.update(0, -1.0);
311    }
312
313    #[test]
314    #[should_panic(expected = "total priority is zero")]
315    fn test_find_zero_total_panics() {
316        let tree = SumTree::new(4);
317        let _ = tree.find(0.5);
318    }
319}