Skip to content

feat(metrics): add ChangelessWaste for privacy-driven coin selection#47

Draft
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:changeless-waste-metric
Draft

feat(metrics): add ChangelessWaste for privacy-driven coin selection#47
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:changeless-waste-metric

Conversation

@evanlinjin
Copy link
Copy Markdown
Member

@evanlinjin evanlinjin commented May 19, 2026

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 Waste metric because optimising for waste tends to consolidate funds in ways most callers don't want (every input has negative waste contribution when long_term_feerate > feerate).

Public additions

  • ChangelessWaste — minimise the waste metric subject to "no change output" per change_policy. Two-case bound:
    • rate_diff >= 0: lower-bound D.input_weight (resize trick when target not met, otherwise cs.input_weight).
    • rate_diff < 0: upper-bound D.input_weight via LP-relaxed knapsack on the candidates that must be excluded to keep excess below min_value.

Internal cleanup

Three private helpers in src/metrics.rs alongside the existing change_lower_bound:

  • resize_bound — find-crossing-input + compute fractional scale. Shared by LowestFee::bound (target-not-met) and ChangelessWaste::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 by ChangelessWaste's rate_diff < 0 UB and LowestFee's "get rid of change" branch.

LowestFee::bound's "target met, has change, can we go changeless?" branch is also rewritten to use lp_min_cost_to_cover instead of a single-benchmark estimate. Provably ≥ the old bound (strictly tighter when amount_above_change_threshold > |ev_benchmark|); in the synthetic benches/lowest_fee_bounds pool 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 old TODO: this metric could be tighter by being more complicated comment is removed.

Bound tightening for ChangelessWaste

The rate_diff < 0 (consolidation) case uses the LP-relaxed knapsack to bound D.input_weight. Round-count savings vs a trivial all_selected upper bound:

n scenario trivial LP knapsack
20 rate_neg 34,296 2,138
30 rate_neg 2M (cap) 74,055

Why no general Waste metric?

Earlier iterations of this PR included a cross-regime Waste metric scoring both changeless and with-change selections via min(LB_changeless, LB_with_change). It was removed because:

  • The metric over-consolidates: when 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.
  • Bitcoin Core uses waste as a tiebreaker between solutions found by other algorithms (knapsack, SRD), never as the BnB objective. Same lesson here.
  • The legitimate use cases — (a) tiebreaking equally-cheap fee solutions, (b) finding good changeless candidates — don't need the cross-regime bound. (a) can be done via tuple combinators with a non-waste primary; (b) is exactly ChangelessWaste.

Test plan

  • cargo test --release — 26 tests pass (including ensure_bound_is_not_too_tight proptests on random pools for both ChangelessWaste and LowestFee).
  • 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).
  • Manual review of ChangelessWaste::bound, the LP-knapsack helper, and the LowestFee::bound cleanup.

🤖 Generated with Claude Code

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>
@evanlinjin evanlinjin force-pushed the changeless-waste-metric branch 2 times, most recently from aa52e55 to 8606b6f Compare May 20, 2026 03:28
evanlinjin and others added 2 commits May 20, 2026 03:36
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>
@evanlinjin evanlinjin force-pushed the changeless-waste-metric branch from 8606b6f to bbaf01b Compare May 20, 2026 03:38
@evanlinjin evanlinjin changed the title feat(metrics): add Waste, ChangelessWaste, ChangelessLowestFee + tighten changeless bound feat(metrics): add ChangelessWaste for privacy-driven coin selection May 20, 2026
@evanlinjin evanlinjin force-pushed the changeless-waste-metric branch 2 times, most recently from a510262 to d03543f Compare May 21, 2026 09:57
evanlinjin and others added 4 commits May 21, 2026 09:59
…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>
@evanlinjin evanlinjin force-pushed the changeless-waste-metric branch from d03543f to a52499b Compare May 21, 2026 10:00
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