feat!: ancestor-aware coin selection (CPFP bump via SelectionProblem) - #64
Draft
evanlinjin wants to merge 14 commits into
Draft
feat!: ancestor-aware coin selection (CPFP bump via SelectionProblem)#64evanlinjin wants to merge 14 commits into
evanlinjin wants to merge 14 commits into
Conversation
evanlinjin
force-pushed
the
feature/ancestor-aware-selection-no-clustor
branch
3 times, most recently
from
August 13, 2026 00:06
a84b9bf to
a6bf5f8
Compare
This was referenced Aug 13, 2026
evanlinjin
force-pushed
the
feature/ancestor-aware-selection-no-clustor
branch
from
August 14, 2026 04:13
2f59d13 to
4934ca4
Compare
…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
force-pushed
the
feature/ancestor-aware-selection-no-clustor
branch
2 times, most recently
from
August 14, 2026 06:31
0c97d3b to
2305384
Compare
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.
evanlinjin
force-pushed
the
feature/ancestor-aware-selection-no-clustor
branch
from
August 14, 2026 07:04
07dd8a6 to
7e965cf
Compare
This was referenced Aug 14, 2026
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.
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.
Summary
Makes coin selection pay for unconfirmed ancestors (CPFP) without putting ancestry on
Candidate.SelectionProblemowns the target, candidates, and ancestor graph;CoinSelectorborrows it.Input<Txid>identifies the transaction each candidate input spends, andAncestorToBump<Txid>supplies ancestor weight, fee, and parent links.(weight, fee)pair. Only ancestors shared by several candidates need bitset de-duplication while scoring a selection.drags_in;SelectionViewmaintains delta-aware per-branch aggregates so metric evaluation does not repeatedly scan selected candidates.LowestFeebound:ancestor_bump_lower_boundis valid for every descendant.Nonefrom funding heuristics. "Selecting everything is unfunded" does not prove that every subset is unfunded.LowestFeeChangelessreplaces the loose genericChangeless<LowestFee>composition. It reusesLowestFeefunding and change decisions, then adds a monotone selected-value bound for pools up to 24 candidates. Larger pools retainLowestFeeordering 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:
CoinSelectorits target instead of threading it through every call #59: bind a selector to one target#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
nested_ancestry_20optimal in 200 rounds andsubsidizing_ancestry_20optimal in 886 rounds, versus no result at 100,000 rounds previously.subsidizing_ancestry_100results: score19006at 100,000 rounds and18925at 20 million rounds.Test plan
cargo fmt --all -- --checkcargo check --all-targets --all-featuresandcargo check --no-default-featurescargo clippy --all-targets --all-features --testswith warnings deniedcargo test --releaseandcargo doc --no-depswith warnings deniedReview guide
SelectionProblem::new: parent walk and private/shared split.CoinSelector::ancestor_bump: union accounting and package netting.CoinSelector::ancestor_bump_lower_bound: why every descendant owes at least this amount.LowestFee::bound_with_ancestors: funded surplus credit and the child-weight fractional relaxation.LowestFeeChangeless: the monotone no-change bound and large-pool ordering fallback.tests/ancestor.rsandtests/lowest_fee_changeless.rs: exhaustive admissibility and BnB-equivalence oracles.