Skip to content

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

Draft
evanlinjin wants to merge 14 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/ancestor-aware-selection-no-clustor
Draft

feat!: ancestor-aware coin selection (CPFP bump via SelectionProblem)#64
evanlinjin wants to merge 14 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/ancestor-aware-selection-no-clustor

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Aug 12, 2026

Copy link
Copy Markdown
Member

Summary

Makes coin selection pay for unconfirmed ancestors (CPFP) without putting ancestry on Candidate.

  • Problem model: SelectionProblem owns the target, candidates, and ancestor graph; CoinSelector borrows it. Input<Txid> identifies the transaction each candidate input spends, and AncestorToBump<Txid> supplies ancestor weight, fee, and parent links.
  • Fee obligation: selecting a coin charges the shortfall of the union of ancestors dragged in by the selection. Each ancestor is counted once, all ancestor weight and fee are netted together, and the bump saturates at zero.
  • Score: the metric still scores the child transaction's fee. The bump is already inside the amount the child must pay, not added again afterward.
  • Private/shared split: ancestors reachable through one candidate are folded into a per-candidate (weight, fee) pair. Only ancestors shared by several candidates need bitset de-duplication while scoring a selection.
  • BnB safety and speed: exclusion batching requires matching drags_in; SelectionView maintains delta-aware per-branch aggregates so metric evaluation does not repeatedly scan selected candidates.
  • Tighter LowestFee bound:
    • A funded node uses its score minus ancestor surplus still reachable by a descendant, clamped to the monotone fee floor.
    • An unfunded node independently relaxes the target-rate, absolute-fee, and RBF constraints into a minimum added child input weight, then evaluates the fee floor at that weight.
    • Candidate-specific ancestor cost is deliberately ignored in the resize: existing package surplus can absorb a later private deficit, so only ancestor_bump_lower_bound is valid for every descendant.
    • The ancestor path never returns None from funding heuristics. "Selecting everything is unfunded" does not prove that every subset is unfunded.
  • Dedicated changeless metric: LowestFeeChangeless replaces the loose generic Changeless<LowestFee> composition. It reuses LowestFee funding and change decisions, then adds a monotone selected-value bound for pools up to 24 candidates. Larger pools retain LowestFee ordering to avoid finite-budget search starvation.

Ancestor weight does not count toward the child transaction's max_weight, and RBF rule 4 prices only child weight.

Closes #24.
Closes #65.
Closes #66.

Dependencies

This self-contained branch includes the prerequisite work developed in:

#60 is the earlier cluster-based exploration; this PR is the simpler no-cluster design.

Out of scope

Benchmark findings

Full report: coinselect-benchmark/FINDINGS.md

  • Ancestor union bumps agree exactly with Bitcoin Core's post-discount combined bumps across all measured runs.
  • coin-select reaches its oracle optimum on all eight brute-force-sized fixtures; Core misses four ancestry optima because overlap discounts are applied only after selection.
  • The dedicated changeless bound makes nested_ancestry_20 optimal in 200 rounds and subsidizing_ancestry_20 optimal in 886 rounds, versus no result at 100,000 rounds previously.
  • The large-pool ordering fallback preserves the pinned subsidizing_ancestry_100 results: score 19006 at 100,000 rounds and 18925 at 20 million rounds.

Test plan

  • Every commit independently passes cargo fmt --all -- --check
  • Every commit independently passes cargo check --all-targets --all-features and cargo check --no-default-features
  • Every commit independently passes cargo clippy --all-targets --all-features --tests with warnings denied
  • Every commit independently passes cargo test --release and cargo doc --no-deps with warnings denied
  • Every commit independently builds in release mode on Rust 1.54 with dev dependencies excluded, matching CI
  • 29 ancestor tests, including union, bump-floor, bound-admissibility, BnB-vs-exhaustive, and changeless-vs-exhaustive proptests
  • 10,000-case release stress runs for bound admissibility and BnB-vs-brute-force
  • Independent exhaustive oracle over 30,000 private, 30,000 shared, and 30,000 mixed-ancestry fixtures for both bound and BnB checks
  • Regressions for package surplus subsidizing a private deficit, absolute/RBF double counting, child-only RBF weight, and large-float cancellation
  • Criterion benchmarks for private/shared ancestry at 20, 50, and 100 candidates
  • Mutation-checked: ancestor union, bump lower bound, BnB look-alike batching, and changeless ancestor handling

Review guide

  1. SelectionProblem::new: parent walk and private/shared split.
  2. CoinSelector::ancestor_bump: union accounting and package netting.
  3. CoinSelector::ancestor_bump_lower_bound: why every descendant owes at least this amount.
  4. LowestFee::bound_with_ancestors: funded surplus credit and the child-weight fractional relaxation.
  5. LowestFeeChangeless: the monotone no-change bound and large-pool ordering fallback.
  6. tests/ancestor.rs and tests/lowest_fee_changeless.rs: exhaustive admissibility and BnB-equivalence oracles.

evanlinjin and others added 3 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>
@evanlinjin
evanlinjin force-pushed the feature/ancestor-aware-selection-no-clustor branch 2 times, most recently from 0c97d3b to 2305384 Compare August 14, 2026 06:31
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.
max_rounds bounds memory as well as work, and the memory side is easy to
miss. The search is best-first over a queue holding a CoinSelector per live
branch, and because both shipped metrics have a bound that grows with each
added input, the queue is always popped shallowest-first and never finishes a
level. The frontier grows with the round count and can reach gigabytes on a
pool of several hundred candidates if allowed to run.

Two things follow for callers, neither previously written down: a wall-clock
deadline is not a substitute for the round limit, because it does not bound
the frontier; and a fallback is required, because above a few hundred
candidates -- sooner with dense shared ancestry -- the search can exhaust its
budget and return RoundLimit while a solution exists.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant