Skip to content

feat!: depth-first branch and bound, and remove the Changeless metric - #84

Draft
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/dfs-remove-changeless-v2
Draft

evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/dfs-remove-changeless-v2

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Stacked on #63 (which is stacked on #59). Only the last 4 commits (5953b2e, acf0a90, 2434ec6, c495ffc) belong to this PR.

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 Changeless metric

LowestFee already 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 fits Target::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 LowestFee and inspect the returned Drain.

Keep running sums of the selection in CoinSelector

input_weight() scanned the selected set three times and selected_value() once. Metrics call them several times per node through excess, rate_excess, implied_fee and similar methods. CoinSelector now tracks selected value, weight, segwit count, and legacy count as running sums, updated in select and deselect. The segwit/legacy split from #63 is what makes four sums enough: a segwit tx adds 2 + legacy_count WU.

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 in tests/bnb.rs each go up by one.

This is a guarantee rather than a speedup. run_bnb no longer returns RoundLimit when 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):

n base + running sums + depth-first + seed
20 219 µs 100 µs 58 µs 60 µs
50 8.75 ms 2.84 ms 1.44 ms 1.42 ms
100 34.4 ms 7.27 ms 5.23 ms 5.92 ms
200 601 ms 107 ms 36.4 ms 36.2 ms

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.

pool, n best-first (+ running sums) depth-first depth-first + seed
synthetic 100 9,635 / 3902 / 28 / 14.7 ms / 5.4 MB — exhausted 15,275 / 3902 / 28 / 6.7 ms / 3.0 MB — exhausted 15,275 / 3902 / 28 / 7.5 ms / 3.0 MB — exhausted
synthetic 200 100k / – / 106 ms / 32 MB 100k / 7018 / 51 / 45 ms / 3.0 MB 100k / 7018 / 51 / 41 ms / 3.0 MB
synthetic 500 100k / – / 108 ms / 39 MB 100k / 15726 / 115 / 66 ms / 3.1 MB 100k / 15726 / 115 / 74 ms / 3.1 MB
synthetic 1000 100k / – / 211 ms / 51 MB 100k / 29934 / 220 / 129 ms / 3.1 MB 100k / 29934 / 220 / 121 ms / 3.1 MB
random 100 2,174 / 628 / 4 / 3.2 ms — exhausted 4,150 / 628 / 4 / 2.5 ms — exhausted 4,131 / 628 / 4 / 2.7 ms — exhausted
random 200 8,641 / 526 / 3 / 10.8 ms / 5.5 MB — exhausted 26,847 / 526 / 3 / 10.6 ms / 3.2 MB — exhausted 26,841 / 526 / 3 / 16.4 ms / 3.2 MB — exhausted
random 500 100k / – / 101 ms / 39 MB 100k / 608 / 4 / 52 ms / 3.3 MB 100k / 608 / 4 / 40 ms / 3.3 MB
random 1000 100k / – / 118 ms / 51 MB 100k / 1035 / 8 / 90 ms / 3.3 MB 100k / 1035 / 8 / 84 ms / 3.3 MB
  • Same optimum wherever both finish. On every case where both searches ran to completion (all n ≤ 100 on the synthetic pool, n ≤ 200 on the random pool), they return the same score and input count.
  • Answers where best-first has none. Once the cap is hit, best-first returns no solution, and depth-first returns one with flat ~3 MB memory. Raising the cap to 1M rounds doesn't change that for best-first: it still finds nothing on synthetic 200/500/1000 or random 1000, and its peak RSS reaches 300–490 MB.
  • Depth-first's capped answers are close to optimal. On random 500 both searches finish with no cap (177k and 182k rounds) and agree on 608, the same score depth-first had already returned at 100k rounds. With a 10M-round cap, depth-first improves synthetic 1000 from 29934 to 29782 and random 1000 from 1035 to 938.
  • Where it's slower. On small pools that finish, depth-first can take more rounds to prove optimality: random 200 takes 8.6k → 26.8k rounds. The same shows up in 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 runs bnb_solutions(LowestFee).take(cap) and reads VmHWM from /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

evanlinjin and others added 7 commits September 23, 2026 07:05
…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
evanlinjin force-pushed the feat/dfs-remove-changeless-v2 branch from c495ffc to 6cf6fc4 Compare September 23, 2026 07:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant