Skip to content

feat!: add dedicated LowestFeeChangeless metric - #3

Closed
evanlinjin wants to merge 17 commits into
feature/ancestor-aware-selection-no-clustorfrom
feature/lowest-fee-changeless
Closed

evanlinjin wants to merge 17 commits into
feature/ancestor-aware-selection-no-clustorfrom
feature/lowest-fee-changeless

Conversation

@evanlinjin

Copy link
Copy Markdown
Owner

Stacking

This is stacked on bitcoindevkit/coin-select#64. The PR contains only the dedicated changeless metric commit.

Summary

  • Add LowestFeeChangeless, a dedicated lowest-fee metric that only accepts selections for which LowestFee chooses no change output.
  • Reuse LowestFee change eligibility and funding-bound logic.
  • Add a changeless-specific monotone bound: selected value, and therefore the no-change fee, cannot decrease as inputs are added.
  • Use the tight bound for pools up to 24 candidates; larger pools retain LowestFee ordering because the tighter best-first ordering can starve useful subtrees under a finite round budget.
  • Remove the generic Changeless<M> wrapper and add From<LowestFee> for LowestFeeChangeless.
  • Extend exhaustive correctness coverage to ancestry and max_weight.

Benchmark Findings

Full report: coinselect-benchmark/FINDINGS.md

The report covers Bitcoin Core v31.1 and coin-select e45bb58 over 33 fixtures, three tracks, and a 100,000-round budget.

  • Ancestor accounting agrees exactly: coin-select union bumps match Core post-discount combined bumps on all 198 measured runs.
  • coin-select can act on shared-ancestor discounts during search; Core applies overlap discounts only after selection and consequently misses valid or optimal ancestor-sharing selections.
  • coin-select reaches its own oracle optimum on all eight brute-force-sized fixtures; Core misses its optimum on four, all with ancestry.
  • nested_ancestry_20 improves from no result at 100,000 rounds to the oracle-optimal result in 200 rounds.
  • subsidizing_ancestry_20 improves from no result at 100,000 rounds to the oracle-optimal result in 886 rounds.
  • Kernel-track budget exhaustion falls from six fixtures to four, and no-result cases fall from four to two.
  • The remaining no-result cases are shared_ancestry_200 and subsidizing_ancestry_200.
  • Kernel median wall time is 1.72 ms for coin-select versus 569 us for Core. Core nodes are much cheaper, while coin-select generally explores far fewer nodes and more often proves its optimum.
  • Priority-queue memory remains the clearest cost: approximately 2.6-88 MB under the standard budget versus Core's flat process baseline.
  • On comparable kernel results, coin-select produces the cheaper package in 26 of 30 cases and lower Core-style waste in 15 cases versus 12 for Core. The engines optimize different objectives, so oracle checks and search effort are the primary comparisons.

The large-pool bound fallback also fixes a search-order regression on subsidizing_ancestry_100: it matches the pinned runner with score 19006 at 100,000 rounds and score 18925 at 20 million rounds.

Verification

  • cargo test --all-targets
  • cargo test --all-features --release
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo check --no-default-features
  • cargo fmt --all -- --check
  • Exhaustive oracle checks for all 20-candidate benchmark fixtures
  • Full coinselect-benchmark kernel matrix and raised-budget regression reproduction

evanlinjin and others added 16 commits July 7, 2026 15:35
Squash of the two-commit perf series (cd1017a "delta-aware BnbMetric via
SelectionView/SelectionCache" + 536a03a "replace duplicated CoinSelector
read methods with compute_view()"), rebased onto the new BnbMetric API
(target passed as a parameter; metric decides its own change output).

Squashed because the first commit's intermediate state (src/selection_cache.rs,
SelectionView-by-value) is fully superseded by the second (src/selection_view.rs,
Cow-backed cache, &SelectionView, compute_view()); replaying both would mean
resolving the same BnbMetric merge against master twice.

The flamegraph on fix/better-memory showed ~65% of run_bnb_lowest_fee time
was in cs.selected_value() (47.5%) and cs.input_weight() (17.3%) -- both
O(|selected|) walks recomputed many times per branch via
excess/is_target_met/drain.

This makes the metric evaluator delta-aware. BnB maintains a SelectionCache
(running aggregates over the selection: value_sum, weight_sum, input_count,
segwit_count, candidate_count) per Branch; inclusion expansions call
cache.add(c), which is O(1). The metric trait now takes a
`&SelectionView<'_>` -- a handle over (&CoinSelector, Cow<SelectionCache>)
-- with O(1) versions of every read method (selected_value, input_weight,
excess, is_target_met, drain, ...).

CoinSelector's ~15 duplicated O(|selected|) read methods collapse into a
single `cs.compute_view()` entry point (fresh cache built once on demand);
the BnB hot path borrows the per-branch cache instead (zero clone).

Adapted to the master-side API changes:
- score/bound/drain take `target: Target` as a parameter (metrics no longer
  store target); BnbIter threads its `target` through to the metric.
- LowestFee owns the change decision (dust_relay_feerate + drain_weights,
  no change_policy) and implements `drain`; its delta-aware not-target-met
  bound loop is preserved.
- Changeless<M> wraps an inner metric, using &SelectionView + target.

All lib, integration and doc tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The original benches capped at 4k candidates for `clone` and 200 for
`run_bnb_lowest_fee`. Real callers span a much wider range -- a typical
wallet has ~1k UTXOs, a large exchange ~10M.

For the O(n)-ish operations (`new`, `clone`, `compute_view`) extend the
parameter list to 64 / 1k / 16k / 256k / 1M / 10M. These all scale
roughly linearly:

  clone/64        51 ns
  clone/1024      52 ns
  clone/16384    260 ns
  clone/262144   2.0 us
  clone/1048576  6.3 us
  clone/10000000  98 us

At 10M UTXOs the `Candidate` slice itself is ~320MB and the selector's
`candidate_order` Vec is ~80MB -- commented as a heads-up for memory-
constrained hosts.

Add new groups:

- `new`: cost of `CoinSelector::new(candidates)` -- allocations grow with
  pool size.
- `compute_view`: cost of building a SelectionView. Scales with
  |selected| rather than |pool|; benched against a fixed sparse
  selection of ~100 candidates regardless of pool size, matching how
  wallets actually use selection.

The BnB bench splits into two explicitly-named groups, because at
n >= 200 best-first exploration does not complete any target-meeting
selection within the round cap (run_bnb returns NoBnbSolution after
exactly 100k rounds):

- `run_bnb_lowest_fee` (n = 20/50/100): end-to-end solution finding.
- `run_bnb_lowest_fee_exhaust_cap` (n = 200/500/1000): exactly
  MAX_ROUNDS rounds of frontier expansion (bound() + branch cloning) --
  the hot path the delta-aware cache optimizes. Per-round cost grows
  roughly linearly:

    run_bnb_lowest_fee_exhaust_cap/200   193 ms
    run_bnb_lowest_fee_exhaust_cap/500   211 ms
    run_bnb_lowest_fee_exhaust_cap/1000  377 ms

Each group asserts at startup that run_bnb's solution-found outcome
matches what the group claims to measure, so a size silently flipping
between the two paths (metric change, bound tightening) fails loudly
instead of corrupting cross-version comparisons.

10M-scale BnB is intentionally not benchmarked: it's impractical at any
finite round budget, and real callers pre-filter / pre-group at that
scale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flamegraph after the delta-aware refactor showed ~32% of run_bnb
time spent walking candidate_order and checking
Bitset::contains(selected) || Bitset::contains(banned) per element,
inlined into insert_new_branches's `cs.unselected().next()`. As BnB
descends and more candidates get selected/banned, each .next() call
scans further before finding the next viable candidate.

But BnB never re-considers a position: each branch's exploration only
moves forward in candidate_order. Inclusion advances by 1; exclusion
advances past every consecutive same-(value, weight) candidate. So we
can store a per-Branch cursor and avoid the scan entirely.

Add `Branch::cursor: usize` (the position the branch will expand on
next). The init branch starts at 0; insert_new_branches advances past
any pre-selected/pre-banned positions on demand, then expands at the
located cursor and hands children their new cursors directly.

One subtlety: the exclusion branch's same-(value, weight) dedup run now
walks raw candidate_order positions, where the old `unselected()` scan
skipped already-decided candidates implicitly. The run must do the same
explicitly -- skip pre-selected/pre-banned positions (advancing the
cursor past them) rather than banning them or letting them end the run.
Otherwise a caller-pre-selected candidate that duplicates an excluded
one ends up simultaneously selected and banned in the selector that
run_bnb hands back, and a pre-decided candidate inside a duplicate run
fragments the equivalence class into redundant branches. Covered by
`bnb_exclusion_dedup_skips_decided_candidates`.

Bench (run_bnb_lowest_fee, n = pool size):

  n=20     166 us -> 147 us  (11%)
  n=50    5.3 ms -> 4.5 ms   (16%)
  n=100   12.6 ms -> 11.5 ms  (9%)
  n=500   200 ms -> 171 ms   (14%)
  n=1000  365 ms -> 248 ms   (32%)

(n >= 200 rows are the exhaust-cap group: fixed 100k rounds of frontier
expansion, so they measure pure per-round cost.)

Largest win at large n where the unselected scan was burning the most
time. Flamegraph confirms the unselected-scan hot spot is gone; new top
is LowestFee::bound itself (the metric's float math + lookahead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 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).
…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>
Ancestor-aware selection needs target, candidates, and bump data as one
unit of work. SelectionProblem owns that; CoinSelector holds a reference
for its lifetime. Candidate stays a plain Copy description of inputs.
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.
Two complementary improvements, neither changing what a selection owes:

1. Bound: credit the least any descendant could still owe for ancestors.
   Shed only surplus that is actually reachable; when nothing overpays the
   floor is the full bump. Cuts search rounds sharply on typical CPFP.

2. Representation: fold ancestors only one candidate can reach into a
   per-candidate (weight, fee) pair. Shared ancestors still de-dupe per
   selection. No bitset work when nothing is shared; local_bump is derived
   on demand rather than stored.

Also add ancestor BnB benches (private and shared paths).
It is the least bump any descendant could still owe, not this
selection's current bump. Drop the S∪D/shed notation.
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.
@evanlinjin
evanlinjin force-pushed the feature/ancestor-aware-selection-no-clustor branch from 2f59d13 to 4934ca4 Compare August 14, 2026 04:13
@evanlinjin
evanlinjin force-pushed the feature/lowest-fee-changeless branch from e45bb58 to 0c7db31 Compare August 14, 2026 04:14
@evanlinjin
evanlinjin force-pushed the feature/ancestor-aware-selection-no-clustor branch from 4934ca4 to 0c97d3b Compare August 14, 2026 05:09
@evanlinjin

Copy link
Copy Markdown
Owner Author

Folded this work into bitcoindevkit#64 so the ancestor-aware PR ships the dedicated changeless metric and its tighter bound as one coherent change.

@evanlinjin evanlinjin closed this Aug 14, 2026
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