feat!: depth-first branch and bound, and remove the Changeless metric - #84
Draft
evanlinjin wants to merge 7 commits into
Draft
evanlinjin wants to merge 7 commits into
evanlinjin wants to merge 7 commits into
Conversation
evanlinjin
marked this pull request as draft
September 22, 2026 08:57
evanlinjin
force-pushed
the
feat/dfs-remove-changeless-v2
branch
2 times, most recently
from
September 22, 2026 09:52
5dc9fcd to
2434ec6
Compare
This was referenced Sep 22, 2026
This was referenced Sep 23, 2026
…call
A selector was built for one target and evaluated against it throughout,
but every method took the target as a parameter, so nothing stopped
`cs.excess(target_a, drain)` being followed by `cs.is_funded(target_b)`.
The correctness arguments in the metrics are all stated at a fixed target
-- `LowestFee::bound`'s proof that a changeless superset always costs
more, `Changeless::change_unavoidable`'s assumption that the drain
decision is monotone in the excess -- and were held together by
convention rather than by types.
`CoinSelector::new` now takes the target and owns it. Twenty signatures
*lose* a parameter rather than gaining one: fifteen public methods
(`excess`, `implied_fee`, `is_funded`, `drain`, `select_until_target_met`,
the four `*_excess`, ...), plus `bnb_solutions` and `run_bnb`, plus all
three `BnbMetric` methods.
The crate had already reached this conclusion one layer down: `BnbIter`
stored the target as a field, took it once in `BnbIter::new`, and then
re-passed it into `metric.score` and `metric.bound` at every node. That
field and the re-threading are both gone.
This is a breaking change, and it reaches `BnbMetric`, so metrics
implemented outside this crate need their signatures updated:
fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>;
fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>;
fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain;
`CoinSelector::target()` exposes the target for metrics that need to read
it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y_count Fixes CoinSelector::input_weight undercounting candidates that group multiple legacy inputs in a segwit transaction (where each legacy input serializes a 1 WU empty witness). Tracking segwit and legacy input counts separately also allows a single Candidate to mix legacy and segwit inputs.
…legacy Replaces the boolean is_segwit parameter in Candidate::new with explicit new_segwit and new_legacy constructors. Clarifies in doc comments that satisfaction_weight is the additional weight required beyond TXIN_BASE_WEIGHT (which already accounts for a 1-byte scriptSigLen).
`LowestFee` already decides for itself whether a selection should carry change, adding one only when it lowers the long-term fee, clears the dust threshold, and fits `Target::max_weight`. A separate changeless objective duplicates that decision and then constrains it. Callers that required a changeless transaction should use `LowestFee` and inspect the returned `Drain`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`input_weight` scanned the selected set three times and `selected_value` once, and metrics call them several times per branch-and-bound node through `excess`, `rate_excess`, `implied_fee`, and friends. Track the selected value, weight, segwit count, and legacy count as running sums updated in `select`/`deselect`, so every aggregate is O(1). Four sums are enough since the segwit/legacy count split: a segwit transaction adds the 2 WU witness header plus 1 WU per legacy input, which is `2 + legacy_count`. `select_all_effective` now goes through `select` so the sums cannot drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the best-first `BinaryHeap` frontier with a depth-first search that visits the child with the better bound first and backtracks in place. Only the current path is held, instead of a cloned selector per frontier node. Both `LowestFee`'s bound and the exact-match test metric grow with depth, so a min-heap always pops the shallowest node: it expands every 1-input prefix, then every 2-input prefix, and on a large pool the round budget runs out before it reaches a funded leaf. Depth-first reaches a funded leaf in as many expansions as the solution has inputs. The round-count assertions in `tests/bnb.rs` move: 164 -> 94 for the feasibility search, and 3194 -> 62452 for the exhaustive exact-match search, where depth-first expands more nodes before proving optimality. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yield the greedy selection before expanding the first node, and adopt its score as the incumbent. Otherwise a caller whose round budget runs out before the first complete selection gets `NoBnbSolution::RoundLimit` and falls through to whatever fallback it has, which on a large pool is far worse than the selection a single greedy pass would have handed it. Only the incumbent changes, not the bound, so the optimum stays reachable and the improving-solutions contract is unaffected. The two round-count assertions in `tests/bnb.rs` each move by one: the seed is a round. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
evanlinjin
force-pushed
the
feat/dfs-remove-changeless-v2
branch
from
September 23, 2026 07:13
c495ffc to
6cf6fc4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces #73. #73 had the same changes on top of the ancestor-aware selection stack. This PR puts them directly on #63 so they can land ahead of that stack.
Remove the
ChangelessmetricLowestFeealready decides for itself whether a selection should carry change. It adds change only when doing so lowers the long-term fee, clears the dust threshold, and fitsTarget::max_weight. A separate changeless objective repeats that decision and then constrains it.This is a breaking change. Callers that required a changeless transaction should use
LowestFeeand inspect the returnedDrain.Keep running sums of the selection in
CoinSelectorinput_weight()scanned the selected set three times andselected_value()once. Metrics call them several times per node throughexcess,rate_excess,implied_feeand similar methods.CoinSelectornow tracks selected value, weight, segwit count, and legacy count as running sums, updated inselectanddeselect. The segwit/legacy split from #63 is what makes four sums enough: a segwit tx adds2 + legacy_countWU.A new proptest (
running_sums_match_recompute) checks the sums against a recompute from the selected set after random select/deselect sequences.Depth-first branch and bound
The priority-queue traversal has a structural problem.
LowestFee's bound grows with depth, so a min-heap always pops the shallowest node. It expands every 1-input prefix, then every 2-input prefix, and on a large pool the round budget runs out before it reaches any funded leaf.Depth-first search with in-place backtracking visits the child with the better bound first. It reaches a funded leaf in as many expansions as the solution has inputs, and it keeps only the current path in memory instead of a frontier of cloned selectors. The running sums make its in-place select/deselect O(1). Like #63's fix, it only treats candidates as interchangeable when value, weight, and segwit/legacy counts all match.
Seed with a greedy incumbent
Before expanding the first node, the search scores the greedy selection (
select_until_target_met), yields it, and adopts its score as the incumbent. Only the incumbent changes, not the bound, so the optimum stays reachable. It costs one round, so the two round counts intests/bnb.rseach go up by one.This is a guarantee rather than a speedup.
run_bnbno longer returnsRoundLimitwhen a valid greedy selection exists. That matters when the solution needs many inputs (on the 1000-coin synthetic pool below, unseeded depth-first still had nothing after 1,000 rounds because the answer is about 220 inputs deep), or when a caller stops on elapsed time rather than a fixed round count. At the 100k-round cap below it changes no result.Benchmarks
Metric is
LowestFee(2 sat/vB target, 10 sat/vB long-term, P2TR drain). Each commit is measured separately: 5953b2e is the base (the changeless removal doesn't touch the search), acf0a90 adds running sums, 2434ec6 adds depth-first search, and c495ffc adds the seed.cargo bench run_bnb_lowest_fee(the repo's synthetic pool, 100k-round cap, criterion median):clone/*stays at 20–31 ns through the running-sums and depth-first commits (one noisy seed-commit sample reached 40 ns), so the four extra sum fields don't cost anything measurable.Search outcome at a 100k-round cap. Each cell shows rounds / score / inputs / time (median of 3) / peak RSS.
-means no solution was found. The "random" pool is wallet-like: log-uniform values from 1k to 10M sat, mixing P2WPKH, P2TR, and P2PKH, with the target at 5% of the pool. Times vary by up to ~50% between runs of the same binary, so read small differences as noise.tests/bnb.rs: 164 → 95 rounds for the feasibility search, but 3194 → 62453 for the exhaustive exact-match search.How these were measured
Each commit was checked out in its own worktree. Criterion ran
cargo bench --bench coin_selector. The outcome table comes from a small untracked example that runsbnb_solutions(LowestFee).take(cap)and readsVmHWMfrom/proc/self/status. Its baseline RSS is about 3 MB. The 1M- and 10M-round figures were measured before the #63 ban fix was rebased in; spot checks after the rebase gave identical rounds, scores and input counts.🤖 Generated with Claude Code