Skip to content

Add Target::max_weight, built on the metric-decides-change refactor (#49)#51

Merged
evanlinjin merged 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/max-weight-on-target
Jul 7, 2026
Merged

Add Target::max_weight, built on the metric-decides-change refactor (#49)#51
evanlinjin merged 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/max-weight-on-target

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Jul 2, 2026

Copy link
Copy Markdown
Member

Alternative to #48 for capping transaction weight during coin selection — e.g. TRUC / BIP-431 packages limited to 10,000 / 1,000 vB.

The key design choice: this is built on the metric-decides-change refactor (#49), because that's what keeps the weight cap from breaking LowestFee's branch-and-bound bound (see Why build on #49).

Four commits: (1) logic, (2) tests, (3) a pure rename of the value-feasibility predicates, (4) an optional bound speedup.

Approach

  • Target::max_weight: Option<u64> — a feasibility constraint that mirrors the value target: value is a lower bound, max_weight an upper bound.
  • These two have opposite monotonicity, so they stay separate predicates: the value check stays monotone (is_funded), the cap is a distinct anti-monotone check (is_within_max_weight). That separation is what preserves the monotonicity the BnB bounds rely on.
  • The cap is enforced only where a selection is committed:
    • LowestFee (which owns the change decision post-feat!: BnB metrics decide the change output themselves #49): refuses change that would bust the cap, rejects any over-cap selection, and prunes subtrees whose lightest (no-drain) form already busts it.
    • select_until_target_met now returns SelectError::MaxWeightExceeded instead of silently returning an over-cap selection.

Why build on #49

LowestFee's branch-and-bound rests on one claim about its bound: a selection with a worthwhile change output can't be beaten by a descendant that adds inputs to become changeless. Whether that holds depends on who decides the change policy.

Before #49, the change policy is decoupled from the metric, so a selection can carry a change output that doesn't actually minimize the fee. Pile non-effective inputs onto it — each costs more in fee than it adds in value, shrinking the excess — until the change vanishes, and the resulting changeless selection can score better. The bound trusted the with-change score as a floor for the subtree; now it's too high, BnB prunes the branch holding the real optimum, and you get a suboptimal selection. (This is the inadmissibility #48 runs into.)

After #49, the metric owns the decision: change is added only when it strictly lowers the long-term fee (change_value > spend_fee). Now dropping a worthwhile change can never help. With A a worthwhile-change selection and B = A + inputs (added value v ≥ 0) made changeless:

LTF_A = (selected_A − target − change_value) + spend_fee   // recovers the change
LTF_B =  selected_B − target                               // burns the excess
LTF_B − LTF_A = v + change_value − spend_fee  >  0         // change_value > spend_fee, v ≥ 0

B always costs more, so the bound stays admissible.

And that's exactly what max_weight needs. The cap enforces itself by refusing a change output that would bust it — i.e. it can force a descendant to be changeless. The proof never asks why B is changeless, only that A's change was worthwhile. So on the #49 base the cap needs nothing more than a weight hard-prune — no bound rewrite, no lost optimality. Fold it into the value predicate on the old base instead, and the suboptimality above comes right back.

Commit 3: renamed predicates

Target now carries two constraints of opposite monotonicity, so the value-only predicates are renamed to say so — "funded/fundable" is money-centric and naturally excludes weight, and -ed/-able distinguishes "this selection covers the value" from "these candidates can cover it":

is_target_metis_funded, is_target_met_with_drainis_funded_with_drain, is_selection_possibleis_fundable.

Commit 4: cap-aware bound (optional speedup)

Correctness only needs the hard-prune above. Commit 4 additionally teaches the bound about the cap — pruning subtrees that are still under-cap but provably can't reach the value target without busting it. It never changes the result (and stays admissible — the ensure_bound_is_not_too_tight proptest passes), but it's a large win in the tight-cap regime a safety rail actually lives in.

Measured BnB rounds to completion on a variable-weight pool, cap-aware bound off → on:

max_weight rounds off → on
too tight (infeasible) whole tree (millions, never terminates) → 0
binds the optimum ~13.5k → 33–359
tight, optimum still fits ~5.3k → ~2.4k
loose (cap doesn't bite) ~5.3k → ~5.3k (no-op)

The headline is the first row: for a too-tight cap the bound proves infeasibility instantly, instead of enumerating the entire subtree before giving up.

Testing

  • max_weight is threaded through the LowestFee proptests, so BnB is checked against exhaustive search with the cap active.
  • A new bnb_respects_max_weight proptest cross-checks BnB feasibility against an independent exhaustive oracle — this is what validates the weight hard-prune.

Breaking changes

  • Target { .. } literals now require a max_weight field.
  • The predicates renamed above (is_target_metis_funded, etc.).

🤖 Generated with Claude Code

@evanlinjin evanlinjin force-pushed the feat/max-weight-on-target branch from 30550ca to 4858294 Compare July 2, 2026 13:24
@evanlinjin evanlinjin self-assigned this Jul 2, 2026
@evanlinjin evanlinjin marked this pull request as draft July 2, 2026 13:32
@evanlinjin evanlinjin force-pushed the feat/max-weight-on-target branch 15 times, most recently from 86c514f to a4fddfe Compare July 4, 2026 14:29
@evanlinjin evanlinjin marked this pull request as ready for review July 4, 2026 14:29
@evanlinjin evanlinjin requested a review from LLFourn July 4, 2026 14:29

@LLFourn LLFourn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the branch-and-bound bound with a focus on whether max_weight is genuinely part
of how the lower bound is computed (vs. a constraint bolted onto a weight-blind bound).

It is. Both branches of LowestFee::bound reason about the cap: the unfunded branch prunes
via a fractional minimum-weight relaxation, and the funded/changeless branch withholds the
change-recovery credit when a change output can no longer fit. The monotone/anti-monotone
split (is_funded vs is_within_max_weight) is what keeps this admissible, and the
ensure_bound_is_not_too_tight proptest — now exercised with the cap active — plus the
bnb_respects_max_weight feasibility oracle back that up. Full suite and clippy are green
locally on the head commit.

Comments are non-blocking. The substantive one is on the funded/changeless branch: its cap
gate reserves room only for the change output, but realizing the credited improvement also
requires adding ≥1 input to clear the dust threshold — so the bound is admissible but looser
than it should be. I opened a minimal follow-up for that:
evanlinjin#2 (mostly a clarity fix so the code matches
its own comment; also strictly tightens the bound — measured strictly fewer BnB rounds in ~0.9%
of varied-weight scenarios, never more). The other two comments are doc-precision points around
the cap semantics, plus a narrow self-limiting perf edge in the unfunded prune. Nice work overall
— the core design is right.

Comment thread src/metrics/lowest_fee.rs
// so if even that (fractionally) can't fit the remaining weight budget, no within-cap
// selection down this branch reaches the target -> prune. This is the fractional
// relaxation, so it never prunes a branch that has an (integer) within-cap solution.
if let Some(max_weight) = target.max_weight {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified: this is the cap-aware lower bound done right — the weight constraint is folded into the bound, not bolted on top of a weight-blind one.

scale * to_resize.weight is the minimum additional weight to reach the target: greedy-by-descending-pwu gives the lightest value-meeting selection, and to_resize is the best value-per-weight input at the margin, so any real (integer) within-cap solution weighs at least this much. Pruning when the fractional minimum already busts the budget therefore can't drop a branch that holds a feasible solution. ensure_bound_is_not_too_tight now runs with max_weight threaded through and passes — exactly the guard this reasoning needs.

Suggestion (non-blocking): replace this check with the direct inequality — the intermediate max_weight.saturating_sub(..) is unnecessary and makes the arithmetic more intricate than it needs to be:

if cs.weight(target.outputs, DrainWeights::NONE) as f32 + scale.0 * to_resize.weight as f32
    > max_weight as f32
{
    return None;
}

That's just the honest statement of the relaxation — "current weight + the perfect input's weight exceeds the cap" — with nothing to clamp. It also fixes a corner the saturating form silently misses: when scale == 0 (the vbyte-rounding band the comment at L269 notes) and cs is already over max_weight, the old test is 0.0 > budget with budget clamped to 0, so it never prunes even though every funded descendant is infeasible.

Comment thread src/metrics/lowest_fee.rs Outdated
Comment thread src/metrics/lowest_fee.rs Outdated
Comment thread tests/common.rs
/// [`CoinSelector::is_funded`] + [`CoinSelector::is_within_max_weight`], so it inherits the
/// exact weight model and is independent of the BnB weight prune it audits. Exponential — small `n`
/// only.
pub fn exact_selection_possible(cs: &CoinSelector, target: Target) -> bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ Nit: exact_selection_possible could be a method on CoinSelector rather than a free function in the test helpers.

@evanlinjin evanlinjin Jul 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's very expensive O(n^2) so will become a footgun for callers.

Comment thread src/coin_selector.rs Outdated
Comment thread src/metrics/lowest_fee.rs Outdated
evanlinjin and others added 5 commits July 7, 2026 11:22
Add an optional `Target::max_weight` cap (in WU) on the resulting
transaction — e.g. for TRUC/BIP-431 packages capped at 10,000/1,000 vB. It's
a feasibility constraint mirroring the value target: value is a lower bound,
`max_weight` an upper bound.

Enforced only where a selection is committed:
- `is_within_max_weight`: the anti-monotone weight predicate, kept separate
  from the monotone value-only `is_target_met` (whose docs disclaim the cap).
- `select_until_target_met` now errors with `SelectError::MaxWeightExceeded`
  instead of silently returning an over-cap selection.
- `LowestFee`: refuse a change output that would bust the cap, reject any
  over-cap selection, and hard-prune subtrees whose lightest (no-drain) form
  already busts it. A cap-agnostic `fee_score` feeds the bound's estimates so
  they stay admissible.

Because `LowestFee` decides change economically, the existing bound proof
survives the cap, so the hard-prune alone suffices. `is_selection_possible`
stays a value-only check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread `max_weight` through `Target`, `StrategyParams` and the proptests so
BnB is checked against exhaustive search with the cap active. Add
`exact_selection_possible`, an exhaustive value-and-weight feasibility oracle,
and a `bnb_respects_max_weight` proptest asserting BnB matches it — this
validates the metric's weight hard-prune. Impossibility assertions switch from
the value-only `is_selection_possible` to this oracle where the cap is in play.

Tighten `compare_against_benchmarks`: filter benchmarks by `score().is_some()`
(actually-valid solutions, reusing the computed score) rather than the
value-only `is_target_met`, add a value-per-weight greedy benchmark so the
comparison isn't vacuous under a tight cap, and assert BnB itself found a valid
solution whenever a benchmark did (a `None` score used to pass silently). Cap
its `n` — the no-solution branch's oracle is O(2^n).

Also mark `bnb_always_finds_solution_if_possible` release-only, matching its
sibling proptest; it's too slow under debug assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Target` now carries two constraints of opposite monotonicity — the value
target (a lower bound, monotone) and `max_weight` (an upper bound,
anti-monotone). Rename the value-only predicates so their names say which one
they check:

- `is_target_met`            -> `is_funded`
- `is_target_met_with_drain` -> `is_funded_with_drain`
- `is_selection_possible`    -> `is_fundable`

"funded"/"fundable" is money-centric and so naturally excludes weight; the
`-ed`/`-able` pair distinguishes "this selection covers the value" from "these
candidates *can* cover it". Pure rename, no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bound was cap-blind: admissible but loose, crediting improvements the
weight budget can't actually afford. Tighten it in the cap-binding regime:

- Not-funded ("slurp") branch: reaching the feerate needs a perfect input
  weighing `scale * to_resize.weight`. `to_resize` is the best value-per-weight
  input available, so if even that can't fit the remaining budget, nothing down
  this branch reaches the target within the cap -> prune. It's a fractional
  relaxation, so it never prunes a branch with an integer solution.
- Changeless "recover value by adding change" branch: only credit the
  improvement if a change output fits the cap. Clearing dust needs heavier
  inputs and the change output adds weight, so if change doesn't fit now it
  never will -> keep `current_score`.

Both stay admissible (`ensure_bound_is_not_too_tight`) and BnB still finds the
optimum (`can_eventually_find_best_solution`). No effect unless `max_weight`
is set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LLFourn and others added 2 commits July 7, 2026 11:22
… bound

The funded/changeless branch of LowestFee's bound credits a potential
"add a change output later" improvement only when it fits under max_weight.
It reserved room for the change output but not for the extra input a
descendant must add to lift the excess over the dust threshold — even
though the adjacent comment already states the improvement needs "more
inputs to clear the dust threshold".

Reserve room for both, via a new CoinSelector::min_input_weight.

This is mostly a clarity fix: the code now does what the comment already
claims. It also strictly tightens the (still-admissible) bound, which cuts
BnB rounds in a small fraction of varied-weight cases and never increases
them.
Follow-up to review on bitcoindevkit#51:

- Simplify the not-funded branch's `max_weight` prune to the direct inequality
  `current_weight + scale * to_resize.weight > max_weight`. This also prunes a
  corner the `saturating_sub` form silently missed: when `scale == 0` and the
  selection is already over-cap, the clamped budget made the test `0.0 > 0.0`,
  which never fires even though every funded descendant is infeasible. Still
  admissible (`ensure_bound_is_not_too_tight` passes).
- `is_within_max_weight` now takes `DrainWeights` rather than a full `Drain` —
  it only ever read `.weights`; this also lets `drain_value` drop a throwaway
  `Drain`.
- Clarify `fee_score`'s doc: it does not reject an over-cap changeless selection
  (only `score` does), but the drain it returns is never over-cap.
- Fix a stale `slurp_index` comment (the input is `to_resize`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@evanlinjin evanlinjin force-pushed the feat/max-weight-on-target branch from b971164 to 0ef98e5 Compare July 7, 2026 11:25
@evanlinjin evanlinjin merged commit 65ec613 into bitcoindevkit:master Jul 7, 2026
5 checks passed
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.

2 participants