From 9c07cbe7f6644a3e5ec8562c2ae074e85c6b7bf4 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Mon, 27 Jul 2026 11:48:24 -0700 Subject: [PATCH] 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. --- routing/missioncontrol.go | 46 ++- routing/patch_config.go | 28 ++ routing/patch_config_test.go | 565 ++++++++++++++++++++++++++ routing/pathfind.go | 5 + routing/payment_session.go | 174 +++++++- routing/result_interpretation.go | 66 ++- routing/result_interpretation_test.go | 2 +- routing/sim_run.go | 25 +- simulation/params_lnd_patch.json | 21 + 9 files changed, 920 insertions(+), 12 deletions(-) create mode 100644 routing/patch_config.go create mode 100644 routing/patch_config_test.go create mode 100644 simulation/params_lnd_patch.json diff --git a/routing/missioncontrol.go b/routing/missioncontrol.go index 80b553233..74b18568d 100644 --- a/routing/missioncontrol.go +++ b/routing/missioncontrol.go @@ -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( diff --git a/routing/patch_config.go b/routing/patch_config.go new file mode 100644 index 000000000..7e9bd0e3c --- /dev/null +++ b/routing/patch_config.go @@ -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 +} diff --git a/routing/patch_config_test.go b/routing/patch_config_test.go new file mode 100644 index 000000000..0f2b48677 --- /dev/null +++ b/routing/patch_config_test.go @@ -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) +} diff --git a/routing/pathfind.go b/routing/pathfind.go index 0507df929..9077fbca3 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -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 diff --git a/routing/payment_session.go b/routing/payment_session.go index 4cddfa2ea..f62cba465 100644 --- a/routing/payment_session.go +++ b/routing/payment_session.go @@ -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 diff --git a/routing/result_interpretation.go b/routing/result_interpretation.go index 713be73ea..e4cdd103f 100644 --- a/routing/result_interpretation.go +++ b/routing/result_interpretation.go @@ -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 { diff --git a/routing/result_interpretation_test.go b/routing/result_interpretation_test.go index 000093b07..82b32e075 100644 --- a/routing/result_interpretation_test.go +++ b/routing/result_interpretation_test.go @@ -738,7 +738,7 @@ func TestResultInterpretation(t *testing.T) { )) } - i := interpretResult(testCase.route, failure) + i := interpretResult(testCase.route, failure, nil) expected := testCase.expectedResult diff --git a/routing/sim_run.go b/routing/sim_run.go index bc51f94f9..706ecef8d 100644 --- a/routing/sim_run.go +++ b/routing/sim_run.go @@ -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() diff --git a/simulation/params_lnd_patch.json b/simulation/params_lnd_patch.json new file mode 100644 index 000000000..b9e23f893 --- /dev/null +++ b/simulation/params_lnd_patch.json @@ -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 + } +}