Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions src/bnb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,8 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> {
}

fn try_record_best(&mut self) -> Option<Ordf32> {
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,
Expand All @@ -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<Ordf32> {
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<Ordf32>) -> bool {
Expand All @@ -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));
}
Expand All @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions src/coin_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = (usize, Candidate)> + 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 {
Expand Down Expand Up @@ -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<Item = (usize, Candidate)> + '_ {
let cands = self.problem.candidates();
self.unselected_indices().map(move |i| (i, cands[i]))
Expand Down
53 changes: 53 additions & 0 deletions src/selection_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -286,19 +294,64 @@ 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,
}
}

pub(crate) fn from_selector(selector: &'a CoinSelector<'a>) -> Self {
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<Item = (usize, Candidate)> + '_ {
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<u64> {
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
Expand Down