From e387278bd56240849af7592e22140012e4d116eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 22 Aug 2023 22:42:28 +0800 Subject: [PATCH] test(coin_select): add inner prop test methods --- nursery/coin_select/tests/common.rs | 335 ++++++++++++++++ nursery/coin_select/tests/lowest_fee.rs | 88 +++++ .../tests/metrics.proptest-regressions | 12 - nursery/coin_select/tests/metrics.rs | 356 ------------------ .../tests/waste.proptest-regressions | 2 + nursery/coin_select/tests/waste.rs | 85 ++++- 6 files changed, 508 insertions(+), 370 deletions(-) create mode 100644 nursery/coin_select/tests/common.rs create mode 100644 nursery/coin_select/tests/lowest_fee.rs delete mode 100644 nursery/coin_select/tests/metrics.proptest-regressions delete mode 100644 nursery/coin_select/tests/metrics.rs diff --git a/nursery/coin_select/tests/common.rs b/nursery/coin_select/tests/common.rs new file mode 100644 index 0000000000..b7b3a42f14 --- /dev/null +++ b/nursery/coin_select/tests/common.rs @@ -0,0 +1,335 @@ +use std::any::type_name; + +use bdk_coin_select::{ + float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, NoBnbSolution, + Target, +}; +use proptest::{ + prop_assert, prop_assert_eq, + test_runner::{RngAlgorithm, TestRng}, +}; +use rand::Rng; + +pub fn can_eventually_find_best_solution( + gen_candidates: GC, + gen_change_policy: GP, + gen_metric: GM, + params: StrategyParams, +) -> Result<(), proptest::test_runner::TestCaseError> +where + M: BnbMetric, + P: Fn(&CoinSelector, Target) -> Drain, + GM: Fn(&StrategyParams, P) -> M, + GC: Fn(usize) -> Vec, + GP: Fn(&StrategyParams) -> P, +{ + println!("== TEST =="); + println!("{}", type_name::()); + + let candidates = gen_candidates(params.n_candidates); + { + println!("\tcandidates:"); + for (i, candidate) in candidates.iter().enumerate() { + println!( + "\t\t[{}] {:?} ev={}", + i, + candidate, + candidate.effective_value(params.feerate()) + ); + } + } + + let target = params.target(); + + let mut metric = gen_metric(¶ms, gen_change_policy(¶ms)); + let change_policy = gen_change_policy(¶ms); + + let mut selection = CoinSelector::new(&candidates, params.base_weight); + let mut exp_selection = selection.clone(); + + println!("\texhaustive search:"); + let now = std::time::Instant::now(); + let exp_result = exhaustive_search(&mut exp_selection, &mut metric); + let exp_change = change_policy(&exp_selection, target); + let exp_result_str = result_string(&exp_result.ok_or("no possible solution"), exp_change); + println!( + "\t\telapsed={:8}s result={}", + now.elapsed().as_secs_f64(), + exp_result_str + ); + + println!("\tbranch and bound:"); + let now = std::time::Instant::now(); + let result = bnb_search(&mut selection, metric); + let change = change_policy(&selection, target); + let result_str = result_string(&result, change); + println!( + "\t\telapsed={:8}s result={}", + now.elapsed().as_secs_f64(), + result_str + ); + + match exp_result { + Some((score_to_match, _max_rounds)) => { + let (score, _rounds) = result.expect("must find solution"); + // [todo] how do we check that `_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"), + } + + Ok(()) +} + +pub fn ensure_bound_is_not_too_tight( + gen_candidates: GC, + gen_change_policy: GP, + gen_metric: GM, + params: StrategyParams, +) -> Result<(), proptest::test_runner::TestCaseError> +where + M: BnbMetric, + P: Fn(&CoinSelector, Target) -> Drain, + GM: Fn(&StrategyParams, P) -> M, + GC: Fn(usize) -> Vec, + GP: Fn(&StrategyParams) -> P, +{ + println!("== TEST =="); + println!("{}", type_name::()); + + let candidates = gen_candidates(params.n_candidates); + { + println!("\tcandidates:"); + for (i, candidate) in candidates.iter().enumerate() { + println!( + "\t\t[{}] {:?} ev={}", + i, + candidate, + candidate.effective_value(params.feerate()) + ); + } + } + + let mut metric = gen_metric(¶ms, gen_change_policy(¶ms)); + + let init_cs = { + let mut cs = CoinSelector::new(&candidates, params.base_weight); + if metric.requires_ordering_by_descending_value_pwu() { + cs.sort_candidates_by_descending_value_pwu(); + } + cs + }; + + for cs in ExhaustiveIter::new(&init_cs).into_iter().flatten() { + if let Some(lb_score) = metric.bound(&cs) { + // This is the branch's lower bound. In other words, this is the BEST selection + // possible (can overshoot) traversing down this branch. Let's check that! + + if let Some(score) = metric.score(&cs) { + prop_assert!( + score >= lb_score, + "selection={} score={} lb={}", + cs, + score, + lb_score + ); + } + + for descendant_cs in ExhaustiveIter::new(&cs).into_iter().flatten() { + if let Some(descendant_score) = metric.score(&descendant_cs) { + prop_assert!( + descendant_score >= lb_score, + "this: {} (score={}), parent: {} (lb={})", + descendant_cs, + descendant_score, + cs, + lb_score + ); + } + } + } + } + Ok(()) +} + +pub struct StrategyParams { + pub n_candidates: usize, + pub target_value: u64, + pub base_weight: u32, + pub min_fee: u64, + pub feerate: f32, + pub feerate_lt_diff: f32, + pub drain_weight: u32, + pub drain_spend_weight: u32, + pub drain_dust: u64, +} + +impl StrategyParams { + pub fn target(&self) -> Target { + Target { + feerate: self.feerate(), + min_fee: self.min_fee, + value: self.target_value, + } + } + + pub fn feerate(&self) -> FeeRate { + FeeRate::from_sat_per_vb(self.feerate) + } + + pub fn long_term_feerate(&self) -> FeeRate { + FeeRate::from_sat_per_vb(((self.feerate + self.feerate_lt_diff) as f32).max(1.0)) + } + + pub fn drain_weights(&self) -> DrainWeights { + DrainWeights { + output_weight: self.drain_weight, + spend_weight: self.drain_spend_weight, + } + } +} + +pub fn gen_candidates(n: usize) -> Vec { + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + core::iter::repeat_with(move || { + let value = rng.gen_range(1..=500_000); + let weight = rng.gen_range(1..=2000); + let input_count = rng.gen_range(1..=2); + let is_segwit = rng.gen_bool(0.01); + + Candidate { + value, + weight, + input_count, + is_segwit, + } + }) + .take(n) + .collect() +} + +pub struct ExhaustiveIter<'a> { + stack: Vec<(CoinSelector<'a>, bool)>, // for branches: (cs, this_index, include?) +} + +impl<'a> ExhaustiveIter<'a> { + fn new(cs: &CoinSelector<'a>) -> Option { + let mut iter = Self { stack: Vec::new() }; + iter.push_branches(cs); + Some(iter) + } + + fn push_branches(&mut self, cs: &CoinSelector<'a>) { + let next_index = match cs.unselected_indices().next() { + Some(next_index) => next_index, + None => return, + }; + + let inclusion_cs = { + let mut cs = cs.clone(); + assert!(cs.select(next_index)); + cs + }; + self.stack.push((inclusion_cs, true)); + + let exclusion_cs = { + let mut cs = cs.clone(); + cs.ban(next_index); + cs + }; + self.stack.push((exclusion_cs, false)); + } +} + +impl<'a> Iterator for ExhaustiveIter<'a> { + type Item = CoinSelector<'a>; + + fn next(&mut self) -> Option { + loop { + let (cs, inclusion) = self.stack.pop()?; + let _more = self.push_branches(&cs); + if inclusion { + return Some(cs); + } + } + } +} + +pub fn exhaustive_search(cs: &mut CoinSelector, metric: &mut M) -> Option<(Ordf32, usize)> +where + M: BnbMetric, +{ + if metric.requires_ordering_by_descending_value_pwu() { + cs.sort_candidates_by_descending_value_pwu(); + } + + let mut best = Option::<(CoinSelector, Ordf32)>::None; + let mut rounds = 0; + + let iter = ExhaustiveIter::new(cs)? + .enumerate() + .inspect(|(i, _)| rounds = *i) + .filter_map(|(_, cs)| metric.score(&cs).map(|score| (cs, score))); + + for (child_cs, score) in iter { + match &mut best { + Some((best_cs, best_score)) => { + if score < *best_score { + *best_cs = child_cs; + *best_score = score; + } + } + best => *best = Some((child_cs, score)), + } + } + + if let Some((best_cs, score)) = &best { + println!("\t\tsolution={}, score={}", best_cs, score); + *cs = best_cs.clone(); + } + + best.map(|(_, score)| (score, rounds)) +} + +pub 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, + })?; + println!("\t\tsolution={}, score={}", selection, score); + *cs = selection; + + Ok((score, rounds)) +} + +pub fn result_string(res: &Result<(Ordf32, usize), E>, change: Drain) -> String +where + E: std::fmt::Debug, +{ + 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), + } +} diff --git a/nursery/coin_select/tests/lowest_fee.rs b/nursery/coin_select/tests/lowest_fee.rs new file mode 100644 index 0000000000..e1855ca8f4 --- /dev/null +++ b/nursery/coin_select/tests/lowest_fee.rs @@ -0,0 +1,88 @@ +mod common; +use bdk_coin_select::change_policy::min_value_and_waste; +use bdk_coin_select::metrics::LowestFee; +use proptest::prelude::*; + +proptest! { + #![proptest_config(ProptestConfig { + ..Default::default() + })] + + #[test] + fn can_eventually_find_best_solution( + n_candidates in 1..20_usize, // candidates (n) + target_value in 500..500_000_u64, // target value (sats) + base_weight in 0..1000_u32, // base weight (wu) + min_fee in 0..1_000_u64, // min fee (sats) + feerate in 1.0..100.0_f32, // feerate (sats/vb) + feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) + drain_weight in 100..=500_u32, // drain weight (wu) + drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) + drain_dust in 100..=1000_u64, // drain dust (sats) + ) { + common::can_eventually_find_best_solution( + common::gen_candidates, + |p| min_value_and_waste( + p.drain_weights(), + p.drain_dust, + p.long_term_feerate(), + ), + |p, cp| LowestFee { + target: p.target(), + long_term_feerate: p.long_term_feerate(), + // [TODO]: Remove this memory leak hack + change_policy: Box::leak(Box::new(cp)), + }, + common::StrategyParams { + n_candidates, + target_value, + base_weight, + min_fee, + feerate, + feerate_lt_diff, + drain_weight, + drain_spend_weight, + drain_dust, + }, + )?; + } + + #[test] + fn ensure_bound_is_not_too_tight( + n_candidates in 0..15_usize, // candidates (n) + target_value in 500..500_000_u64, // target value (sats) + base_weight in 0..641_u32, // base weight (wu) + min_fee in 0..1_000_u64, // min fee (sats) + feerate in 1.0..100.0_f32, // feerate (sats/vb) + feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) + drain_weight in 100..=500_u32, // drain weight (wu) + drain_spend_weight in 1..=1000_u32, // drain spend weight (wu) + drain_dust in 100..=1000_u64, // drain dust (sats) + ) { + common::ensure_bound_is_not_too_tight( + common::gen_candidates, + |p| min_value_and_waste( + p.drain_weights(), + p.drain_dust, + p.long_term_feerate(), + ), + |p, cp| LowestFee { + target: p.target(), + long_term_feerate: p.long_term_feerate(), + // [TODO]: Remove this memory leak hack + change_policy: Box::leak(Box::new(cp)), + }, + common::StrategyParams { + n_candidates, + target_value, + base_weight, + min_fee, + feerate, + feerate_lt_diff, + drain_weight, + drain_spend_weight, + drain_dust, + }, + )?; + } +} diff --git a/nursery/coin_select/tests/metrics.proptest-regressions b/nursery/coin_select/tests/metrics.proptest-regressions deleted file mode 100644 index 1cdfb585d6..0000000000 --- a/nursery/coin_select/tests/metrics.proptest-regressions +++ /dev/null @@ -1,12 +0,0 @@ -# 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 78e8456749053271949d1821613de18d007ef6ddabc85eb0b9dc64b640f85736 # shrinks to n_candidates = 11, target_value = 500, base_weight = 0, min_fee = 0, feerate = 72.77445, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 1, drain_dust = 100 -cc 36b9844f4bd28caa412b4a7e384c370bf9406dd6d1cd3a37409181c096a3da95 # shrinks to n_candidates = 8, target_value = 378748, base_weight = 245, min_fee = 0, feerate = 90.57628, feerate_lt_diff = 41.46504, drain_weight = 408, drain_spend_weight = 1095, drain_dust = 100 -cc 9c5c20afb83a7b1b8dc66404c63379f12ac796f5f23e04ccb568778c84230e18 # shrinks to n_candidates = 11, target_value = 434651, base_weight = 361, min_fee = 0, feerate = 41.85748, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 1, drain_dust = 100 -cc 858be736b81a2b1ca5dafc2d6442c7facfd46af6d14659df3772daf0940b105e # shrinks to n_candidates = 3, target_value = 422791, base_weight = 272, min_fee = 0, feerate = 93.71708, feerate_lt_diff = 8.574516, drain_weight = 100, drain_spend_weight = 703, drain_dust = 100 -cc d643d1aaf1d708ca2a7ce3bf5357a14e82c9d60935b126c9b3f338a9bb0ebed3 # shrinks to n_candidates = 10, target_value = 381886, base_weight = 684, min_fee = 0, feerate = 72.56796, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 354, drain_dust = 100 -cc 931d5609471a5575882a8b2cb2c45884330cb18e95368c89a63cfe507a7c1a62 # shrinks to n_candidates = 10, target_value = 76204, base_weight = 71, min_fee = 0, feerate = 72.3613, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 357, drain_dust = 100 diff --git a/nursery/coin_select/tests/metrics.rs b/nursery/coin_select/tests/metrics.rs deleted file mode 100644 index 2abe544a7e..0000000000 --- a/nursery/coin_select/tests/metrics.rs +++ /dev/null @@ -1,356 +0,0 @@ -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::{FileFailurePersistence, 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(1..=500_000); - let weight = rng.gen_range(1..=2000); - let input_count = rng.gen_range(1..=2); - let is_segwit = rng.gen_bool(0.01); - - Candidate { - value, - weight, - input_count, - is_segwit, - } - }) -} - -struct DynMetric(&'static mut dyn BnbMetric); - -impl DynMetric { - fn new(metric: impl BnbMetric + 'static) -> Self { - Self(Box::leak(Box::new(metric))) - } -} - -impl BnbMetric for DynMetric { - type Score = Ordf32; - - fn score(&mut self, cs: &CoinSelector<'_>) -> Option { - self.0.score(cs) - } - - fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { - self.0.bound(cs) - } - - fn requires_ordering_by_descending_value_pwu(&self) -> bool { - self.0.requires_ordering_by_descending_value_pwu() - } -} - -struct ExhaustiveIter<'a> { - stack: Vec<(CoinSelector<'a>, bool)>, // for branches: (cs, this_index, include?) -} - -impl<'a> ExhaustiveIter<'a> { - fn new(cs: &CoinSelector<'a>) -> Option { - let mut iter = Self { stack: Vec::new() }; - iter.push_branches(cs); - Some(iter) - } - - fn push_branches(&mut self, cs: &CoinSelector<'a>) { - let next_index = match cs.unselected_indices().next() { - Some(next_index) => next_index, - None => return, - }; - - let inclusion_cs = { - let mut cs = cs.clone(); - assert!(cs.select(next_index)); - cs - }; - self.stack.push((inclusion_cs, true)); - - let exclusion_cs = { - let mut cs = cs.clone(); - cs.ban(next_index); - cs - }; - self.stack.push((exclusion_cs, false)); - } -} - -impl<'a> Iterator for ExhaustiveIter<'a> { - type Item = CoinSelector<'a>; - - fn next(&mut self) -> Option { - loop { - let (cs, inclusion) = self.stack.pop()?; - let _more = self.push_branches(&cs); - if inclusion { - return Some(cs); - } - } - } -} - -fn exhaustive_search(cs: &mut CoinSelector, metric: &mut M) -> Option<(Ordf32, usize)> -where - M: BnbMetric, -{ - if metric.requires_ordering_by_descending_value_pwu() { - cs.sort_candidates_by_descending_value_pwu(); - } - - let mut best = Option::<(CoinSelector, Ordf32)>::None; - let mut rounds = 0; - - let iter = ExhaustiveIter::new(cs)? - .enumerate() - .inspect(|(i, _)| rounds = *i) - .filter_map(|(_, cs)| metric.score(&cs).map(|score| (cs, score))); - - for (child_cs, score) in iter { - match &mut best { - Some((best_cs, best_score)) => { - if score < *best_score { - *best_cs = child_cs; - *best_score = score; - } - } - best => *best = Some((child_cs, score)), - } - } - - if let Some((best_cs, score)) = &best { - println!("\t\tsolution={}, score={}", best_cs, score); - *cs = best_cs.clone(); - } - - best.map(|(_, score)| (score, rounds)) -} - -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, - })?; - println!("\t\tsolution={}, score={}", selection, score); - *cs = selection; - - Ok((score, rounds)) -} - -fn result_string(res: &Result<(Ordf32, usize), E>, change: Drain) -> String -where - E: std::fmt::Debug, -{ - 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!()), - failure_persistence: Some(Box::new(FileFailurePersistence::WithSource("proptest-regressions"))), - // cases: u32::MAX, - ..Default::default() - })] - - #[test] - fn can_eventually_find_best_solution( - n_candidates in 1..20_usize, // candidates (n) - target_value in 500..500_000_u64, // target value (sats) - base_weight in 0..1000_u32, // base weight (wu) - min_fee in 0..1_000_u64, // min fee (sats) - feerate in 1.0..100.0_f32, // feerate (sats/vb) - feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) - drain_weight in 100..=500_u32, // drain weight (wu) - drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) - drain_dust in 100..=1000_u64, // drain dust (sats) - ) { - println!("== TEST =="); - let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); - - let candidates = gen_candidate(&mut rng) - .take(n_candidates) - .collect::>(); - - 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); - - { - println!("\tcandidates:"); - for (i, candidate) in candidates.iter().enumerate() { - println!("\t\t[{}] {:?} ev={}", i, candidate, candidate.effective_value(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_factories: [(&str, &dyn Fn() -> DynMetric); 2] = [ - ("lowest_fee", &|| DynMetric::new(LowestFee { - target, - long_term_feerate: feerate_lt, - change_policy: Box::leak(Box::new(min_value_and_waste(drain_weights, drain_dust, feerate_lt))), - })), - ("waste", &|| DynMetric::new(Waste { - target, - long_term_feerate: feerate_lt, - change_policy: Box::leak(Box::new(min_value_and_waste(drain_weights, drain_dust, feerate_lt))), - })), - ]; - - for (metric_name, metric_factory) in metric_factories { - let mut selection = CoinSelector::new(&candidates, base_weight); - let mut exp_selection = selection.clone(); - println!("\t{}:", metric_name); - - let now = std::time::Instant::now(); - let result = bnb_search(&mut selection, metric_factory()); - let change = change_policy(&selection, target); - let result_str = result_string(&result, change); - println!("\t\t{:8}s for bnb: {}", now.elapsed().as_secs_f64(), result_str); - - let now = std::time::Instant::now(); - let exp_result = exhaustive_search(&mut exp_selection, &mut metric_factory()); - let exp_change = change_policy(&exp_selection, target); - let exp_result_str = result_string(&exp_result.ok_or("no possible solution"), exp_change); - println!("\t\t{:8}s for exh: {}", now.elapsed().as_secs_f64(), exp_result_str); - - match exp_result { - Some((score_to_match, _max_rounds)) => { - let (score, _rounds) = result.expect("must find solution"); - // [todo] how do we check that `_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"), - } - } - } - - #[test] - fn ensure_bound_does_not_undershoot( - n_candidates in 0..15_usize, // candidates (n) - target_value in 500..500_000_u64, // target value (sats) - base_weight in 0..641_u32, // base weight (wu) - min_fee in 0..1_000_u64, // min fee (sats) - feerate in 1.0..100.0_f32, // feerate (sats/vb) - feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) - drain_weight in 100..=500_u32, // drain weight (wu) - drain_spend_weight in 1..=1000_u32, // drain spend weight (wu) - drain_dust in 100..=1000_u64, // drain dust (sats) - ) { - println!("== TEST =="); - - let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); - - let candidates = gen_candidate(&mut rng) - .take(n_candidates) - .collect::>(); - - let feerate_lt = FeeRate::from_sat_per_vb(((feerate + feerate_lt_diff) as f32).max(1.0)); - assert!(feerate_lt >= FeeRate::zero()); - let feerate = FeeRate::from_sat_per_vb(feerate); - - { - println!("\tcandidates:"); - for (i, candidate) in candidates.iter().enumerate() { - println!("\t\t[{}] {:?} ev={}", i, candidate, candidate.effective_value(feerate)); - } - } - - let target = Target { - feerate, - min_fee, - value: target_value, - }; - let drain_weights = DrainWeights { - output_weight: drain_weight, - spend_weight: drain_spend_weight, - }; - - let metric_factories: &[(&str, &dyn Fn() -> DynMetric)] = &[ - ("lowest_fee", &|| DynMetric::new(LowestFee { - target, - long_term_feerate: feerate_lt, - change_policy: Box::leak(Box::new(min_value_and_waste(drain_weights, drain_dust, feerate_lt))), - })), - ("waste", &|| DynMetric::new(Waste { - target, - long_term_feerate: feerate_lt, - change_policy: Box::leak(Box::new(min_value_and_waste(drain_weights, drain_dust, feerate_lt))), - })), - ]; - - for (metric_name, metric_factory) in metric_factories { - let mut metric = metric_factory(); - let init_cs = { - let mut cs = CoinSelector::new(&candidates, base_weight); - if metric.requires_ordering_by_descending_value_pwu() { - cs.sort_candidates_by_descending_value_pwu(); - } - cs - }; - - for cs in ExhaustiveIter::new(&init_cs).into_iter().flatten() { - if let Some(lb_score) = metric.bound(&cs) { - // This is the branch's lower bound. In other words, this is the BEST selection - // possible (can overshoot) traversing down this branch. Let's check that! - - if let Some(score) = metric.score(&cs) { - prop_assert!( - score >= lb_score, - "[{}] selection={} score={} lb={}", - metric_name, cs, score, lb_score, - ); - } - - for descendant_cs in ExhaustiveIter::new(&cs).into_iter().flatten() { - if let Some(descendant_score) = metric.score(&descendant_cs) { - prop_assert!( - descendant_score >= lb_score, - "[{}] this: {} (score={}), parent: {} (lb={})", - metric_name, descendant_cs, descendant_score, cs, lb_score, - ); - } - } - } - } - } - } -} diff --git a/nursery/coin_select/tests/waste.proptest-regressions b/nursery/coin_select/tests/waste.proptest-regressions index 4ebb0a013a..eae4aaae72 100644 --- a/nursery/coin_select/tests/waste.proptest-regressions +++ b/nursery/coin_select/tests/waste.proptest-regressions @@ -9,3 +9,5 @@ cc f3c37a516004e7eda9183816d72bede9084ce678830d6582f2d63306f618adee # shrinks to cc a6d03a6d93eb8d5a082d69a3d1677695377823acafe3dba954ac86519accf152 # shrinks to num_inputs = 49, target = 2917, feerate = 9.786607, min_fee = 0, base_weight = 4, long_term_feerate_diff = -0.75053596, change_weight = 77, change_spend_weight = 81 cc a1eccddab6d7da9677575154a27a1e49b391041ed9e32b9bf937efd72ef0ab03 # shrinks to num_inputs = 12, target = 3988, feerate = 4.3125916, min_fee = 453, base_weight = 0, long_term_feerate_diff = -0.018570423, change_weight = 15, change_spend_weight = 32 cc 4bb301aaba29e5f5311bb57c8737279045f7ad594adb91b94c5e080d3ba21933 # shrinks to num_inputs = 33, target = 2023, feerate = 4.4804115, min_fee = 965, base_weight = 0, long_term_feerate_diff = -0.30981845, change_weight = 80, change_spend_weight = 95 +cc 6c1e79f7bd7753a37c1aaebb72f3be418ac092a585e7629ab2331e0f9a585640 # shrinks to n_candidates = 11, target_value = 401712, base_weight = 33, min_fee = 0, feerate = 62.1756, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 253, drain_dust = 100 +cc 617e11dc77968b5d26748b10da6d4916210fb7004a120cff73784d9587816fee # shrinks to n_candidates = 6, target_value = 77118, base_weight = 996, min_fee = 661, feerate = 78.64882, feerate_lt_diff = 46.991302, drain_weight = 188, drain_spend_weight = 1242, drain_dust = 366 diff --git a/nursery/coin_select/tests/waste.rs b/nursery/coin_select/tests/waste.rs index e11dac1d1d..8c4655d29b 100644 --- a/nursery/coin_select/tests/waste.rs +++ b/nursery/coin_select/tests/waste.rs @@ -1,6 +1,9 @@ +mod common; use bdk_coin_select::{ - change_policy, float::Ordf32, metrics::Waste, Candidate, CoinSelector, Drain, DrainWeights, - FeeRate, Target, + change_policy::{self, min_value_and_waste}, + float::Ordf32, + metrics::Waste, + Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Target, }; use proptest::{ prelude::*, @@ -399,6 +402,84 @@ proptest! { dbg!(start.elapsed()); } + + #[test] + fn can_eventually_find_best_solution( + n_candidates in 1..20_usize, // candidates (n) + target_value in 500..500_000_u64, // target value (sats) + base_weight in 0..1000_u32, // base weight (wu) + min_fee in 0..1_000_u64, // min fee (sats) + feerate in 1.0..100.0_f32, // feerate (sats/vb) + feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) + drain_weight in 100..=500_u32, // drain weight (wu) + drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) + drain_dust in 100..=1000_u64, // drain dust (sats) + ) { + common::can_eventually_find_best_solution( + common::gen_candidates, + |p| min_value_and_waste( + p.drain_weights(), + p.drain_dust, + p.long_term_feerate(), + ), + |p, cp| Waste { + target: p.target(), + long_term_feerate: p.long_term_feerate(), + // [TODO]: Remove this memory leak hack + change_policy: Box::leak(Box::new(cp)), + }, + common::StrategyParams { + n_candidates, + target_value, + base_weight, + min_fee, + feerate, + feerate_lt_diff, + drain_weight, + drain_spend_weight, + drain_dust, + }, + )?; + } + + #[test] + fn ensure_bound_is_not_too_tight( + n_candidates in 0..15_usize, // candidates (n) + target_value in 500..500_000_u64, // target value (sats) + base_weight in 0..641_u32, // base weight (wu) + min_fee in 0..1_000_u64, // min fee (sats) + feerate in 1.0..100.0_f32, // feerate (sats/vb) + feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) + drain_weight in 100..=500_u32, // drain weight (wu) + drain_spend_weight in 1..=1000_u32, // drain spend weight (wu) + drain_dust in 100..=1000_u64, // drain dust (sats) + ) { + common::ensure_bound_is_not_too_tight( + common::gen_candidates, + |p| min_value_and_waste( + p.drain_weights(), + p.drain_dust, + p.long_term_feerate(), + ), + |p, cp| Waste { + target: p.target(), + long_term_feerate: p.long_term_feerate(), + // [TODO]: Remove this memory leak hack + change_policy: Box::leak(Box::new(cp)), + }, + common::StrategyParams { + n_candidates, + target_value, + base_weight, + min_fee, + feerate, + feerate_lt_diff, + drain_weight, + drain_spend_weight, + drain_dust, + }, + )?; + } } fn test_wv(mut rng: impl RngCore) -> impl Iterator {