From 758d85415ce7a08277983d5be54c1d1d08b6da78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 17 Aug 2023 15:08:28 +0800 Subject: [PATCH] WIP(coin_select): prop tests for lowest fee metric --- nursery/coin_select/src/bnb.rs | 6 +- nursery/coin_select/src/coin_selector.rs | 6 +- nursery/coin_select/src/metrics/lowest_fee.rs | 54 ++-- nursery/coin_select/src/metrics/waste.rs | 12 + nursery/coin_select/tests/changeless.rs | 13 +- .../metrics_lowest_fee.proptest-regressions | 8 + .../coin_select/tests/metrics_lowest_fee.rs | 281 ++++++++++++++++++ nursery/coin_select/tests/waste.rs | 9 +- 8 files changed, 356 insertions(+), 33 deletions(-) create mode 100644 nursery/coin_select/tests/metrics_lowest_fee.proptest-regressions create mode 100644 nursery/coin_select/tests/metrics_lowest_fee.rs diff --git a/nursery/coin_select/src/bnb.rs b/nursery/coin_select/src/bnb.rs index 6e02cec326..a9d37daca4 100644 --- a/nursery/coin_select/src/bnb.rs +++ b/nursery/coin_select/src/bnb.rs @@ -18,7 +18,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { // for thing in self.queue.iter() { // println!("{} {:?}", &thing.selector, thing.lower_bound); // } - // let _ = std::io::stdin().read_line(&mut String::new()); + // let _ = std::io::stdin().read_line(&mut alloc::string::String::new()); // } let branch = self.queue.pop()?; @@ -48,6 +48,10 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { } } self.best = Some(score.clone()); + println!( + "\tsolution: {} lb={:?}, score={:?}", + &selector, branch.lower_bound, score + ); Some(Some((selector, score))) } } diff --git a/nursery/coin_select/src/coin_selector.rs b/nursery/coin_select/src/coin_selector.rs index 99e3430d30..74a390406a 100644 --- a/nursery/coin_select/src/coin_selector.rs +++ b/nursery/coin_select/src/coin_selector.rs @@ -12,7 +12,7 @@ use alloc::{borrow::Cow, collections::BTreeSet, vec::Vec}; /// [`bnb_solutions`]: CoinSelector::bnb_solutions #[derive(Debug, Clone)] pub struct CoinSelector<'a> { - base_weight: u32, + pub base_weight: u32, candidates: &'a [Candidate], selected: Cow<'a, BTreeSet>, banned: Cow<'a, BTreeSet>, @@ -672,8 +672,8 @@ impl std::error::Error for InsufficientFunds {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct NoBnbSolution { - max_rounds: usize, - rounds: usize, + pub max_rounds: usize, + pub rounds: usize, } impl core::fmt::Display for NoBnbSolution { diff --git a/nursery/coin_select/src/metrics/lowest_fee.rs b/nursery/coin_select/src/metrics/lowest_fee.rs index 319af0727e..25957718b9 100644 --- a/nursery/coin_select/src/metrics/lowest_fee.rs +++ b/nursery/coin_select/src/metrics/lowest_fee.rs @@ -9,6 +9,18 @@ pub struct LowestFee<'c, C> { pub change_policy: &'c C, } +impl<'c, C> Clone for LowestFee<'c, C> { + fn clone(&self) -> Self { + Self { + target: self.target.clone(), + long_term_feerate: self.long_term_feerate.clone(), + change_policy: self.change_policy.clone(), + } + } +} + +impl<'c, C> Copy for LowestFee<'c, C> {} + impl<'c, C> LowestFee<'c, C> where for<'a, 'b> C: Fn(&'b CoinSelector<'a>, Target) -> Drain, @@ -99,7 +111,7 @@ where // change the input's weight to make it's effective value match the excess let perfect_input_weight = - slurp_candidate(finishing_input, excess, self.target.feerate); + slurp(&cs, self.target, excess, finishing_input); (cs.input_weight() as f32 + perfect_input_weight) * self.target.feerate.spwu() @@ -122,7 +134,7 @@ where .find(|(cs, _, _)| cs.is_target_met(self.target, change_lb))?; cs.deselect(slurp_index); - let perfect_excess = i64::min( + let perfect_excess = i64::max( cs.rate_excess(self.target, Drain::none()), cs.absolute_excess(self.target, Drain::none()), ); @@ -135,18 +147,20 @@ where * self.target.feerate.spwu() + change_weights.spend_weight as f32 * self.long_term_feerate.spwu(); + println!("[target_not_met:must_have_change] LB: {}", lowest_fee); Some(Ordf32(lowest_fee)) } // can be changeless! None => { // use the lowest excess to find "perfect candidate weight" let perfect_input_weight = - slurp_candidate(candidate_to_slurp, perfect_excess, self.target.feerate); + slurp(&cs, self.target, perfect_excess, candidate_to_slurp); // the perfect input weight canned the excess and we assume no change let lowest_fee = (cs.input_weight() as f32 + perfect_input_weight) * self.target.feerate.spwu(); + println!("[target_not_met:changeless_possible] LB: {}", lowest_fee); Some(Ordf32(lowest_fee)) } } @@ -157,19 +171,23 @@ where } } -fn slurp_candidate(candidate: Candidate, excess: i64, feerate: FeeRate) -> f32 { - let candidate_weight = candidate.weight as f32; - - // this equation is dervied from: - // * `input_effective_value = input_value - input_weight * feerate` - // * `input_value * new_input_weight = new_input_value * input_weight` - // (ensure we have the same value:weight ratio) - // where we want `input_effective_value` to match `-excess`. - let perfect_weight = -(candidate_weight * excess as f32) - / (candidate.value as f32 - candidate_weight * feerate.spwu()); - - debug_assert!(perfect_weight <= candidate_weight); - - // we can't allow the weight to go negative - perfect_weight.min(0.0) +fn slurp(cs: &CoinSelector, target: Target, excess: i64, candidate: Candidate) -> f32 { + // let input_eff_sum = cs.effective_value(target.feerate) as f32; + // let eff_target = cs.base_weight as f32 * target.feerate.spwu() + target.value as f32; + let vpw = candidate.value_pwu().0; + + // let perfect_weight = + // (-old_excess as f32 + eff_target - input_eff_sum) / (vpw - target.feerate.spwu()); + let perfect_weight = -(excess) as f32 / (dbg!(vpw) - target.feerate.spwu()); + + assert!( + { + let perfect_value = (candidate.value as f32 * perfect_weight) / candidate.weight as f32; + let perfect_vpw = dbg!(dbg!(perfect_value) / dbg!(perfect_weight)); + (vpw - perfect_vpw).abs() < 0.01 + }, + "value:weight ratio must stay the same" + ); + + perfect_weight } diff --git a/nursery/coin_select/src/metrics/waste.rs b/nursery/coin_select/src/metrics/waste.rs index 010c1f8a54..a7f579fc69 100644 --- a/nursery/coin_select/src/metrics/waste.rs +++ b/nursery/coin_select/src/metrics/waste.rs @@ -24,6 +24,18 @@ pub struct Waste<'c, C> { pub change_policy: &'c C, } +impl<'c, C> Clone for Waste<'c, C> { + fn clone(&self) -> Self { + Self { + target: self.target.clone(), + long_term_feerate: self.long_term_feerate.clone(), + change_policy: self.change_policy.clone(), + } + } +} + +impl<'c, C> Copy for Waste<'c, C> {} + impl<'c, C> BnbMetric for Waste<'c, C> where for<'a, 'b> C: Fn(&'b CoinSelector<'a>, Target) -> Drain, diff --git a/nursery/coin_select/tests/changeless.rs b/nursery/coin_select/tests/changeless.rs index 4f3479e4dd..1796191748 100644 --- a/nursery/coin_select/tests/changeless.rs +++ b/nursery/coin_select/tests/changeless.rs @@ -1,5 +1,7 @@ #![allow(unused)] -use bdk_coin_select::{float::Ordf32, metrics, Candidate, CoinSelector, Drain, FeeRate, Target}; +use bdk_coin_select::{ + float::Ordf32, metrics, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Target, +}; use proptest::{ prelude::*, test_runner::{RngAlgorithm, TestRng}, @@ -41,10 +43,9 @@ proptest! { let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); let long_term_feerate = FeeRate::from_sat_per_vb(0.0f32.max(feerate - long_term_feerate_diff)); let feerate = FeeRate::from_sat_per_vb(feerate); - let drain = Drain { - weight: change_weight, + let drain = DrainWeights { + output_weight: change_weight, spend_weight: change_spend_weight, - value: 0 }; let change_policy = bdk_coin_select::change_policy::min_waste(drain, long_term_feerate); @@ -59,7 +60,7 @@ proptest! { min_fee }; - let solutions = cs.branch_and_bound(metrics::Changeless { + let solutions = cs.bnb_solutions(metrics::Changeless { target, change_policy: &change_policy }); @@ -78,7 +79,7 @@ proptest! { let mut naive_select = cs.clone(); naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.effective_value(target.feerate))); // we filter out failing onces below - let _ = naive_select.select_until_target_met(target, drain); + let _ = naive_select.select_until_target_met(target, Drain { weights: drain, value: 0 }); naive_select }, ]; diff --git a/nursery/coin_select/tests/metrics_lowest_fee.proptest-regressions b/nursery/coin_select/tests/metrics_lowest_fee.proptest-regressions new file mode 100644 index 0000000000..933fd78bd0 --- /dev/null +++ b/nursery/coin_select/tests/metrics_lowest_fee.proptest-regressions @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +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 +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 diff --git a/nursery/coin_select/tests/metrics_lowest_fee.rs b/nursery/coin_select/tests/metrics_lowest_fee.rs new file mode 100644 index 0000000000..c35285bda7 --- /dev/null +++ b/nursery/coin_select/tests/metrics_lowest_fee.rs @@ -0,0 +1,281 @@ +use bdk_coin_select::metrics::{LowestFee, Waste}; +use bdk_coin_select::Drain; +use bdk_coin_select::{ + change_policy::min_value_and_waste, float::Ordf32, BnbMetric, Candidate, CoinSelector, + DrainWeights, FeeRate, NoBnbSolution, Target, +}; +use proptest::prelude::*; +use proptest::test_runner::{RngAlgorithm, TestRng}; +use rand::{Rng, RngCore}; + +fn gen_candidate(mut rng: impl RngCore) -> impl Iterator { + core::iter::repeat_with(move || { + let value = rng.gen_range(512..=51_200); + let weight = rng.gen_range(250..=1000); + let input_count = rng.gen_range(1..=3); + let is_segwit = rng.gen_bool(0.7); + + Candidate { + value, + weight, + input_count, + is_segwit, + } + }) +} + +fn exhaustive_combinations<'a>(cs: &'a CoinSelector<'a>) -> Vec> { + let count = cs.candidates().len(); + + let mut selections = Vec::>::new(); + + // stack of branches: (cs, this_index, include?) + let mut stack = Vec::<(CoinSelector<'a>, usize, bool)>::new(); + + // init stack + let next_index = match cs.unselected_indices().next() { + Some(i) => i, + None => return selections, + }; + stack.push(( + { + let mut cs = cs.clone(); + assert!(cs.select(next_index)); + cs + }, + next_index, + true, + )); + stack.push((cs.clone(), next_index, false)); + + loop { + let (cs, this_index, inclusion) = match stack.pop() { + Some(branch) => branch, + None => break, + }; + + let next_index = this_index + 1; + if next_index >= count { + continue; + } + + stack.push(( + { + let mut cs = cs.clone(); + assert!(cs.select(next_index)); + cs + }, + next_index, + true, + )); + stack.push((cs.clone(), next_index, false)); + + if inclusion { + // we found a combination! + selections.push(cs); + } + } + + selections +} + +struct ExhaustiveIter<'a> { + stack: Vec<(CoinSelector<'a>, usize, bool)>, // for branches: (cs, this_index, include?) +} + +impl<'a> ExhaustiveIter<'a> { + fn push_branches(&mut self, cs: &CoinSelector<'a>, next_index: usize) { + let inclusion_cs = { + let mut cs = cs.clone(); + assert!(cs.select(next_index)); + cs + }; + self.stack.push((inclusion_cs, next_index, true)); + self.stack.push((cs.clone(), next_index, false)); + } + + fn new(cs: &CoinSelector<'a>) -> Option { + let mut iter = Self { stack: Vec::new() }; + iter.push_branches(cs, cs.unselected_indices().next()?); + Some(iter) + } +} + +impl<'a> Iterator for ExhaustiveIter<'a> { + type Item = CoinSelector<'a>; + + fn next(&mut self) -> Option { + loop { + let (cs, this_index, inclusion) = self.stack.pop()?; + + let next_index = this_index + 1; + if next_index >= cs.candidates().count() { + continue; + } + self.push_branches(&cs, next_index); + + if !inclusion { + continue; + } + return Some(cs); + } + } +} + +fn exhaustive_search(cs: &mut CoinSelector, metric: M) -> Option<(Ordf32, usize)> +where + M: BnbMetric, +{ + let mut best_score = Option::::None; + + for (i, cs) in ExhaustiveIter::new(cs)?.enumerate() {} + // struct ExhaustiveMetric { + // metric: M, + // fake_best: Ordf32, + // } + + // impl ExhaustiveMetric { + // fn new(metric: M) -> Self { + // Self { + // metric, + // fake_best: Ordf32(0.0), + // } + // } + // } + + // impl> BnbMetric for ExhaustiveMetric { + // type Score = Ordf32; + + // fn score(&mut self, cs: &bdk_coin_select::CoinSelector<'_>) -> Option { + // let score = self.metric.score(cs)?; + // if score < self.fake_best { + // self.fake_best = score; + // } + // Some(score) + // } + + // fn bound(&mut self, _cs: &bdk_coin_select::CoinSelector<'_>) -> Option { + // self.fake_best.0 -= 0.1; + // Some(self.fake_best) + // } + + // fn requires_ordering_by_descending_value_pwu(&self) -> bool { + // self.metric.requires_ordering_by_descending_value_pwu() + // } + // } + + todo!() +} + +fn bnb_search(cs: &mut CoinSelector, metric: M) -> Result<(Ordf32, usize), NoBnbSolution> +where + M: BnbMetric, +{ + let mut rounds = 0_usize; + let (selection, score) = cs + .bnb_solutions(metric) + .inspect(|_| rounds += 1) + .flatten() + .last() + .ok_or(NoBnbSolution { + max_rounds: usize::MAX, + rounds, + })?; + *cs = selection; + + Ok((score, rounds)) +} + +fn result_string(res: &Result<(Ordf32, usize), NoBnbSolution>, change: Drain) -> String { + match res { + Ok((score, rounds)) => { + let drain = if change.is_some() { + format!("{:?}", change) + } else { + "None".to_string() + }; + format!("Ok(score={} rounds={} drain={})", score, rounds, drain) + } + err => format!("{:?}", err), + } +} + +proptest! { + #![proptest_config(ProptestConfig { + source_file: Some(file!()), + ..Default::default() + })] + #[test] + fn can_eventually_find_best_solution( + n_candidates in 0..30_usize, // candidates (n) + target_value in 200..100_000_u64, // target value (sats) + base_weight in 164..641_u32, // base weight (wu) + min_fee in 0..1_000_u64, // min fee (sats) + feerate in 1.0..50.0_f32, // feerate (sats/vb) + feerate_lt_diff in -5.0..5.0_f32, // longterm feerate diff (sats/vb) + drain_weight in 100..=500_u32, // drain weight (wu) + drain_spend_weight in 250..=1000_u32, // drain spend weight (wu) + drain_dust in 100..=400_u64, // drain dust (sats) + ) { + println!("== TEST =="); + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + + let candidates = gen_candidate(&mut rng) + .take(n_candidates) + .collect::>(); + { + println!("\tCandidates:"); + for (i, candidate) in candidates.iter().enumerate() { + println!("\t\t[{}] {:?}", i, candidate); + } + } + + let feerate_lt = FeeRate::from_sat_per_vb(((feerate + feerate_lt_diff) as f32).max(1.0)); + let feerate = FeeRate::from_sat_per_vb(feerate); + + let target = Target { + feerate, + min_fee, + value: target_value, + }; + let drain_weights = DrainWeights { + output_weight: drain_weight, + spend_weight: drain_spend_weight, + }; + let change_policy = min_value_and_waste(drain_weights, drain_dust, feerate_lt); + + let metric = LowestFee { + target, + long_term_feerate: feerate_lt, + change_policy: &change_policy, + }; + + let mut selection = CoinSelector::new(&candidates, base_weight); + let mut exp_selection = selection.clone(); + + let result = bnb_search(&mut selection, metric); + let change = change_policy(&selection, target); + let result_str = result_string(&result, change); + println!("FINAL(bnb): {}", result_str); + + let exp_result = exhaustive_search(&mut exp_selection, metric); + let exp_change = change_policy(&exp_selection, target); + let exp_result_str = result_string(&exp_result.ok_or(NoBnbSolution{ max_rounds: usize::MAX, rounds: 0 }), exp_change); + println!("FINAL(exhaustive): {}", exp_result_str); + + match exp_result { + Some((score_to_match, _max_rounds)) => { + let (score, _rounds) = result.expect("must find solution"); + // [todo] how do we ensure `_rounds` is less than `_max_rounds` MOST of the time? + prop_assert_eq!( + score, + score_to_match, + "score: got={} exp={}", + result_str, + exp_result_str + ); + } + _ => prop_assert!(result.is_err(), "should not find solution"), + } + } +} diff --git a/nursery/coin_select/tests/waste.rs b/nursery/coin_select/tests/waste.rs index 8d95f0618d..23f89edcbb 100644 --- a/nursery/coin_select/tests/waste.rs +++ b/nursery/coin_select/tests/waste.rs @@ -322,10 +322,9 @@ proptest! { let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); let long_term_feerate = FeeRate::from_sat_per_vb(0.0f32.max(feerate - long_term_feerate_diff)); let feerate = FeeRate::from_sat_per_vb(feerate); - let drain = Drain { - weight: change_weight, + let drain = DrainWeights { + output_weight: change_weight, spend_weight: change_spend_weight, - value: 0 }; let change_policy = crate::change_policy::min_waste(drain, long_term_feerate); @@ -340,7 +339,7 @@ proptest! { min_fee }; - let solutions = cs.branch_and_bound(Waste { + let solutions = cs.bnb_solutions(Waste { target, long_term_feerate, change_policy: &change_policy @@ -360,7 +359,7 @@ proptest! { let mut naive_select = cs.clone(); naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.effective_value(target.feerate))); // we filter out failing onces below - let _ = naive_select.select_until_target_met(target, drain); + let _ = naive_select.select_until_target_met(target, Drain { weights: drain, value: 0 }); naive_select }, {