Skip to content

Commit 600400c

Browse files
evanlinjinclaude
andcommitted
refactor(metrics): extract lp_min_cost_to_cover knapsack helper
The LP-relaxed knapsack inside `ChangelessWaste::bound`'s `rate_diff < 0` branch — sort by contribution-per-cost descending, take greedily, fractional last — is a generic primitive: "minimum cost of a fractional subset whose contributions sum to at least delta". Moves it to `src/metrics.rs` next to the other shared helpers (`resize_bound`, `excess_with_drain`, `change_lower_bound`). No behavior change; `ChangelessWaste` proptests still pass. The helper has a second caller coming in the next commit (LowestFee's "get rid of change" branch), which is what motivates the extraction. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ffb3134 commit 600400c

2 files changed

Lines changed: 62 additions & 31 deletions

File tree

src/metrics.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! Branch and bound metrics that can be passed to [`CoinSelector::bnb_solutions`] or
22
//! [`CoinSelector::run_bnb`].
3+
use alloc::vec::Vec;
4+
35
use crate::{bnb::BnbMetric, float::Ordf32, Candidate, ChangePolicy, CoinSelector, Drain, Target};
46
mod lowest_fee;
57
pub use lowest_fee::*;
@@ -125,6 +127,53 @@ fn resize_bound<'a>(
125127
Some((cs_iter, to_resize, scale))
126128
}
127129

130+
/// LP-relaxed fractional knapsack: minimum total `cost` of a fractional subset of `items`
131+
/// whose `contribution`s sum to at least `delta`.
132+
///
133+
/// `items` yields `(contribution, cost)` pairs; both must be positive. The LP-optimal greedy
134+
/// is to sort by `contribution / cost` descending (best ev-reduction per unit cost first) and
135+
/// take each item fully until the last one, which is taken fractionally to exactly cover the
136+
/// remaining deficit.
137+
///
138+
/// Returns `None` if even taking every item doesn't cover `delta` (infeasible). Otherwise
139+
/// returns `Some(min_total_cost)`. The LP-optimal cost is `<=` the cost of any integer
140+
/// solution, so the result is a safe lower bound on the achievable cost.
141+
///
142+
/// Used by metric bounds that need to estimate "minimum cost to satisfy a deficit":
143+
/// - `ChangelessWaste`'s `rate_diff < 0` UB: cost = excluded weight, contribution = ev to
144+
/// exclude in order to drop `excess_with_drain` below `min_value`.
145+
/// - `LowestFee`'s "get rid of change" branch: cost = value paid as fees, contribution = `|ev|`
146+
/// added to push excess below `min_value`.
147+
fn lp_min_cost_to_cover(items: impl IntoIterator<Item = (f32, f32)>, delta: f32) -> Option<f32> {
148+
let mut sorted: Vec<_> = items.into_iter().collect();
149+
sorted.sort_by(|a, b| {
150+
let r_a = a.0 / a.1;
151+
let r_b = b.0 / b.1;
152+
r_b.partial_cmp(&r_a).unwrap_or(core::cmp::Ordering::Equal)
153+
});
154+
155+
let mut remaining = delta;
156+
let mut total_cost = 0.0_f32;
157+
for (contribution, cost) in sorted {
158+
if remaining <= 0.0 {
159+
break;
160+
}
161+
if contribution >= remaining {
162+
total_cost += cost * (remaining / contribution);
163+
remaining = 0.0;
164+
} else {
165+
total_cost += cost;
166+
remaining -= contribution;
167+
}
168+
}
169+
170+
if remaining > 0.0 {
171+
None
172+
} else {
173+
Some(total_cost)
174+
}
175+
}
176+
128177
macro_rules! impl_for_tuple {
129178
($($a:ident $b:tt)*) => {
130179
impl<$($a),*> BnbMetric for ($(($a, f32)),*)

src/metrics/changeless_waste.rs

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
use super::{change_lower_bound, excess_with_drain, resize_bound};
1+
use super::{change_lower_bound, excess_with_drain, lp_min_cost_to_cover, resize_bound};
22
use crate::{bnb::BnbMetric, float::Ordf32, ChangePolicy, CoinSelector, Drain, FeeRate, Target};
3-
use alloc::vec::Vec;
43

54
/// Metric that minimizes the [waste metric] subject to the constraint that the selection produces
65
/// no change output.
@@ -108,42 +107,25 @@ fn ub_changeless_input_weight(
108107
if delta <= 0 {
109108
return d_all_iw;
110109
}
111-
let mut remaining = delta as f32;
112110

113-
let mut pos: Vec<(f32, f32)> = cs
114-
.unselected()
115-
.filter_map(|(_, c)| {
111+
// LP-min weight to exclude such that excluded ev sum >= delta. Items: positive-ev_feerate
112+
// unselected candidates (contribution = ev, cost = weight).
113+
match lp_min_cost_to_cover(
114+
cs.unselected().filter_map(|(_, c)| {
116115
let ev = c.effective_value(target.fee.rate);
117116
if ev > 0.0 {
118117
Some((ev, c.weight as f32))
119118
} else {
120119
None
121120
}
122-
})
123-
.collect();
124-
pos.sort_by(|a, b| {
125-
let r_a = a.0 / a.1;
126-
let r_b = b.0 / b.1;
127-
r_b.partial_cmp(&r_a).unwrap_or(core::cmp::Ordering::Equal)
128-
});
129-
130-
let mut removed_weight = 0.0_f32;
131-
for (ev, w) in pos {
132-
if remaining <= 0.0 {
133-
break;
134-
}
135-
if ev >= remaining {
136-
removed_weight += w * (remaining / ev);
137-
remaining = 0.0;
138-
} else {
139-
removed_weight += w;
140-
remaining -= ev;
121+
}),
122+
delta as f32,
123+
) {
124+
Some(removed_weight) => d_all_iw - removed_weight,
125+
None => {
126+
// Unreachable when `change_lower_bound = None` (which we already checked). Fall
127+
// back to the loose `D_all`-based bound rather than fabricating a tight one.
128+
d_all_iw
141129
}
142130
}
143-
if remaining > 0.0 {
144-
// Unreachable when `change_lower_bound = None` (which we already checked). Fall back
145-
// to the loose `D_all`-based bound rather than fabricating a tight one.
146-
return d_all_iw;
147-
}
148-
d_all_iw - removed_weight
149131
}

0 commit comments

Comments
 (0)