Skip to content

Commit fbfa8f0

Browse files
evanlinjinclaude
andcommitted
test: exercise Target::max_weight and add exact feasibility oracle
Thread `max_weight` through `Target`, `StrategyParams` and the proptests so BnB is checked against exhaustive search with the cap active. Add `exact_selection_possible`, an exhaustive value-and-weight feasibility oracle, and a `bnb_respects_max_weight` proptest asserting BnB matches it — this validates the metric's weight hard-prune. Impossibility assertions switch from the value-only `is_selection_possible` to this oracle where the cap is in play. Tighten `compare_against_benchmarks`: filter benchmarks by `score().is_some()` (actually-valid solutions, reusing the computed score) rather than the value-only `is_target_met`, add a value-per-weight greedy benchmark so the comparison isn't vacuous under a tight cap, and assert BnB itself found a valid solution whenever a benchmark did (a `None` score used to pass silently). Cap its `n` — the no-solution branch's oracle is O(2^n). Also mark `bnb_always_finds_solution_if_possible` release-only, matching its sibling proptest; it's too slow under debug assertions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e98cb10 commit fbfa8f0

5 files changed

Lines changed: 128 additions & 15 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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() {
9090
},
9191
// we're trying to find an exact selection value so set fees to 0
9292
fee: TargetFee::ZERO,
93+
max_weight: None,
9394
};
9495

9596
let solutions = cs.bnb_solutions(target, MinExcessThenWeight);
@@ -124,6 +125,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() {
124125
n_outputs: 1,
125126
},
126127
fee: TargetFee::default(),
128+
max_weight: None,
127129
};
128130

129131
let solutions = cs.bnb_solutions(target, MinExcessThenWeight);
@@ -143,6 +145,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() {
143145

144146
proptest! {
145147
#[test]
148+
#[cfg(not(debug_assertions))] // too slow if compiling for debug
146149
fn bnb_always_finds_solution_if_possible(num_inputs in 1usize..18, target_value in 0u64..10_000) {
147150
let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha);
148151
let wv = test_wv(&mut rng);
@@ -152,6 +155,7 @@ proptest! {
152155
let target = Target {
153156
outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 },
154157
fee: TargetFee::ZERO,
158+
max_weight: None,
155159
};
156160

157161
let solutions = cs.bnb_solutions(target, MinExcessThenWeight);
@@ -198,6 +202,7 @@ proptest! {
198202
outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 },
199203
// we're trying to find an exact selection value so set fees to 0
200204
fee: TargetFee::ZERO,
205+
max_weight: None,
201206
};
202207

203208
let solutions = cs.bnb_solutions(target, MinExcessThenWeight);

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 = || {

tests/common.rs

Lines changed: 55 additions & 5 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

@@ -293,7 +302,7 @@ pub struct ExhaustiveIter<'a> {
293302
}
294303

295304
impl<'a> ExhaustiveIter<'a> {
296-
fn new(cs: &CoinSelector<'a>) -> Option<Self> {
305+
pub fn new(cs: &CoinSelector<'a>) -> Option<Self> {
297306
let mut iter = Self { stack: Vec::new() };
298307
iter.push_branches(cs);
299308
Some(iter)
@@ -372,6 +381,23 @@ where
372381
best.map(|(_, score)| (score, rounds))
373382
}
374383

384+
/// Exact feasibility oracle: does *any* subset of the currently-unbanned candidates (added to the
385+
/// current selection) meet `target`, i.e. cover the value **and** stay within `max_weight`?
386+
///
387+
/// Enumerates every subset via [`ExhaustiveIter`] and reuses the real
388+
/// [`CoinSelector::is_target_met`] + [`CoinSelector::is_within_max_weight`], so it inherits the
389+
/// exact weight model and is independent of the BnB weight prune it audits. Exponential — small `n`
390+
/// only.
391+
pub fn exact_selection_possible(cs: &CoinSelector, target: Target) -> bool {
392+
let feasible =
393+
|s: &CoinSelector| s.is_target_met(target) && s.is_within_max_weight(target, Drain::NONE);
394+
// the current selection itself (no additions) is a valid subset and isn't yielded by the iter
395+
feasible(cs)
396+
|| ExhaustiveIter::new(cs)
397+
.map(|mut iter| iter.any(|(subset, _)| feasible(&subset)))
398+
.unwrap_or(false)
399+
}
400+
375401
pub fn bnb_search<M>(
376402
cs: &mut CoinSelector,
377403
target: Target,
@@ -451,20 +477,42 @@ pub fn compare_against_benchmarks<M: BnbMetric + Clone>(
451477
all_effective_selected.select_all_effective(target.fee.rate);
452478
all_effective_selected
453479
},
480+
{
481+
// Lightest value-meeting greedy selection. Under a binding `max_weight` the
482+
// bulk benchmarks above are all over-cap (score `None`) and get filtered out;
483+
// this one is the relevant baseline that stays feasible when a light solution
484+
// exists, so the comparison below isn't vacuous.
485+
let mut greedy = cs.clone();
486+
greedy.sort_candidates_by_descending_value_pwu();
487+
let _ = greedy.select_until_target_met(target);
488+
greedy
489+
},
454490
];
455491

456492
// add some random selections -- technically it's possible that one of these is better but it's very unlikely if our algorithm is working correctly.
457493
cmp_benchmarks.extend(
458494
(0..10).map(|_| randomly_satisfy_target(&cs, target, &mut rng, metric.clone())),
459495
);
460496

497+
// Only compare against benchmarks that are themselves *valid* solutions. A benchmark
498+
// can meet the target value yet bust `max_weight` (e.g. `select_all` on a tight cap),
499+
// in which case its score is `None` and it isn't a real solution to compare against.
461500
let cmp_benchmarks = cmp_benchmarks
462501
.into_iter()
463-
.filter(|cs| cs.is_target_met(target));
502+
.filter_map(|cs| {
503+
let score = metric.clone().score(&cs, target)?;
504+
Some((cs, score))
505+
})
506+
.collect::<Vec<_>>();
464507
let sol_score = metric.score(&sol, target);
465508

466-
for (_bench_id, mut bench) in cmp_benchmarks.enumerate() {
467-
let bench_score = metric.score(&bench, target);
509+
for (_bench_id, (mut bench, bench_score)) in cmp_benchmarks.into_iter().enumerate() {
510+
prop_assert!(
511+
sol_score.is_some(),
512+
"bnb must be able to find solution if benchmark can"
513+
);
514+
let sol_score = sol_score.expect("must be some");
515+
468516
if sol_score > bench_score {
469517
dbg!(_bench_id);
470518
println!("bnb solution: {}", sol);
@@ -475,7 +523,9 @@ pub fn compare_against_benchmarks<M: BnbMetric + Clone>(
475523
}
476524
}
477525
None => {
478-
prop_assert!(!cs.is_selection_possible(target));
526+
// Full feasibility (value *and* max_weight) is needed here; `is_selection_possible`
527+
// only covers value, so use the exact exhaustive oracle to assert impossibility.
528+
prop_assert!(!exact_selection_possible(&cs, target));
479529
}
480530
}
481531

tests/lowest_fee.rs

Lines changed: 65 additions & 9 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(500u64..4_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(500u64..4_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,20 +71,25 @@ 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+
// No `max_weight` here: `n` is too large for the exhaustive oracle, and this test's
75+
// impossibility check relies on the (value-only) `is_selection_possible`, so a weight cap
76+
// (which BnB could fail on while value is reachable) would break it. Cap handling is
77+
// covered by `bnb_respects_max_weight` and `can_eventually_find_best_solution`.
7278
) {
7379
println!("== TEST ==");
7480

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 };
81+
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: None };
7682
println!("{:?}", params);
7783

78-
let candidates = core::iter::repeat(Candidate {
84+
let candidates = vec![
85+
Candidate {
7986
value: 20_000,
8087
weight: (32 + 4 + 4 + 1) * 4 + 64 + 32,
8188
input_count: 1,
8289
is_segwit: true,
83-
})
84-
.take(params.n_candidates)
85-
.collect::<Vec<_>>();
90+
};
91+
params.n_candidates
92+
];
8693

8794
let mut cs = CoinSelector::new(&candidates);
8895

@@ -101,7 +108,9 @@ proptest! {
101108
#[test]
102109
#[cfg(not(debug_assertions))] // too slow if compiling for debug
103110
fn compare_against_benchmarks(
104-
n_candidates in 0..50_usize, // candidates (n)
111+
// `n` is kept small: the no-solution branch asserts against `exact_selection_possible`,
112+
// an exhaustive O(2^n) oracle (needed because it must be exact w.r.t. the weight cap).
113+
n_candidates in 0..16_usize, // candidates (n)
105114
target_value in 500..1_000_000_u64, // target value (sats)
106115
n_target_outputs in 1usize..150, // the number of outputs we're funding
107116
target_weight in 0..10_000_u32, // the sum of the weight of the outputs (wu)
@@ -112,15 +121,59 @@ proptest! {
112121
drain_spend_weight in 1..=2000_u32, // drain spend weight (wu)
113122
drain_dust in 100..=1000_u64, // drain dust (sats)
114123
n_drain_outputs in 1usize..150, // the number of drain outputs
124+
max_weight in common::maybe_max_weight(500u64..4_000), // optional max tx weight cap (wu)
115125
) {
116126

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 };
127+
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 };
118128
let candidates = common::gen_candidates(params.n_candidates);
119129
let metric = params.lowest_fee_metric();
120130
common::compare_against_benchmarks(params, candidates, metric)?;
121131
}
122132
}
123133

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

143197
let candidates = common::gen_candidates(params.n_candidates);
@@ -177,6 +231,7 @@ fn does_not_create_change_below_spend_cost() {
177231
weight_sum: 200 - TX_FIXED_FIELD_WEIGHT - 1,
178232
n_outputs: 1,
179233
},
234+
max_weight: None,
180235
};
181236

182237
let candidates = vec![
@@ -259,6 +314,7 @@ fn zero_fee_tx() {
259314
weight_sum: 200 - TX_FIXED_FIELD_WEIGHT - 1,
260315
n_outputs: 1,
261316
},
317+
max_weight: None,
262318
};
263319

264320
let candidates = vec![

0 commit comments

Comments
 (0)