Skip to main content

kaspa_mining/mempool/model/frontier/
search_tree.rs

1use super::feerate_key::FeerateTransactionKey;
2use std::iter::FusedIterator;
3use sweep_bptree::tree::visit::{DescendVisit, DescendVisitResult};
4use sweep_bptree::tree::{Argument, SearchArgument};
5use sweep_bptree::{BPlusTree, NodeStoreVec};
6
7type FeerateKey = FeerateTransactionKey;
8
9/// A struct for implementing "weight space" search using the SearchArgument customization.
10/// The weight space is the range `[0, total_weight)` and each key has a "logical" interval allocation
11/// within this space according to its tree position and weight.
12///
13/// We implement the search efficiently by maintaining subtree weights which are updated with each
14/// element insertion/removal. Given a search query `p ∈ [0, total_weight)` we then find the corresponding
15/// element in log time by walking down from the root and adjusting the query according to subtree weights.
16/// For instance if the query point is `123.56` and the top 3 subtrees have weights `120, 10.5 ,100` then we
17/// recursively query the middle subtree with the point `123.56 - 120 = 3.56`.
18///
19/// See SearchArgument implementation below for more details.
20#[derive(Clone, Copy, Debug, Default)]
21struct FeerateWeight(f64);
22
23impl FeerateWeight {
24    /// Returns the weight value
25    pub fn weight(&self) -> f64 {
26        self.0
27    }
28}
29
30impl Argument<FeerateKey> for FeerateWeight {
31    fn from_leaf(keys: &[FeerateKey]) -> Self {
32        Self(keys.iter().map(|k| k.weight()).sum())
33    }
34
35    fn from_inner(_keys: &[FeerateKey], arguments: &[Self]) -> Self {
36        Self(arguments.iter().map(|a| a.0).sum())
37    }
38}
39
40impl SearchArgument<FeerateKey> for FeerateWeight {
41    type Query = f64;
42
43    fn locate_in_leaf(query: Self::Query, keys: &[FeerateKey]) -> Option<usize> {
44        let mut sum = 0.0;
45        for (i, k) in keys.iter().enumerate() {
46            let w = k.weight();
47            sum += w;
48            if query < sum {
49                return Some(i);
50            }
51        }
52        // In order to avoid sensitivity to floating number arithmetics,
53        // we logically "clamp" the search, returning the last leaf if the query
54        // value is out of bounds
55        match keys.len() {
56            0 => None,
57            #[allow(clippy::arithmetic_side_effects, reason = "The preceding arm handles zero, so n >= 1.")]
58            n => Some(n - 1),
59        }
60    }
61
62    fn locate_in_inner(mut query: Self::Query, _keys: &[FeerateKey], arguments: &[Self]) -> Option<(usize, Self::Query)> {
63        // Search algorithm: Locate the next subtree to visit by iterating through `arguments`
64        // and subtracting the query until the correct range is found
65        for (i, a) in arguments.iter().enumerate() {
66            if query >= a.0 {
67                query -= a.0;
68            } else {
69                return Some((i, query));
70            }
71        }
72        // In order to avoid sensitivity to floating number arithmetics,
73        // we logically "clamp" the search, returning the last subtree if the query
74        // value is out of bounds. Eventually this will lead to the return of the
75        // last leaf (see locate_in_leaf as well)
76        match arguments.len() {
77            0 => None,
78            #[allow(clippy::arithmetic_side_effects, reason = "The preceding arm handles zero, so both n - 1 expressions are safe.")]
79            n => Some((n - 1, arguments[n - 1].0)),
80        }
81    }
82}
83
84/// Visitor struct which accumulates the prefix weight up to a provided key (inclusive) in log time.
85///
86/// The basic idea is to use the subtree weights stored in the tree for walking down from the root
87/// to the leaf (corresponding to the searched key), and accumulating all weights proceeding the walk-down path
88struct PrefixWeightVisitor<'a> {
89    /// The key to search up to
90    key: &'a FeerateKey,
91    /// This field accumulates the prefix weight during the visit process
92    accumulated_weight: f64,
93}
94
95impl<'a> PrefixWeightVisitor<'a> {
96    pub fn new(key: &'a FeerateKey) -> Self {
97        Self { key, accumulated_weight: Default::default() }
98    }
99
100    /// Returns the index of the first `key ∈ keys` such that `key > self.key`. If no such key
101    /// exists, the returned index will be the length of `keys`.
102    fn search_in_keys(&self, keys: &[FeerateKey]) -> usize {
103        match keys.binary_search(self.key) {
104            Err(idx) => {
105                // self.key is not in keys, idx is the index of the following key
106                idx
107            }
108            Ok(idx) => {
109                // Exact match, return the following index
110                #[allow(clippy::arithmetic_side_effects, reason = "ARITH-SAFETY(INDEX)")]
111                {
112                    idx + 1
113                }
114            }
115        }
116    }
117}
118
119impl DescendVisit<FeerateKey, (), FeerateWeight> for PrefixWeightVisitor<'_> {
120    type Result = f64;
121
122    fn visit_inner(&mut self, keys: &[FeerateKey], arguments: &[FeerateWeight]) -> DescendVisitResult<Self::Result> {
123        let idx = self.search_in_keys(keys);
124        // Invariants:
125        //      a. arguments.len() == keys.len() + 1 (n inner node keys are the separators between n+1 subtrees)
126        //      b. idx <= keys.len() (hence idx < arguments.len())
127
128        // Based on the invariants, we first accumulate all the subtree weights up to idx
129        for argument in arguments.iter().take(idx) {
130            self.accumulated_weight += argument.weight();
131        }
132
133        // ..and then go down to the idx'th subtree
134        DescendVisitResult::GoDown(idx)
135    }
136
137    fn visit_leaf(&mut self, keys: &[FeerateKey], _values: &[()]) -> Option<Self::Result> {
138        // idx is the index of the key following self.key
139        let idx = self.search_in_keys(keys);
140        // Accumulate all key weights up to idx (which is inclusive if self.key ∈ tree)
141        for key in keys.iter().take(idx) {
142            self.accumulated_weight += key.weight();
143        }
144        // ..and return the final result
145        Some(self.accumulated_weight)
146    }
147}
148
149type InnerTree = BPlusTree<NodeStoreVec<FeerateKey, (), FeerateWeight>>;
150
151/// A transaction search tree sorted by feerate order and searchable for probabilistic weighted sampling.
152///
153/// All `log(n)` expressions below are in base 64 (based on constants chosen within the sweep_bptree crate).
154///
155/// The tree has the following properties:
156///     1. Linear time ordered access (ascending / descending)
157///     2. Insertions/removals in log(n) time
158///     3. Search for a weight point `p ∈ [0, total_weight)` in log(n) time
159///     4. Compute the prefix weight of a key, i.e., the sum of weights up to that key (inclusive)
160///        according to key order, in log(n) time
161///     5. Access the total weight in O(1) time. The total weight has numerical stability since it
162///        is recomputed from subtree weights for each item insertion/removal
163///
164/// Computing the prefix weight is a crucial operation if the tree is used for random sampling and
165/// the tree is highly imbalanced in terms of weight variance.
166/// See [`Frontier::sample_inplace()`](crate::mempool::model::frontier::Frontier::sample_inplace)
167/// for more details.  
168pub struct SearchTree {
169    tree: InnerTree,
170}
171
172impl Default for SearchTree {
173    fn default() -> Self {
174        Self { tree: InnerTree::new(Default::default()) }
175    }
176}
177
178impl Clone for SearchTree {
179    fn clone(&self) -> Self {
180        // `bulk_load` requires sorted keys; `ascending_iter` provides the tree order.
181        Self { tree: InnerTree::bulk_load(self.ascending_iter().cloned().map(|key| (key, ())).collect()) }
182    }
183}
184
185impl SearchTree {
186    pub fn new() -> Self {
187        Self { tree: InnerTree::new(Default::default()) }
188    }
189
190    pub fn len(&self) -> usize {
191        self.tree.len()
192    }
193
194    pub fn is_empty(&self) -> bool {
195        self.len() == 0
196    }
197
198    /// Inserts a key into the tree in log(n) time. Returns `false` if the key was already in the tree.
199    pub fn insert(&mut self, key: FeerateKey) -> bool {
200        self.tree.insert(key, ()).is_none()
201    }
202
203    /// Remove a key from the tree in log(n) time. Returns `false` if the key was not in the tree.
204    pub fn remove(&mut self, key: &FeerateKey) -> bool {
205        self.tree.remove(key).is_some()
206    }
207
208    /// Search for a weight point `query ∈ [0, total_weight)` in log(n) time
209    pub fn search(&self, query: f64) -> &FeerateKey {
210        self.tree.get_by_argument(query).expect("clamped").0
211    }
212
213    /// Remove the key located by a weight point `query ∈ [0, total_weight)` in log(n) time.
214    pub fn remove_by_query(&mut self, query: f64) -> FeerateKey {
215        self.tree.remove_by_argument(query).expect("clamped").0
216    }
217
218    /// Access the total weight in O(1) time
219    pub fn total_weight(&self) -> f64 {
220        self.tree.root_argument().weight()
221    }
222
223    /// Computes the prefix weight of a key, i.e., the sum of weights up to that key (inclusive)
224    /// according to key order, in log(n) time
225    pub fn prefix_weight(&self, key: &FeerateKey) -> f64 {
226        self.tree.descend_visit(PrefixWeightVisitor::new(key)).unwrap()
227    }
228
229    /// Iterate the tree in descending key order (going down from the
230    /// highest key). Linear in the number of keys *actually* iterated.
231    pub fn descending_iter(&self) -> impl DoubleEndedIterator<Item = &FeerateKey> + ExactSizeIterator + FusedIterator {
232        self.tree.iter().rev().map(|(key, ())| key)
233    }
234
235    /// Iterate the tree in ascending key order (going up from the
236    /// lowest key). Linear in the number of keys *actually* iterated.
237    pub fn ascending_iter(&self) -> impl DoubleEndedIterator<Item = &FeerateKey> + ExactSizeIterator + FusedIterator {
238        self.tree.iter().map(|(key, ())| key)
239    }
240
241    /// The lowest key in the tree (by key order)
242    pub fn first(&self) -> Option<&FeerateKey> {
243        self.tree.first().map(|(k, ())| k)
244    }
245
246    /// The highest key in the tree (by key order)
247    pub fn last(&self) -> Option<&FeerateKey> {
248        self.tree.last().map(|(k, ())| k)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::super::feerate_key::tests::build_feerate_key;
255    use super::*;
256    use itertools::Itertools;
257    use std::collections::HashSet;
258    use std::ops::Sub;
259
260    #[test]
261    fn test_feerate_weight_queries() {
262        let mut tree = SearchTree::new();
263        let mass = 2000;
264        // The btree stores N=64 keys at each node/leaf, so we make sure the tree has more than
265        // 64^2 keys in order to trigger at least a few intermediate tree nodes
266        let fees = vec![[123, 113, 10_000, 1000, 2050, 2048]; 64 * (64 + 1)].into_iter().flatten().collect_vec();
267
268        #[allow(clippy::mutable_key_type)]
269        let mut s = HashSet::with_capacity(fees.len());
270        for (i, fee) in fees.iter().copied().enumerate() {
271            let key = build_feerate_key(fee, mass, i as u64);
272            s.insert(key.clone());
273            tree.insert(key);
274        }
275
276        // Randomly remove 1/6 of the items
277        let remove = s.iter().take(fees.len() / 6).cloned().collect_vec();
278        for r in remove {
279            s.remove(&r);
280            tree.remove(&r);
281        }
282
283        // Collect to vec and sort for reference
284        let mut v = s.into_iter().collect_vec();
285        v.sort();
286
287        // Test reverse iteration
288        for (expected, item) in v.iter().rev().zip(tree.descending_iter()) {
289            assert_eq!(&expected, &item);
290            assert!(expected.cmp(item).is_eq()); // Assert Ord equality as well
291        }
292
293        // Sweep through the tree and verify that weight search queries are handled correctly
294        let eps: f64 = 0.001;
295        let mut sum = 0.0;
296        for expected in v.iter() {
297            let weight = expected.weight();
298            let eps = eps.min(weight / 3.0);
299            let samples = [sum + eps, sum + weight / 2.0, sum + weight - eps];
300            for sample in samples {
301                let key = tree.search(sample);
302                assert_eq!(expected, key);
303                assert!(expected.cmp(key).is_eq()); // Assert Ord equality as well
304            }
305            sum += weight;
306        }
307
308        println!("{}, {}", sum, tree.total_weight());
309
310        // Test clamped search bounds
311        assert_eq!(tree.first(), Some(tree.search(f64::NEG_INFINITY)));
312        assert_eq!(tree.first(), Some(tree.search(-1.0)));
313        assert_eq!(tree.first(), Some(tree.search(-eps)));
314        assert_eq!(tree.first(), Some(tree.search(0.0)));
315        assert_eq!(tree.last(), Some(tree.search(sum)));
316        assert_eq!(tree.last(), Some(tree.search(sum + eps)));
317        assert_eq!(tree.last(), Some(tree.search(sum + 1.0)));
318        assert_eq!(tree.last(), Some(tree.search(1.0 / 0.0)));
319        assert_eq!(tree.last(), Some(tree.search(f64::INFINITY)));
320        let _ = tree.search(f64::NAN);
321
322        // Assert prefix weights
323        let mut prefix = Vec::with_capacity(v.len());
324        prefix.push(v[0].weight());
325        for i in 1..v.len() {
326            prefix.push(prefix[i - 1] + v[i].weight());
327        }
328        let eps = v.iter().map(|k| k.weight()).min_by(f64::total_cmp).unwrap() * 1e-4;
329        for (expected_prefix, key) in prefix.into_iter().zip(v) {
330            let prefix = tree.prefix_weight(&key);
331            assert!(expected_prefix.sub(prefix).abs() < eps);
332        }
333    }
334
335    #[test]
336    fn test_tree_rev_iter() {
337        let mut tree = SearchTree::new();
338        let mass = 2000;
339        let fees = vec![[123, 113, 10_000, 1000, 2050, 2048]; 64 * (64 + 1)].into_iter().flatten().collect_vec();
340        let mut v = Vec::with_capacity(fees.len());
341        for (i, fee) in fees.iter().copied().enumerate() {
342            let key = build_feerate_key(fee, mass, i as u64);
343            v.push(key.clone());
344            tree.insert(key);
345        }
346        v.sort();
347
348        for (expected, item) in v.into_iter().rev().zip(tree.descending_iter()) {
349            assert_eq!(&expected, item);
350            assert!(expected.cmp(item).is_eq()); // Assert Ord equality as well
351        }
352    }
353
354    #[test]
355    fn test_remove_by_query_removes_searched_key() {
356        let mut tree = SearchTree::new();
357        let keys = (0..200).map(|i| build_feerate_key(i + 1, 1650, i)).collect_vec();
358
359        for key in keys.iter().cloned() {
360            tree.insert(key);
361        }
362
363        let query = tree.total_weight() / 2.0;
364        let expected = tree.search(query).clone();
365        let removed = tree.remove_by_query(query);
366
367        assert_eq!(removed, expected);
368        assert!(!tree.ascending_iter().any(|candidate| candidate == &removed));
369        assert_eq!(tree.len(), keys.len() - 1);
370    }
371
372    #[test]
373    fn test_clone_preserves_order_and_weight_queries() {
374        for size in [1, 2, 63, 64, 65, 4096, 4097] {
375            let mut tree = SearchTree::new();
376            for i in 0..size {
377                let fee = ((i * 1_103 + size * 37) % 1_000_000 + 1) as u64;
378                let mass = ((i * 97 + size * 13) % 10_000 + 1_000) as u64;
379                tree.insert(build_feerate_key(fee, mass, i as u64));
380            }
381
382            let cloned = tree.clone();
383            let original_keys = tree.ascending_iter().cloned().collect_vec();
384            let cloned_keys = cloned.ascending_iter().cloned().collect_vec();
385
386            assert_eq!(original_keys, cloned_keys, "size {size}");
387            assert_eq!(tree.len(), cloned.len(), "size {size}");
388            let total_weight = original_keys.iter().map(|key| key.weight()).sum::<f64>();
389            let eps = total_weight * 1e-10;
390            assert!(tree.total_weight().sub(cloned.total_weight()).abs() < eps, "size {size}");
391
392            let mut prefix = 0.0;
393            let stride = (size / 32).max(1);
394            for (idx, key) in original_keys.iter().enumerate() {
395                let sample = prefix + key.weight() / 2.0;
396                prefix += key.weight();
397                if idx % stride == 0 {
398                    assert_eq!(tree.search(sample), cloned.search(sample), "size {size}, idx {idx}");
399                    assert!(tree.prefix_weight(key).sub(cloned.prefix_weight(key)).abs() < eps, "size {size}, idx {idx}");
400                }
401            }
402        }
403    }
404
405    #[test]
406    fn test_clone_is_independent_after_mutation() {
407        let mut tree = SearchTree::new();
408        let keys = (0..200).map(|i| build_feerate_key(i + 1, 1650, i)).collect_vec();
409
410        for key in keys.iter().cloned() {
411            tree.insert(key);
412        }
413
414        let mut cloned = tree.clone();
415        for key in keys.iter().take(50) {
416            cloned.remove(key);
417        }
418        for key in keys.iter().skip(200 - 25) {
419            tree.remove(key);
420        }
421
422        assert_eq!(tree.len(), 175);
423        assert_eq!(cloned.len(), 150);
424        for key in keys.iter().take(50) {
425            assert!(tree.ascending_iter().any(|candidate| candidate == key));
426            assert!(!cloned.ascending_iter().any(|candidate| candidate == key));
427        }
428    }
429}