Repository navigation
feat!: depth-first branch and bound, and remove the Changeless metric - #84
Open
evanlinjin wants to merge 4 commits into
Open
evanlinjin wants to merge 4 commits into
evanlinjin wants to merge 4 commits into
Conversation
evanlinjin
marked this pull request as draft
September 22, 2026 08:57
evanlinjin
force-pushed
the
feat/dfs-remove-changeless-v2
branch
2 times, most recently
from
September 22, 2026 09:52
5dc9fcd to
2434ec6
Compare
This was referenced Sep 22, 2026
This was referenced Sep 23, 2026
evanlinjin
force-pushed
the
feat/dfs-remove-changeless-v2
branch
from
September 23, 2026 07:13
c495ffc to
6cf6fc4
Compare
evanlinjin
added a commit
that referenced
this pull request
Sep 25, 2026
…ough every call 57114a8 Give `CoinSelector` its target instead of threading it through every call (志宇) Pull request description: ## Why A `CoinSelector` is built for one target and evaluated against it throughout — yet every method took the target as a parameter. Nothing stopped `cs.excess(target_a, drain)` being followed by `cs.is_funded(target_b)`, and the metrics' correctness arguments (e.g. `LowestFee::bound`'s proof that a changeless superset always costs more) are all stated at a *fixed* target, held together by convention rather than by types. The crate had already reached this conclusion one layer down: `BnbIter` stored the target as a field, took it once in `new`, and re-passed it into `metric.score`/`metric.bound` at every node. This moves the binding up to where it belongs and deletes the re-threading. It also unblocks follow-up work: with the selector knowing its target feerate, ancestor-aware CPFP pricing (#24) can be derived internally at the right rate instead of being validated at every call site. That branch is based on this one. ## What `CoinSelector::new(candidates, target)` owns the target; `CoinSelector::target()` exposes it for metrics that read it. Twenty signatures **lose** a parameter: fifteen public methods (`excess`, `implied_fee`, `is_funded`, `drain`, `select_until_target_met`, the four `*_excess`, …), `bnb_solutions`/`run_bnb`, and all three `BnbMetric` methods. Across the existing tests and benches, no selector was ever evaluated against more than one target — the per-call flexibility had no consumer. ## Breaking changes External `BnbMetric` implementations drop the `target: Target` parameter: ```rust fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>; fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>; fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain; ``` Call sites move the target from each method call to `CoinSelector::new`. To evaluate a second target, `CoinSelector::with_target` copies the selector — selection, bans and candidate order — onto it. Arguments that merely restated part of the target go the same way: `weight` and `implied_feerate` no longer take `TargetOutputs`, `fee` no longer takes `target_value`, and `effective_value` and `select_all_effective` no longer take a `FeeRate`. The unreleased CHANGELOG entry that described the previous target-as-a-parameter API is rewritten to describe this one. ## Stack This is the base of a stack: #59 → #63 → #84. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ACKs for top commit: noahjoeris: ACK 57114a8 Tree-SHA512: bd51a7b4a1a91881e60efffa8ca57b8f2eb52be9aa03162b2420c5f1b24145375450cfdc96e43b0dc40de4ae56be8317fe77a211b22aa2972519a5473b385486
evanlinjin
force-pushed
the
feat/dfs-remove-changeless-v2
branch
4 times, most recently
from
October 1, 2026 01:15
713c356 to
9c8706f
Compare
evanlinjin
added a commit
that referenced
this pull request
Oct 2, 2026
…::is_segwit 6a9bed9 fix: exclude only identical candidates together in branch and bound (志宇) 68a5642 fix!: price every transaction as segwit and drop Candidate::is_segwit (志宇) Pull request description: #84 is stacked on this one. 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). Previously it added 1 WU per non-segwit *candidate*, so a candidate grouping N legacy inputs came out N-1 WU short. Instead of tracking segwit and legacy input counts to price mixed transactions exactly (the previous revision of this PR), this prices **every transaction as segwit**: 1. `Candidate::is_segwit` is removed. `Candidate::weight` is the input's segwit-serialized weight (`TxIn::segwit_weight`), so legacy inputs include their 1 WU empty witness. `TXIN_BASE_WEIGHT` now includes the empty witness count byte (165, was 164) and `TX_FIXED_FIELD_WEIGHT` includes the segwit marker and flag (34, was 32). 2. `Candidate::new(value, satisfaction_weight, is_segwit)` becomes `Candidate::new(value, satisfaction_weight)`. `satisfaction_weight` is now the weight over an unsatisfied `TxIn::default()`, which is exactly miniscript's `Descriptor::max_weight_to_satisfy` for every script type. `TR_KEYSPEND_SATISFACTION_WEIGHT` follows this contract (65, was 66), and `new_tr_keyspend` is just `new` with it. `TR_KEYSPEND_TXIN_WEIGHT` is unchanged. **Trade-off:** a transaction that spends only legacy inputs is overestimated by 2 WU plus 1 WU per input (under 1 sat per input at 1 sat/vB). It only ever overpays, so it never undershoots the target feerate. In return: - Mixed and grouped candidates are priced exactly with no per-type bookkeeping, and a candidate can freely mix legacy and segwit inputs. - One constructor with one contract, so a segwit `max_weight_to_satisfy` can't come out 1 WU short. - Branch and bound's duplicate-banning stays on `(value, weight)`, so there's no second notion of candidate equality that the sort has to keep contiguous. - The `is_segwit` lower-bounding hack in `tests/bnb.rs` goes away. *Note: This replaces #62 and #61. #61 introduced `empty_witness_refund`, and #62 forbade mixing legacy and segwit inputs in a candidate. Both are unnecessary once every transaction is priced as segwit.* 🤖 Generated with [Claude Code](https://claude.com/claude-code) ACKs for top commit: LLFourn: ACK 6a9bed9 Tree-SHA512: 485dc7b6a181a79c612256b244df27277b39278576f73be57176c3f22ab5f3350684a479f4a131bd7552325105043e4bd5fa9261bf024cd11644f62f8a56a5ff
evanlinjin
force-pushed
the
feat/dfs-remove-changeless-v2
branch
from
October 2, 2026 08:56
9c8706f to
04ada8a
Compare
evanlinjin
marked this pull request as ready for review
October 19, 2026 16:56
evanlinjin
force-pushed
the
feat/dfs-remove-changeless-v2
branch
3 times, most recently
from
October 20, 2026 01:15
5da59d3 to
14a2969
Compare
2 tasks done
evanlinjin
added a commit
that referenced
this pull request
Oct 21, 2026
f2eef93 fix(dev-deps): cap proptest below 1.12 (志宇) 7922106 ci: check no_std and WASM builds, harden workflow (志宇) Pull request description: ### Description Stage 1 of fixing the required status checks on `master`, plus a fix for the `test` and `clippy_check` jobs, which fail on `master` and every open PR (e.g. #84). The `Default Branch` ruleset requires 14 checks copied from an older bdk_wallet CI (`miniscript/no-std`, `bdk_chain/hashbrown`, and so on). Nothing in this repo reports them, so every PR sits on "Expected — waiting for status" indefinitely. This PR ports the bdk_wallet jobs that make sense for this crate: - **`Check no_std`**: `cargo check --no-default-features`. The crate is `#![no_std]` with an optional `std` feature, but CI never built it without default features. - **`Check WASM`**: `cargo check --target wasm32-unknown-unknown --no-default-features`. The crate is pure Rust, so bdk_wallet's clang setup isn't needed. - **Hardening**: `permissions: {}` at the workflow level and `persist-credentials: false` on every checkout. Left out: the ARM runner matrix (little value for integer arithmetic), code coverage (needs a `CODECOV_TOKEN` secret) and zizmor. #### Fix: cap `proptest` below 1.12 proptest 1.12 depends on rand 0.10, but our `rand` dev-dependency is 0.9. `Cargo.lock` isn't committed, so CI resolves the newest proptest and the tests fail to build (`TestRng: rand::Rng` is not satisfied). The dev-dependency is now `proptest = ">=1.7, <1.12"`. Moving to rand 0.10 and proptest 1.12 together is left for a follow-up, since rand 0.10 deprecates `RngCore` and the tests would need changes. Once this is merged, open PRs need a rebase onto `master` to go green. ### Follow-up (needs a repo admin) Replace the ruleset's required checks with the jobs this repo actually runs: `Rustfmt`, `clippy_check`, `build-msrv`, `test`, `doc-build`, `Check no_std`, `Check WASM`. ### Checklists - [x] `cargo check --no-default-features` and the WASM check pass locally - [x] `cargo fmt`, `clippy`, `doc` and `test` pass locally on a fresh resolve (no `Cargo.lock`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) ACKs for top commit: notmandatory: ACK f2eef93 noahjoeris: ACK f2eef93 oleonardolima: ACK f2eef93 Tree-SHA512: 6adb92881f91a3d0b7e75a9dde46f9a0e33bae5a011f5452d6788aff0a8183df54c003b4fc6e5702064638e19cbedfe81a3ba109a18e22974798fd38ca6f3feb
`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 twice 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, and input count as running sums updated in `select`/`deselect`, so every aggregate is O(1). `select_all_effective` now goes through `select` so the sums cannot drift. A proptest in `tests/weight.rs` checks the sums against a recompute after random selects and deselects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
evanlinjin
force-pushed
the
feat/dfs-remove-changeless-v2
branch
from
October 22, 2026 08:52
14a2969 to
27ed48c
Compare
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. An exclusion node has the same selection as its parent, so it is not scored again. 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. Backtracking needs two crate-internal `CoinSelector` helpers: `unban`, and `order_pos_to_candidate_index` to recover a frame's candidate from its position in the sort order. 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 `bnb_solutions` doc now says the first item is the greedy selection, and is corrected to say that each later solution has a lower score than the last (the metric is minimised), not a higher one. The two round-count assertions in `tests/bnb.rs` each move by one: the seed is a round. A new test checks that `run_bnb` with a budget of one round returns the greedy selection instead of `RoundLimit`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
evanlinjin
force-pushed
the
feat/dfs-remove-changeless-v2
branch
from
October 23, 2026 06:19
27ed48c to
573b4fe
Compare
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.
Replaces #73. #73 had the same changes on top of the ancestor-aware selection stack. This PR puts them directly on master so they can land ahead of that stack.
Remove the
ChangelessmetricLowestFeealready decides for itself whether a selection should carry change. It adds change only when doing so lowers the long-term fee, clears the dust threshold, and fitsTarget::max_weight. A separate changeless objective repeats that decision and then constrains it.This is a breaking change. Callers that required a changeless transaction should use
LowestFeeand inspect the returnedDrain.Keep running sums of the selection in
CoinSelectorinput_weight()scanned the selected set twice andselected_value()once. Metrics call them several times per node throughexcess,rate_excess,implied_feeand similar methods.CoinSelectornow tracks selected value, weight, and input count as running sums, updated inselectanddeselect.A new proptest (
running_sums_match_recompute) checks the sums against a recompute from the selected set after random select/deselect sequences.Depth-first branch and bound
The priority-queue traversal has a structural problem.
LowestFee's bound grows 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 any funded leaf.Depth-first search with in-place backtracking visits the child with the better bound first. It reaches a funded leaf in as many expansions as the solution has inputs, and it keeps only the current path in memory instead of a frontier of cloned selectors. The running sums make its in-place select/deselect O(1). Like #63's fix, it only treats candidates as interchangeable when value, weight, and input count all match.
Seed with a greedy incumbent
Before expanding the first node, the search scores the greedy selection (
select_until_target_met), yields it, and adopts its score as the incumbent. Only the incumbent changes, not the bound, so the optimum stays reachable. It costs one round, so the two round counts intests/bnb.rseach go up by one.This is a guarantee rather than a speedup.
run_bnbno longer returnsRoundLimitwhen a valid greedy selection exists. That matters when the solution needs many inputs (on the 1000-coin synthetic pool below, unseeded depth-first still had nothing after 1,000 rounds because the answer is about 220 inputs deep), or when a caller stops on elapsed time rather than a fixed round count. At the 100k-round cap below it changes no result.Benchmarks
Metric is
LowestFee(2 sat/vB target, 10 sat/vB long-term, P2TR drain). Each commit is measured separately (numbers are from before the rebase onto the always-segwit #63 and the f64 fee math of #88; commit hashes are updated): cca7f66 is the base (the changeless removal doesn't touch the search), 31ef70b adds running sums, b44a26b adds depth-first search, and 27ed48c adds the seed.cargo bench run_bnb_lowest_fee(the repo's synthetic pool, 100k-round cap, criterion median):clone/*stays at 20–31 ns through the running-sums and depth-first commits (one noisy seed-commit sample reached 40 ns), so the extra sum fields don't cost anything measurable.Search outcome at a 100k-round cap. Each cell shows rounds / score / inputs / time (median of 3) / peak RSS.
-means no solution was found. The "random" pool is wallet-like: log-uniform values from 1k to 10M sat, mixing P2WPKH, P2TR, and P2PKH, with the target at 5% of the pool. Times vary by up to ~50% between runs of the same binary, so read small differences as noise.tests/bnb.rs: 164 → 95 rounds for the feasibility search, but 3194 → 62453 for the exhaustive exact-match search.How these were measured
Each commit was checked out in its own worktree. Criterion ran
cargo bench --bench coin_selector. The outcome table comes from a small untracked example that runsbnb_solutions(LowestFee).take(cap)and readsVmHWMfrom/proc/self/status. Its baseline RSS is about 3 MB. The 1M- and 10M-round figures were measured before the #63 ban fix was rebased in; spot checks after the rebase gave identical rounds, scores and input counts.🤖 Generated with Claude Code