Skip to content

Commit 4858294

Browse files
evanlinjinclaude
andcommitted
test: exercise Target::max_weight and add exact feasibility oracle
Thread `max_weight` through `Target`, `StrategyParams` and every proptest (a 2k..30k wu cap that binds on a healthy fraction of cases), so BnB is compared against exhaustive search *with* the cap active. Add `exact_selection_possible` — an exhaustive oracle reusing the real `is_target_met`/`is_within_max_weight`, independent of the BnB weight prune — and a `bnb_respects_max_weight` proptest asserting BnB feasibility matches it and that `is_selection_possible` never over-claims. Fix `compare_against_benchmarks`: filter benchmarks by `score().is_some()` rather than value-only `is_target_met`, so an over-cap benchmark (e.g. `select_all` under a tight cap, whose score is `None`) isn't treated as a valid solution to compare against. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c5943ae commit 4858294

5 files changed

Lines changed: 99 additions & 8 deletions

File tree

benches/coin_selector.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, FeeRate) {
4242
let target = Target {
4343
fee: TargetFee::from_feerate(target_fr),
4444
outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, total / 2)]),
45+
max_weight: None,
4546
};
4647
(target, long_term_fr)
4748
}

tests/bnb.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() {
9292
},
9393
// we're trying to find an exact selection value so set fees to 0
9494
fee: TargetFee::ZERO,
95+
max_weight: None,
9596
};
9697

9798
let solutions = cs.bnb_solutions(MinExcessThenWeight { target });
@@ -126,6 +127,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() {
126127
n_outputs: 1,
127128
},
128129
fee: TargetFee::default(),
130+
max_weight: None,
129131
};
130132

131133
let solutions = cs.bnb_solutions(MinExcessThenWeight { target });
@@ -154,6 +156,7 @@ proptest! {
154156
let target = Target {
155157
outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 },
156158
fee: TargetFee::ZERO,
159+
max_weight: None,
157160
};
158161

159162
let solutions = cs.bnb_solutions(MinExcessThenWeight { target });
@@ -200,6 +203,7 @@ proptest! {
200203
outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 },
201204
// we're trying to find an exact selection value so set fees to 0
202205
fee: TargetFee::ZERO,
206+
max_weight: None,
203207
};
204208

205209
let solutions = cs.bnb_solutions(MinExcessThenWeight { target });

tests/changeless.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ proptest! {
6565
rate: feerate,
6666
replace,
6767
..TargetFee::ZERO
68-
}
68+
},
69+
max_weight: None,
6970
};
7071

7172
let make_metric = || Changeless {

tests/common.rs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ pub fn maybe_replace(
2525
proptest::option::of(replace(fee_strategy))
2626
}
2727

28+
/// Strategy for an optional [`Target::max_weight`] cap (`None` = unconstrained).
29+
pub fn maybe_max_weight(
30+
weight_strategy: impl Strategy<Value = u64>,
31+
) -> impl Strategy<Value = Option<u64>> {
32+
proptest::option::of(weight_strategy)
33+
}
34+
2835
/// Used for constructing a proptest that compares an exhaustive search result with a bnb result
2936
/// with the given metric.
3037
///
@@ -208,6 +215,7 @@ pub struct StrategyParams {
208215
pub drain_spend_weight: u32,
209216
pub drain_dust: u64,
210217
pub n_drain_outputs: usize,
218+
pub max_weight: Option<u64>,
211219
}
212220

213221
impl StrategyParams {
@@ -223,6 +231,7 @@ impl StrategyParams {
223231
weight_sum: self.target_weight as u64,
224232
n_outputs: self.n_target_outputs,
225233
},
234+
max_weight: self.max_weight,
226235
}
227236
}
228237

@@ -294,7 +303,7 @@ pub struct ExhaustiveIter<'a> {
294303
}
295304

296305
impl<'a> ExhaustiveIter<'a> {
297-
fn new(cs: &CoinSelector<'a>) -> Option<Self> {
306+
pub fn new(cs: &CoinSelector<'a>) -> Option<Self> {
298307
let mut iter = Self { stack: Vec::new() };
299308
iter.push_branches(cs);
300309
Some(iter)
@@ -369,6 +378,23 @@ where
369378
best.map(|(_, score)| (score, rounds))
370379
}
371380

381+
/// Exact feasibility oracle: does *any* subset of the currently-unbanned candidates (added to the
382+
/// current selection) meet `target`, i.e. cover the value **and** stay within `max_weight`?
383+
///
384+
/// Enumerates every subset via [`ExhaustiveIter`] and reuses the real
385+
/// [`CoinSelector::is_target_met`] + [`CoinSelector::is_within_max_weight`], so it inherits the
386+
/// exact weight model and is independent of the BnB weight prune it audits. Exponential — small `n`
387+
/// only.
388+
pub fn exact_selection_possible(cs: &CoinSelector, target: Target) -> bool {
389+
let feasible =
390+
|s: &CoinSelector| s.is_target_met(target) && s.is_within_max_weight(target, Drain::NONE);
391+
// the current selection itself (no additions) is a valid subset and isn't yielded by the iter
392+
feasible(cs)
393+
|| ExhaustiveIter::new(cs)
394+
.map(|mut iter| iter.any(|(subset, _)| feasible(&subset)))
395+
.unwrap_or(false)
396+
}
397+
372398
pub fn bnb_search<M>(
373399
cs: &mut CoinSelector,
374400
metric: M,
@@ -454,12 +480,16 @@ pub fn compare_against_benchmarks<M: BnbMetric + Clone>(
454480
(0..10).map(|_| randomly_satisfy_target(&cs, target, &mut rng, metric.clone())),
455481
);
456482

483+
// Only compare against benchmarks that are themselves *valid* solutions. A benchmark
484+
// can meet the target value yet bust `max_weight` (e.g. `select_all` on a tight cap),
485+
// in which case its score is `None` and it isn't a real solution to compare against.
457486
let cmp_benchmarks = cmp_benchmarks
458487
.into_iter()
459-
.filter(|cs| cs.is_target_met(target));
488+
.filter(|bench| metric.clone().score(bench).is_some())
489+
.collect::<Vec<_>>();
460490
let sol_score = metric.score(&sol);
461491

462-
for (_bench_id, mut bench) in cmp_benchmarks.enumerate() {
492+
for (_bench_id, mut bench) in cmp_benchmarks.into_iter().enumerate() {
463493
let bench_score = metric.score(&bench);
464494
if sol_score > bench_score {
465495
dbg!(_bench_id);

tests/lowest_fee.rs

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ proptest! {
2727
drain_spend_weight in 1..=2000_u32, // drain spend weight (wu)
2828
drain_dust in 100..=1000_u64, // drain dust (sats)
2929
n_drain_outputs in 1usize..150, // the number of drain outputs
30+
max_weight in common::maybe_max_weight(2_000u64..30_000), // optional max tx weight cap (wu)
3031
) {
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 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 , max_weight };
3233
let candidates = common::gen_candidates(params.n_candidates);
3334
let metric = params.lowest_fee_metric();
3435
common::can_eventually_find_best_solution(params, candidates, metric)?;
@@ -48,8 +49,9 @@ proptest! {
4849
drain_spend_weight in 1..=2000_u32, // drain spend weight (wu)
4950
drain_dust in 100..=1000_u64, // drain dust (sats)
5051
n_drain_outputs in 1usize..150, // the number of drain outputs
52+
max_weight in common::maybe_max_weight(2_000u64..30_000), // optional max tx weight cap (wu)
5153
) {
52-
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 };
54+
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 , max_weight };
5355
let candidates = common::gen_candidates(params.n_candidates);
5456
let metric = params.lowest_fee_metric();
5557
common::ensure_bound_is_not_too_tight(params, candidates, metric)?;
@@ -69,10 +71,11 @@ proptest! {
6971
drain_spend_weight in 1..=2000_u32, // drain spend weight (wu)
7072
drain_dust in 100..=1000_u64, // drain dust (sats)
7173
n_drain_outputs in 1usize..150, // the number of drain outputs
74+
max_weight in common::maybe_max_weight(2_000u64..30_000), // optional max tx weight cap (wu)
7275
) {
7376
println!("== TEST ==");
7477

75-
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 };
78+
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 , max_weight };
7679
println!("{:?}", params);
7780

7881
let candidates = core::iter::repeat(Candidate {
@@ -112,15 +115,64 @@ proptest! {
112115
drain_spend_weight in 1..=2000_u32, // drain spend weight (wu)
113116
drain_dust in 100..=1000_u64, // drain dust (sats)
114117
n_drain_outputs in 1usize..150, // the number of drain outputs
118+
max_weight in common::maybe_max_weight(2_000u64..30_000), // optional max tx weight cap (wu)
115119
) {
116120

117-
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 };
121+
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 , max_weight };
118122
let candidates = common::gen_candidates(params.n_candidates);
119123
let metric = params.lowest_fee_metric();
120124
common::compare_against_benchmarks(params, candidates, metric)?;
121125
}
122126
}
123127

128+
proptest! {
129+
// Cheap cases (small n), so run many more than the default to stress the max_weight prune.
130+
#![proptest_config(ProptestConfig { cases: 4096, ..Default::default() })]
131+
132+
/// Cross-check `max_weight` handling against an exact, exhaustive feasibility oracle.
133+
///
134+
/// BnB with unlimited rounds is itself an exact feasibility detector (nothing is pruned before
135+
/// the first incumbent; the only pre-incumbent prune is the weight hard-prune). So
136+
/// `bnb_found == exact_possible` must hold — a mismatch means the prune dropped a feasible
137+
/// subtree. `n` is kept small because the exact oracle is exponential.
138+
#[test]
139+
#[cfg(not(debug_assertions))] // too slow if compiling for debug
140+
fn bnb_respects_max_weight(
141+
n_candidates in 1..16_usize,
142+
target_value in 500..500_000_u64,
143+
n_target_outputs in 1usize..150,
144+
target_weight in 0..10_000_u32,
145+
replace in common::maybe_replace(0u64..10_000),
146+
feerate in 1.0..100.0_f32,
147+
feerate_lt_diff in -5.0..50.0_f32,
148+
drain_weight in 100..=500_u32,
149+
drain_spend_weight in 1..=2000_u32,
150+
drain_dust in 100..=1000_u64,
151+
n_drain_outputs in 1usize..150,
152+
max_weight in common::maybe_max_weight(2_000u64..15_000), // tighter -> binds often
153+
) {
154+
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, max_weight };
155+
let candidates = common::gen_candidates(params.n_candidates);
156+
let target = params.target();
157+
let metric = params.lowest_fee_metric();
158+
159+
let exact_possible = common::exact_selection_possible(&CoinSelector::new(&candidates), target);
160+
161+
let mut cs = CoinSelector::new(&candidates);
162+
let bnb_found = common::bnb_search(&mut cs, metric, usize::MAX).is_ok();
163+
prop_assert_eq!(
164+
bnb_found, exact_possible,
165+
"bnb_found={} but exact_possible={} (weight prune may have dropped a feasible subtree)",
166+
bnb_found, exact_possible
167+
);
168+
169+
// The shipped greedy `is_selection_possible` must never over-claim: a `true` is a real
170+
// witness, so it must imply the exact oracle.
171+
let greedy = CoinSelector::new(&candidates).is_selection_possible(target);
172+
prop_assert!(!greedy || exact_possible, "is_selection_possible over-claimed");
173+
}
174+
}
175+
124176
/// We wrap `LowestFee` in `Changeless` to derive a metric that finds the lowest-fee changeless
125177
/// solution. Constraining to changeless should never take fewer rounds than the unconstrained
126178
/// `LowestFee`.
@@ -138,6 +190,7 @@ fn combined_changeless_metric() {
138190
drain_dust: 200,
139191
n_target_outputs: 1,
140192
n_drain_outputs: 1,
193+
max_weight: None,
141194
};
142195

143196
let candidates = common::gen_candidates(params.n_candidates);
@@ -178,6 +231,7 @@ fn does_not_create_change_below_spend_cost() {
178231
weight_sum: 200 - TX_FIXED_FIELD_WEIGHT - 1,
179232
n_outputs: 1,
180233
},
234+
max_weight: None,
181235
};
182236

183237
let candidates = vec![
@@ -256,6 +310,7 @@ fn zero_fee_tx() {
256310
weight_sum: 200 - TX_FIXED_FIELD_WEIGHT - 1,
257311
n_outputs: 1,
258312
},
313+
max_weight: None,
259314
};
260315

261316
let candidates = vec![

0 commit comments

Comments
 (0)