mirror of
https://github.com/lightningnetwork/lnd.git
synced 2026-08-17 13:06:14 +02:00
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:
parent
70132442fb
commit
9c07cbe7f6
9 changed files with 920 additions and 12 deletions
|
|
@ -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
28
routing/patch_config.go
Normal 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
|
||||
}
|
||||
565
routing/patch_config_test.go
Normal file
565
routing/patch_config_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -738,7 +738,7 @@ func TestResultInterpretation(t *testing.T) {
|
|||
))
|
||||
}
|
||||
|
||||
i := interpretResult(testCase.route, failure)
|
||||
i := interpretResult(testCase.route, failure, nil)
|
||||
|
||||
expected := testCase.expectedResult
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
21
simulation/params_lnd_patch.json
Normal file
21
simulation/params_lnd_patch.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue