Skip to content

Commit 7a9eebb

Browse files
evanlinjinclaude
andcommitted
perf(metrics): tighten ChangelessWaste bound for rate_diff < 0
The previous bound for `rate_diff < 0` was `all_selected.input_weight * rate_diff`, which ignored that selecting every candidate would typically force a change output (making the selection infeasible under the changeless constraint). The new bound recasts the problem as: minimize the weight of candidates excluded from `D_all` such that `excess_with_drain` drops below `change_policy.min_value`. This is a 0/1 covering knapsack; the LP relaxation (sort positive-ev_feerate candidates by `ev/weight` descending and exclude fractionally) gives a safe upper bound on `D.input_weight` for any feasible changeless descendant. Benchmark improvements (round counts, BnB cap 2M): n=15 rate_diff_neg: 3,590 -> 415 (~9x) n=20 rate_diff_neg: 34,296 -> 2,138 (~16x) n=30 rate_diff_neg: 2M cap -> 74,055 (>27x, was hitting cap) rate_diff >= 0 paths are unchanged. `ensure_bound_is_not_too_tight` proptest verifies the new LB across 256 randomized scenarios. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7586dc2 commit 7a9eebb

1 file changed

Lines changed: 90 additions & 19 deletions

File tree

src/metrics/changeless_waste.rs

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use super::change_lower_bound;
22
use crate::{bnb::BnbMetric, float::Ordf32, ChangePolicy, CoinSelector, Drain, FeeRate, Target};
3+
use alloc::vec::Vec;
34

45
/// Metric that minimizes the [waste metric] subject to the constraint that the selection produces
56
/// no change output.
@@ -54,37 +55,34 @@ impl BnbMetric for ChangelessWaste {
5455
// score(D) = D.input_weight * rate_diff + max(0, D.excess)
5556
//
5657
// and `D.excess >= 0` (target met), so `score(D) >= D.input_weight * rate_diff`. The
57-
// bound therefore reduces to finding a lower bound on `D.input_weight * rate_diff`.
58+
// bound therefore reduces to bounding `D.input_weight` in the right direction.
5859

5960
if rate_diff < 0.0 {
60-
// rate_diff < 0: the most negative `D.input_weight * rate_diff` comes from the
61-
// largest possible input_weight, which is bounded by selecting every candidate.
62-
// (`D` need not actually be feasible — we only need an LB on its score.)
63-
let mut all = cs.clone();
64-
all.select_all();
65-
return Some(Ordf32(all.input_weight() as f32 * rate_diff));
61+
// rate_diff < 0: we want an UPPER bound on `D.input_weight`. `all_selected` is a
62+
// safe but loose UB; we tighten by LP-relaxed knapsack over candidates that
63+
// *must* be excluded to keep the selection changeless.
64+
let ub = ub_changeless_input_weight(cs, self.target, self.change_policy);
65+
return Some(Ordf32(ub * rate_diff));
6666
}
6767

68-
// rate_diff >= 0: smaller input_weight gives a smaller `input_weight * rate_diff`, so we
69-
// want a lower bound on `D.input_weight`. `D.input_weight >= cs.input_weight` always
70-
// (selecting only grows), but we can do much better when the target is not yet met.
68+
// rate_diff >= 0: we want a LOWER bound on `D.input_weight`. `cs.input_weight` is a
69+
// safe baseline (input_weight is monotone non-decreasing). Tighten with the resize
70+
// trick when target is not yet met.
7171
if cs.is_target_met(self.target) {
7272
return Some(Ordf32(cs.input_weight() as f32 * rate_diff));
7373
}
7474

75-
// Target not met. Use the same resize trick as `LowestFee::bound`: walk the sorted
76-
// unselected list until we cross the target, then pretend the crossing input was
77-
// perfectly scaled so that the target is hit with zero excess. Among all subsets of
78-
// unselected that reach target, the highest-`value_pwu` candidates are the most
79-
// weight-efficient — so the resize-scaled prefix is a valid lower bound on any
80-
// target-meeting descendant's input_weight.
75+
// Target not met. Same resize trick as `LowestFee::bound`: walk the sorted unselected
76+
// list until we cross the target, then pretend the crossing input was perfectly
77+
// scaled so the target is hit with zero excess. Among all subsets of unselected that
78+
// reach target, the highest-`value_pwu` candidates are the most weight-efficient — so
79+
// the resize-scaled prefix is a valid lower bound on any target-meeting descendant's
80+
// input_weight.
8181
let (mut cs, resize_index, to_resize) = cs
8282
.clone()
8383
.select_iter()
8484
.find(|(cs, _, _)| cs.is_target_met(self.target))?;
8585

86-
// If the find selection already hits target exactly, that's the minimum-weight
87-
// target-meeting subset; the bound is its waste (with `Drain::NONE`).
8886
if cs.excess(self.target, Drain::NONE) == 0 {
8987
return Some(Ordf32(cs.waste(
9088
self.target,
@@ -95,7 +93,6 @@ impl BnbMetric for ChangelessWaste {
9593
}
9694
cs.deselect(resize_index);
9795

98-
// Compute the smallest `scale` of `to_resize` that satisfies each fee constraint.
9996
let mut scale = Ordf32(0.0);
10097

10198
let rate_excess = cs.rate_excess_wu(self.target, Drain::NONE) as f32;
@@ -138,3 +135,77 @@ impl BnbMetric for ChangelessWaste {
138135
true
139136
}
140137
}
138+
139+
/// LP-relaxed upper bound on `D.input_weight` for changeless `D ⊇ cs` (used by the
140+
/// `rate_diff < 0` branch of `ChangelessWaste::bound`).
141+
///
142+
/// Construct `D_all = cs ∪ all unselected`. If `D_all` itself is changeless, the UB is
143+
/// `D_all.input_weight`. Otherwise we must exclude enough excess-contributing
144+
/// (positive-`ev_feerate`) candidates to drop `excess(drain_weights)` below
145+
/// `change_policy.min_value`. To MAXIMIZE the remaining `input_weight` we MINIMIZE the
146+
/// excluded weight, sorting positive-`ev_feerate` candidates by `ev_feerate / weight`
147+
/// descending and removing fractionally until the required `delta` is met.
148+
///
149+
/// The LP relaxation gives a value `>=` any integer solution's excluded weight, so
150+
/// `D_all.input_weight - LP_min` is a safe UB for any feasible `D.input_weight`. The
151+
/// `input_weight()` segwit/varint corrections only ever ADD weight to the parent, never
152+
/// subtract from a subset — so the additive subtraction is safe in the UB direction.
153+
fn ub_changeless_input_weight(
154+
cs: &CoinSelector<'_>,
155+
target: Target,
156+
change_policy: ChangePolicy,
157+
) -> f32 {
158+
let mut d_all = cs.clone();
159+
d_all.select_all();
160+
let d_all_iw = d_all.input_weight() as f32;
161+
162+
let excess_with_drain = d_all.excess(
163+
target,
164+
Drain {
165+
weights: change_policy.drain_weights,
166+
value: 0,
167+
},
168+
);
169+
let delta = excess_with_drain - change_policy.min_value as i64;
170+
if delta <= 0 {
171+
return d_all_iw;
172+
}
173+
let mut remaining = delta as f32;
174+
175+
let mut pos: Vec<(f32, f32)> = cs
176+
.unselected()
177+
.filter_map(|(_, c)| {
178+
let ev = c.effective_value(target.fee.rate);
179+
if ev > 0.0 {
180+
Some((ev, c.weight as f32))
181+
} else {
182+
None
183+
}
184+
})
185+
.collect();
186+
pos.sort_by(|a, b| {
187+
let r_a = a.0 / a.1;
188+
let r_b = b.0 / b.1;
189+
r_b.partial_cmp(&r_a).unwrap_or(core::cmp::Ordering::Equal)
190+
});
191+
192+
let mut removed_weight = 0.0_f32;
193+
for (ev, w) in pos {
194+
if remaining <= 0.0 {
195+
break;
196+
}
197+
if ev >= remaining {
198+
removed_weight += w * (remaining / ev);
199+
remaining = 0.0;
200+
} else {
201+
removed_weight += w;
202+
remaining -= ev;
203+
}
204+
}
205+
if remaining > 0.0 {
206+
// Unreachable when `change_lower_bound = None` (which we already checked). Fall back
207+
// to the loose `D_all`-based bound rather than fabricating a tight one.
208+
return d_all_iw;
209+
}
210+
d_all_iw - removed_weight
211+
}

0 commit comments

Comments
 (0)