Skip to content

feat!: depth-first branch and bound, and remove the changeless metrics - #73

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

feat!: depth-first branch and bound, and remove the changeless metrics#73
evanlinjin wants to merge 24 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/dfs-remove-changeless

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Aug 15, 2026

Copy link
Copy Markdown
Member

Draft. Two things, on top of the ancestor-aware selection stack: search branch and bound depth-first, and remove the changeless metrics.

Benchmarked with coinselect-benchmark, which scores every selection from the fixture with one fee model rather than trusting either engine's own numbers.

Depth-first search

The priority-queue traversal has a structural problem: both shipped metrics' bounds increase with depth, so a min-heap always pops the shallowest node. That is uniform-cost search with a branching factor equal to the candidate count — it expands every 1-input prefix, then every 2-input prefix, and on a large pool the budget is gone before it reaches any funded leaf. Solutions in the benchmark matrix are 1–28 inputs deep; they are not hard to reach, they are a dozen levels down a tree the search never descends.

Depth-first with in-place backtracking reaches a funded leaf in as many expansions as the solution has inputs, and carries one path instead of a frontier.

Measured on the wallet track, equal wall-clock budgets with the round cap lifted, package fee from the shared model, 42 fixtures:

1000 ms budget total package fee vs best-first peak RSS
best-first 1,441,766 555.7 MB
depth-first 1,379,617 −4.31% 3.6 MB

Cheaper selections and a factor of 150 less memory. The memory gap widens with the budget — best-first goes 7 MB → 58 MB → 554 MB as the budget grows 10× twice, while depth-first stays flat, because a best-first search over a depth-increasing bound never finishes a level and so never discards one. Given a 130-second budget the old frontier reached 20 GB on a 500-candidate problem; the round cap was doing double duty as a memory guard.

Removing the changeless metrics

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 max_weight. A separate changeless objective duplicates that decision and then constrains it.

It also does not seed. BnbIter seeds a greedy incumbent so a search that runs out of rounds returns something rather than nothing; a greedy prefix taken in descending value-per-weight overshoots the target, so LowestFee wants change and the changeless metric rejects it — at every prefix length. Measured across the benchmark's 42 fixtures, the greedy seed is accepted by LowestFee 42 times and by LowestFeeChangeless 0 times. Nor is it a matter of picking a better prefix: the ascending prefix overshoots by the least of any greedy ordering and its smallest excess across the matrix is 1270 sat against a changeless ceiling of 580 sat — still 0 of 42.

This is a breaking change. Callers that required a changeless transaction should use LowestFee and inspect the returned Drain.

What is not here

An ancestor_bump_upper_bound — a ceiling mirroring ancestor_bump_lower_bound — was implemented and measured on both traversals. It is left out on purpose: its only call site was the changeless window cut, so it becomes dead code with the metrics gone. For the record it was a byte-identical no-op on the wallet track (42/42) and its entire measured value was two kernel-track conversions under depth-first. It is one cherry-pick away if a changeless-style objective ever returns.

A pre-existing optimality gap, characterised but not fixed

While benchmarking I found that best-first and depth-first can both report the tree exhausted and still disagree, on no_ancestry_1000. It is deterministic and reproducible at a 20-second budget with the round cap lifted, and it goes both ways:

metric best-first depth-first who missed the optimum
LowestFee (44 inputs, changeless, both exhausted) 30463 30462 best-first, by 1 sat
LowestFeeChangeless (44 inputs, both exhausted) 30460 30462 depth-first, by 2 sats

Two exhaustive searches over one objective must agree, so on each row one of them terminated before finding the better selection. Note the direction on the row that matters: on LowestFee — the metric that survives this PR — the shipped best-first search is the one that misses it, and depth-first is correct. So this is a pre-existing gap that depth-first improves here, not something the traversal change introduces.

What I can rule out: it is not size alone (no fixture at n ≤ 20 shows it, and both searches match a brute-force oracle over all 2^n subsets on every one of them), and it is not ancestry (this fixture has no unconfirmed parents at all). The magnitudes — 1 and 2 sats against a ~30,000 sat score — point at f32 arithmetic in the bound making it very slightly inadmissible, which would let the "best remaining lower bound exceeds the incumbent" termination fire one step early. I have not isolated that, so treat it as a hypothesis.

It is worth a reviewer's eye on the bound's precision rather than on the traversal.

evanlinjin and others added 20 commits August 14, 2026 04:52
…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).
…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>
Move the fixed target, candidates, and optional ancestor graph into one
immutable problem object. CoinSelector now borrows that object, keeping
all calculations tied to the same inputs and allowing ancestry metadata
to remain separate from Candidate.

Provide new_no_ancestors for prebuilt candidates and new for constructing
candidates from input groups and their unconfirmed transaction graph.
Selecting an unconfirmed coin means paying to bump its ancestors. The
feerate obligation includes the shortfall of the union of ancestors the
selected candidates drag in (each charged once; weight and fee netted;
saturates at 0).

Score is still the child fee — the bump is already inside it. With
ancestors, LowestFee falls back to a loose but admissible fee floor;
tightening is a follow-up. BnB only batch-bans look-alikes with the same
drags_in; Changeless disables its prune when ancestors are present.
Precompute ancestors reachable through exactly one candidate as summed
private packages. Keep bitset de-duplication only for ancestors shared by
multiple candidates, preserving exact union accounting while reducing the
common-path work in every fee calculation.

Add Criterion coverage for private and shared ancestry at 20, 50, and 100
candidates, plus exhaustive regressions for the optimized representation.
For funded nodes, subtract the ancestor surplus still reachable by a
descendant. For unfunded nodes, derive a minimum added child weight from
independent fractional relaxations of the target-rate, absolute-fee, and
RBF constraints, then evaluate the fee floor at that weight.

Candidate ancestry is deliberately represented only by the global bump
lower bound: package surplus can absorb a later private deficit, so a
per-candidate ancestor cost is not admissible. Keep infeasibility prunes
off because ancestor funding is non-monotone.

Add regressions for package subsidy, absolute/RBF double counting, and
large-float cancellation, plus the existing exhaustive proptests.
Maintain aggregate selection state per branch and expose it through SelectionView so metric evaluation avoids repeatedly walking selected candidates. Track each branch's candidate cursor to skip repeated scans, and extend benchmarks across wallet- and exchange-scale pools.
Keep SelectionView's hypothetical updates set-like and synchronize
ancestor reachability when branches exclude candidates. Remove unsound
funding and changeless assumptions exposed by non-monotone ancestor debt,
and preserve conservative fee rounding in the bound.

Add regressions for public view updates, exclusion transitions, weight
caps, mixed serialization overhead, and floating-point edge cases.
Separate deterministic solution-finding cases from larger pools expected
to exhaust the fixed round cap. Assert each fixture's expected search
outcome before measuring it so benchmark comparisons cannot silently time
different paths.
Store private ancestor totals directly and allocate shared reference
tracking only when the problem actually has shared ancestry. Preserve an
explicit precision allowance for large floating-point ancestor fees so the
smaller cache does not tighten the admissible bound.
Replace generic metric composition with a changeless metric that reuses
LowestFee's funding, weight-cap, dust, and change decisions. Add a
monotone selected-value bound for pools up to 24 candidates while retaining
LowestFee's ordering for larger pools to avoid finite-round starvation.

Cover the constrained objective with exhaustive and serialization-edge
regressions, and document the migration from Changeless and tuple metrics.
Replace the best-first BinaryHeap frontier with depth-first search that
visits the better-bound child first and backtracks in place. This drops
per-branch selector/cache clones and, under a round cap, finds complete
solutions on large pools where the old frontier often exhausted the
budget without a selection.
`LowestFeeChangeless` only applied its selected-value bound to pools of at
most 24 candidates. The cap existed because best-first search treats a
bound as a priority: a bound that grows with the selection pushed funded
branches to the back of the heap, so on a big pool the frontier starved
before it reached one.

Depth-first search reads a bound as a cut instead of a ranking — it
finishes a branch's descendants before its siblings — so the bound can be
applied at every pool size, where it prunes inclusion branches that have
already overshot the incumbent.
Yield the greedy selection before expanding the first node, and adopt its
score as the incumbent. The search is otherwise not anytime: 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 for free.

Only the incumbent changes, not the bound, so the optimum stays reachable
and the improving-solutions contract is unaffected. Metrics that reject the
greedy prefix outright — `LowestFeeChangeless`, which will not score a
selection that overshoots — are unchanged, and `RoundLimit` still means what
it did for them.

The two round-count assertions in `tests/bnb.rs` each move by one: the seed
is a round.
Bitcoin Core's `SelectCoinsBnB` computes `is_feerate_high` once and lets it
decide whether a prune that is only sometimes valid may fire; it does not
drop the prune because the general case is unsound. `bound_with_ancestors`
took the other route — "never returns `None`" — on the grounds that a fat
private deficit can un-fund a prefix a subset would have funded, so
infeasibility is not something it may claim.

That argument covers "select everything and it is still unfunded". It does
not cover the case this relaxation can prove outright: a fee constraint
whose deficit the best input still available cannot close at *any* weight.
Descendants only add, the deficit is already computed against the
branch-wide `ancestor_bump_lower_bound`, and the gain already ignores
whatever ancestors those inputs would drag in — so the estimate is
optimistic on every axis, and a deficit it still cannot close belongs to an
empty subtree.

The scan that finds the best value-per-weight candidate already runs, so
the test is free. It also prunes the unfunded leaves that had nothing left
to add, which the old path could only rank.
Port Bitcoin Core's `SelectCoinsBnB` lookahead. Core keeps a running
`curr_available_value` over the coins it has not decided on yet and
backtracks as soon as that total cannot close the gap to the target; the
cut needs no incumbent, so it fires from the very first descent. We had
the same idea only in `LowestFee::bound`'s no-ancestor path, as an O(n)
rescan that ran after the relaxation had already been set up, and not at
all when the problem has ancestors.

`SelectionCache` now carries the value and weight of the undecided
candidates worth selecting, maintained by the same add/sub/ban/unban hooks
that already track reachable ancestor surplus, so the test is O(1).

Two one-sided relaxations keep it from pruning a branch that holds a
solution: only candidates with positive standalone effective value count
toward the total, and the current ancestor bump is swapped for
`ancestor_bump_lower_bound`, which holds for the whole subtree. That
second one is what lets the prune run with ancestors present, where
funding is not monotone and "select everything and it is still unfunded"
would have been an unsound claim.
LowestFee already decides for itself whether a selection should carry a
change output, adding one only when it lowers the long-term fee, clears the
dust threshold and fits max_weight. A separate changeless objective duplicates
that decision and constrains it, and nothing in the crate needs the constraint.

Removes LowestFeeChangeless along with the Changeless wrapper the unreleased
changelog already retired, plus their tests and proptest regressions.

BREAKING CHANGE: LowestFeeChangeless and Changeless are gone. Callers that
required a changeless transaction should use LowestFee and inspect the Drain it
returns.
@evanlinjin
evanlinjin force-pushed the feat/dfs-remove-changeless branch from 0bbd330 to a71f693 Compare August 15, 2026 22:56
evanlinjin and others added 4 commits August 15, 2026 23:58
`bound_with_ancestors` scanned every undecided candidate at each unfunded node
to find the greatest value-per-weight and to notice weightless value. Branch
and bound asks for that bound at every unfunded node, so an O(n) scan there
made per-node cost grow with the pool: measured on shared_ancestry_*, 2389
ns/round at n=500 rising to 9384 at n=2000, against 385-2056 for the
no-ancestry fixtures.

The metric already requires candidates in descending value-per-weight order,
and that order is keyed on f32. The exact f64 maximum can therefore only lie
inside the run sharing the first undecided candidate's f32 key, which is why
the old code scanned in f64 rather than taking the first: two exact ratios can
tie in f32 and be ordered either way. Scanning just that run keeps the exact
answer without touching the tail. Weightless value becomes a counter kept
where the undecided aggregates already are.

5.9x to 8.8x faster per round at n=500 to 2000, and byte-identical results:
across all 42 benchmark fixtures the score, selection, round count and
exhausted flag are unchanged.

A debug assertion checks the tie-run result against a full scan, so the
ordering assumption is verified on every node the test suite searches.
`SelectionView` overrides these with cache-backed versions, so every call site
in the crate and its tests already resolved to the view; the `CoinSelector`
copies recomputed the same answers by iterating and had no callers left.

Removes `effective_value`, `implied_feerate`, `rate_excess_wu`,
`replacement_excess_wu` and `waste`, plus the two private helpers they were the
last users of.

`missing` and `drain` are deliberately kept even though the view also has them:
the crate's own front-page example calls them on a bare `CoinSelector`, which
is the case they exist for. The same argument keeps the rest of the overlap --
`weight`, `excess`, `is_funded` and friends all have live callers holding a
selector rather than a view, and routing those through `compute_view` would
cost an O(n) cache build to replace an O(n) method.

BREAKING CHANGE: obtain a `SelectionView` with `CoinSelector::compute_view` and
call the removed methods there.
`SelectionView` answered every one of these from its cache while the `CoinSelector`
copy recomputed the same figure by iterating the selection. Keeping both meant two
implementations of the weight model, the excess model and the ancestor bump, and the
slower one was the default a caller reached for.

Removes `absolute_excess`, `ancestor_bump`, `ancestor_bump_lower_bound`, `drain`,
`drain_value`, `excess`, `fee`, `implied_fee`, `input_weight`, `is_funded`,
`is_funded_with_drain`, `is_within_max_weight`, `missing`, `rate_excess`,
`replacement_excess`, `selected_value` and `weight` from `CoinSelector`, along with
the two private helpers they were the last users of.

`select_until` now hands its predicate a `&SelectionView` and maintains that view's
cache incrementally, so the greedy pass behind `select_until_target_met` -- which
seeds every branch-and-bound search -- costs one cache build plus O(1) per step
instead of rescanning the selection on every iteration.

The crate's own front-page example now goes through `compute_view` too, which is what
the removed methods were kept for.

BREAKING CHANGE: obtain a `SelectionView` with `CoinSelector::compute_view` and call
the removed methods there. `CoinSelector::select_until` takes a predicate over
`&SelectionView` rather than `&CoinSelector`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLiTkESMktypGJhFag2ZBM
`seed` carried `(CoinSelector, Ordf32)` while `best` separately held the same
score. They are set together in `seed_greedy_incumbent` and nothing runs
between construction and the first `next()`, so the score in the tuple was
always exactly `best`. Store the selection alone and read the score from
`best` when yielding.

No behaviour change: identical score, selection, round count and exhausted
flag on all 42 benchmark fixtures.
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