diff --git a/src/bnb.rs b/src/bnb.rs index 174e666..1b5fc37 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -172,9 +172,8 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } fn try_record_best(&mut self) -> Option { - let score = self - .metric - .score(&SelectionView::with_cache(&self.selector, &self.cache))?; + let view = SelectionView::with_cache_from(&self.selector, &self.cache, self.cursor()); + let score = self.metric.score(&view)?; let better = match self.best { Some(best_score) => score < best_score, None => true, @@ -187,9 +186,15 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } } + /// Bound the current node, telling the view how much of the candidate order it can skip. + /// + /// Every candidate before the cursor has already been decided — included by an inclusion frame, + /// or banned by an exclusion one — so a metric asking about undecided candidates never has to + /// look at them. That is what keeps the cost of a node proportional to the answer rather than + /// to the depth it was found at. fn bound_of_current(&mut self) -> Option { - self.metric - .bound(&SelectionView::with_cache(&self.selector, &self.cache)) + let view = SelectionView::with_cache_from(&self.selector, &self.cache, self.cursor()); + self.metric.bound(&view) } fn is_promising(&self, bound: Option) -> bool { @@ -210,7 +215,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } fn next_candidate(&self, start: usize) -> Option<(usize, usize)> { - for (cursor, (index, _)) in (start..).zip(self.selector.candidates().skip(start)) { + for (cursor, (index, _)) in (start..).zip(self.selector.candidates_from(start)) { if !self.selector.is_selected(index) && !self.selector.banned().contains(index) { return Some((index, cursor)); } @@ -229,7 +234,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { let to_ban_drags_in = self.selector.problem().drags_in(index); let mut banned = alloc::vec![index]; let mut next_cursor = cursor + 1; - for (next_index, next) in self.selector.candidates().skip(cursor + 1) { + for (next_index, next) in self.selector.candidates_from(cursor + 1) { if self.selector.is_selected(next_index) || self.selector.banned().contains(next_index) { next_cursor += 1; diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 30a444f..49930dc 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -66,6 +66,21 @@ impl<'a> CoinSelector<'a> { self.candidate_order.iter().map(move |i| (*i, cands[*i])) } + /// [`candidates`](Self::candidates), skipping the first `from_position` of the sorted order. + /// + /// `from_position` is a position in that order, not an index into + /// [`SelectionProblem::candidates`] — unlike the `index` each item carries. It may equal the + /// candidate count, which yields nothing; past that is a caller bug and panics. + pub(crate) fn candidates_from( + &self, + from_position: usize, + ) -> impl DoubleEndedIterator + ExactSizeIterator + '_ { + let cands = self.problem.candidates(); + self.candidate_order[from_position..] + .iter() + .map(move |i| (*i, cands[*i])) + } + /// Get the candidate at `index`. `index` refers to its position in /// [`SelectionProblem::candidates`]. pub fn candidate(&self, index: usize) -> Candidate { @@ -239,6 +254,10 @@ impl<'a> CoinSelector<'a> { /// The candidates are returned in sorted order. See [`sort_candidates_by`]. /// /// [`sort_candidates_by`]: Self::sort_candidates_by + /// Note [`SelectionView`](crate::SelectionView) shadows this with a version that skips the + /// prefix branch and bound has already decided. A method *on `CoinSelector`* that calls + /// `self.unselected()` gets this one even when reached through a view, so anything on the + /// search's hot path belongs on the view instead. pub fn unselected(&self) -> impl DoubleEndedIterator + '_ { let cands = self.problem.candidates(); self.unselected_indices().map(move |i| (i, cands[i])) diff --git a/src/selection_view.rs b/src/selection_view.rs index a2c2c1e..afb9e70 100644 --- a/src/selection_view.rs +++ b/src/selection_view.rs @@ -274,6 +274,14 @@ impl SelectionCache { pub struct SelectionView<'a> { selector: &'a CoinSelector<'a>, cache: Cow<'a, SelectionCache>, + /// Position in the selector's candidate order before which every candidate is already decided + /// — selected or banned. Zero is always correct; a larger value is a promise from whoever built + /// the view, and only lets the undecided scan skip a prefix it would have filtered out anyway. + /// + /// Branch and bound decides candidates in order, so at depth `d` the first `d` positions are + /// all decided. Without this, every query for "the best undecided candidate" walks those `d` + /// entries first, which makes the per-node cost grow with the pool rather than with the answer. + decided_before: usize, } impl<'a> Deref for SelectionView<'a> { @@ -286,9 +294,31 @@ impl<'a> Deref for SelectionView<'a> { impl<'a> SelectionView<'a> { pub(crate) fn with_cache(selector: &'a CoinSelector<'a>, cache: &'a SelectionCache) -> Self { + Self::with_cache_from(selector, cache, 0) + } + + /// [`with_cache`](Self::with_cache), promising that every candidate before position + /// `decided_before` in the candidate order is already selected or banned. + /// + /// The promise is only ever an optimisation: it lets the undecided scan start past a prefix it + /// would otherwise filter away one entry at a time. A debug assertion checks it. + pub(crate) fn with_cache_from( + selector: &'a CoinSelector<'a>, + cache: &'a SelectionCache, + decided_before: usize, + ) -> Self { + debug_assert!( + selector + .candidates() + .take(decided_before) + .all(|(index, _)| selector.is_selected(index) + || selector.banned().contains(index)), + "an undecided candidate sits before `decided_before`, so skipping the prefix would hide it", + ); Self { selector, cache: Cow::Borrowed(cache), + decided_before, } } @@ -296,9 +326,32 @@ impl<'a> SelectionView<'a> { Self { selector, cache: Cow::Owned(SelectionCache::from_selector(selector)), + decided_before: 0, } } + /// The undecided candidates, in the selector's candidate order. + /// + /// Shadows [`CoinSelector::unselected`], which always starts at the front of the order. This + /// one starts past the prefix the view was built knowing is already decided, so a caller that + /// knows it does not pay to rediscover it. Views built outside branch and bound know nothing, + /// and get the same answer by the same work. + pub fn unselected(&self) -> impl DoubleEndedIterator + '_ { + self.selector + .candidates_from(self.decided_before) + .filter(move |(index, _)| { + !(self.selector.is_selected(*index) || self.selector.banned().contains(*index)) + }) + } + + /// Shadows [`CoinSelector::min_input_weight`] so it uses the view's undecided scan. + /// + /// `LowestFee`'s bound asks for this at every funded node once `Target::max_weight` is set, and + /// the inherited version walks the whole candidate order to answer. + pub fn min_input_weight(&self) -> Option { + self.unselected().map(|(_, candidate)| candidate.weight).min() + } + /// The underlying selector, which is not changed by hypothetical view updates. pub fn selector(&self) -> &'a CoinSelector<'a> { self.selector