feat: seed branch and bound with a greedy incumbent - #70
Draft
evanlinjin wants to merge 16 commits into
Draft
Conversation
…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.
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.
Port Bitcoin Core's defining `SelectCoinsBnB` cut: it backtracks on
`curr_value > selection_target + cost_of_change`, and because that window
is a constant known before the search starts, the cut needs no incumbent.
`LowestFeeChangeless` had no equivalent, so with `best` still `None` — and
it stays `None` until a changeless selection is found, which is the hard
part — nothing was pruned at all. The ceiling comes straight off
`LowestFee::drain_value`, which is what decides whether a selection counts
as changeless here.
Two things stop it porting directly, and both are checked rather than
assumed:
Adding a candidate does not always raise the excess. Its own weight is not
the whole cost — the input-count varint grows, the first segwit input adds
the witness header, and every legacy input in a segwit transaction
serializes an empty witness — so `max_future_input_overhead` widens the
ceiling by every weight unit the serialization could still gain.
More importantly, a candidate that costs more than it brings is exactly
what a changeless selection may need. Given
`c0 = (1000, 611)`, `c1 = (35977, 372)`, target 33719 at 9.716 sat/vb,
`{c1}` alone overshoots and wants change, and it is the *negative*
effective-value `c0` that burns 484 sats of excess and lands the pair
inside the window at the brute-force optimum of 3258. So this metric
cannot lean on a pre-filtered pool the way Core does — Core can filter
because nothing in its objective wants such a coin — and the cut instead
runs only where the cache confirms every undecided candidate raises the
excess. Unconfirmed ancestors and a `max_weight` cap switch it off for the
same reason: both give a descendant a way back into the window.
Yield the greedy selection before the first pop, 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. Across the coinselect-benchmark matrix this turns 13 budget-exhausted searches into complete answers and changes no other selection. 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.
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.
Important
Stacked on #69, which is itself stacked on #64. This branch is one commit on top of #69's head (
fb5a021), and this repo doesn't carry either branch, so the diff below currently includes both. Review after they merge, at which point this reduces to the single commit described here. Draft until then.Branch and bound is not anytime. A caller whose round budget runs out before the first complete selection gets
NoBnbSolution::RoundLimitand falls through to whatever fallback it has — on a large pool that is far worse than the selection a single greedy pass would have handed it for free. In the benchmark matrix this is not a corner case: on the 500–2000 candidate fixtures the search regularly spends its entire 100,000-node budget on shallow branches and returns nothing.This yields the greedy selection before expanding the first node, and adopts its score as the incumbent.
Only the incumbent changes, never the bound, so the optimum stays reachable and the improving-solutions contract is unaffected — the seed is simply the first solution the iterator yields, and everything after it still has to beat it.
It yields nothing for a metric that rejects the greedy prefix outright.
LowestFeeChangelessis the case: overshooting the target is exactly what a greedy pass does and exactly what that metric will not score, soRoundLimitstill means for it what it meant before.Measured
coinselect-benchmarkbench.py compare-revs, 126 fixture/track pairs, 100,000-node budget, measured on this branch against #64's head:The 13 are the searches that previously exhausted their budget and returned nothing:
Not one selection anywhere else in the matrix changes, which is what you would expect from a change that only supplies an incumbent: where the search already finished, it finished at the same answer.
The two branches compose exactly — measured together, the stack is #69's node reduction plus these 13 recovered solutions, with no interference. One changes the bound, the other the incumbent.
Notes
NoBnbSolution::RoundLimitis now much rarer. It means the metric rejected the greedy selection too, rather than "we ran out of rounds".tests/bnb.rseach move by one — the seed is a round.run_bnb_returns_the_greedy_selection_on_a_tight_budget: 500 candidates,max_rounds = 1, must still come back funded.cargo fmt/check/clippy/doc/testand--no-default-features.🤖 Generated with Claude Code
https://claude.ai/code/session_019qxj8PzFNeqeKw7XkuzvK3