routing: the distillation patch, one live fix and one measured negative

In this commit, we land exp-021's instrument: two flag-gated changes
to lnd's own payment stack, byte-identical to stock with both flags
off (proven against a pre-change binary across four tiers).

soft_unknown is the live half. An unreadable failure now penalizes
exactly one pair (the lowest-probability hop at the attempt amount)
instead of every pair of the route in both directions, which is the
mechanism exp-019 showed spiraling lnd into give-ups from a 10%
unreadable-error rate. The smoke shows the intended signature: with
the flag on, lnd's trajectory is invariant to the unreadable rate.

adaptive_split is the measured negative, kept as instrumentation.
Three designs were built and each reduced to the same thing:
descend geometrically from the learned bound, which lnd's blind
halving already does at the fastest ratio of any variant, for free.
The supremum search paid a wire attempt per linear step; the backoff
variant re-derived halving with a slower constant; the
expected-value ladder degenerates to its top rung under apriori's
flat belief and cannot escape the retry loop bimodal pins itself
into. The conclusion the writeup carries: the reactive split-retry
control flow is not where the evolved routers' edge lives, so the
distillation question moves to plan-time mechanisms.
This commit is contained in:
Olaoluwa Osuntokun 2026-07-27 11:48:24 -07:00
parent 70132442fb
commit 9c07cbe7f6
9 changed files with 920 additions and 12 deletions

View file

@ -127,6 +127,10 @@ type MissionControl struct {
// results that mission control collects.
estimator Estimator
// patch gates the optional bound-aware behaviors. Only SoftUnknown is
// read here.
patch PatchConfig
// onConfigUpdate is a function that is called whenever the
// mission control state is updated.
onConfigUpdate fn.Option[func(cfg *MissionControlConfig)]
@ -202,6 +206,11 @@ type MissionControlConfig struct {
// since the previously recorded failure before the failure amount may
// be raised.
MinFailureRelaxInterval time.Duration
// Patch gates the optional bound-aware behaviors. Only SoftUnknown is
// read here; it is carried as the whole struct so that a node
// configures one section rather than one flag per component.
Patch PatchConfig
}
func (c *MissionControlConfig) validate() error {
@ -418,6 +427,7 @@ func (m *MissionController) initMissionControl(namespace string) (
),
store: store,
estimator: cfg.Estimator,
patch: cfg.Patch,
log: log.WithPrefix(fmt.Sprintf("[%s]:", namespace)),
onConfigUpdate: cfg.OnConfigUpdate,
}
@ -486,6 +496,7 @@ func (m *MissionControl) GetConfig() *MissionControlConfig {
MaxMcHistory: m.store.maxRecords,
McFlushInterval: m.store.flushInterval,
MinFailureRelaxInterval: m.state.minFailureRelaxInterval,
Patch: m.patch,
}
}
@ -509,6 +520,7 @@ func (m *MissionControl) SetConfig(cfg *MissionControlConfig) error {
m.store.maxRecords = cfg.MaxMcHistory
m.state.minFailureRelaxInterval = cfg.MinFailureRelaxInterval
m.estimator = cfg.Estimator
m.patch = cfg.Patch
// Execute the callback function if it is set.
m.onConfigUpdate.WhenSome(func(f func(cfg *MissionControlConfig)) {
@ -543,6 +555,16 @@ func (m *MissionControl) GetProbability(fromNode, toNode route.Vertex,
m.mu.Lock()
defer m.mu.Unlock()
return m.probability(fromNode, toNode, amt, capacity)
}
// probability returns the success probability of a payment from fromNode along
// the edge to toNode.
//
// NOTE: the caller must hold the mission control lock.
func (m *MissionControl) probability(fromNode, toNode route.Vertex,
amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 {
now := m.cfg.clock.Now()
results, _ := m.state.getLastPairResult(fromNode)
@ -669,8 +691,28 @@ func (m *MissionControl) processPaymentResult(result *paymentResult) (
func (m *MissionControl) applyPaymentResult(
result *paymentResult) *paymentsdb.FailureReason {
// Interpret result.
i := interpretResult(&result.route.Val, result.failure.ValOpt())
// Interpret result. With the soft unknown-failure policy enabled we
// hand the interpretation a probability oracle, so that it can pick
// the single least promising hop of a route it cannot attribute a
// failure to.
//
// NOTE: the capacity is passed as zero because mission control has no
// graph access, and deliberately so. The apriori estimator treats a
// zero capacity as "no capacity information" and leaves its estimate
// unscaled, which is what we want: the ordering across the hops of one
// route should come from what we have learned about them.
var hopProbability hopProbabilityFunc
if m.patch.SoftUnknown {
hopProbability = func(from, to route.Vertex,
amt lnwire.MilliSatoshi) float64 {
return m.probability(from, to, amt, 0)
}
}
i := interpretResult(
&result.route.Val, result.failure.ValOpt(), hopProbability,
)
if i.policyFailure != nil {
if m.state.requestSecondChance(

28
routing/patch_config.go Normal file
View file

@ -0,0 +1,28 @@
package routing
// PatchConfig gates two changes to how a payment reacts to the knowledge
// mission control already holds. Both default to false, in which case every
// code path guarded by this struct is exactly the code that shipped before
// it existed.
//
// The two knobs are independent so that they can be ablated separately, but
// they share a motivation. Mission control records a failure as an amount
// bound: "this pair could not carry X". Path finding consults that bound,
// because the estimator gates on the amount it is asked about. Nothing above
// path finding ever asks a different amount, so the bound can only ever be
// used to answer the question the caller already fixed. These two knobs let
// the payment loop ask a better question instead.
type PatchConfig struct {
// AdaptiveSplit replaces the blind halving of the shard amount on a
// no-route result with a search for the largest amount that path
// finding can still route. Every probe of that search is an ordinary
// path finding call, so every probe respects every bound mission
// control holds; no new state is recorded and the estimator is
// untouched.
AdaptiveSplit bool
// SoftUnknown replaces the whole-route penalty applied to a failure
// that could not be attributed to any hop with a penalty on the single
// least promising hop of the attempted route.
SoftUnknown bool
}

View file

@ -0,0 +1,565 @@
package routing
import (
"os"
"testing"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
// patchTestAmt is the amount every split test asks for, chosen well above the
// default minimum shard amount so that there is an interval to search.
const patchTestAmt = lnwire.MilliSatoshi(1_000_000_000)
// newPatchTestSession builds a payment session for an mpp-capable payment of
// patchTestAmt, with the given patch config and mission control.
func newPatchTestSession(t *testing.T, patch PatchConfig,
mc MissionControlQuerier) *paymentSession {
t.Helper()
var paymentAddr [32]byte
payment := &LightningPayment{
Target: route.Vertex{},
Amount: patchTestAmt,
FeeLimit: lnwire.MaxMilliSatoshi,
CltvLimit: 1000,
FinalCLTVDelta: 40,
MaxParts: 16,
PaymentAddr: fn.Some(paymentAddr),
DestFeatures: lnwire.NewFeatureVector(
lnwire.NewRawFeatureVector(
lnwire.TLVOnionPayloadRequired,
lnwire.PaymentAddrOptional,
lnwire.MPPOptional,
), lnwire.Features,
),
}
var paymentHash [32]byte
require.NoError(t, payment.SetPaymentHash(paymentHash))
session, err := newPaymentSession(
payment, route.Vertex{},
func(Graph) (bandwidthHints, error) {
return &mockBandwidthHints{}, nil
},
&sessionGraph{}, mc,
PathFindingConfig{MinProbability: 0.01, Patch: patch},
)
require.NoError(t, err)
return session
}
// patchTestPath is a single hop path, the smallest thing newRoute accepts.
func patchTestPath() []*unifiedEdge {
return []*unifiedEdge{{
policy: &models.CachedEdgePolicy{
ToNodePubKey: func() route.Vertex {
return route.Vertex{1}
},
ToNodeFeatures: lnwire.NewFeatureVector(
lnwire.NewRawFeatureVector(
lnwire.TLVOnionPayloadOptional,
lnwire.PaymentAddrOptional,
), lnwire.Features,
),
},
}}
}
// setCappedPathFinder installs a path finder that routes any amount at or
// below cap with a flat probability, and fails above it. It records every
// amount it was asked about.
func setCappedPathFinder(s *paymentSession,
cap lnwire.MilliSatoshi) *[]lnwire.MilliSatoshi {
return setScoredPathFinder(s, func(
amt lnwire.MilliSatoshi) (float64, bool) {
return 1.0, amt <= cap
})
}
// setScoredPathFinder installs a path finder whose answer and route
// probability are supplied per amount, which is how a test plants a belief for
// the expected value ladder to price.
func setScoredPathFinder(s *paymentSession,
score func(lnwire.MilliSatoshi) (float64, bool)) *[]lnwire.MilliSatoshi {
probes := make([]lnwire.MilliSatoshi, 0)
s.pathFinder = func(_ *graphParams, _ *RestrictParams,
_ *PathFindingConfig, _, _, _ route.Vertex,
amt lnwire.MilliSatoshi, _ float64, _ int32) ([]*unifiedEdge,
float64, error) {
probes = append(probes, amt)
prob, ok := score(amt)
if !ok {
return nil, 0, errNoPathFound
}
return patchTestPath(), prob, nil
}
return &probes
}
// TestAdaptiveSplitDisabled asserts that with the patch off, a no-route result
// still walks the blind halving ladder and yields the same shard it always
// has.
func TestAdaptiveSplitDisabled(t *testing.T) {
t.Parallel()
session := newPatchTestSession(t, PatchConfig{}, &MissionControl{})
probes := setCappedPathFinder(session, 300_000_000)
rt, err := session.RequestRoute(
patchTestAmt, lnwire.MaxMilliSatoshi, 0, 0, nil,
)
require.NoError(t, err)
// The blind policy halves until it fits: 1e9, 5e8, 2.5e8.
require.Equal(t, []lnwire.MilliSatoshi{
1_000_000_000, 500_000_000, 250_000_000,
}, *probes)
require.EqualValues(t, 250_000_000, rt.Hops[0].AmtToForward)
}
// TestAdaptiveSplitLadder asserts the shape of the search: the rungs are the
// fixed fractions of the failing amount, in order, and none is probed twice.
func TestAdaptiveSplitLadder(t *testing.T) {
t.Parallel()
patch := PatchConfig{AdaptiveSplit: true}
session := newPatchTestSession(t, patch, &MissionControl{})
probes := setCappedPathFinder(session, 300_000_000)
_, err := session.RequestRoute(
patchTestAmt, lnwire.MaxMilliSatoshi, 0, 0, nil,
)
require.NoError(t, err)
expected := []lnwire.MilliSatoshi{patchTestAmt}
for _, fraction := range adaptiveSplitLadder {
expected = append(expected, lnwire.MilliSatoshi(
fraction*float64(patchTestAmt),
))
}
require.Equal(t, expected, *probes)
// The budget is the ladder itself, on top of the call that failed.
require.Len(t, *probes, 1+len(adaptiveSplitLadder))
}
// TestAdaptiveSplitArgmax asserts the choice rule: among the routable rungs,
// the shard is the one maximizing fraction times route probability, not the
// largest one.
func TestAdaptiveSplitArgmax(t *testing.T) {
t.Parallel()
patch := PatchConfig{AdaptiveSplit: true}
session := newPatchTestSession(t, patch, &MissionControl{})
// A calibrated belief: the big rungs route but are barely believed,
// the quarter rung is believed. Expected values are 0.75*0.05=0.0375,
// 0.5*0.05=0.025, 0.375*0.05=0.019, 0.25*0.9=0.225, 0.125*0.9=0.1125,
// so the quarter rung wins despite being far from the frontier.
setScoredPathFinder(session, func(
amt lnwire.MilliSatoshi) (float64, bool) {
if amt >= patchTestAmt {
return 0, false
}
if amt > patchTestAmt/4 {
return 0.05, true
}
return 0.9, true
})
rt, err := session.RequestRoute(
patchTestAmt, lnwire.MaxMilliSatoshi, 0, 0, nil,
)
require.NoError(t, err)
require.EqualValues(
t, patchTestAmt/4, rt.Hops[0].AmtToForward,
)
}
// TestAdaptiveSplitFlatBelief is the degeneracy the estimator arm exists to
// test: when every routable amount is believed equally, expected value is
// maximized at the largest rung and the ladder collapses to a fixed geometric
// step.
func TestAdaptiveSplitFlatBelief(t *testing.T) {
t.Parallel()
patch := PatchConfig{AdaptiveSplit: true}
session := newPatchTestSession(t, patch, &MissionControl{})
setCappedPathFinder(session, patchTestAmt-1)
rt, err := session.RequestRoute(
patchTestAmt, lnwire.MaxMilliSatoshi, 0, 0, nil,
)
require.NoError(t, err)
require.EqualValues(t, lnwire.MilliSatoshi(
adaptiveSplitLadder[0]*float64(patchTestAmt),
), rt.Hops[0].AmtToForward)
}
// TestAdaptiveSplitRungFloor asserts that rungs below the minimum shard amount
// are skipped rather than clamped, so no probe is ever spent on an amount we
// would refuse to send.
func TestAdaptiveSplitRungFloor(t *testing.T) {
t.Parallel()
patch := PatchConfig{AdaptiveSplit: true}
session := newPatchTestSession(t, patch, &MissionControl{})
// Ask for an amount whose lower rungs fall under the floor.
const request = lnwire.MilliSatoshi(30_000_000)
probes := setCappedPathFinder(session, request-1)
rt, err := session.RequestRoute(
request, lnwire.MaxMilliSatoshi, 0, 0, nil,
)
require.NoError(t, err)
require.GreaterOrEqual(t, rt.Hops[0].AmtToForward, session.minShardAmt)
for _, probe := range (*probes)[1:] {
require.GreaterOrEqual(t, probe, session.minShardAmt)
}
// 0.25 and 0.125 of 30M fall under the 10M floor, so only three rungs
// are priced.
require.Len(t, *probes, 1+3)
}
// TestAdaptiveSplitNoRungRoutes asserts the fallback: when belief rejects
// every rung, the payment does not abandon, it resumes the blind descent from
// just under the bottom rung and keeps halving toward the floor.
func TestAdaptiveSplitNoRungRoutes(t *testing.T) {
t.Parallel()
patch := PatchConfig{AdaptiveSplit: true}
session := newPatchTestSession(t, patch, &MissionControl{})
probes := setCappedPathFinder(session, 1)
_, err := session.RequestRoute(
patchTestAmt, lnwire.MaxMilliSatoshi, 0, 0, nil,
)
require.ErrorIs(t, err, errNoPathFound)
bottom := lnwire.MilliSatoshi(
adaptiveSplitLadder[len(adaptiveSplitLadder)-1] *
float64(patchTestAmt),
)
// The ladder is priced once and once only, and the descent then
// continues below its bottom rung rather than stopping there.
require.Greater(t, len(*probes), 1+len(adaptiveSplitLadder))
below := (*probes)[1+len(adaptiveSplitLadder):]
require.Equal(t, bottom-1, below[0])
for i, probe := range below {
require.Less(t, probe, bottom)
if i > 0 {
require.Less(t, probe, below[i-1])
}
}
// It stops at the floor, exactly as the blind policy does.
require.GreaterOrEqual(t, below[len(below)-1], session.minShardAmt)
}
// TestAdaptiveSplitFallbackRoutes asserts that a route the fallback finds
// below the ladder is actually used, rather than being discovered and then
// discarded.
func TestAdaptiveSplitFallbackRoutes(t *testing.T) {
t.Parallel()
patch := PatchConfig{AdaptiveSplit: true}
session := newPatchTestSession(t, patch, &MissionControl{})
// Only amounts near the floor route, so every rung is rejected and the
// shard can only come from the tail of the blind descent. Halving
// lands near the floor rather than on it, so the cap is set at twice
// the floor to leave the descent a rung it can take.
cap := 2 * session.minShardAmt
setCappedPathFinder(session, cap)
rt, err := session.RequestRoute(
patchTestAmt, lnwire.MaxMilliSatoshi, 0, 0, nil,
)
require.NoError(t, err)
bottom := lnwire.MilliSatoshi(
adaptiveSplitLadder[len(adaptiveSplitLadder)-1] *
float64(patchTestAmt),
)
shard := rt.Hops[0].AmtToForward
require.Less(t, shard, bottom)
require.LessOrEqual(t, shard, cap)
require.GreaterOrEqual(t, shard, session.minShardAmt)
}
// TestAdaptiveSplitRespectsFailAmt is the load bearing test for part A: the
// probes are ordinary path finding calls, so the amount the search settles on
// is one that mission control's recorded failure bound still permits. Nothing
// teaches the search about the bound; it falls out of asking path finding
// about a smaller amount.
func TestAdaptiveSplitRespectsFailAmt(t *testing.T) {
t.Parallel()
const failAmt = lnwire.MilliSatoshi(400_000_000)
var (
from = route.Vertex{10}
to = route.Vertex{11}
)
mc := newPatchTestMC(t, PatchConfig{})
// Plant a bound: the second pair of this route could not carry
// failAmt, reported by its upstream node as a temporary channel
// failure, which is the ordinary way a liquidity bound is learned.
rt := &route.Route{
SourcePubKey: route.Vertex{9},
TotalAmount: failAmt,
Hops: []*route.Hop{
{PubKeyBytes: from, AmtToForward: failAmt},
{PubKeyBytes: to, AmtToForward: failAmt},
},
}
failIdx := 1
_, err := mc.ReportPaymentFail(
0, rt, &failIdx, lnwire.NewTemporaryChannelFailure(nil),
)
require.NoError(t, err)
require.EqualValues(
t, failAmt, mc.GetPairHistorySnapshot(from, to).FailAmt,
)
patch := PatchConfig{AdaptiveSplit: true}
session := newPatchTestSession(t, patch, mc)
// A miniature of path finding: the only edge to the target is the pair
// we just planted a bound on, and it is only usable while the
// estimator still gives it a chance at the amount asked for.
probes := make([]lnwire.MilliSatoshi, 0)
session.pathFinder = func(_ *graphParams, r *RestrictParams,
cfg *PathFindingConfig, _, _, _ route.Vertex,
amt lnwire.MilliSatoshi, _ float64, _ int32) ([]*unifiedEdge,
float64, error) {
probes = append(probes, amt)
prob := r.ProbabilitySource(from, to, amt, 0)
if prob < cfg.MinProbability {
return nil, 0, errNoPathFound
}
return patchTestPath(), prob, nil
}
found, err := session.RequestRoute(
patchTestAmt, lnwire.MaxMilliSatoshi, 0, 0, nil,
)
require.NoError(t, err)
// The search must land strictly under the planted bound: the estimator
// zeroes the pair at or above failAmt, and passes it below.
shard := found.Hops[0].AmtToForward
require.Less(t, shard, failAmt)
require.GreaterOrEqual(t, shard, session.minShardAmt)
// The bound is what stopped it, not the budget: the rungs above the
// bound were priced and refused, and a rung below it was taken.
require.Len(t, probes, 1+len(adaptiveSplitLadder))
}
// newPatchTestMC builds a real mission control instance on a throwaway db.
func newPatchTestMC(t *testing.T, patch PatchConfig) *MissionControl {
t.Helper()
file, err := os.CreateTemp(t.TempDir(), "*.db")
require.NoError(t, err)
require.NoError(t, file.Close())
db, err := kvdb.Open(
kvdb.BoltBackendName, file.Name(), true,
kvdb.DefaultDBTimeout, false,
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, db.Close())
})
estimator, err := NewAprioriEstimator(AprioriConfig{
PenaltyHalfLife: testPenaltyHalfLife,
AprioriHopProbability: testAprioriHopProbability,
AprioriWeight: testAprioriWeight,
CapacityFraction: testCapacityFraction,
})
require.NoError(t, err)
mcCfg := &MissionControlConfig{Estimator: estimator, Patch: patch}
controller, err := NewMissionController(db, route.Vertex{}, mcCfg)
require.NoError(t, err)
mc, err := controller.GetNamespacedStore(
DefaultMissionControlNamespace,
)
require.NoError(t, err)
return mc
}
// patchTestRoute builds an mcRoute of n hops away from a fixed source, every
// hop forwarding the same amount.
func patchTestRoute(n int, amt lnwire.MilliSatoshi) *mcRoute {
hops := make([]*route.Hop, n)
for i := range hops {
hops[i] = &route.Hop{
ChannelID: uint64(i + 1),
PubKeyBytes: route.Vertex{byte(i + 1)},
AmtToForward: amt,
}
}
return extractMCRoute(&route.Route{
SourcePubKey: route.Vertex{},
TotalAmount: amt,
Hops: hops,
})
}
// TestSoftUnknownDisabled asserts that with the patch off an unattributable
// failure still blacklists every pair of the route in both directions.
func TestSoftUnknownDisabled(t *testing.T) {
t.Parallel()
const amt = lnwire.MilliSatoshi(100_000)
rt := patchTestRoute(3, amt)
i := interpretResult(rt, fn.Some(newPaymentFailure(nil, nil)), nil)
// Three hops, both directions, all at amount zero.
require.Len(t, i.pairResults, 6)
for _, result := range i.pairResults {
require.False(t, result.success)
require.EqualValues(t, 0, result.amt)
}
}
// TestSoftUnknownSinglePair asserts the semantics of part B: exactly one pair
// is penalized, it is the lowest probability hop of the route, the penalty is
// recorded at the amount that hop was asked to forward, and the reverse
// direction is left alone.
func TestSoftUnknownSinglePair(t *testing.T) {
t.Parallel()
const amt = lnwire.MilliSatoshi(100_000)
rt := patchTestRoute(3, amt)
// Make the middle hop the least promising one.
weakest := NewDirectedNodePair(
rt.hops.Val[0].pubKeyBytes.Val, rt.hops.Val[1].pubKeyBytes.Val,
)
probability := func(from, to route.Vertex,
_ lnwire.MilliSatoshi) float64 {
if NewDirectedNodePair(from, to) == weakest {
return 0.1
}
return 0.9
}
i := interpretResult(
rt, fn.Some(newPaymentFailure(nil, nil)), probability,
)
require.Len(t, i.pairResults, 1)
result, ok := i.pairResults[weakest]
require.True(t, ok, "weakest pair not penalized")
require.False(t, result.success)
require.EqualValues(t, amt, result.amt)
// The reverse direction carries no evidence and must not be touched.
_, ok = i.pairResults[weakest.Reverse()]
require.False(t, ok)
// A single hop route keeps the existing node level treatment.
single := interpretResult(
patchTestRoute(1, amt),
fn.Some(newPaymentFailure(nil, nil)), probability,
)
require.NotNil(t, single.nodeFailure)
require.NotNil(t, single.finalFailureReason)
}
// TestSoftUnknownTieBreak asserts that when the estimator cannot separate the
// hops, the penalty goes to the hop furthest from us, the one we know least
// about.
func TestSoftUnknownTieBreak(t *testing.T) {
t.Parallel()
const amt = lnwire.MilliSatoshi(100_000)
rt := patchTestRoute(3, amt)
flat := func(_, _ route.Vertex, _ lnwire.MilliSatoshi) float64 {
return 0.5
}
i := interpretResult(rt, fn.Some(newPaymentFailure(nil, nil)), flat)
last := NewDirectedNodePair(
rt.hops.Val[1].pubKeyBytes.Val, rt.hops.Val[2].pubKeyBytes.Val,
)
require.Len(t, i.pairResults, 1)
require.Contains(t, i.pairResults, last)
}
// TestSoftUnknownEndToEnd drives the policy through a real mission control, to
// confirm the config knob reaches the interpretation and that the recorded
// entry is a bound a smaller retry can route around rather than a blacklist.
func TestSoftUnknownEndToEnd(t *testing.T) {
t.Parallel()
const amt = lnwire.MilliSatoshi(100_000)
mc := newPatchTestMC(t, PatchConfig{SoftUnknown: true})
rt := &route.Route{
SourcePubKey: route.Vertex{},
TotalAmount: amt,
Hops: []*route.Hop{
{PubKeyBytes: route.Vertex{1}, AmtToForward: amt},
{PubKeyBytes: route.Vertex{2}, AmtToForward: amt},
{PubKeyBytes: route.Vertex{3}, AmtToForward: amt},
},
}
// A nil failure source and message is how an unreadable onion error
// arrives.
_, err := mc.ReportPaymentFail(0, rt, nil, nil)
require.NoError(t, err)
snapshot := mc.GetHistorySnapshot()
require.Len(t, snapshot.Pairs, 1)
// The recorded failure amount is the attempt amount, not zero, which
// is what makes it a bound.
require.EqualValues(t, amt, snapshot.Pairs[0].TimedPairResult.FailAmt)
}

View file

@ -504,6 +504,11 @@ type PathFindingConfig struct {
// MinProbability defines the minimum success probability of the
// returned route.
MinProbability float64
// Patch gates the optional bound-aware behaviors. Only AdaptiveSplit
// is read here; it is carried as the whole struct so that a node
// configures one section rather than one flag per component.
Patch PatchConfig
}
// getOutgoingBalance returns the maximum available balance in any of the

View file

@ -75,6 +75,13 @@ var (
DefaultShardMinAmt = lnwire.NewMSatFromSatoshis(10000)
)
// adaptiveSplitLadder is the fixed ladder of fractions of the failing amount
// that the bound-aware split probes, largest first. Its length is the probe
// budget: a no-route result costs at most this many extra path finding calls,
// against the unbounded log2(amt/minShard) halvings the blind policy can
// spend.
var adaptiveSplitLadder = [...]float64{0.75, 0.5, 0.375, 0.25, 0.125}
// Error returns the string representation of the noRouteError.
func (e noRouteError) Error() string {
switch e {
@ -308,7 +315,13 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi,
maxAmt = *p.payment.MaxShardAmt
}
var path []*unifiedEdge
// probability is the success probability path finding assigns to the
// route it returned. Stock lnd discards it here; the bound-aware split
// is the first caller that needs it.
var (
path []*unifiedEdge
probability float64
)
findPath := func(graph graphdb.NodeTraverser) error {
// We'll also obtain a set of bandwidthHints from the lower
// layer for each of our outbound channels. This will allow the
@ -324,7 +337,7 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi,
p.log.Debugf("pathfinding for amt=%v", maxAmt)
// Find a route for the current amount.
path, _, err = p.pathFinder(
path, probability, err = p.pathFinder(
&graphParams{
additionalEdges: p.additionalEdges,
bandwidthHints: bandwidthHints,
@ -343,7 +356,12 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi,
return nil
}
for {
// runPathFinding executes a single path finding call for the current
// value of maxAmt. It splits the two error classes apart: the first
// return value is the unwrapped path finding error, which the caller
// may recover from by trying another amount, while the second is a
// critical error that ends the payment immediately.
runPathFinding := func() (error, error) {
err := p.graphSessFactory.GraphSession(
context.TODO(),
findPath, func() {
@ -360,7 +378,22 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi,
//
//nolint:errorlint
pErr, _ := err.(*pathFindingError)
err = pErr.Unwrap()
return pErr.Unwrap(), nil
}
return nil, nil
}
// ladderSpent records that the bound-aware ladder has already been
// priced once for this route request and found nothing, after which
// the blind policy owns the rest of the descent.
var ladderSpent bool
for {
err, critical := runPathFinding()
if critical != nil {
return nil, critical
}
// Otherwise, we'll switch on the path finding error.
@ -406,6 +439,46 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi,
return nil, errNoPathFound
}
// With the bound-aware policy active, we don't guess at
// the next shard size at all: we price a ladder of
// candidate shards and send the best one.
if p.pathFindingConfig.Patch.AdaptiveSplit &&
!ladderSpent {
found, critical := p.searchShardAmt(
runPathFinding, &maxAmt, &path,
&probability,
)
if critical != nil {
return nil, critical
}
if found {
// The search left maxAmt at the chosen
// shard and path at its route, so
// we're done.
break
}
// Belief rejected every rung. The ladder is a
// fast path over the top of the range, not a
// replacement for the range: hand what is left
// below it back to the blind policy rather
// than abandon a payment that policy could
// still complete. maxAmt now sits just under
// the bottom rung.
ladderSpent = true
if maxAmt < p.minShardAmt {
p.log.Debugf("not splitting because "+
"minimum shard amount %v has "+
"been reached", p.minShardAmt)
return nil, errNoPathFound
}
continue
}
// This is where the magic happens. If we can't find a
// route, try it for half the amount.
maxAmt /= 2
@ -458,6 +531,99 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi,
}
}
// searchShardAmt chooses the next shard by pricing a ladder of candidate
// amounts against path finding, and rewrites amt and path to the winner. It
// reports whether any rung was routable at all; when none was, amt is left
// just under the ladder's bottom rung so that the caller can resume the blind
// descent over the range the ladder does not cover.
//
// This is the inverted question: the blind policy fixes an amount and asks
// whether the graph can carry it, while the ladder asks which of several
// amounts is worth the most. Every rung is an ordinary path finding call, so
// every rung respects every bound mission control holds. No new state, no
// estimator change.
//
// The choice is by expected value, fraction times route probability, which is
// mx_c3's harvest ladder with lnd's own belief as the value model. That makes
// the estimator load bearing rather than incidental: under a miscalibrated
// belief, where everything below the last failure looks equally fine, the
// argmax degenerates to the largest rung and the policy becomes a blind
// geometric descent slower than halving. That degeneracy is measured, not
// hypothetical, which is why the estimator is an arm of the experiment.
//
// NOTE: amt must be an amount that path finding has just rejected, and runPath
// must run path finding for the current value of *amt, leaving its result in
// path and prob.
func (p *paymentSession) searchShardAmt(runPath func() (error, error),
amt *lnwire.MilliSatoshi, path *[]*unifiedEdge,
prob *float64) (bool, error) {
// If the requested amount is already at or below the floor then there
// is nothing left to split off, exactly as in the blind policy. Leave
// the amount under the floor so that the caller stops there too.
if *amt <= p.minShardAmt {
*amt = p.minShardAmt - 1
return false, nil
}
var (
failingAmt = *amt
lowestRung = *amt
bestAmt lnwire.MilliSatoshi
bestPath []*unifiedEdge
bestValue float64
)
for _, fraction := range adaptiveSplitLadder {
rung := lnwire.MilliSatoshi(fraction * float64(failingAmt))
// Rungs under the floor are not shards we are willing to send,
// so they are skipped rather than clamped: clamping would
// price the same amount several times.
if rung < p.minShardAmt {
continue
}
*amt, lowestRung = rung, rung
err, critical := runPath()
if critical != nil {
return false, critical
}
if err != nil {
continue
}
// Ties keep the earlier, larger rung: the ladder descends, so
// this is the amount that completes the payment in fewer
// shards when the value model cannot separate the two.
if value := fraction * *prob; value > bestValue {
bestValue, bestAmt, bestPath = value, rung, *path
}
}
// No rung was routable. Leave the amount just under the bottom rung so
// that the caller can resume the blind descent over the range the
// ladder does not cover.
if bestPath == nil {
*amt = lowestRung - 1
p.log.Debugf("no routable shard on the ladder below failing "+
"amount %v, resuming blind descent at %v", failingAmt,
*amt)
return false, nil
}
p.log.Debugf("bound-aware split: shard %v of failing amount %v, "+
"expected value %v", bestAmt, failingAmt, bestValue)
*amt, *path = bestAmt, bestPath
return true, nil
}
// UpdateAdditionalEdge updates the channel edge policy for a private edge. It
// validates the message signature and checks it's up to date, then applies the
// updates to the supplied policy. It returns a boolean to indicate whether

View file

@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"io"
"math"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
@ -76,15 +77,29 @@ type interpretedResult struct {
// that connection. This is used to control the second chance logic for
// policy failures.
policyFailure *DirectedNodePair
// hopProbability returns the current success probability of forwarding
// amt from one node to another. It is nil unless the soft
// unknown-failure policy is enabled, and is only consulted for a
// failure that could not be attributed to any hop.
hopProbability hopProbabilityFunc
}
// hopProbabilityFunc reports the success probability the estimator currently
// assigns to carrying amt from one node to the next.
type hopProbabilityFunc func(from, to route.Vertex,
amt lnwire.MilliSatoshi) float64
// interpretResult interprets a payment outcome and returns an object that
// contains information required to update mission control.
func interpretResult(rt *mcRoute,
failure fn.Option[paymentFailure]) *interpretedResult {
// contains information required to update mission control. A non-nil
// hopProbability enables the soft unknown-failure policy; passing nil keeps
// the historical interpretation of every outcome.
func interpretResult(rt *mcRoute, failure fn.Option[paymentFailure],
hopProbability hopProbabilityFunc) *interpretedResult {
i := &interpretedResult{
pairResults: make(map[DirectedNodePair]pairResult),
pairResults: make(map[DirectedNodePair]pairResult),
hopProbability: hopProbability,
}
return fn.ElimOption(failure, func() *interpretedResult {
@ -596,12 +611,55 @@ func (i *interpretedResult) processPaymentOutcomeUnknown(route *mcRoute) {
return
}
// With the soft policy active, penalize a single hop instead of the
// whole route.
if i.hopProbability != nil {
i.failWeakestPair(route)
return
}
// Otherwise penalize all channels in the route to make sure the
// responsible node is at least hit too. We even penalize the connection
// to our own peer, because that peer could also be responsible.
i.failPairRange(route, 0, n-1)
}
// failWeakestPair penalizes exactly one hop of an unattributable failure: the
// one the estimator already considers least likely to have carried the amount
// it was asked to carry. Ties go to the hop furthest from us, which is the hop
// we know least about.
//
// The blanket alternative above deletes 2n pairs on the strength of no
// evidence at all, and it is measurably self-destructive: at a 10% unreadable
// error rate it drives lnd's give-up rate from 0.31 to 0.71, because the route
// set is exhausted faster than it can be explored. The routers that handle
// this well learn *nothing* from an unattributable failure, on the grounds
// that a failure nobody claimed is not evidence about anybody. lnd cannot go
// quite that far, because its retry loop needs the next attempt to differ from
// the last one or it will loop; penalizing the single weakest hop at the
// attempt amount is the least it can record while still guaranteeing that
// progress. The amount matters as much as the count: recorded at the attempt
// amount rather than at zero, the entry is a bound that a smaller retry can
// route around instead of a blacklisting.
func (i *interpretedResult) failWeakestPair(rt *mcRoute) {
var (
weakestIdx int
weakestProb = math.Inf(1)
)
for idx := range rt.hops.Val {
pair, amt := getPair(rt, idx)
prob := i.hopProbability(pair.From, pair.To, amt)
if prob <= weakestProb {
weakestIdx, weakestProb = idx, prob
}
}
// Record the failure against that pair alone, in the forward direction
// only, and at the amount we actually tried to push through it.
i.failPairBalance(rt, weakestIdx)
}
// extractMCRoute extracts the fields required by MC from the Route struct to
// create the more minimal mcRoute struct.
func extractMCRoute(r *route.Route) *mcRoute {

View file

@ -738,7 +738,7 @@ func TestResultInterpretation(t *testing.T) {
))
}
i := interpretResult(testCase.route, failure)
i := interpretResult(testCase.route, failure, nil)
expected := testCase.expectedResult

View file

@ -40,6 +40,25 @@ type SimParams struct {
// MinProbability is the minimum success probability a candidate
// route must have to be attempted.
MinProbability float64 `json:"min_probability"`
// Patch enables the bound-aware behaviors distilled from the evolved
// routers into lnd's own payment loop. Both knobs default to off, in
// which case the lnd arm is the stock production stack.
Patch SimPatchParams `json:"patch,omitempty"`
}
// SimPatchParams mirrors PatchConfig in the params JSON.
type SimPatchParams struct {
AdaptiveSplit bool `json:"adaptive_split,omitempty"`
SoftUnknown bool `json:"soft_unknown,omitempty"`
}
// patchConfig converts the params to a PatchConfig.
func (p *SimParams) patchConfig() PatchConfig {
return PatchConfig{
AdaptiveSplit: p.Patch.AdaptiveSplit,
SoftUnknown: p.Patch.SoftUnknown,
}
}
// SimAprioriParams mirrors AprioriConfig in JSON-friendly units.
@ -122,6 +141,7 @@ func (p *SimParams) pathFindingConfig() PathFindingConfig {
),
AttemptCostPPM: p.AttemptCostPPM,
MinProbability: p.MinProbability,
Patch: p.patchConfig(),
}
}
@ -321,7 +341,10 @@ func NewSimRunner(graph *SimGraph, params *SimParams, source route.Vertex,
// Mission control is anchored to the source node so that local
// channels get the distinct local probability estimate, just like on
// a real node.
mcCfg := &MissionControlConfig{Estimator: estimator}
mcCfg := &MissionControlConfig{
Estimator: estimator,
Patch: params.patchConfig(),
}
mcController, err := NewMissionController(db, source, mcCfg)
if err != nil {
cleanup()

View file

@ -0,0 +1,21 @@
{
"estimator": "apriori",
"apriori": {
"penalty_half_life_sec": 3600,
"hop_probability": 0.6,
"weight": 0.5,
"capacity_fraction": 0.9999
},
"bimodal": {
"scale_msat": 300000000,
"node_weight": 0.2,
"decay_time_sec": 604800
},
"attempt_cost_msat": 100000,
"attempt_cost_ppm": 1000,
"min_probability": 0.01,
"patch": {
"adaptive_split": true,
"soft_unknown": true
}
}