feat!: ancestor-aware coin selection (CPFP bump via SelectionProblem) - #85
Draft
evanlinjin wants to merge 13 commits into
Draft
evanlinjin wants to merge 13 commits into
evanlinjin wants to merge 13 commits into
Conversation
This was referenced Sep 22, 2026
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/ancestor-aware-v2
branch
from
September 23, 2026 07:13
1f22e4e to
603c71b
Compare
A `SelectionProblem` owns the target and candidates for one selection run, and `CoinSelector::new` takes a `&SelectionProblem` instead of a candidate slice and a target. This gives unconfirmed-ancestor data, which belongs to the problem rather than to any one candidate, somewhere to live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JS62WmZ68RL2mY8Aae7bxR
Selecting an unconfirmed coin means paying to bump its unconfirmed ancestors (CPFP). The feerate obligation now includes the shortfall of the union of ancestors the selected candidates drag in: each ancestor charged once, weight and fee netted over the union, saturating at 0. The score is still the child's fee, since the bump is already inside it. `SelectionProblem::new` builds candidates from `Input` groups and `AncestorToBump`s. Ancestors reachable through one candidate only are folded into that candidate up front; the rest are de-duplicated per selection. `CoinSelector` keeps both as running totals (private sums, and a refcount per shared ancestor) in an `AncestorTotals` updated from `select`/`deselect`, like the value and weight sums, so `ancestor_bump` is O(1) during the search. The bump is computed in `f64` rather than with `implied_fee_wu`'s `f32`: an `f32` rate converts exactly and `weight * rate` is then exact below 2^29 WU, so the bump is the exact shortfall and later lower bounds can be compared against it without a rounding allowance. Per-candidate ancestor sets are stored flat (indices plus offsets) rather than as a dense bitset per candidate, so memory and setup time scale with the entries, not candidates x ancestors (bitcoindevkit#75). With ancestors, funding is not monotone, so `LowestFee` falls back to a loose but admissible fee floor; tightening it is a follow-up. Branch and bound only groups look-alike candidates that drag in the same ancestors. `is_fundable` now checks the current selection first, since the witness header can make a worth-its-weight candidate lower the excess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JS62WmZ68RL2mY8Aae7bxR
The fee-floor fallback from the previous commit is admissible but blind to what ancestors cost, so the search explores far more than it needs to. `CoinSelector::ancestor_bump_lower_bound` is the least bump this selection or any extension of it could owe: what it owes now, minus the surplus that still-reachable ancestors pay above the target rate. Private ancestors are netted per candidate, since they arrive together; shared ones are credited individually. `fee_floor` adds it to the rate floor. `LowestFee::bound_with_ancestors` then bounds a funded node by its score minus the surplus a descendant could pick up (and minus change it could still add), clamped to the fee floor, and an unfunded node by the least child weight each fee constraint needs, filled at the best undecided value per weight. It never returns `None`: funding is not monotone. The reachable surplus is a running total in `AncestorTotals`, updated as `select`, `deselect`, `ban` and `unban` change a candidate's reachability (reachable means neither selected nor banned), so the bound is O(1) in the ancestors. It is kept in whole satoshis rounded up per group rather than as an `f64` sum: depth-first search adds and removes the same figures millions of times, and integer sums return exactly to where they were. What is owed is computed exactly in `f64`, as the bump is, so the bound needs no rounding allowance. The best undecided value per weight scans only the first run of candidates tied in `f32` order, not the whole pool, relying on the order the metric already requires; a debug assertion checks it against a full scan. A unit test checks that the running totals after 20,000 random selects, deselects, bans and unbans always equal a selector rebuilt from the same selected and banned sets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JS62WmZ68RL2mY8Aae7bxR
`run_bnb_lowest_fee_ancestors` runs branch and bound on pools where a third of the coins sit on a two-long unconfirmed chain, each coin on its own chain (private) or all on one (shared), at 20, 50 and 100 candidates. `new_with_ancestors` times building a `SelectionProblem` with ancestors and a `CoinSelector` over it at 20,000 and 200,000 candidates. Setup must scale with the ancestor entries rather than candidates times ancestors, or a large pool spends its time budget before the search starts (bitcoindevkit#75). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JS62WmZ68RL2mY8Aae7bxR
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, and it is constant-time where `LowestFee::bound` otherwise scans candidates. The totals `CoinSelector` already keeps for reachable ancestor surplus now also carry the value and weight of the reachable candidates worth selecting, maintained by the same `select`/`deselect`/`ban`/`unban` hooks, so the test costs nothing per node. The struct holds more than ancestors now, hence `SelectionTotals`. 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. A regression test covers exactly that: an optimum reachable only by adding a coin worth less than its own weight, because it drags in an ancestor that overpays. The greedy seed funds the target another way there, so the search has to find it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JS62WmZ68RL2mY8Aae7bxR
Branch and bound decides candidates in cursor order, so at a node with
cursor `c` every candidate at position `0..c` is already decided —
selected by an inclusion frame, or banned by an exclusion one. Three hot
paths ignored that and walked the decided prefix anyway:
- `exclusion_plan` resumed with `candidates().skip(cursor + 1)`.
`candidates()` returns a `Map`, which has no `nth` override, so `Skip`
advances it one element at a time.
- `next_candidate` did the same.
- `LowestFee::bound` asks for the undecided candidates (the best value
per weight, and the lightest one under a weight cap), and
`unselected()` starts at the front of the order.
`CoinSelector` now carries the position before which everything is
decided. The search sets it per node, `unselected_indices` starts there,
and `candidates_from` slices the order instead of skipping through it.
The promise is only ever an optimisation — it lets a scan start past a
prefix it would otherwise filter away one entry at a time — and anything
that can make an earlier candidate undecided again (`deselect`,
unbanning, re-sorting) puts it back to zero. A debug assertion checks it
on every node the test suite searches.
Identical selections, scores and round counts on every ancestor pool
measured; this is a pure cost reduction. Median wall clock at a
100k-round cap, ancestor pools of a third unconfirmed coins:
candidates 100 500 1000 2000
before 43ms 80ms 152ms 151ms
after 19ms 19ms 20ms 18ms
Per-node cost no longer grows with the pool.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS62WmZ68RL2mY8Aae7bxR
evanlinjin
force-pushed
the
feat/ancestor-aware-v2
branch
from
September 23, 2026 07:16
603c71b to
25e9368
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.
Redoes #64 on top of the current stack: ancestor-aware (CPFP) coin selection, where selecting an unconfirmed coin pays to bring the ancestors it drags in up to the target feerate.
#64 was built on the best-first search that #84 replaces, so this is a port rather than a rebase. It also folds in the fixes that #64's own follow-ups found — #75's sparse ancestor sets, #73's lookahead prune, and #77's cheaper nodes — instead of leaving them for later.
The model
SelectionProblemowns the target, the candidates and the ancestor graph.CoinSelector::newtakes&SelectionProblem. Build one withSelectionProblem::new_no_ancestors(target, candidates), orSelectionProblem::new(target, input_groups, ancestors)fromInputs andAncestorToBumps.CoinSelector::ancestor_bumpreports it. The score stays the child transaction's fee, since the bump is already inside it.No
SelectionView#64 added a
SelectionViewtype: a&CoinSelectorplus a copy-on-write cache of aggregates, with about 30 duplicated query methods, public hypotheticaladd/sub, andBnbMetrictaking&SelectionView. That design exists because best-first search clones a selector per branch.#84's depth-first search mutates one selector in place, so the cache can live on the selector itself.
CoinSelectoralready keeps running sums of the selection's value, weight and input counts; this PR adds the ancestor figures next to them in aSelectionTotals, updated by the sameselect/deselect/ban/unbancalls. Metrics still take&CoinSelector, there is no second type to keep in step, and ad hoc callers getancestor_bump()and O(1) aggregates without building anything.The main risk of that choice is the totals drifting out of step with the bitsets. A unit test runs 20,000 random selects, deselects, bans and unbans and checks after every one that the totals equal a selector rebuilt from the same selected and banned sets.
Exact ancestor fees, no precision allowance
#64 carried an
ancestor_fee_precision_slack: the bump went throughFeeRate::implied_fee_wu'sf32arithmetic while the bound was computed inf64, so the bound could sit above the bump and had to be padded.Here both come from one
f64helper. Anf32rate has a 24-bit significand and converts tof64exactly, soweight × rateis exact for any ancestor weight below 2^29 WU — orders of magnitude beyond any real package. The two agree by construction and the allowance is gone. The child transaction's own fee still usesFeeRate'sf32math, so nothing outside ancestor accounting changes.The reachable-surplus totals are kept in whole satoshis, rounded up per group, rather than as
f64sums: depth-first search adds and subtracts the same figures millions of times, and integer sums return exactly to where they started.Sparse ancestor sets (#75), from the start
Each candidate's ancestor set is a sorted
&[u32]slice out of one flat array, not a dense bitset over every ancestor. #64's layout cost candidates × ancestors in both memory and setup time, which on a 200,000-candidate pool meant 1.3 GB and 464 ms before the search expanded a single node.Lookahead prune (#73), included
Bitcoin Core's
SelectCoinsBnBkeeps a running total of what the undecided coins can still contribute and backtracks when it cannot close the gap.SelectionTotalscarries that total too, so the test is O(1) and fires before any relaxation does work.It is safe with ancestors because it swaps the current bump for
ancestor_bump_lower_bound, which holds for the whole subtree. A coin worth less than its own weight can still fund a selection by dragging in an ancestor that overpays, and the prune must not cut that off —lookahead_keeps_a_branch_funded_only_by_a_subsidizing_ancestorcovers exactly that case, with the greedy seed funding the target another way so the search has to find it.Cheaper nodes (#77), included
The search decides candidates in cursor order, so at a node with cursor
cevery candidate before positioncis already selected or banned. Three hot paths walked that prefix anyway at every node: the two scans in the search (candidates().skip(..)on aMap, which has nonthoverride, so it advances one element at a time) andLowestFee::bound's queries about the undecided candidates.CoinSelectornow carries the position before which everything is decided, and the search sets it per node. The promise is only ever an optimisation — it lets a scan start past a prefix it would otherwise filter away entry by entry — anddeselect, unbanning and re-sorting put it back to zero. A debug assertion checks it on every node the test suite searches.It leaves every selection, score and round count unchanged, and makes the per-node cost flat in the pool size rather than growing with it: at a 100k-round cap the private pools take 23 ms at 100 candidates and 22 ms at 2000, where before the fix that was 43 ms and 151 ms.
Not included
LowestFeeChangeless. feat!: depth-first branch and bound, and remove the Changeless metric #84 removed the changeless metrics;LowestFeedecides for itself whether a selection carries change.ancestor_bump_upper_bound. Its only call site was the changeless window cut.max_roundsguidance about the frontier's memory, which no longer applies to a depth-first search.Benchmarks
Pools where a third of the coins sit on a two-long unconfirmed chain: one chain each (private) or all on one (shared).
LowestFee, 100k-round cap. Compared against #64 (best-first,SelectionView) and #75's tip (depth-first,SelectionView, sparse sets), which is the closest like-for-like.Search outcome — rounds / score / inputs / time (median of 3) / peak RSS.
-means no solution found.cargo bench(criterion medians):ancestors/private/100ancestors/shared/100new_with_ancestors/20000new_with_ancestors/200000The no-ancestor
run_bnb_lowest_fee/200benchmark gains from #77 too: 43 ms before that commit, 32 ms after.Design questions settled by measurement
Test plan
cargo fmt --check,cargo check,cargo clippy -D warnings,cargo doc -D warnings,cargo test --release, andcargo build --no-default-featuresbuild-msrvCI job doesNonereally means empty, and branch and bound lands on the brute-force optimum🤖 Generated with Claude Code
https://claude.ai/code/session_01JS62WmZ68RL2mY8Aae7bxR