Skip to content

Commit 9dd8f8f

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 281f73c commit 9dd8f8f

1 file changed

Lines changed: 123 additions & 34 deletions

File tree

src/metrics/changeless_waste.rs

Lines changed: 123 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, DrainWeights, FeeRate, Target};
2+
use alloc::vec::Vec;
23

34
/// Metric that minimizes the [waste metric] subject to the constraint that the selection produces
45
/// no change output.
@@ -41,34 +42,60 @@ impl ChangelessWaste {
4142
///
4243
/// [`LowestFee`]: crate::metrics::LowestFee
4344
fn drain_value(&self, cs: &CoinSelector<'_>, target: Target) -> Option<u64> {
44-
// The change output pays for its own weight, so the value we'd actually recover is the
45-
// excess remaining after accounting for that weight.
46-
let excess_with_drain_weight = cs.excess(
47-
target,
48-
Drain {
49-
weights: self.drain_weights,
50-
value: 0,
51-
},
52-
);
45+
let excess_with_drain_weight = self.excess_with_drain_weight(cs, target);
5346

5447
// Adding change is only worth it if the value we'd recover exceeds the future cost of
5548
// spending it (i.e. it lowers the long-term fee).
56-
let drain_spend_cost = self
57-
.long_term_feerate
58-
.implied_fee_wu(self.drain_weights.spend_weight);
59-
if excess_with_drain_weight <= drain_spend_cost as i64 {
49+
if excess_with_drain_weight <= self.drain_spend_cost() as i64 {
6050
return None;
6151
}
6252

6353
// ...and only if the change output would not be dust.
64-
let dust_threshold = self.drain_weights.dust_threshold(self.dust_relay_feerate);
65-
if excess_with_drain_weight < dust_threshold as i64 {
54+
if excess_with_drain_weight < self.dust_threshold() as i64 {
6655
return None;
6756
}
6857

6958
Some(excess_with_drain_weight.unsigned_abs())
7059
}
7160

61+
/// The excess of `cs` after accounting for the weight (but not value) of a would-be change
62+
/// output. This is the quantity the change decision (see [`drain_value`]) is made on.
63+
///
64+
/// [`drain_value`]: Self::drain_value
65+
fn excess_with_drain_weight(&self, cs: &CoinSelector<'_>, target: Target) -> i64 {
66+
// The change output pays for its own weight, so the value we'd actually recover is the
67+
// excess remaining after accounting for that weight.
68+
cs.excess(
69+
target,
70+
Drain {
71+
weights: self.drain_weights,
72+
value: 0,
73+
},
74+
)
75+
}
76+
77+
/// The future fee of spending a would-be change output. Change below this is never worthwhile.
78+
fn drain_spend_cost(&self) -> u64 {
79+
self.long_term_feerate
80+
.implied_fee_wu(self.drain_weights.spend_weight)
81+
}
82+
83+
/// The dust threshold of a would-be change output. Change below this is never created.
84+
fn dust_threshold(&self) -> u64 {
85+
self.drain_weights.dust_threshold(self.dust_relay_feerate)
86+
}
87+
88+
/// The largest `excess_with_drain_weight` for which a selection is still changeless.
89+
///
90+
/// A selection is changeless (see [`drain_value`]) when its excess is `<= drain_spend_cost` OR
91+
/// `< dust_threshold`. The union of those two regions is `excess <= max(drain_spend_cost,
92+
/// dust_threshold - 1)`, so this is the inclusive upper edge of the changeless region.
93+
///
94+
/// [`drain_value`]: Self::drain_value
95+
fn changeless_max_excess(&self) -> i64 {
96+
(self.drain_spend_cost() as i64).max(self.dust_threshold() as i64 - 1)
97+
}
98+
7299
/// Whether every selection reachable down this branch (the current one and any superset of it)
73100
/// would have a change output — so no changeless solution exists here and the branch can be
74101
/// pruned.
@@ -98,6 +125,72 @@ impl ChangelessWaste {
98125

99126
self.drain_value(&least_excess, target).is_some()
100127
}
128+
129+
/// LP-relaxed upper bound on `D.input_weight` for changeless `D ⊇ cs` (used by the
130+
/// `rate_diff < 0` branch of [`bound`]).
131+
///
132+
/// Construct `D_all = cs ∪ all unselected`. If `D_all` itself is changeless, the UB is
133+
/// `D_all.input_weight`. Otherwise we must exclude enough excess-contributing
134+
/// (positive-`effective_value`) candidates to drop `excess_with_drain_weight` down to
135+
/// [`changeless_max_excess`]. To MAXIMIZE the remaining `input_weight` we MINIMIZE the excluded
136+
/// weight, sorting positive-`ev` candidates by `ev / weight` descending and removing
137+
/// fractionally until the required `delta` is met.
138+
///
139+
/// The LP relaxation gives a value `>=` any integer solution's excluded weight, so
140+
/// `D_all.input_weight - LP_min` is a safe UB for any feasible `D.input_weight`. The
141+
/// `input_weight()` segwit/varint corrections only ever ADD weight to the parent, never
142+
/// subtract from a subset — so the additive subtraction is safe in the UB direction.
143+
///
144+
/// [`bound`]: BnbMetric::bound
145+
/// [`changeless_max_excess`]: Self::changeless_max_excess
146+
fn ub_changeless_input_weight(&self, cs: &CoinSelector<'_>, target: Target) -> f32 {
147+
let mut d_all = cs.clone();
148+
d_all.select_all();
149+
let d_all_iw = d_all.input_weight() as f32;
150+
151+
let delta = self.excess_with_drain_weight(&d_all, target) - self.changeless_max_excess();
152+
if delta <= 0 {
153+
return d_all_iw;
154+
}
155+
let mut remaining = delta as f32;
156+
157+
let mut pos: Vec<(f32, f32)> = cs
158+
.unselected()
159+
.filter_map(|(_, c)| {
160+
let ev = c.effective_value(target.fee.rate);
161+
if ev > 0.0 {
162+
Some((ev, c.weight as f32))
163+
} else {
164+
None
165+
}
166+
})
167+
.collect();
168+
pos.sort_by(|a, b| {
169+
let r_a = a.0 / a.1;
170+
let r_b = b.0 / b.1;
171+
r_b.partial_cmp(&r_a).unwrap_or(core::cmp::Ordering::Equal)
172+
});
173+
174+
let mut removed_weight = 0.0_f32;
175+
for (ev, w) in pos {
176+
if remaining <= 0.0 {
177+
break;
178+
}
179+
if ev >= remaining {
180+
removed_weight += w * (remaining / ev);
181+
remaining = 0.0;
182+
} else {
183+
removed_weight += w;
184+
remaining -= ev;
185+
}
186+
}
187+
if remaining > 0.0 {
188+
// Unreachable when `change_unavoidable = false` (which the caller already checked).
189+
// Fall back to the loose `D_all`-based bound rather than fabricating a tight one.
190+
return d_all_iw;
191+
}
192+
d_all_iw - removed_weight
193+
}
101194
}
102195

103196
impl BnbMetric for ChangelessWaste {
@@ -130,37 +223,34 @@ impl BnbMetric for ChangelessWaste {
130223
// score(D) = D.input_weight * rate_diff + max(0, D.excess)
131224
//
132225
// and `D.excess >= 0` (target met), so `score(D) >= D.input_weight * rate_diff`. The
133-
// bound therefore reduces to finding a lower bound on `D.input_weight * rate_diff`.
226+
// bound therefore reduces to bounding `D.input_weight` in the right direction.
134227

135228
if rate_diff < 0.0 {
136-
// rate_diff < 0: the most negative `D.input_weight * rate_diff` comes from the
137-
// largest possible input_weight, which is bounded by selecting every candidate.
138-
// (`D` need not actually be feasible — we only need an LB on its score.)
139-
let mut all = cs.clone();
140-
all.select_all();
141-
return Some(Ordf32(all.input_weight() as f32 * rate_diff));
229+
// rate_diff < 0: we want an UPPER bound on `D.input_weight`. `all_selected` is a
230+
// safe but loose UB; we tighten by LP-relaxed knapsack over candidates that
231+
// *must* be excluded to keep the selection changeless.
232+
let ub = self.ub_changeless_input_weight(cs, target);
233+
return Some(Ordf32(ub * rate_diff));
142234
}
143235

144-
// rate_diff >= 0: smaller input_weight gives a smaller `input_weight * rate_diff`, so we
145-
// want a lower bound on `D.input_weight`. `D.input_weight >= cs.input_weight` always
146-
// (selecting only grows), but we can do much better when the target is not yet met.
236+
// rate_diff >= 0: we want a LOWER bound on `D.input_weight`. `cs.input_weight` is a
237+
// safe baseline (input_weight is monotone non-decreasing). Tighten with the resize
238+
// trick when target is not yet met.
147239
if cs.is_target_met(target) {
148240
return Some(Ordf32(cs.input_weight() as f32 * rate_diff));
149241
}
150242

151-
// Target not met. Use the same resize trick as `LowestFee::bound`: walk the sorted
152-
// unselected list until we cross the target, then pretend the crossing input was
153-
// perfectly scaled so that the target is hit with zero excess. Among all subsets of
154-
// unselected that reach target, the highest-`value_pwu` candidates are the most
155-
// weight-efficient — so the resize-scaled prefix is a valid lower bound on any
156-
// target-meeting descendant's input_weight.
243+
// Target not met. Same resize trick as `LowestFee::bound`: walk the sorted unselected
244+
// list until we cross the target, then pretend the crossing input was perfectly
245+
// scaled so the target is hit with zero excess. Among all subsets of unselected that
246+
// reach target, the highest-`value_pwu` candidates are the most weight-efficient — so
247+
// the resize-scaled prefix is a valid lower bound on any target-meeting descendant's
248+
// input_weight.
157249
let (mut cs, resize_index, to_resize) = cs
158250
.clone()
159251
.select_iter()
160252
.find(|(cs, _, _)| cs.is_target_met(target))?;
161253

162-
// If the find selection already hits target exactly, that's the minimum-weight
163-
// target-meeting subset; the bound is its waste (with `Drain::NONE`).
164254
if cs.excess(target, Drain::NONE) == 0 {
165255
return Some(Ordf32(cs.waste(
166256
target,
@@ -171,7 +261,6 @@ impl BnbMetric for ChangelessWaste {
171261
}
172262
cs.deselect(resize_index);
173263

174-
// Compute the smallest `scale` of `to_resize` that satisfies each fee constraint.
175264
let mut scale = Ordf32(0.0);
176265

177266
let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32;

0 commit comments

Comments
 (0)