Skip to content

Commit 055510e

Browse files
committed
WIP(coin_select): prop tests for lowest fee metric
`can_eventually_find_best_solution` ensures that the lowest fee metric is always able to find the best solution. We find the best solution via an exhaustive search. Essentially, this ensures that our bounding method never undershoots. `ensure_bound_does_not_undershoot` is a more fine-grained test for the above. The checks descendant solutions after a branch, and ensures that the branch's lower-bound is never bet with the descendant solutions.
1 parent c1a9dec commit 055510e

File tree

8 files changed

+414
-72
lines changed

8 files changed

+414
-72
lines changed

nursery/coin_select/src/bnb.rs

+6-6
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> {
1818
// for thing in self.queue.iter() {
1919
// println!("{} {:?}", &thing.selector, thing.lower_bound);
2020
// }
21-
// let _ = std::io::stdin().read_line(&mut String::new());
21+
// let _ = std::io::stdin().read_line(&mut alloc::string::String::new());
2222
// }
2323

2424
let branch = self.queue.pop()?;
@@ -88,14 +88,14 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> {
8888
}
8989

9090
let next_unselected = cs.unselected_indices().next().unwrap();
91-
let mut inclusion_cs = cs.clone();
92-
inclusion_cs.select(next_unselected);
91+
9392
let mut exclusion_cs = cs.clone();
9493
exclusion_cs.ban(next_unselected);
94+
self.consider_adding_to_queue(&exclusion_cs, true);
9595

96-
for (child_cs, is_exclusion) in &[(&inclusion_cs, false), (&exclusion_cs, true)] {
97-
self.consider_adding_to_queue(child_cs, *is_exclusion)
98-
}
96+
let mut inclusion_cs = cs.clone();
97+
inclusion_cs.select(next_unselected);
98+
self.consider_adding_to_queue(&inclusion_cs, false);
9999
}
100100
}
101101

nursery/coin_select/src/coin_selector.rs

+3-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use alloc::{borrow::Cow, collections::BTreeSet, vec::Vec};
1212
/// [`bnb_solutions`]: CoinSelector::bnb_solutions
1313
#[derive(Debug, Clone)]
1414
pub struct CoinSelector<'a> {
15-
base_weight: u32,
15+
pub base_weight: u32,
1616
candidates: &'a [Candidate],
1717
selected: Cow<'a, BTreeSet<usize>>,
1818
banned: Cow<'a, BTreeSet<usize>>,
@@ -672,8 +672,8 @@ impl std::error::Error for InsufficientFunds {}
672672

673673
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674674
pub struct NoBnbSolution {
675-
max_rounds: usize,
676-
rounds: usize,
675+
pub max_rounds: usize,
676+
pub rounds: usize,
677677
}
678678

679679
impl core::fmt::Display for NoBnbSolution {

nursery/coin_select/src/metrics/lowest_fee.rs

+67-52
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,35 @@ pub struct LowestFee<'c, C> {
99
pub change_policy: &'c C,
1010
}
1111

12+
impl<'c, C> Clone for LowestFee<'c, C> {
13+
fn clone(&self) -> Self {
14+
Self {
15+
target: self.target.clone(),
16+
long_term_feerate: self.long_term_feerate.clone(),
17+
change_policy: self.change_policy.clone(),
18+
}
19+
}
20+
}
21+
22+
impl<'c, C> Copy for LowestFee<'c, C> {}
23+
1224
impl<'c, C> LowestFee<'c, C>
1325
where
1426
for<'a, 'b> C: Fn(&'b CoinSelector<'a>, Target) -> Drain,
1527
{
16-
fn calculate_metric(&self, cs: &CoinSelector<'_>, drain_weights: Option<DrainWeights>) -> f32 {
28+
fn calc_metric(&self, cs: &CoinSelector<'_>, drain_weights: Option<DrainWeights>) -> f32 {
29+
self.calc_metric_lb(cs, drain_weights)
30+
+ match drain_weights {
31+
Some(_) => {
32+
let selected_value = cs.selected_value();
33+
assert!(selected_value >= self.target.value);
34+
(cs.selected_value() - self.target.value) as f32
35+
}
36+
None => 0.0,
37+
}
38+
}
39+
40+
fn calc_metric_lb(&self, cs: &CoinSelector<'_>, drain_weights: Option<DrainWeights>) -> f32 {
1741
match drain_weights {
1842
// with change
1943
Some(drain_weights) => {
@@ -22,10 +46,7 @@ where
2246
+ drain_weights.spend_weight as f32 * self.long_term_feerate.spwu()
2347
}
2448
// changeless
25-
None => {
26-
cs.input_weight() as f32 * self.target.feerate.spwu()
27-
+ (cs.selected_value() - self.target.value) as f32
28-
}
49+
None => cs.input_weight() as f32 * self.target.feerate.spwu(),
2950
}
3051
}
3152
}
@@ -48,7 +69,7 @@ where
4869
None
4970
};
5071

51-
Some(Ordf32(self.calculate_metric(cs, drain_weights)))
72+
Some(Ordf32(self.calc_metric(cs, drain_weights)))
5273
}
5374

5475
fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Self::Score> {
@@ -66,11 +87,12 @@ where
6687
// Target is met, is it possible to add further inputs to remove drain output?
6788
// If we do, can we get a better score?
6889

69-
// First lower bound candidate is just the selection itself.
70-
let mut lower_bound = self.calculate_metric(cs, change_lb_weights);
90+
// First lower bound candidate is just the selection itself (include excess).
91+
let mut lower_bound = self.calc_metric(cs, change_lb_weights);
7192

72-
// Since a changeless solution may exist, we should try reduce the excess
7393
if change_lb.is_none() {
94+
// Since a changeless solution may exist, we should try minimize the excess with by
95+
// adding as much -ev candidates as possible
7496
let selection_with_as_much_negative_ev_as_possible = cs
7597
.clone()
7698
.select_iter()
@@ -95,18 +117,19 @@ where
95117
});
96118
let lower_bound_changeless = match can_do_better_by_slurping {
97119
Some(finishing_input) => {
98-
let excess = cs.rate_excess(self.target, Drain::none());
120+
let excess = dbg!(cs.rate_excess(self.target, Drain::none()));
99121

100122
// change the input's weight to make it's effective value match the excess
101123
let perfect_input_weight =
102-
slurp_candidate(finishing_input, excess, self.target.feerate);
124+
slurp(&cs, self.target, excess, finishing_input);
103125

104-
(cs.input_weight() as f32 + perfect_input_weight)
126+
(cs.input_weight() as f32 + dbg!(perfect_input_weight))
105127
* self.target.feerate.spwu()
106128
}
107-
None => self.calculate_metric(&cs, None),
129+
None => self.calc_metric(&cs, None),
108130
};
109131

132+
// assert!(lower_bound_changeless > lower_bound);
110133
lower_bound = lower_bound.min(lower_bound_changeless)
111134
}
112135
}
@@ -122,54 +145,46 @@ where
122145
.find(|(cs, _, _)| cs.is_target_met(self.target, change_lb))?;
123146
cs.deselect(slurp_index);
124147

125-
let perfect_excess = i64::min(
126-
cs.rate_excess(self.target, Drain::none()),
127-
cs.absolute_excess(self.target, Drain::none()),
128-
);
148+
let mut lower_bound = self.calc_metric_lb(&cs, change_lb_weights);
129149

130-
match change_lb_weights {
131-
// must have change!
132-
Some(change_weights) => {
133-
// [todo] This will not be perfect, just a placeholder for now
134-
let lowest_fee = (cs.input_weight() + change_weights.output_weight) as f32
135-
* self.target.feerate.spwu()
136-
+ change_weights.spend_weight as f32 * self.long_term_feerate.spwu();
150+
if change_lb_weights.is_none() {
151+
// changeless solution is possible, find the max excess we need to rid of
152+
let perfect_excess = i64::max(
153+
cs.rate_excess(self.target, Drain::none()),
154+
cs.absolute_excess(self.target, Drain::none()),
155+
);
137156

138-
Some(Ordf32(lowest_fee))
139-
}
140-
// can be changeless!
141-
None => {
142-
// use the lowest excess to find "perfect candidate weight"
143-
let perfect_input_weight =
144-
slurp_candidate(candidate_to_slurp, perfect_excess, self.target.feerate);
157+
// use the lowest excess to find "perfect candidate weight"
158+
let perfect_input_weight = slurp(&cs, self.target, perfect_excess, candidate_to_slurp);
145159

146-
// the perfect input weight canned the excess and we assume no change
147-
let lowest_fee =
148-
(cs.input_weight() as f32 + perfect_input_weight) * self.target.feerate.spwu();
149-
150-
Some(Ordf32(lowest_fee))
151-
}
160+
lower_bound += perfect_input_weight * self.target.feerate.spwu();
152161
}
162+
163+
Some(Ordf32(lower_bound))
153164
}
154165

155166
fn requires_ordering_by_descending_value_pwu(&self) -> bool {
156167
true
157168
}
158169
}
159170

160-
fn slurp_candidate(candidate: Candidate, excess: i64, feerate: FeeRate) -> f32 {
161-
let candidate_weight = candidate.weight as f32;
162-
163-
// this equation is dervied from:
164-
// * `input_effective_value = input_value - input_weight * feerate`
165-
// * `input_value * new_input_weight = new_input_value * input_weight`
166-
// (ensure we have the same value:weight ratio)
167-
// where we want `input_effective_value` to match `-excess`.
168-
let perfect_weight = -(candidate_weight * excess as f32)
169-
/ (candidate.value as f32 - candidate_weight * feerate.spwu());
170-
171-
debug_assert!(perfect_weight <= candidate_weight);
172-
173-
// we can't allow the weight to go negative
174-
perfect_weight.min(0.0)
171+
fn slurp(_cs: &CoinSelector, target: Target, excess: i64, candidate: Candidate) -> f32 {
172+
// let input_eff_sum = cs.effective_value(target.feerate) as f32;
173+
// let eff_target = cs.base_weight as f32 * target.feerate.spwu() + target.value as f32;
174+
let vpw = candidate.value_pwu().0;
175+
176+
// let perfect_weight =
177+
// (-old_excess as f32 + eff_target - input_eff_sum) / (vpw - target.feerate.spwu());
178+
let perfect_weight = -excess as f32 / (vpw - target.feerate.spwu());
179+
180+
debug_assert!(
181+
{
182+
let perfect_value = (candidate.value as f32 * perfect_weight) / candidate.weight as f32;
183+
let perfect_vpw = perfect_value / perfect_weight;
184+
(vpw - perfect_vpw).abs() < 0.01
185+
},
186+
"value:weight ratio must stay the same"
187+
);
188+
189+
perfect_weight.max(0.0)
175190
}

nursery/coin_select/src/metrics/waste.rs

+12
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ pub struct Waste<'c, C> {
2424
pub change_policy: &'c C,
2525
}
2626

27+
impl<'c, C> Clone for Waste<'c, C> {
28+
fn clone(&self) -> Self {
29+
Self {
30+
target: self.target.clone(),
31+
long_term_feerate: self.long_term_feerate.clone(),
32+
change_policy: self.change_policy.clone(),
33+
}
34+
}
35+
}
36+
37+
impl<'c, C> Copy for Waste<'c, C> {}
38+
2739
impl<'c, C> BnbMetric for Waste<'c, C>
2840
where
2941
for<'a, 'b> C: Fn(&'b CoinSelector<'a>, Target) -> Drain,

nursery/coin_select/tests/changeless.rs

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#![allow(unused)]
2-
use bdk_coin_select::{float::Ordf32, metrics, Candidate, CoinSelector, Drain, FeeRate, Target};
2+
use bdk_coin_select::{
3+
float::Ordf32, metrics, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Target,
4+
};
35
use proptest::{
46
prelude::*,
57
test_runner::{RngAlgorithm, TestRng},
@@ -41,10 +43,9 @@ proptest! {
4143
let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha);
4244
let long_term_feerate = FeeRate::from_sat_per_vb(0.0f32.max(feerate - long_term_feerate_diff));
4345
let feerate = FeeRate::from_sat_per_vb(feerate);
44-
let drain = Drain {
45-
weight: change_weight,
46+
let drain = DrainWeights {
47+
output_weight: change_weight,
4648
spend_weight: change_spend_weight,
47-
value: 0
4849
};
4950

5051
let change_policy = bdk_coin_select::change_policy::min_waste(drain, long_term_feerate);
@@ -59,7 +60,7 @@ proptest! {
5960
min_fee
6061
};
6162

62-
let solutions = cs.branch_and_bound(metrics::Changeless {
63+
let solutions = cs.bnb_solutions(metrics::Changeless {
6364
target,
6465
change_policy: &change_policy
6566
});
@@ -78,7 +79,7 @@ proptest! {
7879
let mut naive_select = cs.clone();
7980
naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.effective_value(target.feerate)));
8081
// we filter out failing onces below
81-
let _ = naive_select.select_until_target_met(target, drain);
82+
let _ = naive_select.select_until_target_met(target, Drain { weights: drain, value: 0 });
8283
naive_select
8384
},
8485
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc a75fec9349c584791ef633224614369ec3721bccbe91bb24fe96978637a01098 # shrinks to n_candidates = 13, target_value = 16056, base_weight = 484, min_fee = 0, feerate = 37.709625, feerate_lt_diff = 0.0, drain_weight = 220, drain_spend_weight = 369, drain_dust = 100
8+
cc 19a016e1ac5ec54d3b513b0e09a1a287ab49c6454313e2220b881be5bfa18df5 # shrinks to n_candidates = 0, target_value = 200, base_weight = 164, min_fee = 0, feerate = 1.0, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 250, drain_dust = 100
9+
cc 9ded3861c49dc9c6992657857771e24b5538b38917c701104b527de3ba155f5f # shrinks to n_candidates = 1, target_value = 200, base_weight = 164, min_fee = 0, feerate = 1.0, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 250, drain_dust = 100

0 commit comments

Comments
 (0)