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}