feat(metrics): add ChangelessWaste for privacy-driven coin selection#47
Draft
evanlinjin wants to merge 7 commits into
Draft
feat(metrics): add ChangelessWaste for privacy-driven coin selection#47evanlinjin wants to merge 7 commits into
evanlinjin wants to merge 7 commits into
Conversation
Two benchmark groups in benches/coin_selector.rs:
- `clone/{n}`: direct cost of CoinSelector::clone() at n =
64/256/1024/4096 candidates. Measures the per-node BnB allocation
the bitset/Arc layout was introduced to make cheap.
- `run_bnb_lowest_fee/{n}`: end-to-end run_bnb throughput on a
deterministic synthetic pool with the LowestFee metric, at n =
20/50/100/200 candidates. Reflects realistic library usage.
The pool generator is deterministic so results are comparable across
commits. Criterion is added as a dev-dependency (does not affect
downstream consumers).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aa52e55 to
8606b6f
Compare
Adds a waste metric restricted to changeless selections. Removing the change output from the picture eliminates the non-monotonic discontinuity that complicates the general waste bound, so the LB reduces to a lower bound on `input_weight * (feerate - long_term_feerate)`. For target-not-met, rate_diff >= 0 we reuse LowestFee::bound's resize trick applied to input_weight rather than fee. Includes proptests `can_eventually_find_best_solution` and `ensure_bound_is_not_too_tight`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous bound for `rate_diff < 0` was `all_selected.input_weight * rate_diff`, which ignored that selecting every candidate would typically force a change output (making the selection infeasible under the changeless constraint). The new bound recasts the problem as: minimize the weight of candidates excluded from `D_all` such that `excess_with_drain` drops below `change_policy.min_value`. This is a 0/1 covering knapsack; the LP relaxation (sort positive-ev_feerate candidates by `ev/weight` descending and exclude fractionally) gives a safe upper bound on `D.input_weight` for any feasible changeless descendant. Benchmark improvements (round counts, BnB cap 2M): n=15 rate_diff_neg: 3,590 -> 415 (~9x) n=20 rate_diff_neg: 34,296 -> 2,138 (~16x) n=30 rate_diff_neg: 2M cap -> 74,055 (>27x, was hitting cap) rate_diff >= 0 paths are unchanged. `ensure_bound_is_not_too_tight` proptest verifies the new LB across 256 randomized scenarios. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8606b6f to
bbaf01b
Compare
a510262 to
d03543f
Compare
…helpers The same "resize trick" was duplicated in three places: LowestFee::bound, ChangelessLowestFee::bound, and changeless_waste::resize_target_lb_input_weight. Each one ran the same find-then-deselect dance plus the same rate/replace/absolute scale calculation, only differing in what they computed from `scale` at the end. Extracts: - `resize_bound(cs, target)` -> Option<(CoinSelector, Candidate, scale)>: the find + scale calculation. Callers compute their own ideal_fee / ideal_input_weight from `(cs, to_resize, scale)`. Exact-match (zero excess) is folded into the same return shape via `scale == 1.0`. - `excess_with_drain(cs, target, change_policy)`: shortcut for the "would-be excess if change were materialised" calculation that ChangePolicy uses to decide whether to add a drain. Net: -91 lines, all bound logic in one place. The `resize_target_lb_input_weight` helper in changeless_waste.rs is removed; callers use `resize_bound` directly. `Waste::lb_with_change_waste` still has its own small `excess_with_drain > min_value` feasibility check inline; the helper handles the raw call. All 31 tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The LP-relaxed knapsack inside `ChangelessWaste::bound`'s `rate_diff < 0` branch — sort by contribution-per-cost descending, take greedily, fractional last — is a generic primitive: "minimum cost of a fractional subset whose contributions sum to at least delta". Moves it to `src/metrics.rs` next to the other shared helpers (`resize_bound`, `excess_with_drain`, `change_lower_bound`). No behavior change; `ChangelessWaste` proptests still pass. The helper has a second caller coming in the next commit (LowestFee's "get rid of change" branch), which is what motivates the extraction. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the single-benchmark estimate in `LowestFee::bound`'s "target met, has change, can we go changeless?" branch with the shared `lp_min_cost_to_cover` helper. The old code used only the lowest-value_pwu unselected candidate as a benchmark and assumed it scaled arbitrarily, which under-estimates the cost when the benchmark's `|ev|` is less than the amount above the change threshold. LP-relaxed knapsack handles this correctly: takes the benchmark fully (it's the most-efficient by definition), then progressively worse-ratio candidates until the deficit is covered. The bound is provably tighter than V1 (or equal): for any `amount <= |ev_benchmark|`, the LP answer equals V1's; for `amount > |ev_benchmark|`, the LP answer is strictly larger (tighter LB), because V1 over-scales the benchmark beyond its actual size. In the synthetic `benches/lowest_fee_bounds` pool the V1/V2 round counts are identical — the path "amount > benchmark's |ev|" doesn't fire in this particular pool shape. The tightening is for adversarial / dust-heavy pools the proptest occasionally exercises. `ensure_bound_is_not_too_tight` proptest still passes (all 7 LowestFee tests green). Removes the TODO comment that pointed at this exact tightening opportunity. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a Criterion bench that runs both bound versions side-by-side on a synthetic pool (mix of normal UTXOs + low-value dust to exercise the "go changeless via neg-ev" branch). Prints round-count comparison at the start and times each version. In this synthetic, V1 and V2 produce identical round counts — the tightening case (`amount_above_change_threshold > |ev_benchmark|`) doesn't fire because the dust candidates all have similar |ev|/value ratios, so the single-benchmark estimate happens to coincide with the LP-optimal cost. The proptest is what validates V2 is correct on more diverse pools. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
d03543f to
a52499b
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.
Summary
Adds a waste metric restricted to changeless selections, plus an internal cleanup of
LowestFee::bound.The new metric is for privacy-driven coin selection — CoinJoin / PayJoin / send-to-one-shot addresses where the change output is itself a leak. This matches the use case Bitcoin Core's BnB-CB algorithm covers. We deliberately don't expose a cross-regime
Wastemetric because optimising for waste tends to consolidate funds in ways most callers don't want (every input has negative waste contribution whenlong_term_feerate > feerate).Public additions
ChangelessWaste— minimise the waste metric subject to "no change output" perchange_policy. Two-case bound:rate_diff >= 0: lower-boundD.input_weight(resize trick when target not met, otherwisecs.input_weight).rate_diff < 0: upper-boundD.input_weightvia LP-relaxed knapsack on the candidates that must be excluded to keep excess belowmin_value.Internal cleanup
Three private helpers in
src/metrics.rsalongside the existingchange_lower_bound:resize_bound— find-crossing-input + compute fractional scale. Shared byLowestFee::bound(target-not-met) andChangelessWaste::bound.excess_with_drain— shortcut for "would-be excess if change materialised".lp_min_cost_to_cover— LP-relaxed fractional knapsack ("minimum cost of a fractional subset whose contributions sum to at least delta"). Shared byChangelessWaste'srate_diff < 0UB andLowestFee's "get rid of change" branch.LowestFee::bound's "target met, has change, can we go changeless?" branch is also rewritten to uselp_min_cost_to_coverinstead of a single-benchmark estimate. Provably ≥ the old bound (strictly tighter whenamount_above_change_threshold > |ev_benchmark|); in the syntheticbenches/lowest_fee_boundspool the round counts come out identical (the divergence case doesn't fire on that pool shape), but the code is ~9 lines shorter and the named helper makes the intent clear. The oldTODO: this metric could be tighter by being more complicatedcomment is removed.Bound tightening for
ChangelessWasteThe
rate_diff < 0(consolidation) case uses the LP-relaxed knapsack to boundD.input_weight. Round-count savings vs a trivialall_selectedupper bound:Why no general
Wastemetric?Earlier iterations of this PR included a cross-regime
Wastemetric scoring both changeless and with-change selections viamin(LB_changeless, LB_with_change). It was removed because:long_term_feerate > feerate(a common low-fee scenario) every input has negative waste contribution, so the BnB optimum is "select every UTXO". Most callers do not want this.ChangelessWaste.Test plan
cargo test --release— 26 tests pass (includingensure_bound_is_not_too_tightproptests on random pools for bothChangelessWasteandLowestFee).cargo clippy --all-targets— clean.cargo fmt --check— clean on every commit individually.cargo build— succeeds on every commit individually (verified on stable + MSRV 1.54.0).ChangelessWaste::bound, the LP-knapsack helper, and theLowestFee::boundcleanup.🤖 Generated with Claude Code