Skip to content

Commit fffd1c0

Browse files
committed
Merge #52: refactor!: pass Target to BnbMetric methods instead of storing it
304ac2d refactor!: pass Target to BnbMetric methods instead of storing it (志宇) Pull request description: ## Motivation `BnbMetric` implementations currently each **store** their own `Target` (`LowestFee { target, .. }`, `Changeless { target, inner }`). That has two problems: 1. **`Changeless<M>` duplicates the target** and must keep its copy in sync with the inner metric's — a silent-mismatch footgun (both fields are public and set independently). 2. It's **inconsistent with the rest of the library**, where `target` is always a *parameter*: `cs.excess(target, drain)`, `cs.is_target_met(target)`, `cs.drain(target, policy)`. The metric structs are the only place target is baked into state. ## What this does Make `target` a parameter of the metric methods (and the BnB entry points): ```rust pub trait BnbMetric { fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32>; fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32>; fn drain(&mut self, cs: &CoinSelector<'_>, target: Target) -> Drain; fn requires_ordering_by_descending_value_pwu(&self) -> bool { false } } // cs.run_bnb(target, metric, max_rounds) // cs.bnb_solutions(target, metric) ``` Consequently: - `LowestFee` → `{ long_term_feerate, dust_relay_feerate, drain_weights }` (no `target`). - `Changeless<M>` → `Changeless<M>(pub M)` — with `target` gone it collapses to a single field, so it becomes a plain newtype: `Changeless(LowestFee { .. })`. The duplicate target is gone **by construction**, so the "must match inner's target" invariant is deleted rather than documented. - `BnbIter` holds the single `target` and threads it to the metric. Conceptually this reflects the right split: `target` is the *problem/constraints*; the metric is the *objective*. The feerates/drain-weights stay in the metric (objective config); target — universal and problem-level — becomes a shared input, so a mismatch across composed metrics is unrepresentable. Argument order is `cs`, then `target` (then the rest), keeping the same `cs`-before-`target` relative order the library already uses (where `cs` is usually the receiver). ## Notes - The `target` field and its plumbing simply disappear, so the net diff is small despite the wide surface. - Breaking change on the unreleased 0.5.0 API surface (post-#49). - `cargo fmt` / `clippy` / `doc` / full test suite + doctests / `no_std` build are all green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ACKs for top commit: evanlinjin: ACK 304ac2d Tree-SHA512: 1087e9154ffcb57358209e2da97e1cd685c732da02385d278427da45be660fe0febff366985b1114095230b135d10a29f2d7aa7dd99591aba43b98fcbe345080
2 parents e2e154a + 304ac2d commit fffd1c0

11 files changed

Lines changed: 121 additions & 122 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Unreleased
22

3+
- **Breaking:** `BnbMetric`'s `score`, `bound`, and `drain` take the `target: Target` as a parameter, and `CoinSelector::run_bnb`/`bnb_solutions` gain a leading `target` argument. Consequently `LowestFee` and `Changeless` no longer store a `target` field. This removes the target that `Changeless<M>` previously had to keep in sync with its inner metric, and aligns the metric API with the rest of `CoinSelector`, where `target` is always passed in.
34
- **Breaking:** `BnbMetric` metrics now decide the change output themselves. The trait gains a `drain(&mut self, cs) -> Drain` method; call it on a branch-and-bound solution (or the `LowestFee` metric directly) to get the change output the metric optimized against, instead of computing a separate `ChangePolicy`.
45
- **Breaking:** `CoinSelector::run_bnb` now returns `(Ordf32, Drain)` instead of just `Ordf32`, handing back the change output the metric decided on for the winning selection.
56
- **Breaking:** `LowestFee` no longer takes a `change_policy`. It now takes `dust_relay_feerate: FeeRate` and `drain_weights: DrainWeights`, and adds change only when doing so lowers the long-term fee and the change would not be dust.

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,21 +140,20 @@ let dust_relay_feerate = FeeRate::from_sat_per_vb(3.0);
140140
// decides for itself whether to add a change output: change is added whenever doing so reduces the
141141
// long-term fee (factoring in the cost to spend the output later on) and the change wouldn't be dust.
142142
let mut metric = LowestFee {
143-
target,
144143
long_term_feerate, // used to calculate the cost of spending the change output in the future
145144
dust_relay_feerate,
146145
drain_weights,
147146
};
148147

149148
// We run the branch and bound algorithm with a max round limit of 100,000.
150149
// On success it returns the score along with the change output the metric decided on.
151-
let change = match coin_selector.run_bnb(metric, 100_000) {
150+
let change = match coin_selector.run_bnb(target, metric, 100_000) {
152151
Err(err) => {
153152
println!("failed to find a solution: {}", err);
154153
// fall back to naive selection
155154
coin_selector.select_until_target_met(target).expect("a selection was impossible!");
156155
// the metric still decides the change output for whatever we end up selecting
157-
metric.drain(&coin_selector)
156+
metric.drain(&coin_selector, target)
158157
}
159158
Ok((score, change)) => {
160159
println!("we found a solution with score {}", score);

benches/coin_selector.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,11 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) {
7575
|| selector.clone(),
7676
|mut sel| {
7777
let metric = LowestFee {
78-
target,
7978
long_term_feerate,
8079
dust_relay_feerate: FeeRate::from_sat_per_vb(1.0),
8180
drain_weights: DrainWeights::TR_KEYSPEND,
8281
};
83-
let _ = sel.run_bnb(metric, black_box(100_000));
82+
let _ = sel.run_bnb(target, metric, black_box(100_000));
8483
sel
8584
},
8685
BatchSize::SmallInput,

src/bnb.rs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use core::cmp::Reverse;
22

3-
use crate::{float::Ordf32, Drain};
3+
use crate::{float::Ordf32, Drain, Target};
44

55
use super::CoinSelector;
66
use alloc::collections::BinaryHeap;
@@ -11,6 +11,8 @@ use alloc::collections::BinaryHeap;
1111
pub(crate) struct BnbIter<'a, M: BnbMetric> {
1212
queue: BinaryHeap<Branch<'a>>,
1313
best: Option<Ordf32>,
14+
/// The target the metric scores selections against.
15+
pub(crate) target: Target,
1416
/// The `BnBMetric` that will score each selection
1517
pub(crate) metric: M,
1618
}
@@ -53,7 +55,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> {
5355

5456
let mut return_val = None;
5557
if !branch.is_exclusion {
56-
if let Some(score) = self.metric.score(&selector) {
58+
if let Some(score) = self.metric.score(&selector, self.target) {
5759
let better = match self.best {
5860
Some(best_score) => score < best_score,
5961
None => true,
@@ -71,10 +73,11 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> {
7173
}
7274

7375
impl<'a, M: BnbMetric> BnbIter<'a, M> {
74-
pub(crate) fn new(mut selector: CoinSelector<'a>, metric: M) -> Self {
76+
pub(crate) fn new(mut selector: CoinSelector<'a>, target: Target, metric: M) -> Self {
7577
let mut iter = BnbIter {
7678
queue: BinaryHeap::default(),
7779
best: None,
80+
target,
7881
metric,
7982
};
8083

@@ -88,7 +91,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> {
8891
}
8992

9093
fn consider_adding_to_queue(&mut self, cs: &CoinSelector<'a>, is_exclusion: bool) {
91-
let bound = self.metric.bound(cs);
94+
let bound = self.metric.bound(cs, self.target);
9295
if let Some(bound) = bound {
9396
let is_good_enough = match self.best {
9497
Some(best) => best > bound,
@@ -199,25 +202,25 @@ impl Eq for Branch<'_> {}
199202
///
200203
/// This is to be used as input for [`CoinSelector::run_bnb`] or [`CoinSelector::bnb_solutions`].
201204
pub trait BnbMetric {
202-
/// Get the score of a given selection.
205+
/// Get the score of a given selection for `target`.
203206
///
204207
/// If this returns `None`, the selection is invalid.
205-
fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>;
208+
fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32>;
206209

207-
/// Get the lower bound score using a heuristic.
210+
/// Get the lower bound score using a heuristic for `target`.
208211
///
209212
/// This represents the best possible score of all descendant branches (according to the
210213
/// heuristic).
211214
///
212215
/// If this returns `None`, the current branch and all descendant branches will not have valid
213216
/// solutions.
214-
fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>;
217+
fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32>;
215218

216-
/// The change output (a.k.a. drain) this metric decides on for the given selection, or
217-
/// [`Drain::NONE`] if it decides there should be no change.
219+
/// The change output (a.k.a. drain) this metric decides on for the given selection and `target`,
220+
/// or [`Drain::NONE`] if it decides there should be no change.
218221
///
219222
/// Call this on a branch-and-bound solution to get the change output the metric optimized against.
220-
fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain;
223+
fn drain(&mut self, cs: &CoinSelector<'_>, target: Target) -> Drain;
221224

222225
/// Returns whether the metric requies we order candidates by descending value per weight unit.
223226
fn requires_ordering_by_descending_value_pwu(&self) -> bool {

src/coin_selector.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -562,9 +562,10 @@ impl<'a> CoinSelector<'a> {
562562
/// Most of the time, you would want to use [`CoinSelector::run_bnb`] instead.
563563
pub fn bnb_solutions<M: BnbMetric>(
564564
&self,
565+
target: Target,
565566
metric: M,
566567
) -> impl Iterator<Item = Option<(CoinSelector<'a>, Ordf32)>> {
567-
crate::bnb::BnbIter::new(self.clone(), metric)
568+
crate::bnb::BnbIter::new(self.clone(), target, metric)
568569
}
569570

570571
/// Run branch and bound to minimize the score of the provided [`BnbMetric`].
@@ -576,10 +577,11 @@ impl<'a> CoinSelector<'a> {
576577
/// Use [`CoinSelector::bnb_solutions`] to access the branch and bound iterator directly.
577578
pub fn run_bnb<M: BnbMetric>(
578579
&mut self,
580+
target: Target,
579581
metric: M,
580582
max_rounds: usize,
581583
) -> Result<(Ordf32, Drain), NoBnbSolution> {
582-
let mut iter = crate::bnb::BnbIter::new(self.clone(), metric);
584+
let mut iter = crate::bnb::BnbIter::new(self.clone(), target, metric);
583585
let mut rounds = 0_usize;
584586
let best = iter
585587
.by_ref()
@@ -588,7 +590,7 @@ impl<'a> CoinSelector<'a> {
588590
.flatten()
589591
.last();
590592
let (selector, score) = best.ok_or(NoBnbSolution { max_rounds, rounds })?;
591-
let drain = iter.metric.drain(&selector);
593+
let drain = iter.metric.drain(&selector, target);
592594
*self = selector;
593595
Ok((score, drain))
594596
}

src/metrics/changeless.rs

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@ use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, Target};
55
/// A selection is scored by `inner` only if the inner metric decides it should *not* have a change
66
/// output (see [`BnbMetric::drain`]); otherwise it is treated as invalid. This lets you find, for
77
/// example, the lowest-fee changeless solution via `Changeless<LowestFee>`.
8-
///
9-
/// `target` must match the target `inner` is optimizing for. It's used only by the branch-pruning
10-
/// heuristic (to tell which candidates reduce the excess); the change decision and the scoring are
11-
/// delegated entirely to `inner`.
128
#[derive(Clone, Copy, Debug)]
13-
pub struct Changeless<M> {
14-
/// The target of the resultant selection. Must match the target of `inner`.
15-
pub target: Target,
9+
pub struct Changeless<M>(
1610
/// The inner metric that scores changeless solutions and owns the change decision.
17-
pub inner: M,
18-
}
11+
pub M,
12+
);
1913

2014
impl<M: BnbMetric> Changeless<M> {
2115
/// Whether every selection reachable down this branch (the current one and any superset of it)
@@ -32,50 +26,50 @@ impl<M: BnbMetric> Changeless<M> {
3226
/// are next to each other, which [`requires_ordering_by_descending_value_pwu`] guarantees.
3327
///
3428
/// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu
35-
fn change_unavoidable(&mut self, cs: &CoinSelector<'_>) -> bool {
36-
if self.inner.drain(cs).is_none() {
29+
fn change_unavoidable(&mut self, cs: &CoinSelector<'_>, target: Target) -> bool {
30+
if self.0.drain(cs, target).is_none() {
3731
return false;
3832
}
3933

4034
let mut least_excess = cs.clone();
4135
cs.unselected()
4236
.rev()
43-
.take_while(|(_, wv)| wv.effective_value(self.target.fee.rate) < 0.0)
37+
.take_while(|(_, wv)| wv.effective_value(target.fee.rate) < 0.0)
4438
.for_each(|(index, _)| {
4539
least_excess.select(index);
4640
});
4741

48-
self.inner.drain(&least_excess).is_some()
42+
self.0.drain(&least_excess, target).is_some()
4943
}
5044
}
5145

5246
impl<M: BnbMetric> BnbMetric for Changeless<M> {
53-
fn drain(&mut self, _cs: &CoinSelector<'_>) -> Drain {
47+
fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain {
5448
// by definition a changeless selection never has a change output
5549
Drain::NONE
5650
}
5751

58-
fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32> {
52+
fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
5953
// Reject selections that have change. We don't need an explicit target-met check: `inner`
6054
// returns `None` for invalid (e.g. not-target-met) selections.
6155
//
6256
// NOTE: for metrics whose `score` recomputes the drain (e.g. `LowestFee`), this evaluates
6357
// the drain decision twice per node. Sharing it would mean threading the drain into
6458
// `score`, which we avoid to keep metrics composable.
65-
if self.inner.drain(cs).is_some() {
59+
if self.0.drain(cs, target).is_some() {
6660
return None;
6761
}
68-
self.inner.score(cs)
62+
self.0.score(cs, target)
6963
}
7064

71-
fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32> {
72-
if self.change_unavoidable(cs) {
65+
fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
66+
if self.change_unavoidable(cs, target) {
7367
// every descendant has change, so no changeless solution is reachable
7468
None
7569
} else {
7670
// the changeless-constrained optimum is no better than the inner metric's unconstrained
7771
// optimum, so the inner bound is a valid lower bound
78-
self.inner.bound(cs)
72+
self.0.bound(cs, target)
7973
}
8074
}
8175

src/metrics/lowest_fee.rs

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate
1717
/// dust threshold implied by `dust_relay_feerate`.
1818
#[derive(Clone, Copy)]
1919
pub struct LowestFee {
20-
/// The target parameters for the resultant selection.
21-
pub target: Target,
2220
/// The estimated feerate needed to spend our change output later.
2321
pub long_term_feerate: FeeRate,
2422
/// The feerate used to determine the dust threshold of the change output.
@@ -29,11 +27,11 @@ pub struct LowestFee {
2927

3028
impl LowestFee {
3129
/// The value the change output should have, or `None` if this selection should be changeless.
32-
fn drain_value(&self, cs: &CoinSelector<'_>) -> Option<u64> {
30+
fn drain_value(&self, cs: &CoinSelector<'_>, target: Target) -> Option<u64> {
3331
// The change output pays for its own weight, so the value we'd actually recover is the
3432
// excess remaining after accounting for that weight.
3533
let excess_with_drain_weight = cs.excess(
36-
self.target,
34+
target,
3735
Drain {
3836
weights: self.drain_weights,
3937
value: 0,
@@ -60,21 +58,22 @@ impl LowestFee {
6058
}
6159

6260
impl BnbMetric for LowestFee {
63-
fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain {
64-
self.drain_value(cs).map_or(Drain::NONE, |value| Drain {
65-
weights: self.drain_weights,
66-
value,
67-
})
61+
fn drain(&mut self, cs: &CoinSelector<'_>, target: Target) -> Drain {
62+
self.drain_value(cs, target)
63+
.map_or(Drain::NONE, |value| Drain {
64+
weights: self.drain_weights,
65+
value,
66+
})
6867
}
6968

70-
fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32> {
71-
if !cs.is_target_met(self.target) {
69+
fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
70+
if !cs.is_target_met(target) {
7271
return None;
7372
}
7473

7574
let long_term_fee = {
76-
let drain = self.drain(cs);
77-
let fee_for_the_tx = cs.fee(self.target.value(), drain.value);
75+
let drain = self.drain(cs, target);
76+
let fee_for_the_tx = cs.fee(target.value(), drain.value);
7877
assert!(
7978
fee_for_the_tx >= 0,
8079
"must not be called unless selection has met target: fee={}",
@@ -89,9 +88,9 @@ impl BnbMetric for LowestFee {
8988
Some(Ordf32(long_term_fee as f32))
9089
}
9190

92-
fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32> {
93-
if cs.is_target_met(self.target) {
94-
let current_score = self.score(cs).unwrap();
91+
fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
92+
if cs.is_target_met(target) {
93+
let current_score = self.score(cs, target).unwrap();
9594

9695
// `current_score` is already a valid lower bound for a selection that has change: a
9796
// descendant can never lower the fee by removing an existing (worthwhile) change
@@ -112,17 +111,17 @@ impl BnbMetric for LowestFee {
112111
// `drain_value`, where `change_value` is `excess_with_drain_weight` and `spend_fee` is
113112
// `drain_spend_cost`). With `v >= 0` the difference is strictly positive: B always
114113
// costs more.
115-
if self.drain_value(cs).is_none() {
114+
if self.drain_value(cs, target).is_none() {
116115
// But a descendant might *add* a change output that improves the metric. This
117116
// happens when the current selection is changeless only because the change would be
118117
// dust: a descendant with more excess could clear the dust threshold and recover
119118
// value that is currently burned to fees.
120119
let cost_of_adding_change = self.drain_weights.waste(
121-
self.target.fee.rate,
120+
target.fee.rate,
122121
self.long_term_feerate,
123-
self.target.outputs.n_outputs,
122+
target.outputs.n_outputs,
124123
);
125-
let cost_of_no_change = cs.excess(self.target, Drain::NONE);
124+
let cost_of_no_change = cs.excess(target, Drain::NONE);
126125

127126
let best_score_with_change =
128127
Ordf32(current_score.0 - cost_of_no_change as f32 + cost_of_adding_change);
@@ -137,11 +136,11 @@ impl BnbMetric for LowestFee {
137136
let (mut cs, resize_index, to_resize) = cs
138137
.clone()
139138
.select_iter()
140-
.find(|(cs, _, _)| cs.is_target_met(self.target))?;
139+
.find(|(cs, _, _)| cs.is_target_met(target))?;
141140

142141
// If this selection is already perfect, return its score directly.
143-
if cs.excess(self.target, Drain::NONE) == 0 {
144-
return Some(self.score(&cs).unwrap());
142+
if cs.excess(target, Drain::NONE) == 0 {
143+
return Some(self.score(&cs, target).unwrap());
145144
};
146145
cs.deselect(resize_index);
147146

@@ -162,13 +161,12 @@ impl BnbMetric for LowestFee {
162161
//
163162
// In the perfect scenario, no additional fee would be required to pay for rounding up when converting from weight units to
164163
// vbytes and so all fee calculations below are performed on weight units directly.
165-
let rate_excess = cs.rate_excess_wu(self.target, Drain::NONE) as f32;
164+
let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32;
166165
let mut scale = Ordf32(0.0);
167166

168167
if rate_excess < 0.0 {
169168
let remaining_value_to_reach_feerate = rate_excess.abs();
170-
let effective_value_of_resized_input =
171-
to_resize.effective_value(self.target.fee.rate);
169+
let effective_value_of_resized_input = to_resize.effective_value(target.fee.rate);
172170
if effective_value_of_resized_input > 0.0 {
173171
let feerate_scale =
174172
remaining_value_to_reach_feerate / effective_value_of_resized_input;
@@ -180,8 +178,8 @@ impl BnbMetric for LowestFee {
180178

181179
// We can use the same approach for replacement we just have to use the
182180
// incremental_relay_feerate.
183-
if let Some(replace) = self.target.fee.replace {
184-
let replace_excess = cs.replacement_excess_wu(self.target, Drain::NONE) as f32;
181+
if let Some(replace) = target.fee.replace {
182+
let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32;
185183
if replace_excess < 0.0 {
186184
let remaining_value_to_reach_feerate = replace_excess.abs();
187185
let effective_value_of_resized_input =
@@ -198,7 +196,7 @@ impl BnbMetric for LowestFee {
198196
// Handle absolute fee constraint. Unlike feerate and replacement, the
199197
// absolute fee is a fixed amount (not weight-proportional), so we just
200198
// need enough raw value to cover the gap.
201-
let absolute_excess = cs.absolute_excess(self.target, Drain::NONE) as f32;
199+
let absolute_excess = cs.absolute_excess(target, Drain::NONE) as f32;
202200
if absolute_excess < 0.0 {
203201
let remaining = absolute_excess.abs();
204202
if to_resize.value > 0 {
@@ -212,7 +210,7 @@ impl BnbMetric for LowestFee {
212210
// `scale` could be 0 even if `is_target_met` is `false` due to the latter being based on
213211
// rounded-up vbytes.
214212
let ideal_fee = scale.0 * to_resize.value as f32 + cs.selected_value() as f32
215-
- self.target.value() as f32;
213+
- target.value() as f32;
216214
assert!(ideal_fee >= 0.0);
217215

218216
Some(Ordf32(ideal_fee))

0 commit comments

Comments
 (0)