Skip to content

feat!: ancestor-aware coin selection (CPFP bump via SelectionProblem) - #85

Draft
evanlinjin wants to merge 13 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/ancestor-aware-v2
Draft

evanlinjin wants to merge 13 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/ancestor-aware-v2

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Draft, and stacked on #84 (which is stacked on #63 → #59). Only the last 6 commits (86f7e7f, 0655995, db04370, aebff64, 5a3cbb0, 1f22e4e) belong to this PR.

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

  • SelectionProblem owns the target, the candidates and the ancestor graph. CoinSelector::new takes &SelectionProblem. Build one with SelectionProblem::new_no_ancestors(target, candidates), or SelectionProblem::new(target, input_groups, ancestors) from Inputs and AncestorToBumps.
  • What a selection owes is the shortfall of the union of the ancestors its selected candidates drag in: each ancestor charged once, weight and fee netted over the union, saturating at zero. CoinSelector::ancestor_bump reports it. The score stays the child transaction's fee, since the bump is already inside it.
  • Private vs shared. Ancestors only one candidate can reach are folded into that candidate up front; only ancestors several candidates can reach are de-duplicated per selection.
  • Funding is not monotone. A coin can drag in an ancestor that costs more than the coin is worth, and a descendant can owe less than its parent by dragging in one that overpays. Nothing in the search may reason from "select everything and it is still unfunded".

No SelectionView

#64 added a SelectionView type: a &CoinSelector plus a copy-on-write cache of aggregates, with about 30 duplicated query methods, public hypothetical add/sub, and BnbMetric taking &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. CoinSelector already keeps running sums of the selection's value, weight and input counts; this PR adds the ancestor figures next to them in a SelectionTotals, updated by the same select/deselect/ban/unban calls. Metrics still take &CoinSelector, there is no second type to keep in step, and ad hoc callers get ancestor_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 through FeeRate::implied_fee_wu's f32 arithmetic while the bound was computed in f64, so the bound could sit above the bump and had to be padded.

Here both come from one f64 helper. An f32 rate has a 24-bit significand and converts to f64 exactly, so weight × rate is 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 uses FeeRate's f32 math, so nothing outside ancestor accounting changes.

The reachable-surplus totals are kept in whole satoshis, rounded up per group, rather than as f64 sums: 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 SelectCoinsBnB keeps a running total of what the undecided coins can still contribute and backtracks when it cannot close the gap. SelectionTotals carries 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_ancestor covers 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 c every candidate before position c is already selected or banned. Three hot paths walked that prefix anyway at every node: the two scans in the search (candidates().skip(..) on a Map, which has no nth override, so it advances one element at a time) and LowestFee::bound's queries about the undecided candidates.

CoinSelector now 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 — and deselect, 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

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.

pool, n #64 #75 tip this PR
private 50 12,753 / 2685 / 19 / 17 ms / 6 MB 12,825 / 2685 / 19 / 9 ms / 2 MB 12,825 / 2685 / 19 / 5 ms / 2 MB
private 100 100k / – / 162 ms / 33 MB 100k / 4714 / 34 / 45 ms / 2 MB 100k / 4714 / 34 / 23 ms / 2 MB
private 500 100k / – / 306 ms / 42 MB 100k / 19124 / 140 / 75 ms / 2 MB 100k / 19124 / 140 / 20 ms / 2 MB
private 1000 100k / – / 536 ms / 55 MB 100k / 74243 / 219 / 141 ms / 2 MB 100k / 74243 / 219 / 21 ms / 2 MB
private 2000 100k / – / 1061 ms / 79 MB 100k / 143695 / 426 / 254 ms / 2 MB 100k / 143695 / 426 / 22 ms / 2 MB
shared 100 100k / – / 162 ms / 39 MB 100k / 4714 / 34 / 51 ms / 2 MB 100k / 4714 / 34 / 36 ms / 2 MB
shared 500 100k / – / 362 ms / 48 MB 100k / 16951 / 115 / 61 ms / 2 MB 100k / 16951 / 115 / 20 ms / 2 MB
shared 1000 100k / – / 543 ms / 61 MB 100k / 31043 / 219 / 73 ms / 2 MB 100k / 31043 / 219 / 21 ms / 2 MB
shared 2000 100k / – / 995 ms / 85 MB 100k / 59095 / 426 / 106 ms / 2 MB 100k / 59095 / 426 / 30 ms / 2 MB

cargo bench (criterion medians):

#64 #75 tip this PR
ancestors/private/100 142 ms 38 ms 24 ms
ancestors/shared/100 154 ms 36 ms 26 ms
new_with_ancestors/20000 19 ms 1.4 ms 1.3 ms
new_with_ancestors/200000 1.80 s 14.4 ms 14.1 ms

The no-ancestor run_bnb_lowest_fee/200 benchmark gains from #77 too: 43 ms before that commit, 32 ms after.

Design questions settled by measurement

  • Is the private/shared split worth it? Yes, through bound quality rather than speed. Forcing every ancestor down the shared path costs nothing measurable when no ancestor overpays, but on a pool with overpaying tips the search needs 10,803 rounds instead of 235 at n=20, stops finishing at n=50, and returns worse scores at n=100–500. Netting a private chain as one group is what stops an overpaying tip being credited while its chain still owes.

Test plan

  • Every commit independently passes cargo fmt --check, cargo check, cargo clippy -D warnings, cargo doc -D warnings, cargo test --release, and cargo build --no-default-features
  • Every commit independently builds on Rust 1.54 with dev-dependencies stripped, as the build-msrv CI job does
  • 28 ancestor tests: union accounting, non-monotone funding, the bump lower bound, both bound relaxations, and the lookahead
  • Proptests at 5,000 cases: the bump matches a from-scratch union recompute, the lower bound holds for every descendant, the bound never exceeds any score in its subtree and None really means empty, and branch and bound lands on the brute-force optimum
  • Running totals checked against a rebuilt selector after 20,000 random mutations
  • Mutation-checked: the ban grouping, the reachability hooks, and the lookahead's bump credit each fail a test when broken
  • The decided-prefix promise is checked by a debug assertion on every node the debug test suite searches, and the ancestor pools return identical selections, scores and round counts with and without it

🤖 Generated with Claude Code

https://claude.ai/code/session_01JS62WmZ68RL2mY8Aae7bxR

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/ancestor-aware-v2 branch from 1f22e4e to 603c71b Compare September 23, 2026 07:13
evanlinjin and others added 6 commits September 23, 2026 07:16
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
evanlinjin force-pushed the feat/ancestor-aware-v2 branch from 603c71b to 25e9368 Compare September 23, 2026 07:16
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