Skip to content

Commit df23640

Browse files
evanlinjinclaude
andcommitted
perf(metrics): use lp_min_cost_to_cover in LowestFee bound
Replaces the single-benchmark estimate in `LowestFee::bound`'s "target met, has change, can we go changeless?" branch with the shared `lp_min_cost_to_cover` helper. The old code used only the lowest-value_pwu unselected candidate as a benchmark and assumed it scaled arbitrarily, which under-estimates the cost when the benchmark's `|ev|` is less than the amount above the change threshold. LP-relaxed knapsack handles this correctly: takes the benchmark fully (it's the most-efficient by definition), then progressively worse-ratio candidates until the deficit is covered. The bound is provably tighter than V1 (or equal): for any `amount <= |ev_benchmark|`, the LP answer equals V1's; for `amount > |ev_benchmark|`, the LP answer is strictly larger (tighter LB), because V1 over-scales the benchmark beyond its actual size. In the synthetic `benches/lowest_fee_bounds` pool the V1/V2 round counts are identical — the path "amount > benchmark's |ev|" doesn't fire in this particular pool shape. The tightening is for adversarial / dust-heavy pools the proptest occasionally exercises. `ensure_bound_is_not_too_tight` proptest still passes (all 7 LowestFee tests green). Removes the TODO comment that pointed at this exact tightening opportunity. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 600400c commit df23640

1 file changed

Lines changed: 31 additions & 36 deletions

File tree

src/metrics/lowest_fee.rs

Lines changed: 31 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -53,43 +53,38 @@ impl BnbMetric for LowestFee {
5353

5454
// I think this whole if statement could be removed if we made this metric decide the change policy
5555
if let Some(drain_value) = drain_value {
56-
// it's possible that adding another input might reduce your long term fee if it
57-
// gets rid of an expensive change output. Our strategy is to take the lowest sat
58-
// per value candidate we have and use it as a benchmark. We imagine it has the
59-
// perfect value (but the same sats per weight unit) to get rid of the change output
60-
// by adding negative effective value (i.e. perfectly reducing excess to the point
61-
// where change wouldn't be added according to the policy).
62-
//
63-
// TODO: This metric could be tighter by being more complicated but this seems to be
64-
// good enough for now.
65-
let amount_above_change_threshold = drain_value - self.change_policy.min_value;
66-
67-
if let Some((_, low_sats_per_wu_candidate)) = cs.unselected().next_back() {
68-
let ev = low_sats_per_wu_candidate.effective_value(self.target.fee.rate);
69-
// we can only reduce excess if ev is negative
70-
if ev < -0.0 {
71-
let value_per_negative_effective_value =
72-
low_sats_per_wu_candidate.value as f32 / ev.abs();
73-
// this is how much absolute value we have to add to cancel out the excess
74-
let extra_value_needed_to_get_rid_of_change = amount_above_change_threshold
75-
as f32
76-
* value_per_negative_effective_value;
77-
78-
// NOTE: the drain_value goes to fees if we get rid of it so it's part of
79-
// the cost of removing the change output
80-
let cost_of_getting_rid_of_change =
81-
extra_value_needed_to_get_rid_of_change + drain_value as f32;
82-
let cost_of_change = self.change_policy.drain_weights.waste(
83-
self.target.fee.rate,
84-
self.long_term_feerate,
85-
self.target.outputs.n_outputs,
86-
);
87-
let best_score_without_change = Ordf32(
88-
current_score.0 + cost_of_getting_rid_of_change - cost_of_change,
89-
);
90-
if best_score_without_change < current_score {
91-
return Some(best_score_without_change);
56+
// It might be possible to reduce the long-term fee by adding negative-ev inputs
57+
// until `excess` falls below `min_value`, making the selection changeless. The
58+
// LP-relaxed minimum cost to do so: sort negative-ev unselected candidates by
59+
// `|ev| / value` descending (most excess reduction per unit value cost) and take
60+
// fractionally until the deficit is covered. The total value added becomes extra
61+
// fee paid (since the changeless tx has no drain to absorb it).
62+
let amount_above_change_threshold =
63+
(drain_value - self.change_policy.min_value) as f32;
64+
let extra_value_needed_to_get_rid_of_change = super::lp_min_cost_to_cover(
65+
cs.unselected().filter_map(|(_, c)| {
66+
let ev = c.effective_value(self.target.fee.rate);
67+
if ev < 0.0 {
68+
Some((-ev, c.value as f32))
69+
} else {
70+
None
9271
}
72+
}),
73+
amount_above_change_threshold,
74+
);
75+
if let Some(extra_value) = extra_value_needed_to_get_rid_of_change {
76+
// NOTE: the drain_value goes to fees if we get rid of it so it's part of
77+
// the cost of removing the change output.
78+
let cost_of_getting_rid_of_change = extra_value + drain_value as f32;
79+
let cost_of_change = self.change_policy.drain_weights.waste(
80+
self.target.fee.rate,
81+
self.long_term_feerate,
82+
self.target.outputs.n_outputs,
83+
);
84+
let best_score_without_change =
85+
Ordf32(current_score.0 + cost_of_getting_rid_of_change - cost_of_change);
86+
if best_score_without_change < current_score {
87+
return Some(best_score_without_change);
9388
}
9489
}
9590
} else {

0 commit comments

Comments
 (0)