Skip to content

Commit 281f73c

Browse files
evanlinjinclaude
andcommitted
feat(metrics): add ChangelessWaste BnB metric
Adds a waste metric restricted to changeless selections. Removing the change output from the picture eliminates the non-monotonic discontinuity that complicates the general waste bound, so the LB reduces to a lower bound on `input_weight * (feerate - long_term_feerate)`. For target-not-met, rate_diff >= 0 we reuse LowestFee::bound's resize trick applied to input_weight rather than fee. Includes proptests `can_eventually_find_best_solution` and `ensure_bound_is_not_too_tight`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fffd1c0 commit 281f73c

3 files changed

Lines changed: 362 additions & 0 deletions

File tree

src/metrics.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ mod lowest_fee;
77
pub use lowest_fee::*;
88
mod changeless;
99
pub use changeless::*;
10+
mod changeless_waste;
11+
pub use changeless_waste::*;

src/metrics/changeless_waste.rs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, DrainWeights, FeeRate, Target};
2+
3+
/// Metric that minimizes the [waste metric] subject to the constraint that the selection produces
4+
/// no change output.
5+
///
6+
/// For a changeless selection, waste reduces to:
7+
///
8+
/// > `input_weight * (feerate - long_term_feerate) + max(0, excess)`
9+
///
10+
/// Excess in a changeless transaction goes to the miner as fees and is therefore fully counted as
11+
/// waste.
12+
///
13+
/// Restricting to changeless solutions removes the non-monotonic discontinuity that the general
14+
/// (with-change) waste metric has when an input flips the change output on or off, which makes a
15+
/// correct bound much easier to construct.
16+
///
17+
/// Like [`LowestFee`], `ChangelessWaste` decides for itself whether a selection *would* have a
18+
/// change output (using the same rule: change is worthwhile when the recovered excess outweighs the
19+
/// future cost of spending it and clears the dust threshold). Selections that would have change are
20+
/// rejected, so only genuinely changeless selections are scored.
21+
///
22+
/// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection
23+
/// [`LowestFee`]: crate::metrics::LowestFee
24+
#[derive(Clone, Copy, Debug)]
25+
pub struct ChangelessWaste {
26+
/// The estimated feerate needed to spend a change output later. This is used by the metric
27+
/// even though the scored selections do not have a change output — the long-term feerate
28+
/// defines the `feerate - long_term_feerate` weight cost of each input.
29+
pub long_term_feerate: FeeRate,
30+
/// The feerate used to determine the dust threshold of the change output.
31+
pub dust_relay_feerate: FeeRate,
32+
/// The weights of the change output that would be added.
33+
pub drain_weights: DrainWeights,
34+
}
35+
36+
impl ChangelessWaste {
37+
/// The value the change output would have, or `None` if this selection should be changeless.
38+
///
39+
/// This is the same change decision as [`LowestFee`]: the metric owns its change policy instead
40+
/// of taking one as input.
41+
///
42+
/// [`LowestFee`]: crate::metrics::LowestFee
43+
fn drain_value(&self, cs: &CoinSelector<'_>, target: Target) -> Option<u64> {
44+
// The change output pays for its own weight, so the value we'd actually recover is the
45+
// excess remaining after accounting for that weight.
46+
let excess_with_drain_weight = cs.excess(
47+
target,
48+
Drain {
49+
weights: self.drain_weights,
50+
value: 0,
51+
},
52+
);
53+
54+
// Adding change is only worth it if the value we'd recover exceeds the future cost of
55+
// spending it (i.e. it lowers the long-term fee).
56+
let drain_spend_cost = self
57+
.long_term_feerate
58+
.implied_fee_wu(self.drain_weights.spend_weight);
59+
if excess_with_drain_weight <= drain_spend_cost as i64 {
60+
return None;
61+
}
62+
63+
// ...and only if the change output would not be dust.
64+
let dust_threshold = self.drain_weights.dust_threshold(self.dust_relay_feerate);
65+
if excess_with_drain_weight < dust_threshold as i64 {
66+
return None;
67+
}
68+
69+
Some(excess_with_drain_weight.unsigned_abs())
70+
}
71+
72+
/// Whether every selection reachable down this branch (the current one and any superset of it)
73+
/// would have a change output — so no changeless solution exists here and the branch can be
74+
/// pruned.
75+
///
76+
/// The change decision is assumed monotone in the excess (see [`drain_value`]), so the
77+
/// reachable selection least likely to have change is the one with the smallest excess: the
78+
/// current selection plus every remaining negative-effective-value candidate. If even that
79+
/// selection still has change, then so does every reachable selection.
80+
///
81+
/// NOTE: this relies on candidates being sorted so that all negative effective value candidates
82+
/// are next to each other, which [`requires_ordering_by_descending_value_pwu`] guarantees.
83+
///
84+
/// [`drain_value`]: Self::drain_value
85+
/// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu
86+
fn change_unavoidable(&mut self, cs: &CoinSelector<'_>, target: Target) -> bool {
87+
if self.drain_value(cs, target).is_none() {
88+
return false;
89+
}
90+
91+
let mut least_excess = cs.clone();
92+
cs.unselected()
93+
.rev()
94+
.take_while(|(_, wv)| wv.effective_value(target.fee.rate) < 0.0)
95+
.for_each(|(index, _)| {
96+
least_excess.select(index);
97+
});
98+
99+
self.drain_value(&least_excess, target).is_some()
100+
}
101+
}
102+
103+
impl BnbMetric for ChangelessWaste {
104+
fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain {
105+
// By definition a changeless selection never has a change output.
106+
Drain::NONE
107+
}
108+
109+
fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
110+
if !cs.is_target_met(target) {
111+
return None;
112+
}
113+
// Reject selections that would have change — this metric only scores changeless solutions.
114+
if self.drain_value(cs, target).is_some() {
115+
return None;
116+
}
117+
let waste = cs.waste(target, self.long_term_feerate, Drain::NONE, 1.0);
118+
Some(Ordf32(waste))
119+
}
120+
121+
fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
122+
// Prune branches where every descendant is forced to have a change output.
123+
if self.change_unavoidable(cs, target) {
124+
return None;
125+
}
126+
127+
let rate_diff = target.fee.rate.spwu() - self.long_term_feerate.spwu();
128+
129+
// For any changeless target-meeting descendant D ⊇ cs:
130+
// score(D) = D.input_weight * rate_diff + max(0, D.excess)
131+
//
132+
// and `D.excess >= 0` (target met), so `score(D) >= D.input_weight * rate_diff`. The
133+
// bound therefore reduces to finding a lower bound on `D.input_weight * rate_diff`.
134+
135+
if rate_diff < 0.0 {
136+
// rate_diff < 0: the most negative `D.input_weight * rate_diff` comes from the
137+
// largest possible input_weight, which is bounded by selecting every candidate.
138+
// (`D` need not actually be feasible — we only need an LB on its score.)
139+
let mut all = cs.clone();
140+
all.select_all();
141+
return Some(Ordf32(all.input_weight() as f32 * rate_diff));
142+
}
143+
144+
// rate_diff >= 0: smaller input_weight gives a smaller `input_weight * rate_diff`, so we
145+
// want a lower bound on `D.input_weight`. `D.input_weight >= cs.input_weight` always
146+
// (selecting only grows), but we can do much better when the target is not yet met.
147+
if cs.is_target_met(target) {
148+
return Some(Ordf32(cs.input_weight() as f32 * rate_diff));
149+
}
150+
151+
// Target not met. Use the same resize trick as `LowestFee::bound`: walk the sorted
152+
// unselected list until we cross the target, then pretend the crossing input was
153+
// perfectly scaled so that the target is hit with zero excess. Among all subsets of
154+
// unselected that reach target, the highest-`value_pwu` candidates are the most
155+
// weight-efficient — so the resize-scaled prefix is a valid lower bound on any
156+
// target-meeting descendant's input_weight.
157+
let (mut cs, resize_index, to_resize) = cs
158+
.clone()
159+
.select_iter()
160+
.find(|(cs, _, _)| cs.is_target_met(target))?;
161+
162+
// If the find selection already hits target exactly, that's the minimum-weight
163+
// target-meeting subset; the bound is its waste (with `Drain::NONE`).
164+
if cs.excess(target, Drain::NONE) == 0 {
165+
return Some(Ordf32(cs.waste(
166+
target,
167+
self.long_term_feerate,
168+
Drain::NONE,
169+
1.0,
170+
)));
171+
}
172+
cs.deselect(resize_index);
173+
174+
// Compute the smallest `scale` of `to_resize` that satisfies each fee constraint.
175+
let mut scale = Ordf32(0.0);
176+
177+
let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32;
178+
if rate_excess < 0.0 {
179+
let remaining = rate_excess.abs();
180+
let ev_resized = to_resize.effective_value(target.fee.rate);
181+
if ev_resized > 0.0 {
182+
scale = scale.max(Ordf32(remaining / ev_resized));
183+
} else {
184+
return None;
185+
}
186+
}
187+
if let Some(replace) = target.fee.replace {
188+
let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32;
189+
if replace_excess < 0.0 {
190+
let remaining = replace_excess.abs();
191+
let ev_resized = to_resize.effective_value(replace.incremental_relay_feerate);
192+
if ev_resized > 0.0 {
193+
scale = scale.max(Ordf32(remaining / ev_resized));
194+
} else {
195+
return None;
196+
}
197+
}
198+
}
199+
let absolute_excess = cs.absolute_excess(target, Drain::NONE) as f32;
200+
if absolute_excess < 0.0 {
201+
let remaining = absolute_excess.abs();
202+
if to_resize.value > 0 {
203+
scale = scale.max(Ordf32(remaining / to_resize.value as f32));
204+
} else {
205+
return None;
206+
}
207+
}
208+
209+
let ideal_input_weight = cs.input_weight() as f32 + scale.0 * to_resize.weight as f32;
210+
Some(Ordf32(ideal_input_weight * rate_diff))
211+
}
212+
213+
fn requires_ordering_by_descending_value_pwu(&self) -> bool {
214+
true
215+
}
216+
}

tests/changeless_waste.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#![allow(unused_imports)]
2+
3+
mod common;
4+
use bdk_coin_select::metrics::ChangelessWaste;
5+
use bdk_coin_select::{
6+
BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Replace, Target, TargetFee,
7+
TargetOutputs, TX_FIXED_FIELD_WEIGHT,
8+
};
9+
use proptest::prelude::*;
10+
11+
proptest! {
12+
#![proptest_config(ProptestConfig {
13+
..Default::default()
14+
})]
15+
16+
#[test]
17+
#[cfg(not(debug_assertions))] // too slow if compiling for debug
18+
fn can_eventually_find_best_solution(
19+
n_candidates in 1..15_usize,
20+
target_value in 500..500_000_u64,
21+
n_target_outputs in 1usize..150,
22+
target_weight in 0..10_000_u32,
23+
replace in common::maybe_replace(0u64..10_000),
24+
feerate in 1.0..100.0_f32,
25+
feerate_lt_diff in -5.0..50.0_f32,
26+
drain_weight in 100..=500_u32,
27+
drain_spend_weight in 1..=2000_u32,
28+
drain_dust in 100..=1000_u64,
29+
n_drain_outputs in 1usize..150,
30+
) {
31+
let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs };
32+
let candidates = common::gen_candidates(params.n_candidates);
33+
let metric = ChangelessWaste {
34+
long_term_feerate: params.long_term_feerate(),
35+
dust_relay_feerate: params.dust_relay_feerate(),
36+
drain_weights: params.drain_weights(),
37+
};
38+
common::can_eventually_find_best_solution(params, candidates, metric)?;
39+
}
40+
41+
#[test]
42+
#[cfg(not(debug_assertions))] // too slow if compiling for debug
43+
fn ensure_bound_is_not_too_tight(
44+
n_candidates in 0..12_usize,
45+
target_value in 500..500_000_u64,
46+
n_target_outputs in 1usize..150,
47+
target_weight in 0..10_000_u32,
48+
replace in common::maybe_replace(0u64..10_000),
49+
feerate in 1.0..100.0_f32,
50+
feerate_lt_diff in -5.0..50.0_f32,
51+
drain_weight in 100..=500_u32,
52+
drain_spend_weight in 1..=2000_u32,
53+
drain_dust in 100..=1000_u64,
54+
n_drain_outputs in 1usize..150,
55+
) {
56+
let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs };
57+
let candidates = common::gen_candidates(params.n_candidates);
58+
let metric = ChangelessWaste {
59+
long_term_feerate: params.long_term_feerate(),
60+
dust_relay_feerate: params.dust_relay_feerate(),
61+
drain_weights: params.drain_weights(),
62+
};
63+
common::ensure_bound_is_not_too_tight(params, candidates, metric)?;
64+
}
65+
}
66+
67+
/// Sanity-check: the BnB solution must never have a change output, and its waste must be
68+
/// no greater than the waste of any changeless brute-force selection we try.
69+
#[test]
70+
fn solution_is_changeless_and_not_worse_than_naive() {
71+
let params = common::StrategyParams {
72+
n_candidates: 12,
73+
target_value: 90_000,
74+
n_target_outputs: 1,
75+
target_weight: 200 - TX_FIXED_FIELD_WEIGHT as u32 - 1,
76+
replace: None,
77+
feerate: 10.0,
78+
feerate_lt_diff: 2.0, // long_term_feerate < feerate (rate_diff > 0)
79+
drain_weight: 200,
80+
drain_spend_weight: 600,
81+
drain_dust: 200,
82+
n_drain_outputs: 1,
83+
};
84+
85+
let candidates = common::gen_candidates(params.n_candidates);
86+
let mut cs = CoinSelector::new(&candidates);
87+
88+
let mut metric = ChangelessWaste {
89+
long_term_feerate: params.long_term_feerate(),
90+
dust_relay_feerate: params.dust_relay_feerate(),
91+
drain_weights: params.drain_weights(),
92+
};
93+
94+
match common::bnb_search(&mut cs, params.target(), metric, usize::MAX) {
95+
Ok((_score, _rounds)) => {
96+
// A scored solution is changeless by construction: `score` returns `None` for any
97+
// selection the metric would give a change output, so a returned solution is one the
98+
// metric was able to score.
99+
assert!(
100+
metric.score(&cs, params.target()).is_some(),
101+
"BnB result must be changeless and meet the target"
102+
);
103+
assert!(cs.is_target_met(params.target()));
104+
}
105+
Err(_) => {
106+
// No changeless solution exists for this combo — that's allowed.
107+
}
108+
}
109+
}
110+
111+
/// When `rate_diff < 0`, the metric will tend to consolidate (add inputs to reduce input_waste),
112+
/// but only as long as it can keep the selection changeless.
113+
#[test]
114+
fn consolidation_regime_stays_changeless() {
115+
let params = common::StrategyParams {
116+
n_candidates: 10,
117+
target_value: 50_000,
118+
n_target_outputs: 1,
119+
target_weight: 200 - TX_FIXED_FIELD_WEIGHT as u32 - 1,
120+
replace: None,
121+
feerate: 2.0,
122+
feerate_lt_diff: 10.0, // long_term_feerate > feerate (rate_diff < 0)
123+
drain_weight: 200,
124+
drain_spend_weight: 600,
125+
drain_dust: 200,
126+
n_drain_outputs: 1,
127+
};
128+
129+
let candidates = common::gen_candidates(params.n_candidates);
130+
let mut cs = CoinSelector::new(&candidates);
131+
132+
let mut metric = ChangelessWaste {
133+
long_term_feerate: params.long_term_feerate(),
134+
dust_relay_feerate: params.dust_relay_feerate(),
135+
drain_weights: params.drain_weights(),
136+
};
137+
138+
if common::bnb_search(&mut cs, params.target(), metric, usize::MAX).is_ok() {
139+
assert!(
140+
metric.score(&cs, params.target()).is_some(),
141+
"result must be changeless"
142+
);
143+
}
144+
}

0 commit comments

Comments
 (0)