mirror of
https://github.com/lightningnetwork/lnd.git
synced 2025-03-13 11:09:23 +01:00
Merge pull request #6815 from bitromortac/2205-bimodal
pathfinding: probability for bimodal distribution
This commit is contained in:
commit
38dc67e1ef
25 changed files with 3237 additions and 963 deletions
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/routing"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
@ -45,26 +46,12 @@ var setCfgCommand = cli.Command{
|
|||
Category: "Mission Control",
|
||||
Usage: "Set mission control's config.",
|
||||
Description: `
|
||||
Update the config values being used by mission control to calculate
|
||||
the probability that payment routes will succeed.
|
||||
`,
|
||||
Update the config values being used by mission control to calculate the
|
||||
probability that payment routes will succeed. The estimator type must be
|
||||
provided to set estimator-related parameters.`,
|
||||
Flags: []cli.Flag{
|
||||
cli.DurationFlag{
|
||||
Name: "halflife",
|
||||
Usage: "the amount of time taken to restore a node " +
|
||||
"or channel to 50% probability of success.",
|
||||
},
|
||||
cli.Float64Flag{
|
||||
Name: "hopprob",
|
||||
Usage: "the probability of success assigned " +
|
||||
"to hops that we have no information about",
|
||||
},
|
||||
cli.Float64Flag{
|
||||
Name: "weight",
|
||||
Usage: "the degree to which mission control should " +
|
||||
"rely on historical results, expressed as " +
|
||||
"value in [0;1]",
|
||||
}, cli.UintFlag{
|
||||
// General settings.
|
||||
cli.UintFlag{
|
||||
Name: "pmtnr",
|
||||
Usage: "the number of payments mission control " +
|
||||
"should store",
|
||||
|
@ -74,6 +61,48 @@ var setCfgCommand = cli.Command{
|
|||
Usage: "the amount of time to wait after a failure " +
|
||||
"before raising failure amount",
|
||||
},
|
||||
// Probability estimator.
|
||||
cli.StringFlag{
|
||||
Name: "estimator",
|
||||
Usage: "the probability estimator to use, choose " +
|
||||
"between 'apriori' or 'bimodal'",
|
||||
},
|
||||
// Apriori config.
|
||||
cli.DurationFlag{
|
||||
Name: "apriorihalflife",
|
||||
Usage: "the amount of time taken to restore a node " +
|
||||
"or channel to 50% probability of success.",
|
||||
},
|
||||
cli.Float64Flag{
|
||||
Name: "apriorihopprob",
|
||||
Usage: "the probability of success assigned " +
|
||||
"to hops that we have no information about",
|
||||
},
|
||||
cli.Float64Flag{
|
||||
Name: "aprioriweight",
|
||||
Usage: "the degree to which mission control should " +
|
||||
"rely on historical results, expressed as " +
|
||||
"value in [0, 1]",
|
||||
},
|
||||
// Bimodal config.
|
||||
cli.DurationFlag{
|
||||
Name: "bimodaldecaytime",
|
||||
Usage: "the time span after which we phase out " +
|
||||
"learnings from previous payment attempts",
|
||||
},
|
||||
cli.Uint64Flag{
|
||||
Name: "bimodalscale",
|
||||
Usage: "controls the assumed channel liquidity " +
|
||||
"imbalance in the network, measured in msat. " +
|
||||
"a low value (compared to typical channel " +
|
||||
"capacity) anticipates unbalanced channels.",
|
||||
},
|
||||
cli.Float64Flag{
|
||||
Name: "bimodalweight",
|
||||
Usage: "controls the degree to which the probability " +
|
||||
"estimator takes into account other channels " +
|
||||
"of a router",
|
||||
},
|
||||
},
|
||||
Action: actionDecorator(setCfg),
|
||||
}
|
||||
|
@ -85,51 +114,140 @@ func setCfg(ctx *cli.Context) error {
|
|||
|
||||
client := routerrpc.NewRouterClient(conn)
|
||||
|
||||
resp, err := client.GetMissionControlConfig(
|
||||
// Fetch current mission control config which we update to create our
|
||||
// response.
|
||||
mcCfg, err := client.GetMissionControlConfig(
|
||||
ctxc, &routerrpc.GetMissionControlConfigRequest{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// haveValue is a helper variable to determine if a flag has been set or
|
||||
// the help should be displayed.
|
||||
var haveValue bool
|
||||
|
||||
if ctx.IsSet("halflife") {
|
||||
haveValue = true
|
||||
resp.Config.HalfLifeSeconds = uint64(ctx.Duration(
|
||||
"halflife",
|
||||
).Seconds())
|
||||
}
|
||||
|
||||
if ctx.IsSet("hopprob") {
|
||||
haveValue = true
|
||||
resp.Config.HopProbability = float32(ctx.Float64("hopprob"))
|
||||
}
|
||||
|
||||
if ctx.IsSet("weight") {
|
||||
haveValue = true
|
||||
resp.Config.Weight = float32(ctx.Float64("weight"))
|
||||
}
|
||||
|
||||
// Handle general mission control settings.
|
||||
if ctx.IsSet("pmtnr") {
|
||||
haveValue = true
|
||||
resp.Config.MaximumPaymentResults = uint32(ctx.Int("pmtnr"))
|
||||
mcCfg.Config.MaximumPaymentResults = uint32(ctx.Int("pmtnr"))
|
||||
}
|
||||
|
||||
if ctx.IsSet("failrelax") {
|
||||
haveValue = true
|
||||
resp.Config.MinimumFailureRelaxInterval = uint64(ctx.Duration(
|
||||
mcCfg.Config.MinimumFailureRelaxInterval = uint64(ctx.Duration(
|
||||
"failrelax",
|
||||
).Seconds())
|
||||
}
|
||||
|
||||
// We switch between estimators and set corresponding configs. If
|
||||
// estimator is not set, we ignore the values.
|
||||
if ctx.IsSet("estimator") {
|
||||
switch ctx.String("estimator") {
|
||||
case routing.AprioriEstimatorName:
|
||||
haveValue = true
|
||||
|
||||
// If we switch from another estimator, initialize with
|
||||
// default values.
|
||||
if mcCfg.Config.Model !=
|
||||
routerrpc.MissionControlConfig_APRIORI {
|
||||
|
||||
dCfg := routing.DefaultAprioriConfig()
|
||||
aParams := &routerrpc.AprioriParameters{
|
||||
HalfLifeSeconds: uint64(
|
||||
dCfg.PenaltyHalfLife.Seconds(),
|
||||
),
|
||||
HopProbability: dCfg.AprioriHopProbability, //nolint:lll
|
||||
Weight: dCfg.AprioriWeight,
|
||||
}
|
||||
|
||||
// We make sure the correct config is set.
|
||||
mcCfg.Config.EstimatorConfig =
|
||||
&routerrpc.MissionControlConfig_Apriori{
|
||||
Apriori: aParams,
|
||||
}
|
||||
}
|
||||
|
||||
// We update all values for the apriori estimator.
|
||||
mcCfg.Config.Model = routerrpc.
|
||||
MissionControlConfig_APRIORI
|
||||
|
||||
aCfg := mcCfg.Config.GetApriori()
|
||||
if ctx.IsSet("apriorihalflife") {
|
||||
aCfg.HalfLifeSeconds = uint64(ctx.Duration(
|
||||
"apriorihalflife",
|
||||
).Seconds())
|
||||
}
|
||||
|
||||
if ctx.IsSet("apriorihopprob") {
|
||||
aCfg.HopProbability = ctx.Float64(
|
||||
"apriorihopprob",
|
||||
)
|
||||
}
|
||||
|
||||
if ctx.IsSet("aprioriweight") {
|
||||
aCfg.Weight = ctx.Float64("aprioriweight")
|
||||
}
|
||||
|
||||
case routing.BimodalEstimatorName:
|
||||
haveValue = true
|
||||
|
||||
// If we switch from another estimator, initialize with
|
||||
// default values.
|
||||
if mcCfg.Config.Model !=
|
||||
routerrpc.MissionControlConfig_BIMODAL {
|
||||
|
||||
dCfg := routing.DefaultBimodalConfig()
|
||||
bParams := &routerrpc.BimodalParameters{
|
||||
DecayTime: uint64(
|
||||
dCfg.BimodalDecayTime.Seconds(),
|
||||
),
|
||||
ScaleMsat: uint64(
|
||||
dCfg.BimodalScaleMsat,
|
||||
),
|
||||
NodeWeight: dCfg.BimodalNodeWeight,
|
||||
}
|
||||
|
||||
// We make sure the correct config is set.
|
||||
mcCfg.Config.EstimatorConfig =
|
||||
&routerrpc.MissionControlConfig_Bimodal{
|
||||
Bimodal: bParams,
|
||||
}
|
||||
}
|
||||
|
||||
// We update all values for the bimodal estimator.
|
||||
mcCfg.Config.Model = routerrpc.
|
||||
MissionControlConfig_BIMODAL
|
||||
|
||||
bCfg := mcCfg.Config.GetBimodal()
|
||||
if ctx.IsSet("bimodaldecaytime") {
|
||||
bCfg.DecayTime = uint64(ctx.Duration(
|
||||
"bimodaldecaytime",
|
||||
).Seconds())
|
||||
}
|
||||
|
||||
if ctx.IsSet("bimodalscale") {
|
||||
bCfg.ScaleMsat = ctx.Uint64("bimodalscale")
|
||||
}
|
||||
|
||||
if ctx.IsSet("bimodalweight") {
|
||||
bCfg.NodeWeight = ctx.Float64(
|
||||
"bimodalweight",
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown estimator %v",
|
||||
ctx.String("estimator"))
|
||||
}
|
||||
}
|
||||
|
||||
if !haveValue {
|
||||
return cli.ShowCommandHelp(ctx, "setmccfg")
|
||||
}
|
||||
|
||||
_, err = client.SetMissionControlConfig(
|
||||
ctxc, &routerrpc.SetMissionControlConfigRequest{
|
||||
Config: resp.Config,
|
||||
Config: mcCfg.Config,
|
||||
},
|
||||
)
|
||||
return err
|
||||
|
|
|
@ -465,6 +465,9 @@ type Config struct {
|
|||
|
||||
// ActiveNetParams contains parameters of the target chain.
|
||||
ActiveNetParams chainreg.BitcoinNetParams
|
||||
|
||||
// Estimator is used to estimate routing probabilities.
|
||||
Estimator routing.Estimator
|
||||
}
|
||||
|
||||
// DefaultConfig returns all default values for the Config struct.
|
||||
|
|
|
@ -408,6 +408,16 @@ in the lnwire package](https://github.com/lightningnetwork/lnd/pull/7303)
|
|||
|
||||
* [Pathfinding takes capacity of edges into account to improve success
|
||||
probability estimation.](https://github.com/lightningnetwork/lnd/pull/6857)
|
||||
* [A new probability model ("bimodal") is added which models channel based
|
||||
liquidities within a probability theory framework.](
|
||||
https://github.com/lightningnetwork/lnd/pull/6815)
|
||||
|
||||
## Configuration
|
||||
* Note that [this pathfinding change](https://github.com/lightningnetwork/lnd/pull/6815)
|
||||
introduces a breaking change in lnd.conf apriori parameters under the routing
|
||||
section, see sample-lnd.conf for an updated configuration. The behavior of
|
||||
`lncli setmccfg/getmccfg` is altered as well.
|
||||
|
||||
|
||||
### Tooling and documentation
|
||||
|
||||
|
@ -465,6 +475,7 @@ refactor the itest for code health and maintenance.
|
|||
* Andras Banki-Horvath
|
||||
* andreihod
|
||||
* Antoni Spaanderman
|
||||
* bitromortac
|
||||
* Carla Kirk-Cohen
|
||||
* Carsten Otto
|
||||
* Chris Geihsler
|
||||
|
|
|
@ -42,14 +42,23 @@ type Config struct {
|
|||
// DefaultConfig defines the config defaults.
|
||||
func DefaultConfig() *Config {
|
||||
defaultRoutingConfig := RoutingConfig{
|
||||
AprioriHopProbability: routing.DefaultAprioriHopProbability,
|
||||
AprioriWeight: routing.DefaultAprioriWeight,
|
||||
MinRouteProbability: routing.DefaultMinRouteProbability,
|
||||
PenaltyHalfLife: routing.DefaultPenaltyHalfLife,
|
||||
AttemptCost: routing.DefaultAttemptCost.ToSatoshis(),
|
||||
AttemptCostPPM: routing.DefaultAttemptCostPPM,
|
||||
MaxMcHistory: routing.DefaultMaxMcHistory,
|
||||
McFlushInterval: routing.DefaultMcFlushInterval,
|
||||
ProbabilityEstimatorType: routing.DefaultEstimator,
|
||||
MinRouteProbability: routing.DefaultMinRouteProbability,
|
||||
|
||||
AttemptCost: routing.DefaultAttemptCost.ToSatoshis(),
|
||||
AttemptCostPPM: routing.DefaultAttemptCostPPM,
|
||||
MaxMcHistory: routing.DefaultMaxMcHistory,
|
||||
McFlushInterval: routing.DefaultMcFlushInterval,
|
||||
AprioriConfig: &AprioriConfig{
|
||||
HopProbability: routing.DefaultAprioriHopProbability,
|
||||
Weight: routing.DefaultAprioriWeight,
|
||||
PenaltyHalfLife: routing.DefaultPenaltyHalfLife,
|
||||
},
|
||||
BimodalConfig: &BimodalConfig{
|
||||
Scale: int64(routing.DefaultBimodalScaleMsat),
|
||||
NodeWeight: routing.DefaultBimodalNodeWeight,
|
||||
DecayTime: routing.DefaultBimodalDecayTime,
|
||||
},
|
||||
}
|
||||
|
||||
return &Config{
|
||||
|
@ -60,13 +69,21 @@ func DefaultConfig() *Config {
|
|||
// GetRoutingConfig returns the routing config based on this sub server config.
|
||||
func GetRoutingConfig(cfg *Config) *RoutingConfig {
|
||||
return &RoutingConfig{
|
||||
AprioriHopProbability: cfg.AprioriHopProbability,
|
||||
AprioriWeight: cfg.AprioriWeight,
|
||||
MinRouteProbability: cfg.MinRouteProbability,
|
||||
AttemptCost: cfg.AttemptCost,
|
||||
AttemptCostPPM: cfg.AttemptCostPPM,
|
||||
PenaltyHalfLife: cfg.PenaltyHalfLife,
|
||||
MaxMcHistory: cfg.MaxMcHistory,
|
||||
McFlushInterval: cfg.McFlushInterval,
|
||||
ProbabilityEstimatorType: cfg.ProbabilityEstimatorType,
|
||||
MinRouteProbability: cfg.MinRouteProbability,
|
||||
AttemptCost: cfg.AttemptCost,
|
||||
AttemptCostPPM: cfg.AttemptCostPPM,
|
||||
MaxMcHistory: cfg.MaxMcHistory,
|
||||
McFlushInterval: cfg.McFlushInterval,
|
||||
AprioriConfig: &AprioriConfig{
|
||||
HopProbability: cfg.AprioriConfig.HopProbability,
|
||||
Weight: cfg.AprioriConfig.Weight,
|
||||
PenaltyHalfLife: cfg.AprioriConfig.PenaltyHalfLife,
|
||||
},
|
||||
BimodalConfig: &BimodalConfig{
|
||||
Scale: cfg.BimodalConfig.Scale,
|
||||
NodeWeight: cfg.BimodalConfig.NodeWeight,
|
||||
DecayTime: cfg.BimodalConfig.DecayTime,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -467,6 +467,93 @@ message SetMissionControlConfigResponse {
|
|||
}
|
||||
|
||||
message MissionControlConfig {
|
||||
/*
|
||||
Deprecated, use AprioriParameters. The amount of time mission control will
|
||||
take to restore a penalized node or channel back to 50% success probability,
|
||||
expressed in seconds. Setting this value to a higher value will penalize
|
||||
failures for longer, making mission control less likely to route through
|
||||
nodes and channels that we have previously recorded failures for.
|
||||
*/
|
||||
uint64 half_life_seconds = 1 [deprecated = true];
|
||||
|
||||
/*
|
||||
Deprecated, use AprioriParameters. The probability of success mission
|
||||
control should assign to hop in a route where it has no other information
|
||||
available. Higher values will make mission control more willing to try hops
|
||||
that we have no information about, lower values will discourage trying these
|
||||
hops.
|
||||
*/
|
||||
float hop_probability = 2 [deprecated = true];
|
||||
|
||||
/*
|
||||
Deprecated, use AprioriParameters. The importance that mission control
|
||||
should place on historical results, expressed as a value in [0;1]. Setting
|
||||
this value to 1 will ignore all historical payments and just use the hop
|
||||
probability to assess the probability of success for each hop. A zero value
|
||||
ignores hop probability completely and relies entirely on historical
|
||||
results, unless none are available.
|
||||
*/
|
||||
float weight = 3 [deprecated = true];
|
||||
|
||||
/*
|
||||
The maximum number of payment results that mission control will store.
|
||||
*/
|
||||
uint32 maximum_payment_results = 4;
|
||||
|
||||
/*
|
||||
The minimum time that must have passed since the previously recorded failure
|
||||
before we raise the failure amount.
|
||||
*/
|
||||
uint64 minimum_failure_relax_interval = 5;
|
||||
|
||||
enum ProbabilityModel {
|
||||
APRIORI = 0;
|
||||
BIMODAL = 1;
|
||||
}
|
||||
|
||||
/*
|
||||
ProbabilityModel defines which probability estimator should be used in
|
||||
pathfinding.
|
||||
*/
|
||||
ProbabilityModel Model = 6;
|
||||
|
||||
/*
|
||||
EstimatorConfig is populated dependent on the estimator type.
|
||||
*/
|
||||
oneof EstimatorConfig {
|
||||
AprioriParameters apriori = 7;
|
||||
BimodalParameters bimodal = 8;
|
||||
}
|
||||
}
|
||||
|
||||
message BimodalParameters {
|
||||
/*
|
||||
NodeWeight defines how strongly other previous forwardings on channels of a
|
||||
router should be taken into account when computing a channel's probability
|
||||
to route. The allowed values are in the range [0, 1], where a value of 0
|
||||
means that only direct information about a channel is taken into account.
|
||||
*/
|
||||
double node_weight = 1;
|
||||
|
||||
/*
|
||||
ScaleMsat describes the scale over which channels statistically have some
|
||||
liquidity left. The value determines how quickly the bimodal distribution
|
||||
drops off from the edges of a channel. A larger value (compared to typical
|
||||
channel capacities) means that the drop off is slow and that channel
|
||||
balances are distributed more uniformly. A small value leads to the
|
||||
assumption of very unbalanced channels.
|
||||
*/
|
||||
uint64 scale_msat = 2;
|
||||
|
||||
/*
|
||||
DecayTime describes the information decay of knowledge about previous
|
||||
successes and failures in channels. The smaller the decay time, the quicker
|
||||
we forget about past forwardings.
|
||||
*/
|
||||
uint64 decay_time = 3;
|
||||
}
|
||||
|
||||
message AprioriParameters {
|
||||
/*
|
||||
The amount of time mission control will take to restore a penalized node
|
||||
or channel back to 50% success probability, expressed in seconds. Setting
|
||||
|
@ -482,7 +569,7 @@ message MissionControlConfig {
|
|||
control more willing to try hops that we have no information about, lower
|
||||
values will discourage trying these hops.
|
||||
*/
|
||||
float hop_probability = 2;
|
||||
double hop_probability = 2;
|
||||
|
||||
/*
|
||||
The importance that mission control should place on historical results,
|
||||
|
@ -492,18 +579,7 @@ message MissionControlConfig {
|
|||
completely and relies entirely on historical results, unless none are
|
||||
available.
|
||||
*/
|
||||
float weight = 3;
|
||||
|
||||
/*
|
||||
The maximum number of payment results that mission control will store.
|
||||
*/
|
||||
uint32 maximum_payment_results = 4;
|
||||
|
||||
/*
|
||||
The minimum time that must have passed since the previously recorded failure
|
||||
before we raise the failure amount.
|
||||
*/
|
||||
uint64 minimum_failure_relax_interval = 5;
|
||||
double weight = 3;
|
||||
}
|
||||
|
||||
message QueryProbabilityRequest {
|
||||
|
|
|
@ -593,6 +593,14 @@
|
|||
],
|
||||
"default": "IN_FLIGHT"
|
||||
},
|
||||
"MissionControlConfigProbabilityModel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"APRIORI",
|
||||
"BIMODAL"
|
||||
],
|
||||
"default": "APRIORI"
|
||||
},
|
||||
"lnrpcAMPRecord": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -1073,6 +1081,46 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"routerrpcAprioriParameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"half_life_seconds": {
|
||||
"type": "string",
|
||||
"format": "uint64",
|
||||
"description": "The amount of time mission control will take to restore a penalized node\nor channel back to 50% success probability, expressed in seconds. Setting\nthis value to a higher value will penalize failures for longer, making\nmission control less likely to route through nodes and channels that we\nhave previously recorded failures for."
|
||||
},
|
||||
"hop_probability": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "The probability of success mission control should assign to hop in a route\nwhere it has no other information available. Higher values will make mission\ncontrol more willing to try hops that we have no information about, lower\nvalues will discourage trying these hops."
|
||||
},
|
||||
"weight": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "The importance that mission control should place on historical results,\nexpressed as a value in [0;1]. Setting this value to 1 will ignore all\nhistorical payments and just use the hop probability to assess the\nprobability of success for each hop. A zero value ignores hop probability\ncompletely and relies entirely on historical results, unless none are\navailable."
|
||||
}
|
||||
}
|
||||
},
|
||||
"routerrpcBimodalParameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node_weight": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "NodeWeight defines how strongly other previous forwardings on channels of a\nrouter should be taken into account when computing a channel's probability\nto route. The allowed values are in the range [0, 1], where a value of 0\nmeans that only direct information about a channel is taken into account."
|
||||
},
|
||||
"scale_msat": {
|
||||
"type": "string",
|
||||
"format": "uint64",
|
||||
"description": "ScaleMsat describes the scale over which channels statistically have some\nliquidity left. The value determines how quickly the bimodal distribution\ndrops off from the edges of a channel. A larger value (compared to typical\nchannel capacities) means that the drop off is slow and that channel\nbalances are distributed more uniformly. A small value leads to the\nassumption of very unbalanced channels."
|
||||
},
|
||||
"decay_time": {
|
||||
"type": "string",
|
||||
"format": "uint64",
|
||||
"description": "DecayTime describes the information decay of knowledge about previous\nsuccesses and failures in channels. The smaller the decay time, the quicker\nwe forget about past forwardings."
|
||||
}
|
||||
}
|
||||
},
|
||||
"routerrpcBuildRouteRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -1400,17 +1448,17 @@
|
|||
"half_life_seconds": {
|
||||
"type": "string",
|
||||
"format": "uint64",
|
||||
"description": "The amount of time mission control will take to restore a penalized node\nor channel back to 50% success probability, expressed in seconds. Setting\nthis value to a higher value will penalize failures for longer, making\nmission control less likely to route through nodes and channels that we\nhave previously recorded failures for."
|
||||
"description": "Deprecated, use AprioriParameters. The amount of time mission control will\ntake to restore a penalized node or channel back to 50% success probability,\nexpressed in seconds. Setting this value to a higher value will penalize\nfailures for longer, making mission control less likely to route through\nnodes and channels that we have previously recorded failures for."
|
||||
},
|
||||
"hop_probability": {
|
||||
"type": "number",
|
||||
"format": "float",
|
||||
"description": "The probability of success mission control should assign to hop in a route\nwhere it has no other information available. Higher values will make mission\ncontrol more willing to try hops that we have no information about, lower\nvalues will discourage trying these hops."
|
||||
"description": "Deprecated, use AprioriParameters. The probability of success mission\ncontrol should assign to hop in a route where it has no other information\navailable. Higher values will make mission control more willing to try hops\nthat we have no information about, lower values will discourage trying these\nhops."
|
||||
},
|
||||
"weight": {
|
||||
"type": "number",
|
||||
"format": "float",
|
||||
"description": "The importance that mission control should place on historical results,\nexpressed as a value in [0;1]. Setting this value to 1 will ignore all\nhistorical payments and just use the hop probability to assess the\nprobability of success for each hop. A zero value ignores hop probability\ncompletely and relies entirely on historical results, unless none are\navailable."
|
||||
"description": "Deprecated, use AprioriParameters. The importance that mission control\nshould place on historical results, expressed as a value in [0;1]. Setting\nthis value to 1 will ignore all historical payments and just use the hop\nprobability to assess the probability of success for each hop. A zero value\nignores hop probability completely and relies entirely on historical\nresults, unless none are available."
|
||||
},
|
||||
"maximum_payment_results": {
|
||||
"type": "integer",
|
||||
|
@ -1421,6 +1469,16 @@
|
|||
"type": "string",
|
||||
"format": "uint64",
|
||||
"description": "The minimum time that must have passed since the previously recorded failure\nbefore we raise the failure amount."
|
||||
},
|
||||
"Model": {
|
||||
"$ref": "#/definitions/MissionControlConfigProbabilityModel",
|
||||
"description": "ProbabilityModel defines which probability estimator should be used in\npathfinding."
|
||||
},
|
||||
"apriori": {
|
||||
"$ref": "#/definitions/routerrpcAprioriParameters"
|
||||
},
|
||||
"bimodal": {
|
||||
"$ref": "#/definitions/routerrpcBimodalParameters"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -454,39 +454,137 @@ func (s *Server) GetMissionControlConfig(ctx context.Context,
|
|||
req *GetMissionControlConfigRequest) (*GetMissionControlConfigResponse,
|
||||
error) {
|
||||
|
||||
// Query the current mission control config.
|
||||
cfg := s.cfg.RouterBackend.MissionControl.GetConfig()
|
||||
return &GetMissionControlConfigResponse{
|
||||
resp := &GetMissionControlConfigResponse{
|
||||
Config: &MissionControlConfig{
|
||||
HalfLifeSeconds: uint64(cfg.PenaltyHalfLife.Seconds()),
|
||||
HopProbability: float32(cfg.AprioriHopProbability),
|
||||
Weight: float32(cfg.AprioriWeight),
|
||||
MaximumPaymentResults: uint32(cfg.MaxMcHistory),
|
||||
MinimumFailureRelaxInterval: uint64(cfg.MinFailureRelaxInterval.Seconds()),
|
||||
MaximumPaymentResults: uint32(cfg.MaxMcHistory),
|
||||
MinimumFailureRelaxInterval: uint64(
|
||||
cfg.MinFailureRelaxInterval.Seconds(),
|
||||
),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// We only populate fields based on the current estimator.
|
||||
switch v := cfg.Estimator.Config().(type) {
|
||||
case routing.AprioriConfig:
|
||||
resp.Config.Model = MissionControlConfig_APRIORI
|
||||
aCfg := AprioriParameters{
|
||||
HalfLifeSeconds: uint64(v.PenaltyHalfLife.Seconds()),
|
||||
HopProbability: v.AprioriHopProbability,
|
||||
Weight: v.AprioriWeight,
|
||||
}
|
||||
|
||||
// Populate deprecated fields.
|
||||
resp.Config.HalfLifeSeconds = uint64(
|
||||
v.PenaltyHalfLife.Seconds(),
|
||||
)
|
||||
resp.Config.HopProbability = float32(v.AprioriHopProbability)
|
||||
resp.Config.Weight = float32(v.AprioriWeight)
|
||||
|
||||
resp.Config.EstimatorConfig = &MissionControlConfig_Apriori{
|
||||
Apriori: &aCfg,
|
||||
}
|
||||
|
||||
case routing.BimodalConfig:
|
||||
resp.Config.Model = MissionControlConfig_BIMODAL
|
||||
bCfg := BimodalParameters{
|
||||
NodeWeight: v.BimodalNodeWeight,
|
||||
ScaleMsat: uint64(v.BimodalScaleMsat),
|
||||
DecayTime: uint64(v.BimodalDecayTime.Seconds()),
|
||||
}
|
||||
|
||||
resp.Config.EstimatorConfig = &MissionControlConfig_Bimodal{
|
||||
Bimodal: &bCfg,
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown estimator config type %T", v)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// SetMissionControlConfig returns our current mission control config.
|
||||
// SetMissionControlConfig sets parameters in the mission control config.
|
||||
func (s *Server) SetMissionControlConfig(ctx context.Context,
|
||||
req *SetMissionControlConfigRequest) (*SetMissionControlConfigResponse,
|
||||
error) {
|
||||
|
||||
cfg := &routing.MissionControlConfig{
|
||||
ProbabilityEstimatorCfg: routing.ProbabilityEstimatorCfg{
|
||||
PenaltyHalfLife: time.Duration(
|
||||
req.Config.HalfLifeSeconds,
|
||||
) * time.Second,
|
||||
AprioriHopProbability: float64(req.Config.HopProbability),
|
||||
AprioriWeight: float64(req.Config.Weight),
|
||||
},
|
||||
mcCfg := &routing.MissionControlConfig{
|
||||
MaxMcHistory: int(req.Config.MaximumPaymentResults),
|
||||
MinFailureRelaxInterval: time.Duration(
|
||||
req.Config.MinimumFailureRelaxInterval,
|
||||
) * time.Second,
|
||||
}
|
||||
|
||||
switch req.Config.Model {
|
||||
case MissionControlConfig_APRIORI:
|
||||
var aprioriConfig routing.AprioriConfig
|
||||
|
||||
// Determine the apriori config with backward compatibility
|
||||
// should the api use deprecated fields.
|
||||
switch v := req.Config.EstimatorConfig.(type) {
|
||||
case *MissionControlConfig_Bimodal:
|
||||
return nil, fmt.Errorf("bimodal config " +
|
||||
"provided, but apriori model requested")
|
||||
|
||||
case *MissionControlConfig_Apriori:
|
||||
aprioriConfig = routing.AprioriConfig{
|
||||
PenaltyHalfLife: time.Duration(
|
||||
v.Apriori.HalfLifeSeconds,
|
||||
) * time.Second,
|
||||
AprioriHopProbability: v.Apriori.HopProbability,
|
||||
AprioriWeight: v.Apriori.Weight,
|
||||
}
|
||||
|
||||
default:
|
||||
aprioriConfig = routing.AprioriConfig{
|
||||
PenaltyHalfLife: time.Duration(
|
||||
int64(req.Config.HalfLifeSeconds),
|
||||
) * time.Second,
|
||||
AprioriHopProbability: float64(
|
||||
req.Config.HopProbability,
|
||||
),
|
||||
AprioriWeight: float64(req.Config.Weight),
|
||||
}
|
||||
}
|
||||
|
||||
estimator, err := routing.NewAprioriEstimator(aprioriConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcCfg.Estimator = estimator
|
||||
|
||||
case MissionControlConfig_BIMODAL:
|
||||
cfg, ok := req.Config.
|
||||
EstimatorConfig.(*MissionControlConfig_Bimodal)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("bimodal estimator requested " +
|
||||
"but corresponding config not set")
|
||||
}
|
||||
bCfg := cfg.Bimodal
|
||||
|
||||
bimodalConfig := routing.BimodalConfig{
|
||||
BimodalDecayTime: time.Duration(
|
||||
bCfg.DecayTime,
|
||||
) * time.Second,
|
||||
BimodalScaleMsat: lnwire.MilliSatoshi(bCfg.ScaleMsat),
|
||||
BimodalNodeWeight: bCfg.NodeWeight,
|
||||
}
|
||||
|
||||
estimator, err := routing.NewBimodalEstimator(bimodalConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcCfg.Estimator = estimator
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown estimator type %v",
|
||||
req.Config.Model)
|
||||
}
|
||||
|
||||
return &SetMissionControlConfigResponse{},
|
||||
s.cfg.RouterBackend.MissionControl.SetConfig(cfg)
|
||||
s.cfg.RouterBackend.MissionControl.SetConfig(mcCfg)
|
||||
}
|
||||
|
||||
// QueryMissionControl exposes the internal mission control state to callers. It
|
||||
|
|
|
@ -7,28 +7,16 @@ import (
|
|||
)
|
||||
|
||||
// RoutingConfig contains the configurable parameters that control routing.
|
||||
//
|
||||
//nolint:lll
|
||||
type RoutingConfig struct {
|
||||
// ProbabilityEstimatorType sets the estimator to use.
|
||||
ProbabilityEstimatorType string `long:"estimator" choice:"apriori" choice:"bimodal" description:"Probability estimator used for pathfinding." `
|
||||
|
||||
// MinRouteProbability is the minimum required route success probability
|
||||
// to attempt the payment.
|
||||
MinRouteProbability float64 `long:"minrtprob" description:"Minimum required route success probability to attempt the payment"`
|
||||
|
||||
// AprioriHopProbability is the assumed success probability of a hop in
|
||||
// a route when no other information is available.
|
||||
AprioriHopProbability float64 `long:"apriorihopprob" description:"Assumed success probability of a hop in a route when no other information is available."`
|
||||
|
||||
// AprioriWeight is a value in the range [0, 1] that defines to what
|
||||
// extent historical results should be extrapolated to untried
|
||||
// connections. Setting it to one will completely ignore historical
|
||||
// results and always assume the configured a priori probability for
|
||||
// untried connections. A value of zero will ignore the a priori
|
||||
// probability completely and only base the probability on historical
|
||||
// results, unless there are none available.
|
||||
AprioriWeight float64 `long:"aprioriweight" description:"Weight of the a priori probability in success probability estimation. Valid values are in [0, 1]."`
|
||||
|
||||
// PenaltyHalfLife defines after how much time a penalized node or
|
||||
// channel is back at 50% probability.
|
||||
PenaltyHalfLife time.Duration `long:"penaltyhalflife" description:"Defines the duration after which a penalized node or channel is back at 50% probability"`
|
||||
|
||||
// AttemptCost is the fixed virtual cost in path finding of a failed
|
||||
// payment attempt. It is used to trade off potentially better routes
|
||||
// against their probability of succeeding.
|
||||
|
@ -47,4 +35,51 @@ type RoutingConfig struct {
|
|||
// McFlushInterval defines the timer interval to use to flush mission
|
||||
// control state to the DB.
|
||||
McFlushInterval time.Duration `long:"mcflushinterval" description:"the timer interval to use to flush mission control state to the DB"`
|
||||
|
||||
// AprioriConfig defines parameters for the apriori probability.
|
||||
AprioriConfig *AprioriConfig `group:"apriori" namespace:"apriori" description:"configuration for the apriori pathfinding probability estimator"`
|
||||
|
||||
// BimodalConfig defines parameters for the bimodal probability.
|
||||
BimodalConfig *BimodalConfig `group:"bimodal" namespace:"bimodal" description:"configuration for the bimodal pathfinding probability estimator"`
|
||||
}
|
||||
|
||||
// AprioriConfig defines parameters for the apriori probability.
|
||||
//
|
||||
//nolint:lll
|
||||
type AprioriConfig struct {
|
||||
// HopProbability is the assumed success probability of a hop in a route
|
||||
// when no other information is available.
|
||||
HopProbability float64 `long:"hopprob" description:"Assumed success probability of a hop in a route when no other information is available."`
|
||||
|
||||
// Weight is a value in the range [0, 1] that defines to what extent
|
||||
// historical results should be extrapolated to untried connections.
|
||||
// Setting it to one will completely ignore historical results and
|
||||
// always assume the configured a priori probability for untried
|
||||
// connections. A value of zero will ignore the a priori probability
|
||||
// completely and only base the probability on historical results,
|
||||
// unless there are none available.
|
||||
Weight float64 `long:"weight" description:"Weight of the a priori probability in success probability estimation. Valid values are in [0, 1]."`
|
||||
|
||||
// PenaltyHalfLife defines after how much time a penalized node or
|
||||
// channel is back at 50% probability.
|
||||
PenaltyHalfLife time.Duration `long:"penaltyhalflife" description:"Defines the duration after which a penalized node or channel is back at 50% probability"`
|
||||
}
|
||||
|
||||
// BimodalConfig defines parameters for the bimodal probability.
|
||||
//
|
||||
//nolint:lll
|
||||
type BimodalConfig struct {
|
||||
// Scale describes the scale over which channels still have some
|
||||
// liquidity left on both channel ends. A value of 0 means that we
|
||||
// assume perfectly unbalanced channels, a very high value means
|
||||
// randomly balanced channels.
|
||||
Scale int64 `long:"scale" description:"Defines the unbalancedness assumed for the network, the amount defined in msat."`
|
||||
|
||||
// NodeWeight defines how strongly non-routed channels should be taken
|
||||
// into account for probability estimation. Valid values are in [0,1].
|
||||
NodeWeight float64 `long:"nodeweight" description:"Defines how strongly non-routed channels should be taken into account for probability estimation. Valid values are in [0, 1]."`
|
||||
|
||||
// DecayTime is the scale for the exponential information decay over
|
||||
// time for previous successes or failures.
|
||||
DecayTime time.Duration `long:"decaytime" description:"Describes the information decay of knowledge about previous successes and failures in channels."`
|
||||
}
|
||||
|
|
|
@ -86,6 +86,20 @@ func (h *HarnessRPC) SetMissionControlConfig(
|
|||
h.NoError(err, "SetMissionControlConfig")
|
||||
}
|
||||
|
||||
// SetMissionControlConfigAssertErr makes a RPC call to the node's
|
||||
// SetMissionControlConfig and asserts that we error.
|
||||
func (h *HarnessRPC) SetMissionControlConfigAssertErr(
|
||||
config *routerrpc.MissionControlConfig) {
|
||||
|
||||
ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
req := &routerrpc.SetMissionControlConfigRequest{Config: config}
|
||||
_, err := h.Router.SetMissionControlConfig(ctxt, req)
|
||||
require.Error(h, err, "expect an error from setting import mission "+
|
||||
"control")
|
||||
}
|
||||
|
||||
// ResetMissionControl makes a RPC call to the node's ResetMissionControl and
|
||||
// asserts.
|
||||
func (h *HarnessRPC) ResetMissionControl() {
|
||||
|
|
|
@ -1003,25 +1003,85 @@ func testQueryRoutes(ht *lntemp.HarnessTest) {
|
|||
func testMissionControlCfg(t *testing.T, hn *node.HarnessNode) {
|
||||
t.Helper()
|
||||
|
||||
startCfg := hn.RPC.GetMissionControlConfig()
|
||||
// Getting and setting does not alter the configuration.
|
||||
startCfg := hn.RPC.GetMissionControlConfig().Config
|
||||
hn.RPC.SetMissionControlConfig(startCfg)
|
||||
resp := hn.RPC.GetMissionControlConfig()
|
||||
require.True(t, proto.Equal(startCfg, resp.Config))
|
||||
|
||||
// We test that setting and getting leads to the same config if all
|
||||
// fields are set.
|
||||
cfg := &routerrpc.MissionControlConfig{
|
||||
HalfLifeSeconds: 8000,
|
||||
HopProbability: 0.8,
|
||||
Weight: 0.3,
|
||||
MaximumPaymentResults: 30,
|
||||
MinimumFailureRelaxInterval: 60,
|
||||
Model: routerrpc.
|
||||
MissionControlConfig_APRIORI,
|
||||
EstimatorConfig: &routerrpc.MissionControlConfig_Apriori{
|
||||
Apriori: &routerrpc.AprioriParameters{
|
||||
HalfLifeSeconds: 8000,
|
||||
HopProbability: 0.8,
|
||||
Weight: 0.3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
hn.RPC.SetMissionControlConfig(cfg)
|
||||
|
||||
resp := hn.RPC.GetMissionControlConfig()
|
||||
require.True(t, proto.Equal(cfg, resp.Config))
|
||||
// The deprecated fields should be populated.
|
||||
cfg.HalfLifeSeconds = 8000
|
||||
cfg.HopProbability = 0.8
|
||||
cfg.Weight = 0.3
|
||||
respCfg := hn.RPC.GetMissionControlConfig().Config
|
||||
require.True(t, proto.Equal(cfg, respCfg))
|
||||
|
||||
hn.RPC.SetMissionControlConfig(startCfg.Config)
|
||||
// Switching to another estimator is possible.
|
||||
cfg = &routerrpc.MissionControlConfig{
|
||||
Model: routerrpc.
|
||||
MissionControlConfig_BIMODAL,
|
||||
EstimatorConfig: &routerrpc.MissionControlConfig_Bimodal{
|
||||
Bimodal: &routerrpc.BimodalParameters{
|
||||
ScaleMsat: 1_000,
|
||||
DecayTime: 500,
|
||||
},
|
||||
},
|
||||
}
|
||||
hn.RPC.SetMissionControlConfig(cfg)
|
||||
respCfg = hn.RPC.GetMissionControlConfig().Config
|
||||
require.NotNil(t, respCfg.GetBimodal())
|
||||
|
||||
// If parameters are not set in the request, they will have zero values
|
||||
// after.
|
||||
require.Zero(t, respCfg.MaximumPaymentResults)
|
||||
require.Zero(t, respCfg.MinimumFailureRelaxInterval)
|
||||
require.Zero(t, respCfg.GetBimodal().NodeWeight)
|
||||
|
||||
// Setting deprecated values will initialize the apriori estimator.
|
||||
cfg = &routerrpc.MissionControlConfig{
|
||||
MaximumPaymentResults: 30,
|
||||
MinimumFailureRelaxInterval: 60,
|
||||
HopProbability: 0.8,
|
||||
Weight: 0.3,
|
||||
HalfLifeSeconds: 8000,
|
||||
}
|
||||
hn.RPC.SetMissionControlConfig(cfg)
|
||||
respCfg = hn.RPC.GetMissionControlConfig().Config
|
||||
require.NotNil(t, respCfg.GetApriori())
|
||||
|
||||
// Setting the wrong config results in an error.
|
||||
cfg = &routerrpc.MissionControlConfig{
|
||||
Model: routerrpc.
|
||||
MissionControlConfig_APRIORI,
|
||||
EstimatorConfig: &routerrpc.MissionControlConfig_Bimodal{
|
||||
Bimodal: &routerrpc.BimodalParameters{
|
||||
ScaleMsat: 1_000,
|
||||
},
|
||||
},
|
||||
}
|
||||
hn.RPC.SetMissionControlConfigAssertErr(cfg)
|
||||
|
||||
// Undo any changes.
|
||||
hn.RPC.SetMissionControlConfig(startCfg)
|
||||
resp = hn.RPC.GetMissionControlConfig()
|
||||
require.True(t, proto.Equal(startCfg.Config, resp.Config))
|
||||
require.True(t, proto.Equal(startCfg, resp.Config))
|
||||
}
|
||||
|
||||
// testMissionControlImport tests import of mission control results from an
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/lightningnetwork/lnd/zpay32"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -68,6 +69,14 @@ func newIntegratedRoutingContext(t *testing.T) *integratedRoutingContext {
|
|||
// defaults would break the unit tests. The actual values picked aren't
|
||||
// critical to excite certain behavior, but do need to be aligned with
|
||||
// the test case assertions.
|
||||
aCfg := AprioriConfig{
|
||||
PenaltyHalfLife: 30 * time.Minute,
|
||||
AprioriHopProbability: 0.6,
|
||||
AprioriWeight: 0.5,
|
||||
}
|
||||
estimator, err := NewAprioriEstimator(aCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := integratedRoutingContext{
|
||||
t: t,
|
||||
graph: graph,
|
||||
|
@ -75,11 +84,7 @@ func newIntegratedRoutingContext(t *testing.T) *integratedRoutingContext {
|
|||
finalExpiry: 40,
|
||||
|
||||
mcCfg: MissionControlConfig{
|
||||
ProbabilityEstimatorCfg: ProbabilityEstimatorCfg{
|
||||
PenaltyHalfLife: 30 * time.Minute,
|
||||
AprioriHopProbability: 0.6,
|
||||
AprioriWeight: 0.5,
|
||||
},
|
||||
Estimator: estimator,
|
||||
},
|
||||
|
||||
pathFindingCfg: PathFindingConfig{
|
||||
|
|
|
@ -67,7 +67,10 @@ func TestProbabilityExtrapolation(t *testing.T) {
|
|||
// If we use a static value for the node probability (no extrapolation
|
||||
// of data from other channels), all ten bad channels will be tried
|
||||
// first before switching to the paid channel.
|
||||
ctx.mcCfg.AprioriWeight = 1
|
||||
estimator, ok := ctx.mcCfg.Estimator.(*AprioriEstimator)
|
||||
if ok {
|
||||
estimator.AprioriWeight = 1
|
||||
}
|
||||
attempts, err = ctx.testPayment(1)
|
||||
require.NoError(t, err, "payment failed")
|
||||
if len(attempts) != 11 {
|
||||
|
|
|
@ -103,7 +103,7 @@ type MissionControl struct {
|
|||
|
||||
// estimator is the probability estimator that is used with the payment
|
||||
// results that mission control collects.
|
||||
estimator *probabilityEstimator
|
||||
estimator Estimator
|
||||
|
||||
sync.Mutex
|
||||
|
||||
|
@ -116,9 +116,8 @@ type MissionControl struct {
|
|||
// MissionControlConfig defines parameters that control mission control
|
||||
// behaviour.
|
||||
type MissionControlConfig struct {
|
||||
// ProbabilityEstimatorConfig is the config we will use for probability
|
||||
// calculations.
|
||||
ProbabilityEstimatorCfg
|
||||
// Estimator gives probability estimates for node pairs.
|
||||
Estimator Estimator
|
||||
|
||||
// MaxMcHistory defines the maximum number of payment results that are
|
||||
// held on disk.
|
||||
|
@ -135,10 +134,6 @@ type MissionControlConfig struct {
|
|||
}
|
||||
|
||||
func (c *MissionControlConfig) validate() error {
|
||||
if err := c.ProbabilityEstimatorCfg.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.MaxMcHistory < 0 {
|
||||
return ErrInvalidMcHistory
|
||||
}
|
||||
|
@ -152,11 +147,8 @@ func (c *MissionControlConfig) validate() error {
|
|||
|
||||
// String returns a string representation of a mission control config.
|
||||
func (c *MissionControlConfig) String() string {
|
||||
return fmt.Sprintf("Penalty Half Life: %v, Apriori Hop "+
|
||||
"Probablity: %v, Maximum History: %v, Apriori Weight: %v, "+
|
||||
"Minimum Failure Relax Interval: %v", c.PenaltyHalfLife,
|
||||
c.AprioriHopProbability, c.MaxMcHistory, c.AprioriWeight,
|
||||
c.MinFailureRelaxInterval)
|
||||
return fmt.Sprintf("maximum history: %v, minimum failure relax "+
|
||||
"interval: %v", c.MaxMcHistory, c.MinFailureRelaxInterval)
|
||||
}
|
||||
|
||||
// TimedPairResult describes a timestamped pair result.
|
||||
|
@ -212,7 +204,8 @@ type paymentResult struct {
|
|||
func NewMissionControl(db kvdb.Backend, self route.Vertex,
|
||||
cfg *MissionControlConfig) (*MissionControl, error) {
|
||||
|
||||
log.Debugf("Instantiating mission control with config: %v", cfg)
|
||||
log.Debugf("Instantiating mission control with config: %v, %v", cfg,
|
||||
cfg.Estimator)
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
|
@ -225,17 +218,12 @@ func NewMissionControl(db kvdb.Backend, self route.Vertex,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
estimator := &probabilityEstimator{
|
||||
ProbabilityEstimatorCfg: cfg.ProbabilityEstimatorCfg,
|
||||
prevSuccessProbability: prevSuccessProbability,
|
||||
}
|
||||
|
||||
mc := &MissionControl{
|
||||
state: newMissionControlState(cfg.MinFailureRelaxInterval),
|
||||
now: time.Now,
|
||||
selfNode: self,
|
||||
store: store,
|
||||
estimator: estimator,
|
||||
estimator: cfg.Estimator,
|
||||
}
|
||||
|
||||
if err := mc.init(); err != nil {
|
||||
|
@ -284,7 +272,7 @@ func (m *MissionControl) GetConfig() *MissionControlConfig {
|
|||
defer m.Unlock()
|
||||
|
||||
return &MissionControlConfig{
|
||||
ProbabilityEstimatorCfg: m.estimator.ProbabilityEstimatorCfg,
|
||||
Estimator: m.estimator,
|
||||
MaxMcHistory: m.store.maxRecords,
|
||||
McFlushInterval: m.store.flushInterval,
|
||||
MinFailureRelaxInterval: m.state.minFailureRelaxInterval,
|
||||
|
@ -305,11 +293,12 @@ func (m *MissionControl) SetConfig(cfg *MissionControlConfig) error {
|
|||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
log.Infof("Updating mission control cfg: %v", cfg)
|
||||
log.Infof("Active mission control cfg: %v, estimator: %v", cfg,
|
||||
cfg.Estimator)
|
||||
|
||||
m.store.maxRecords = cfg.MaxMcHistory
|
||||
m.state.minFailureRelaxInterval = cfg.MinFailureRelaxInterval
|
||||
m.estimator.ProbabilityEstimatorCfg = cfg.ProbabilityEstimatorCfg
|
||||
m.estimator = cfg.Estimator
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -344,10 +333,10 @@ func (m *MissionControl) GetProbability(fromNode, toNode route.Vertex,
|
|||
|
||||
// Use a distinct probability estimation function for local channels.
|
||||
if fromNode == m.selfNode {
|
||||
return m.estimator.getLocalPairProbability(now, results, toNode)
|
||||
return m.estimator.LocalPairProbability(now, results, toNode)
|
||||
}
|
||||
|
||||
return m.estimator.getPairProbability(
|
||||
return m.estimator.PairProbability(
|
||||
now, results, toNode, amt, capacity,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -90,15 +90,17 @@ func (ctx *mcTestContext) restartMc() {
|
|||
require.NoError(ctx.t, ctx.mc.store.storeResults())
|
||||
}
|
||||
|
||||
aCfg := AprioriConfig{
|
||||
PenaltyHalfLife: testPenaltyHalfLife,
|
||||
AprioriHopProbability: testAprioriHopProbability,
|
||||
AprioriWeight: testAprioriWeight,
|
||||
}
|
||||
estimator, err := NewAprioriEstimator(aCfg)
|
||||
require.NoError(ctx.t, err)
|
||||
|
||||
mc, err := NewMissionControl(
|
||||
ctx.db, mcTestSelf,
|
||||
&MissionControlConfig{
|
||||
ProbabilityEstimatorCfg: ProbabilityEstimatorCfg{
|
||||
PenaltyHalfLife: testPenaltyHalfLife,
|
||||
AprioriHopProbability: testAprioriHopProbability,
|
||||
AprioriWeight: testAprioriWeight,
|
||||
},
|
||||
},
|
||||
&MissionControlConfig{Estimator: estimator},
|
||||
)
|
||||
if err != nil {
|
||||
ctx.t.Fatal(err)
|
||||
|
|
|
@ -46,6 +46,10 @@ type pathFinder = func(g *graphParams, r *RestrictParams,
|
|||
[]*channeldb.CachedEdgePolicy, float64, error)
|
||||
|
||||
var (
|
||||
// DefaultEstimator is the default estimator used for computing
|
||||
// probabilities in pathfinding.
|
||||
DefaultEstimator = AprioriEstimatorName
|
||||
|
||||
// DefaultAttemptCost is the default fixed virtual cost in path finding
|
||||
// of a failed payment attempt. It is used to trade off potentially
|
||||
// better routes against their probability of succeeding.
|
||||
|
|
346
routing/probability_apriori.go
Normal file
346
routing/probability_apriori.go
Normal file
|
@ -0,0 +1,346 @@
|
|||
package routing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
)
|
||||
|
||||
const (
|
||||
// capacityCutoffFraction and capacitySmearingFraction define how
|
||||
// capacity-related probability reweighting works.
|
||||
// capacityCutoffFraction defines the fraction of the channel capacity
|
||||
// at which the effect roughly sets in and capacitySmearingFraction
|
||||
// defines over which range the factor changes from 1 to 0.
|
||||
//
|
||||
// We may fall below the minimum required probability
|
||||
// (DefaultMinRouteProbability) when the amount comes close to the
|
||||
// available capacity of a single channel of the route in case of no
|
||||
// prior knowledge about the channels. We want such routes still to be
|
||||
// available and therefore a probability reduction should not completely
|
||||
// drop the total probability below DefaultMinRouteProbability.
|
||||
// For this to hold for a three-hop route we require:
|
||||
// (DefaultAprioriHopProbability)^3 * minCapacityFactor >
|
||||
// DefaultMinRouteProbability
|
||||
//
|
||||
// For DefaultAprioriHopProbability = 0.6 and
|
||||
// DefaultMinRouteProbability = 0.01 this results in
|
||||
// minCapacityFactor ~ 0.05. The following combination of parameters
|
||||
// fulfill the requirement with capacityFactor(cap, cap) ~ 0.076 (see
|
||||
// tests).
|
||||
|
||||
// The capacityCutoffFraction is a trade-off between usage of the
|
||||
// provided capacity and expected probability reduction when we send the
|
||||
// full amount. The success probability in the random balance model can
|
||||
// be approximated with P(a) = 1 - a/c, for amount a and capacity c. If
|
||||
// we require a probability P(a) > 0.25, this translates into a value of
|
||||
// 0.75 for a/c.
|
||||
capacityCutoffFraction = 0.75
|
||||
|
||||
// We don't want to have a sharp drop of the capacity factor to zero at
|
||||
// capacityCutoffFraction, but a smooth smearing such that some residual
|
||||
// probability is left when spending the whole amount, see above.
|
||||
capacitySmearingFraction = 0.1
|
||||
|
||||
// AprioriEstimatorName is used to identify the apriori probability
|
||||
// estimator.
|
||||
AprioriEstimatorName = "apriori"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidHalflife is returned when we get an invalid half life.
|
||||
ErrInvalidHalflife = errors.New("penalty half life must be >= 0")
|
||||
|
||||
// ErrInvalidHopProbability is returned when we get an invalid hop
|
||||
// probability.
|
||||
ErrInvalidHopProbability = errors.New("hop probability must be in " +
|
||||
"[0, 1]")
|
||||
|
||||
// ErrInvalidAprioriWeight is returned when we get an apriori weight
|
||||
// that is out of range.
|
||||
ErrInvalidAprioriWeight = errors.New("apriori weight must be in [0, 1]")
|
||||
)
|
||||
|
||||
// AprioriConfig contains configuration for our probability estimator.
|
||||
type AprioriConfig struct {
|
||||
// PenaltyHalfLife defines after how much time a penalized node or
|
||||
// channel is back at 50% probability.
|
||||
PenaltyHalfLife time.Duration
|
||||
|
||||
// AprioriHopProbability is the assumed success probability of a hop in
|
||||
// a route when no other information is available.
|
||||
AprioriHopProbability float64
|
||||
|
||||
// AprioriWeight is a value in the range [0, 1] that defines to what
|
||||
// extent historical results should be extrapolated to untried
|
||||
// connections. Setting it to one will completely ignore historical
|
||||
// results and always assume the configured a priori probability for
|
||||
// untried connections. A value of zero will ignore the a priori
|
||||
// probability completely and only base the probability on historical
|
||||
// results, unless there are none available.
|
||||
AprioriWeight float64
|
||||
}
|
||||
|
||||
// validate checks the configuration of the estimator for allowed values.
|
||||
func (p AprioriConfig) validate() error {
|
||||
if p.PenaltyHalfLife < 0 {
|
||||
return ErrInvalidHalflife
|
||||
}
|
||||
|
||||
if p.AprioriHopProbability < 0 || p.AprioriHopProbability > 1 {
|
||||
return ErrInvalidHopProbability
|
||||
}
|
||||
|
||||
if p.AprioriWeight < 0 || p.AprioriWeight > 1 {
|
||||
return ErrInvalidAprioriWeight
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultAprioriConfig returns the default configuration for the estimator.
|
||||
func DefaultAprioriConfig() AprioriConfig {
|
||||
return AprioriConfig{
|
||||
PenaltyHalfLife: DefaultPenaltyHalfLife,
|
||||
AprioriHopProbability: DefaultAprioriHopProbability,
|
||||
AprioriWeight: DefaultAprioriWeight,
|
||||
}
|
||||
}
|
||||
|
||||
// AprioriEstimator returns node and pair probabilities based on historical
|
||||
// payment results. It uses a preconfigured success probability value for
|
||||
// untried hops (AprioriHopProbability) and returns a high success probability
|
||||
// for hops that could previously conduct a payment (prevSuccessProbability).
|
||||
// Successful edges are retried until proven otherwise. Recently failed hops are
|
||||
// penalized by an exponential time decay (PenaltyHalfLife), after which they
|
||||
// are reconsidered for routing. If information was learned about a forwarding
|
||||
// node, the information is taken into account to estimate a per node
|
||||
// probability that mixes with the a priori probability (AprioriWeight).
|
||||
type AprioriEstimator struct {
|
||||
// AprioriConfig contains configuration options for our estimator.
|
||||
AprioriConfig
|
||||
|
||||
// prevSuccessProbability is the assumed probability for node pairs that
|
||||
// successfully relayed the previous attempt.
|
||||
prevSuccessProbability float64
|
||||
}
|
||||
|
||||
// NewAprioriEstimator creates a new AprioriEstimator.
|
||||
func NewAprioriEstimator(cfg AprioriConfig) (*AprioriEstimator, error) {
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AprioriEstimator{
|
||||
AprioriConfig: cfg,
|
||||
prevSuccessProbability: prevSuccessProbability,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Compile-time checks that interfaces are implemented.
|
||||
var _ Estimator = (*AprioriEstimator)(nil)
|
||||
var _ estimatorConfig = (*AprioriConfig)(nil)
|
||||
|
||||
// Config returns the estimator's configuration.
|
||||
func (p *AprioriEstimator) Config() estimatorConfig {
|
||||
return p.AprioriConfig
|
||||
}
|
||||
|
||||
// String returns the estimator's configuration as a string representation.
|
||||
func (p *AprioriEstimator) String() string {
|
||||
return fmt.Sprintf("estimator type: %v, penalty halflife time: %v, "+
|
||||
"apriori hop probability: %v, apriori weight: %v, previous "+
|
||||
"success probability: %v", AprioriEstimatorName,
|
||||
p.PenaltyHalfLife, p.AprioriHopProbability, p.AprioriWeight,
|
||||
p.prevSuccessProbability)
|
||||
}
|
||||
|
||||
// getNodeProbability calculates the probability for connections from a node
|
||||
// that have not been tried before. The results parameter is a list of last
|
||||
// payment results for that node.
|
||||
func (p *AprioriEstimator) getNodeProbability(now time.Time,
|
||||
results NodeResults, amt lnwire.MilliSatoshi,
|
||||
capacity btcutil.Amount) float64 {
|
||||
|
||||
// We reduce the apriori hop probability if the amount comes close to
|
||||
// the capacity.
|
||||
apriori := p.AprioriHopProbability * capacityFactor(amt, capacity)
|
||||
|
||||
// If the channel history is not to be taken into account, we can return
|
||||
// early here with the configured a priori probability.
|
||||
if p.AprioriWeight == 1 {
|
||||
return apriori
|
||||
}
|
||||
|
||||
// If there is no channel history, our best estimate is still the a
|
||||
// priori probability.
|
||||
if len(results) == 0 {
|
||||
return apriori
|
||||
}
|
||||
|
||||
// The value of the apriori weight is in the range [0, 1]. Convert it to
|
||||
// a factor that properly expresses the intention of the weight in the
|
||||
// following weight average calculation. When the apriori weight is 0,
|
||||
// the apriori factor is also 0. This means it won't have any effect on
|
||||
// the weighted average calculation below. When the apriori weight
|
||||
// approaches 1, the apriori factor goes to infinity. It will heavily
|
||||
// outweigh any observations that have been collected.
|
||||
aprioriFactor := 1/(1-p.AprioriWeight) - 1
|
||||
|
||||
// Calculate a weighted average consisting of the apriori probability
|
||||
// and historical observations. This is the part that incentivizes nodes
|
||||
// to make sure that all (not just some) of their channels are in good
|
||||
// shape. Senders will steer around nodes that have shown a few
|
||||
// failures, even though there may be many channels still untried.
|
||||
//
|
||||
// If there is just a single observation and the apriori weight is 0,
|
||||
// this single observation will totally determine the node probability.
|
||||
// The node probability is returned for all other channels of the node.
|
||||
// This means that one failure will lead to the success probability
|
||||
// estimates for all other channels being 0 too. The probability for the
|
||||
// channel that was tried will not even recover, because it is
|
||||
// recovering to the node probability (which is zero). So one failure
|
||||
// effectively prunes all channels of the node forever. This is the most
|
||||
// aggressive way in which we can penalize nodes and unlikely to yield
|
||||
// good results in a real network.
|
||||
probabilitiesTotal := apriori * aprioriFactor
|
||||
totalWeight := aprioriFactor
|
||||
|
||||
for _, result := range results {
|
||||
switch {
|
||||
// Weigh success with a constant high weight of 1. There is no
|
||||
// decay. Amt is never zero, so this clause is never executed
|
||||
// when result.SuccessAmt is zero.
|
||||
case amt <= result.SuccessAmt:
|
||||
totalWeight++
|
||||
probabilitiesTotal += p.prevSuccessProbability
|
||||
|
||||
// Weigh failures in accordance with their age. The base
|
||||
// probability of a failure is considered zero, so nothing needs
|
||||
// to be added to probabilitiesTotal.
|
||||
case !result.FailTime.IsZero() && amt >= result.FailAmt:
|
||||
age := now.Sub(result.FailTime)
|
||||
totalWeight += p.getWeight(age)
|
||||
}
|
||||
}
|
||||
|
||||
return probabilitiesTotal / totalWeight
|
||||
}
|
||||
|
||||
// getWeight calculates a weight in the range [0, 1] that should be assigned to
|
||||
// a payment result. Weight follows an exponential curve that starts at 1 when
|
||||
// the result is fresh and asymptotically approaches zero over time. The rate at
|
||||
// which this happens is controlled by the penaltyHalfLife parameter.
|
||||
func (p *AprioriEstimator) getWeight(age time.Duration) float64 {
|
||||
exp := -age.Hours() / p.PenaltyHalfLife.Hours()
|
||||
return math.Pow(2, exp)
|
||||
}
|
||||
|
||||
// capacityFactor is a multiplier that can be used to reduce the probability
|
||||
// depending on how much of the capacity is sent. The limits are 1 for amt == 0
|
||||
// and 0 for amt >> cutoffMsat. The function drops significantly when amt
|
||||
// reaches cutoffMsat. smearingMsat determines over which scale the reduction
|
||||
// takes place.
|
||||
func capacityFactor(amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 {
|
||||
// If we don't have information about the capacity, which can be the
|
||||
// case for hop hints or local channels, we return unity to not alter
|
||||
// anything.
|
||||
if capacity == 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
capMsat := float64(lnwire.NewMSatFromSatoshis(capacity))
|
||||
amtMsat := float64(amt)
|
||||
|
||||
if amtMsat > capMsat {
|
||||
return 0
|
||||
}
|
||||
|
||||
cutoffMsat := capacityCutoffFraction * capMsat
|
||||
smearingMsat := capacitySmearingFraction * capMsat
|
||||
|
||||
// We compute a logistic function mirrored around the y axis, centered
|
||||
// at cutoffMsat, decaying over the smearingMsat scale.
|
||||
denominator := 1 + math.Exp(-(amtMsat-cutoffMsat)/smearingMsat)
|
||||
|
||||
return 1 - 1/denominator
|
||||
}
|
||||
|
||||
// PairProbability estimates the probability of successfully traversing to
|
||||
// toNode based on historical payment outcomes for the from node. Those outcomes
|
||||
// are passed in via the results parameter.
|
||||
func (p *AprioriEstimator) PairProbability(now time.Time,
|
||||
results NodeResults, toNode route.Vertex, amt lnwire.MilliSatoshi,
|
||||
capacity btcutil.Amount) float64 {
|
||||
|
||||
nodeProbability := p.getNodeProbability(now, results, amt, capacity)
|
||||
|
||||
return p.calculateProbability(
|
||||
now, results, nodeProbability, toNode, amt,
|
||||
)
|
||||
}
|
||||
|
||||
// LocalPairProbability estimates the probability of successfully traversing
|
||||
// our own local channels to toNode.
|
||||
func (p *AprioriEstimator) LocalPairProbability(
|
||||
now time.Time, results NodeResults, toNode route.Vertex) float64 {
|
||||
|
||||
// For local channels that have never been tried before, we assume them
|
||||
// to be successful. We have accurate balance and online status
|
||||
// information on our own channels, so when we select them in a route it
|
||||
// is close to certain that those channels will work.
|
||||
nodeProbability := p.prevSuccessProbability
|
||||
|
||||
return p.calculateProbability(
|
||||
now, results, nodeProbability, toNode, lnwire.MaxMilliSatoshi,
|
||||
)
|
||||
}
|
||||
|
||||
// calculateProbability estimates the probability of successfully traversing to
|
||||
// toNode based on historical payment outcomes and a fall-back node probability.
|
||||
func (p *AprioriEstimator) calculateProbability(
|
||||
now time.Time, results NodeResults,
|
||||
nodeProbability float64, toNode route.Vertex,
|
||||
amt lnwire.MilliSatoshi) float64 {
|
||||
|
||||
// Retrieve the last pair outcome.
|
||||
lastPairResult, ok := results[toNode]
|
||||
|
||||
// If there is no history for this pair, return the node probability
|
||||
// that is a probability estimate for untried channel.
|
||||
if !ok {
|
||||
return nodeProbability
|
||||
}
|
||||
|
||||
// For successes, we have a fixed (high) probability. Those pairs will
|
||||
// be assumed good until proven otherwise. Amt is never zero, so this
|
||||
// clause is never executed when lastPairResult.SuccessAmt is zero.
|
||||
if amt <= lastPairResult.SuccessAmt {
|
||||
return p.prevSuccessProbability
|
||||
}
|
||||
|
||||
// Take into account a minimum penalize amount. For balance errors, a
|
||||
// failure may be reported with such a minimum to prevent too aggressive
|
||||
// penalization. If the current amount is smaller than the amount that
|
||||
// previously triggered a failure, we act as if this is an untried
|
||||
// channel.
|
||||
if lastPairResult.FailTime.IsZero() || amt < lastPairResult.FailAmt {
|
||||
return nodeProbability
|
||||
}
|
||||
|
||||
timeSinceLastFailure := now.Sub(lastPairResult.FailTime)
|
||||
|
||||
// Calculate success probability based on the weight of the last
|
||||
// failure. When the failure is fresh, its weight is 1 and we'll return
|
||||
// probability 0. Over time the probability recovers to the node
|
||||
// probability. It would be as if this channel was never tried before.
|
||||
weight := p.getWeight(timeSinceLastFailure)
|
||||
probability := nodeProbability * (1 - weight)
|
||||
|
||||
return probability
|
||||
}
|
289
routing/probability_apriori_test.go
Normal file
289
routing/probability_apriori_test.go
Normal file
|
@ -0,0 +1,289 @@
|
|||
package routing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
// Define node identifiers.
|
||||
node1 = 1
|
||||
node2 = 2
|
||||
node3 = 3
|
||||
|
||||
// untriedNode is a node id for which we don't record any results in
|
||||
// this test. This can be used to assert the probability for untried
|
||||
// ndoes.
|
||||
untriedNode = 255
|
||||
|
||||
// Define test estimator parameters.
|
||||
aprioriHopProb = 0.6
|
||||
aprioriWeight = 0.75
|
||||
aprioriPrevSucProb = 0.95
|
||||
|
||||
// testCapacity is used to define a capacity for some channels.
|
||||
testCapacity = btcutil.Amount(100_000)
|
||||
testAmount = lnwire.MilliSatoshi(50_000_000)
|
||||
|
||||
// Defines the capacityFactor for testAmount and testCapacity.
|
||||
capFactor = 0.9241
|
||||
)
|
||||
|
||||
type estimatorTestContext struct {
|
||||
t *testing.T
|
||||
estimator *AprioriEstimator
|
||||
|
||||
// results contains a list of last results. Every element in the list
|
||||
// corresponds to the last result towards a node. The list index equals
|
||||
// the node id. So the first element in the list is the result towards
|
||||
// node 0.
|
||||
results map[int]TimedPairResult
|
||||
}
|
||||
|
||||
func newEstimatorTestContext(t *testing.T) *estimatorTestContext {
|
||||
return &estimatorTestContext{
|
||||
t: t,
|
||||
estimator: &AprioriEstimator{
|
||||
AprioriConfig: AprioriConfig{
|
||||
AprioriHopProbability: aprioriHopProb,
|
||||
AprioriWeight: aprioriWeight,
|
||||
PenaltyHalfLife: time.Hour,
|
||||
},
|
||||
prevSuccessProbability: aprioriPrevSucProb,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// assertPairProbability asserts that the calculated success probability is
|
||||
// correct.
|
||||
func (c *estimatorTestContext) assertPairProbability(now time.Time,
|
||||
toNode byte, amt lnwire.MilliSatoshi, capacity btcutil.Amount,
|
||||
expectedProb float64) {
|
||||
|
||||
c.t.Helper()
|
||||
|
||||
results := make(NodeResults)
|
||||
for i, r := range c.results {
|
||||
results[route.Vertex{byte(i)}] = r
|
||||
}
|
||||
|
||||
const tolerance = 0.01
|
||||
|
||||
p := c.estimator.PairProbability(
|
||||
now, results, route.Vertex{toNode}, amt, capacity,
|
||||
)
|
||||
diff := p - expectedProb
|
||||
if diff > tolerance || diff < -tolerance {
|
||||
c.t.Fatalf("expected probability %v for node %v, but got %v",
|
||||
expectedProb, toNode, p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbabilityEstimatorNoResults tests the probability estimation when no
|
||||
// results are available.
|
||||
func TestProbabilityEstimatorNoResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := newEstimatorTestContext(t)
|
||||
|
||||
// A zero amount does not trigger capacity rescaling.
|
||||
ctx.assertPairProbability(
|
||||
testTime, 0, 0, testCapacity, aprioriHopProb,
|
||||
)
|
||||
|
||||
// We expect a reduced probability when a higher amount is used.
|
||||
expected := aprioriHopProb * capFactor
|
||||
ctx.assertPairProbability(
|
||||
testTime, 0, testAmount, testCapacity, expected,
|
||||
)
|
||||
}
|
||||
|
||||
// TestProbabilityEstimatorOneSuccess tests the probability estimation for nodes
|
||||
// that have a single success result.
|
||||
func TestProbabilityEstimatorOneSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := newEstimatorTestContext(t)
|
||||
|
||||
ctx.results = map[int]TimedPairResult{
|
||||
node1: {
|
||||
SuccessAmt: testAmount,
|
||||
},
|
||||
}
|
||||
|
||||
// Because of the previous success, this channel keep reporting a high
|
||||
// probability.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node1, 100, testCapacity, aprioriPrevSucProb,
|
||||
)
|
||||
|
||||
// The apriori success probability indicates that in the past we were
|
||||
// able to send the full amount. We don't want to reduce this
|
||||
// probability with the capacity factor, which is tested here.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node1, testAmount, testCapacity, aprioriPrevSucProb,
|
||||
)
|
||||
|
||||
// Untried channels are also influenced by the success. With a
|
||||
// aprioriWeight of 0.75, the a priori probability is assigned weight 3.
|
||||
expectedP := (3*aprioriHopProb + 1*aprioriPrevSucProb) / 4
|
||||
ctx.assertPairProbability(
|
||||
testTime, untriedNode, 100, testCapacity, expectedP,
|
||||
)
|
||||
|
||||
// Check that the correct probability is computed for larger amounts.
|
||||
apriori := aprioriHopProb * capFactor
|
||||
|
||||
expectedP = (3*apriori + 1*aprioriPrevSucProb) / 4
|
||||
ctx.assertPairProbability(
|
||||
testTime, untriedNode, testAmount, testCapacity, expectedP,
|
||||
)
|
||||
}
|
||||
|
||||
// TestProbabilityEstimatorOneFailure tests the probability estimation for nodes
|
||||
// that have a single failure.
|
||||
func TestProbabilityEstimatorOneFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := newEstimatorTestContext(t)
|
||||
|
||||
ctx.results = map[int]TimedPairResult{
|
||||
node1: {
|
||||
FailTime: testTime.Add(-time.Hour),
|
||||
FailAmt: lnwire.MilliSatoshi(50),
|
||||
},
|
||||
}
|
||||
|
||||
// For an untried node, we expected the node probability. The weight for
|
||||
// the failure after one hour is 0.5. This makes the node probability
|
||||
// 0.51:
|
||||
expectedNodeProb := (3*aprioriHopProb + 0.5*0) / 3.5
|
||||
ctx.assertPairProbability(
|
||||
testTime, untriedNode, 100, testCapacity, expectedNodeProb,
|
||||
)
|
||||
|
||||
// The pair probability decays back to the node probability. With the
|
||||
// weight at 0.5, we expected a pair probability of 0.5 * 0.51 = 0.25.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node1, 100, testCapacity, expectedNodeProb/2,
|
||||
)
|
||||
}
|
||||
|
||||
// TestProbabilityEstimatorMix tests the probability estimation for nodes for
|
||||
// which a mix of successes and failures is recorded.
|
||||
func TestProbabilityEstimatorMix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := newEstimatorTestContext(t)
|
||||
|
||||
ctx.results = map[int]TimedPairResult{
|
||||
node1: {
|
||||
SuccessAmt: lnwire.MilliSatoshi(1000),
|
||||
},
|
||||
node2: {
|
||||
FailTime: testTime.Add(-2 * time.Hour),
|
||||
FailAmt: lnwire.MilliSatoshi(50),
|
||||
},
|
||||
node3: {
|
||||
FailTime: testTime.Add(-3 * time.Hour),
|
||||
FailAmt: lnwire.MilliSatoshi(50),
|
||||
},
|
||||
}
|
||||
|
||||
// We expect the probability for a previously successful channel to
|
||||
// remain high.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node1, 100, testCapacity, prevSuccessProbability,
|
||||
)
|
||||
|
||||
// For an untried node, we expected the node probability to be returned.
|
||||
// This is a weighted average of the results above and the a priori
|
||||
// probability: 0.62.
|
||||
expectedNodeProb := (3*aprioriHopProb + 1*prevSuccessProbability) /
|
||||
(3 + 1 + 0.25 + 0.125)
|
||||
|
||||
ctx.assertPairProbability(
|
||||
testTime, untriedNode, 100, testCapacity, expectedNodeProb,
|
||||
)
|
||||
|
||||
// For the previously failed connection with node 1, we expect 0.75 *
|
||||
// the node probability = 0.47.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node2, 100, testCapacity, expectedNodeProb*0.75,
|
||||
)
|
||||
}
|
||||
|
||||
// TestCapacityCutoff tests the mathematical expression and limits for the
|
||||
// capacity factor.
|
||||
func TestCapacityCutoff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
capacitySat := 1_000_000
|
||||
capacityMSat := capacitySat * 1000
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
amountMsat int
|
||||
expectedFactor float64
|
||||
}{
|
||||
{
|
||||
name: "zero amount",
|
||||
expectedFactor: 1,
|
||||
},
|
||||
{
|
||||
name: "low amount",
|
||||
amountMsat: capacityMSat / 10,
|
||||
expectedFactor: 0.998,
|
||||
},
|
||||
{
|
||||
name: "half amount",
|
||||
amountMsat: capacityMSat / 2,
|
||||
expectedFactor: 0.924,
|
||||
},
|
||||
{
|
||||
name: "cutoff amount",
|
||||
amountMsat: int(
|
||||
capacityCutoffFraction * float64(capacityMSat),
|
||||
),
|
||||
expectedFactor: 0.5,
|
||||
},
|
||||
{
|
||||
name: "high amount",
|
||||
amountMsat: capacityMSat * 80 / 100,
|
||||
expectedFactor: 0.377,
|
||||
},
|
||||
{
|
||||
// Even when we spend the full capacity, we still want
|
||||
// to have some residual probability to not throw away
|
||||
// routes due to a min probability requirement of the
|
||||
// whole path.
|
||||
name: "full amount",
|
||||
amountMsat: capacityMSat,
|
||||
expectedFactor: 0.076,
|
||||
},
|
||||
{
|
||||
name: "more than capacity",
|
||||
amountMsat: capacityMSat + 1,
|
||||
expectedFactor: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := capacityFactor(
|
||||
lnwire.MilliSatoshi(test.amountMsat),
|
||||
btcutil.Amount(capacitySat),
|
||||
)
|
||||
require.InDelta(t, test.expectedFactor, got, 0.001)
|
||||
})
|
||||
}
|
||||
}
|
536
routing/probability_bimodal.go
Normal file
536
routing/probability_bimodal.go
Normal file
|
@ -0,0 +1,536 @@
|
|||
package routing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBimodalScaleMsat is the default value for BimodalScaleMsat in
|
||||
// BimodalConfig. It describes the distribution of funds in the LN based
|
||||
// on empirical findings. We assume an unbalanced network by default.
|
||||
DefaultBimodalScaleMsat = lnwire.MilliSatoshi(300_000_000)
|
||||
|
||||
// DefaultBimodalNodeWeight is the default value for the
|
||||
// BimodalNodeWeight in BimodalConfig. It is chosen such that past
|
||||
// forwardings on other channels of a router are only slightly taken
|
||||
// into account.
|
||||
DefaultBimodalNodeWeight = 0.2
|
||||
|
||||
// DefaultBimodalDecayTime is the default value for BimodalDecayTime.
|
||||
// We will forget about previous learnings about channel liquidity on
|
||||
// the timescale of about a week.
|
||||
DefaultBimodalDecayTime = 7 * 24 * time.Hour
|
||||
|
||||
// BimodalScaleMsatLimit is the maximum value for BimodalScaleMsat to
|
||||
// avoid numerical issues.
|
||||
BimodalScaleMsatMax = lnwire.MilliSatoshi(21e17)
|
||||
|
||||
// BimodalEstimatorName is used to identify the bimodal estimator.
|
||||
BimodalEstimatorName = "bimodal"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidScale is returned when we get a scale below or equal zero.
|
||||
ErrInvalidScale = errors.New("scale must be >= 0 and sane")
|
||||
|
||||
// ErrInvalidNodeWeight is returned when we get a node weight that is
|
||||
// out of range.
|
||||
ErrInvalidNodeWeight = errors.New("node weight must be in [0, 1]")
|
||||
|
||||
// ErrInvalidDecayTime is returned when we get a decay time below zero.
|
||||
ErrInvalidDecayTime = errors.New("decay time must be larger than zero")
|
||||
)
|
||||
|
||||
// BimodalConfig contains configuration for our probability estimator.
|
||||
type BimodalConfig struct {
|
||||
// BimodalNodeWeight defines how strongly other previous forwardings on
|
||||
// channels of a router should be taken into account when computing a
|
||||
// channel's probability to route. The allowed values are in the range
|
||||
// [0, 1], where a value of 0 means that only direct information about a
|
||||
// channel is taken into account.
|
||||
BimodalNodeWeight float64
|
||||
|
||||
// BimodalScaleMsat describes the scale over which channels
|
||||
// statistically have some liquidity left. The value determines how
|
||||
// quickly the bimodal distribution drops off from the edges of a
|
||||
// channel. A larger value (compared to typical channel capacities)
|
||||
// means that the drop off is slow and that channel balances are
|
||||
// distributed more uniformly. A small value leads to the assumption of
|
||||
// very unbalanced channels.
|
||||
BimodalScaleMsat lnwire.MilliSatoshi
|
||||
|
||||
// BimodalDecayTime is the scale for the exponential information decay
|
||||
// over time for previous successes or failures.
|
||||
BimodalDecayTime time.Duration
|
||||
}
|
||||
|
||||
// validate checks the configuration of the estimator for allowed values.
|
||||
func (p BimodalConfig) validate() error {
|
||||
if p.BimodalDecayTime <= 0 {
|
||||
return fmt.Errorf("%v: %w", BimodalEstimatorName,
|
||||
ErrInvalidDecayTime)
|
||||
}
|
||||
|
||||
if p.BimodalNodeWeight < 0 || p.BimodalNodeWeight > 1 {
|
||||
return fmt.Errorf("%v: %w", BimodalEstimatorName,
|
||||
ErrInvalidNodeWeight)
|
||||
}
|
||||
|
||||
if p.BimodalScaleMsat == 0 || p.BimodalScaleMsat > BimodalScaleMsatMax {
|
||||
return fmt.Errorf("%v: %w", BimodalEstimatorName,
|
||||
ErrInvalidScale)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultBimodalConfig returns the default configuration for the estimator.
|
||||
func DefaultBimodalConfig() BimodalConfig {
|
||||
return BimodalConfig{
|
||||
BimodalNodeWeight: DefaultBimodalNodeWeight,
|
||||
BimodalScaleMsat: DefaultBimodalScaleMsat,
|
||||
BimodalDecayTime: DefaultBimodalDecayTime,
|
||||
}
|
||||
}
|
||||
|
||||
// BimodalEstimator returns node and pair probabilities based on historical
|
||||
// payment results based on a liquidity distribution model of the LN. The main
|
||||
// function is to estimate the direct channel probability based on a depleted
|
||||
// liquidity distribution model, with additional information decay over time. A
|
||||
// per-node probability can be mixed with the direct probability, taking into
|
||||
// account successes/failures on other channels of the forwarder.
|
||||
type BimodalEstimator struct {
|
||||
// BimodalConfig contains configuration options for our estimator.
|
||||
BimodalConfig
|
||||
}
|
||||
|
||||
// NewBimodalEstimator creates a new BimodalEstimator.
|
||||
func NewBimodalEstimator(cfg BimodalConfig) (*BimodalEstimator, error) {
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &BimodalEstimator{
|
||||
BimodalConfig: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Compile-time checks that interfaces are implemented.
|
||||
var _ Estimator = (*BimodalEstimator)(nil)
|
||||
var _ estimatorConfig = (*BimodalConfig)(nil)
|
||||
|
||||
// config returns the current configuration of the estimator.
|
||||
func (p *BimodalEstimator) Config() estimatorConfig {
|
||||
return p.BimodalConfig
|
||||
}
|
||||
|
||||
// String returns the estimator's configuration as a string representation.
|
||||
func (p *BimodalEstimator) String() string {
|
||||
return fmt.Sprintf("estimator type: %v, decay time: %v, liquidity "+
|
||||
"scale: %v, node weight: %v", BimodalEstimatorName,
|
||||
p.BimodalDecayTime, p.BimodalScaleMsat, p.BimodalNodeWeight)
|
||||
}
|
||||
|
||||
// PairProbability estimates the probability of successfully traversing to
|
||||
// toNode based on historical payment outcomes for the from node. Those outcomes
|
||||
// are passed in via the results parameter.
|
||||
func (p *BimodalEstimator) PairProbability(now time.Time,
|
||||
results NodeResults, toNode route.Vertex, amt lnwire.MilliSatoshi,
|
||||
capacity btcutil.Amount) float64 {
|
||||
|
||||
// We first compute the probability for the desired hop taking into
|
||||
// account previous knowledge.
|
||||
directProbability := p.directProbability(
|
||||
now, results, toNode, amt, lnwire.NewMSatFromSatoshis(capacity),
|
||||
)
|
||||
|
||||
// The final probability is computed by taking into account other
|
||||
// channels of the from node.
|
||||
return p.calculateProbability(directProbability, now, results, toNode)
|
||||
}
|
||||
|
||||
// LocalPairProbability computes the probability to reach toNode given a set of
|
||||
// previous learnings.
|
||||
func (p *BimodalEstimator) LocalPairProbability(now time.Time,
|
||||
results NodeResults, toNode route.Vertex) float64 {
|
||||
|
||||
// For direct local probabilities we assume to know exactly how much we
|
||||
// can send over a channel, which assumes that channels are active and
|
||||
// have enough liquidity.
|
||||
directProbability := 1.0
|
||||
|
||||
// If we had an unexpected failure for this node, we reduce the
|
||||
// probability for some time to avoid infinite retries.
|
||||
result, ok := results[toNode]
|
||||
if ok && !result.FailTime.IsZero() {
|
||||
timeAgo := now.Sub(result.FailTime)
|
||||
|
||||
// We only expect results in the past to get a probability
|
||||
// between 0 and 1.
|
||||
if timeAgo < 0 {
|
||||
timeAgo = 0
|
||||
}
|
||||
exponent := -float64(timeAgo) / float64(p.BimodalDecayTime)
|
||||
directProbability -= math.Exp(exponent)
|
||||
}
|
||||
|
||||
return directProbability
|
||||
}
|
||||
|
||||
// directProbability computes the probability to reach a node based on the
|
||||
// liquidity distribution in the LN.
|
||||
func (p *BimodalEstimator) directProbability(now time.Time,
|
||||
results NodeResults, toNode route.Vertex, amt lnwire.MilliSatoshi,
|
||||
capacity lnwire.MilliSatoshi) float64 {
|
||||
|
||||
// We first determine the time-adjusted success and failure amounts to
|
||||
// then compute a probability. We know that we can send a zero amount.
|
||||
successAmount := lnwire.MilliSatoshi(0)
|
||||
|
||||
// We know that we cannot send the full capacity.
|
||||
failAmount := capacity
|
||||
|
||||
// If we have information about past successes or failures, we modify
|
||||
// them with a time decay.
|
||||
result, ok := results[toNode]
|
||||
if ok {
|
||||
// Apply a time decay for the amount we cannot send.
|
||||
if !result.FailTime.IsZero() {
|
||||
failAmount = cannotSend(
|
||||
result.FailAmt, capacity, now, result.FailTime,
|
||||
p.BimodalDecayTime,
|
||||
)
|
||||
}
|
||||
|
||||
// Apply a time decay for the amount we can send.
|
||||
if !result.SuccessTime.IsZero() {
|
||||
successAmount = canSend(
|
||||
result.SuccessAmt, now, result.SuccessTime,
|
||||
p.BimodalDecayTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the direct channel probability.
|
||||
probability, err := p.probabilityFormula(
|
||||
capacity, successAmount, failAmount, amt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("error computing probability: %v", err)
|
||||
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return probability
|
||||
}
|
||||
|
||||
// calculateProbability computes the total hop probability combining the channel
|
||||
// probability and historic forwarding data of other channels of the node we try
|
||||
// to send from.
|
||||
//
|
||||
// Goals:
|
||||
// * We want to incentivize good routing nodes: the more routable channels a
|
||||
// node has, the more we want to incentivize (vice versa for failures).
|
||||
// -> We reduce/increase the direct probability depending on past
|
||||
// failures/successes for other channels of the node.
|
||||
//
|
||||
// * We want to be forgiving/give other nodes a chance as well: we want to
|
||||
// forget about (non-)routable channels over time.
|
||||
// -> We weight the successes/failures with a time decay such that they will not
|
||||
// influence the total probability if a long time went by.
|
||||
//
|
||||
// * If we don't have other info, we want to solely rely on the direct
|
||||
// probability.
|
||||
//
|
||||
// * We want to be able to specify how important the other channels are compared
|
||||
// to the direct channel.
|
||||
// -> Introduce a node weight factor that weights the direct probability against
|
||||
// the node-wide average. The larger the node weight, the more important other
|
||||
// channels of the node are.
|
||||
//
|
||||
// How do failures on low fee nodes redirect routing to higher fee nodes?
|
||||
// Assumptions:
|
||||
// * attemptCostPPM of 1000 PPM
|
||||
// * constant direct channel probability of P0 (usually 0.5 for large amounts)
|
||||
// * node weight w of 0.2
|
||||
//
|
||||
// The question we want to answer is:
|
||||
// How often would a zero-fee node be tried (even if there were failures for its
|
||||
// other channels) over trying a high-fee node with 2000 PPM and no direct
|
||||
// knowledge about the channel to send over?
|
||||
//
|
||||
// The probability of a route of length l is P(l) = l * P0.
|
||||
//
|
||||
// The total probability after n failures (with the implemented method here) is:
|
||||
// P(l, n) = P(l-1) * P(n)
|
||||
// = P(l-1) * (P0 + n*0) / (1 + n*w)
|
||||
// = P(l) / (1 + n*w)
|
||||
//
|
||||
// Condition for a high-fee channel to overcome a low fee channel in the
|
||||
// Dijkstra weight function (only looking at fee and probability PPM terms):
|
||||
// highFeePPM + attemptCostPPM * 1/P(l) = 0PPM + attemptCostPPM * 1/P(l, n)
|
||||
// highFeePPM/attemptCostPPM = 1/P(l, n) - 1/P(l) =
|
||||
// = (1 + n*w)/P(l) - 1/P(l) =
|
||||
// = n*w/P(l)
|
||||
//
|
||||
// Therefore:
|
||||
// n = (highFeePPM/attemptCostPPM) * (P(l)/w) =
|
||||
// = (2000/1000) * 0.5 * l / w = l/w
|
||||
//
|
||||
// For a one-hop route we get:
|
||||
// n = 1/0.2 = 5 tolerated failures
|
||||
//
|
||||
// For a three-hop route we get:
|
||||
// n = 3/0.2 = 15 tolerated failures
|
||||
//
|
||||
// For more details on the behavior see tests.
|
||||
func (p *BimodalEstimator) calculateProbability(directProbability float64,
|
||||
now time.Time, results NodeResults, toNode route.Vertex) float64 {
|
||||
|
||||
// If we don't take other channels into account, we can return early.
|
||||
if p.BimodalNodeWeight == 0.0 {
|
||||
return directProbability
|
||||
}
|
||||
|
||||
// w is a parameter which determines how strongly the other channels of
|
||||
// a node should be incorporated, the higher the stronger.
|
||||
w := p.BimodalNodeWeight
|
||||
|
||||
// dt determines the timeliness of the previous successes/failures
|
||||
// to be taken into account.
|
||||
dt := float64(p.BimodalDecayTime)
|
||||
|
||||
// The direct channel probability is weighted fully, all other results
|
||||
// are weighted according to how recent the information is.
|
||||
totalProbabilities := directProbability
|
||||
totalWeights := 1.0
|
||||
|
||||
for peer, result := range results {
|
||||
// We don't include the direct hop probability here because it
|
||||
// is already included in totalProbabilities.
|
||||
if peer == toNode {
|
||||
continue
|
||||
}
|
||||
|
||||
// We add probabilities weighted by how recent the info is.
|
||||
var weight float64
|
||||
if result.SuccessAmt > 0 {
|
||||
exponent := -float64(now.Sub(result.SuccessTime)) / dt
|
||||
weight = math.Exp(exponent)
|
||||
totalProbabilities += w * weight
|
||||
totalWeights += w * weight
|
||||
}
|
||||
if result.FailAmt > 0 {
|
||||
exponent := -float64(now.Sub(result.FailTime)) / dt
|
||||
weight = math.Exp(exponent)
|
||||
|
||||
// Failures don't add to total success probability.
|
||||
totalWeights += w * weight
|
||||
}
|
||||
}
|
||||
|
||||
return totalProbabilities / totalWeights
|
||||
}
|
||||
|
||||
// canSend returns the sendable amount over the channel, respecting time decay.
|
||||
// canSend approaches zero, if we wait for a much longer time than the decay
|
||||
// time.
|
||||
func canSend(successAmount lnwire.MilliSatoshi, now, successTime time.Time,
|
||||
decayConstant time.Duration) lnwire.MilliSatoshi {
|
||||
|
||||
// The factor approaches 0 for successTime a long time in the past,
|
||||
// is 1 when the successTime is now.
|
||||
factor := math.Exp(
|
||||
-float64(now.Sub(successTime)) / float64(decayConstant),
|
||||
)
|
||||
|
||||
canSend := factor * float64(successAmount)
|
||||
|
||||
return lnwire.MilliSatoshi(canSend)
|
||||
}
|
||||
|
||||
// cannotSend returns the not sendable amount over the channel, respecting time
|
||||
// decay. cannotSend approaches the capacity, if we wait for a much longer time
|
||||
// than the decay time.
|
||||
func cannotSend(failAmount, capacity lnwire.MilliSatoshi, now,
|
||||
failTime time.Time, decayConstant time.Duration) lnwire.MilliSatoshi {
|
||||
|
||||
if failAmount > capacity {
|
||||
failAmount = capacity
|
||||
}
|
||||
|
||||
// The factor approaches 0 for failTime a long time in the past and it
|
||||
// is 1 when the failTime is now.
|
||||
factor := math.Exp(
|
||||
-float64(now.Sub(failTime)) / float64(decayConstant),
|
||||
)
|
||||
|
||||
cannotSend := capacity - lnwire.MilliSatoshi(
|
||||
factor*float64(capacity-failAmount),
|
||||
)
|
||||
|
||||
return cannotSend
|
||||
}
|
||||
|
||||
// primitive computes the indefinite integral of our assumed (normalized)
|
||||
// liquidity probability distribution. The distribution of liquidity x here is
|
||||
// the function P(x) ~ exp(-x/s) + exp((x-c)/s), i.e., two exponentials residing
|
||||
// at the ends of channels. This means that we expect liquidity to be at either
|
||||
// side of the channel with capacity c. The s parameter (scale) defines how far
|
||||
// the liquidity leaks into the channel. A very low scale assumes completely
|
||||
// unbalanced channels, a very high scale assumes a random distribution. More
|
||||
// details can be found in
|
||||
// https://github.com/lightningnetwork/lnd/issues/5988#issuecomment-1131234858.
|
||||
func (p *BimodalEstimator) primitive(c, x float64) float64 {
|
||||
s := float64(p.BimodalScaleMsat)
|
||||
|
||||
// The indefinite integral of P(x) is given by
|
||||
// Int P(x) dx = H(x) = s * (-e(-x/s) + e((x-c)/s)),
|
||||
// and its norm from 0 to c can be computed from it,
|
||||
// norm = [H(x)]_0^c = s * (-e(-c/s) + 1 -(1 + e(-c/s))).
|
||||
ecs := math.Exp(-c / s)
|
||||
exs := math.Exp(-x / s)
|
||||
|
||||
// It would be possible to split the next term and reuse the factors
|
||||
// from before, but this can lead to numerical issues with large
|
||||
// numbers.
|
||||
excs := math.Exp((x - c) / s)
|
||||
|
||||
// norm can only become zero, if c is zero, which we sorted out before
|
||||
// calling this method.
|
||||
norm := -2*ecs + 2
|
||||
|
||||
// We end up with the primitive function of the normalized P(x).
|
||||
return (-exs + excs) / norm
|
||||
}
|
||||
|
||||
// integral computes the integral of our liquidity distribution from the lower
|
||||
// to the upper value.
|
||||
func (p *BimodalEstimator) integral(capacity, lower, upper float64) float64 {
|
||||
if lower < 0 || lower > upper {
|
||||
log.Errorf("probability integral limits nonsensical: capacity:"+
|
||||
"%v lower: %v upper: %v", capacity, lower, upper)
|
||||
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return p.primitive(capacity, upper) - p.primitive(capacity, lower)
|
||||
}
|
||||
|
||||
// probabilityFormula computes the expected probability for a payment of
|
||||
// amountMsat given prior learnings for a channel of certain capacity.
|
||||
// successAmountMsat and failAmountMsat stand for the unsettled success and
|
||||
// failure amounts, respectively. The formula is derived using the formalism
|
||||
// presented in Pickhardt et al., https://arxiv.org/abs/2103.08576.
|
||||
func (p *BimodalEstimator) probabilityFormula(capacityMsat, successAmountMsat,
|
||||
failAmountMsat, amountMsat lnwire.MilliSatoshi) (float64, error) {
|
||||
|
||||
// Convert to positive-valued floats.
|
||||
capacity := float64(capacityMsat)
|
||||
successAmount := float64(successAmountMsat)
|
||||
failAmount := float64(failAmountMsat)
|
||||
amount := float64(amountMsat)
|
||||
|
||||
// Capacity being zero is a sentinel value to ignore the probability
|
||||
// estimation, we'll return the full probability here.
|
||||
if capacity == 0.0 {
|
||||
return 1.0, nil
|
||||
}
|
||||
|
||||
// We cannot send more than the capacity.
|
||||
if amount > capacity {
|
||||
return 0.0, nil
|
||||
}
|
||||
|
||||
// Mission control may have some outdated values, we correct them here.
|
||||
// TODO(bitromortac): there may be better decisions to make in these
|
||||
// cases, e.g., resetting failAmount=cap and successAmount=0.
|
||||
|
||||
// failAmount should be capacity at max.
|
||||
if failAmount > capacity {
|
||||
failAmount = capacity
|
||||
}
|
||||
|
||||
// successAmount should be capacity at max.
|
||||
if successAmount > capacity {
|
||||
successAmount = capacity
|
||||
}
|
||||
|
||||
// The next statement is a safety check against an illogical condition,
|
||||
// otherwise the renormalization integral would become zero. This may
|
||||
// happen if a large channel gets closed and smaller ones remain, but
|
||||
// it should recover with the time decay.
|
||||
if failAmount <= successAmount {
|
||||
log.Tracef("fail amount (%v) is larger than or equal the "+
|
||||
"success amount (%v) for capacity (%v)",
|
||||
failAmountMsat, successAmountMsat, capacityMsat)
|
||||
|
||||
return 0.0, nil
|
||||
}
|
||||
|
||||
// We cannot send more than the fail amount.
|
||||
if amount >= failAmount {
|
||||
return 0.0, nil
|
||||
}
|
||||
|
||||
// The success probability for payment amount a is the integral over the
|
||||
// prior distribution P(x), the probability to find liquidity between
|
||||
// the amount a and channel capacity c (or failAmount a_f):
|
||||
// P(X >= a | X < a_f) = Integral_{a}^{a_f} P(x) dx
|
||||
prob := p.integral(capacity, amount, failAmount)
|
||||
if math.IsNaN(prob) {
|
||||
return 0.0, fmt.Errorf("non-normalized probability is NaN, "+
|
||||
"capacity: %v, amount: %v, fail amount: %v",
|
||||
capacity, amount, failAmount)
|
||||
}
|
||||
|
||||
// If we have payment information, we need to adjust the prior
|
||||
// distribution P(x) and get the posterior distribution by renormalizing
|
||||
// the prior distribution in such a way that the probability mass lies
|
||||
// between a_s and a_f.
|
||||
reNorm := p.integral(capacity, successAmount, failAmount)
|
||||
if math.IsNaN(reNorm) {
|
||||
return 0.0, fmt.Errorf("normalization factor is NaN, "+
|
||||
"capacity: %v, success amount: %v, fail amount: %v",
|
||||
capacity, successAmount, failAmount)
|
||||
}
|
||||
|
||||
// The normalization factor can only be zero if the success amount is
|
||||
// equal or larger than the fail amount. This should not happen as we
|
||||
// have checked this scenario above.
|
||||
if reNorm == 0.0 {
|
||||
return 0.0, fmt.Errorf("normalization factor is zero, "+
|
||||
"capacity: %v, success amount: %v, fail amount: %v",
|
||||
capacity, successAmount, failAmount)
|
||||
}
|
||||
|
||||
prob /= reNorm
|
||||
|
||||
// Note that for payment amounts smaller than successAmount, we can get
|
||||
// a value larger than unity, which we cap here to get a proper
|
||||
// probability.
|
||||
if prob > 1.0 {
|
||||
if amount > successAmount {
|
||||
return 0.0, fmt.Errorf("unexpected large probability "+
|
||||
"(%v) capacity: %v, amount: %v, success "+
|
||||
"amount: %v, fail amount: %v", prob, capacity,
|
||||
amount, successAmount, failAmount)
|
||||
}
|
||||
|
||||
return 1.0, nil
|
||||
} else if prob < 0.0 {
|
||||
return 0.0, fmt.Errorf("negative probability "+
|
||||
"(%v) capacity: %v, amount: %v, success "+
|
||||
"amount: %v, fail amount: %v", prob, capacity,
|
||||
amount, successAmount, failAmount)
|
||||
}
|
||||
|
||||
return prob, nil
|
||||
}
|
664
routing/probability_bimodal_test.go
Normal file
664
routing/probability_bimodal_test.go
Normal file
|
@ -0,0 +1,664 @@
|
|||
package routing
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
smallAmount = lnwire.MilliSatoshi(400_000)
|
||||
largeAmount = lnwire.MilliSatoshi(5_000_000)
|
||||
capacity = lnwire.MilliSatoshi(10_000_000)
|
||||
scale = lnwire.MilliSatoshi(400_000)
|
||||
)
|
||||
|
||||
// TestSuccessProbability tests that we get correct probability estimates for
|
||||
// the direct channel probability.
|
||||
func TestSuccessProbability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expectedProbability float64
|
||||
tolerance float64
|
||||
successAmount lnwire.MilliSatoshi
|
||||
failAmount lnwire.MilliSatoshi
|
||||
amount lnwire.MilliSatoshi
|
||||
capacity lnwire.MilliSatoshi
|
||||
}{
|
||||
// We can't send more than the capacity.
|
||||
{
|
||||
name: "no info, larger than capacity",
|
||||
capacity: capacity,
|
||||
successAmount: 0,
|
||||
failAmount: capacity,
|
||||
amount: capacity + 1,
|
||||
expectedProbability: 0.0,
|
||||
},
|
||||
// With the current model we don't prefer any channels if the
|
||||
// send amount is large compared to the scale but small compared
|
||||
// to the capacity.
|
||||
{
|
||||
name: "no info, large amount",
|
||||
capacity: capacity,
|
||||
successAmount: 0,
|
||||
failAmount: capacity,
|
||||
amount: largeAmount,
|
||||
expectedProbability: 0.5,
|
||||
},
|
||||
// We always expect to be able to "send" an amount of 0.
|
||||
{
|
||||
name: "no info, zero amount",
|
||||
capacity: capacity,
|
||||
successAmount: 0,
|
||||
failAmount: capacity,
|
||||
amount: 0,
|
||||
expectedProbability: 1.0,
|
||||
},
|
||||
// We can't send the whole capacity.
|
||||
{
|
||||
name: "no info, full capacity",
|
||||
capacity: capacity,
|
||||
successAmount: 0,
|
||||
failAmount: capacity,
|
||||
amount: capacity,
|
||||
expectedProbability: 0.0,
|
||||
},
|
||||
// Sending a small amount will have a higher probability to go
|
||||
// through than a large amount.
|
||||
{
|
||||
name: "no info, small amount",
|
||||
capacity: capacity,
|
||||
successAmount: 0,
|
||||
failAmount: capacity,
|
||||
amount: smallAmount,
|
||||
expectedProbability: 0.684,
|
||||
tolerance: 0.001,
|
||||
},
|
||||
// If we had an unsettled success, we are sure we can send a
|
||||
// lower amount.
|
||||
{
|
||||
name: "previous success, lower amount",
|
||||
capacity: capacity,
|
||||
successAmount: largeAmount,
|
||||
failAmount: capacity,
|
||||
amount: smallAmount,
|
||||
expectedProbability: 1.0,
|
||||
},
|
||||
// If we had an unsettled success, we are sure we can send the
|
||||
// same amount.
|
||||
{
|
||||
name: "previous success, success amount",
|
||||
capacity: capacity,
|
||||
successAmount: largeAmount,
|
||||
failAmount: capacity,
|
||||
amount: largeAmount,
|
||||
expectedProbability: 1.0,
|
||||
},
|
||||
// If we had an unsettled success with a small amount, we know
|
||||
// with increased probability that we can send a comparable
|
||||
// higher amount.
|
||||
{
|
||||
name: "previous success, larger amount",
|
||||
capacity: capacity,
|
||||
successAmount: smallAmount / 2,
|
||||
failAmount: capacity,
|
||||
amount: smallAmount,
|
||||
expectedProbability: 0.851,
|
||||
tolerance: 0.001,
|
||||
},
|
||||
// If we had a large unsettled success before, we know we can
|
||||
// send even larger payments with high probability.
|
||||
{
|
||||
name: "previous large success, larger " +
|
||||
"amount",
|
||||
capacity: capacity,
|
||||
successAmount: largeAmount / 2,
|
||||
failAmount: capacity,
|
||||
amount: largeAmount,
|
||||
expectedProbability: 0.998,
|
||||
tolerance: 0.001,
|
||||
},
|
||||
// If we had a failure before, we can't send with the fail
|
||||
// amount.
|
||||
{
|
||||
name: "previous failure, fail amount",
|
||||
capacity: capacity,
|
||||
failAmount: largeAmount,
|
||||
amount: largeAmount,
|
||||
expectedProbability: 0.0,
|
||||
},
|
||||
// We can't send a higher amount than the fail amount either.
|
||||
{
|
||||
name: "previous failure, larger fail " +
|
||||
"amount",
|
||||
capacity: capacity,
|
||||
failAmount: largeAmount,
|
||||
amount: largeAmount + smallAmount,
|
||||
expectedProbability: 0.0,
|
||||
},
|
||||
// We expect a diminished non-zero probability if we try to send
|
||||
// an amount that's lower than the last fail amount.
|
||||
{
|
||||
name: "previous failure, lower than fail " +
|
||||
"amount",
|
||||
capacity: capacity,
|
||||
failAmount: largeAmount,
|
||||
amount: smallAmount,
|
||||
expectedProbability: 0.368,
|
||||
tolerance: 0.001,
|
||||
},
|
||||
// From here on we deal with mixed previous successes and
|
||||
// failures.
|
||||
// We expect to be always able to send a tiny amount.
|
||||
{
|
||||
name: "previous f/s, very small amount",
|
||||
capacity: capacity,
|
||||
failAmount: largeAmount,
|
||||
successAmount: smallAmount,
|
||||
amount: 0,
|
||||
expectedProbability: 1.0,
|
||||
},
|
||||
// We expect to be able to send up to the previous success
|
||||
// amount will full certainty.
|
||||
{
|
||||
name: "previous f/s, success amount",
|
||||
capacity: capacity,
|
||||
failAmount: largeAmount,
|
||||
successAmount: smallAmount,
|
||||
amount: smallAmount,
|
||||
expectedProbability: 1.0,
|
||||
},
|
||||
// This tests a random value between small amount and large
|
||||
// amount.
|
||||
{
|
||||
name: "previous f/s, between f/s",
|
||||
capacity: capacity,
|
||||
failAmount: largeAmount,
|
||||
successAmount: smallAmount,
|
||||
amount: smallAmount + largeAmount/10,
|
||||
expectedProbability: 0.287,
|
||||
tolerance: 0.001,
|
||||
},
|
||||
// We still can't send the fail amount.
|
||||
{
|
||||
name: "previous f/s, fail amount",
|
||||
capacity: capacity,
|
||||
failAmount: largeAmount,
|
||||
successAmount: smallAmount,
|
||||
amount: largeAmount,
|
||||
expectedProbability: 0.0,
|
||||
},
|
||||
// Same success and failure amounts (illogical).
|
||||
{
|
||||
name: "previous f/s, same",
|
||||
capacity: capacity,
|
||||
failAmount: largeAmount,
|
||||
successAmount: largeAmount,
|
||||
amount: largeAmount,
|
||||
expectedProbability: 0.0,
|
||||
},
|
||||
// Higher success than failure amount (illogical).
|
||||
{
|
||||
name: "previous f/s, higher success",
|
||||
capacity: capacity,
|
||||
failAmount: smallAmount,
|
||||
successAmount: largeAmount,
|
||||
expectedProbability: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
estimator := BimodalEstimator{
|
||||
BimodalConfig: BimodalConfig{BimodalScaleMsat: scale},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p, err := estimator.probabilityFormula(
|
||||
test.capacity, test.successAmount,
|
||||
test.failAmount, test.amount,
|
||||
)
|
||||
require.InDelta(t, test.expectedProbability, p,
|
||||
test.tolerance)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegral tests certain limits of the probability distribution integral.
|
||||
func TestIntegral(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
defaultScale := lnwire.NewMSatFromSatoshis(300_000)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
capacity float64
|
||||
lower float64
|
||||
upper float64
|
||||
scale lnwire.MilliSatoshi
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "all zero",
|
||||
expected: math.NaN(),
|
||||
scale: defaultScale,
|
||||
},
|
||||
{
|
||||
name: "all same",
|
||||
capacity: 1,
|
||||
lower: 1,
|
||||
upper: 1,
|
||||
scale: defaultScale,
|
||||
},
|
||||
{
|
||||
name: "large numbers, low lower",
|
||||
capacity: 21e17,
|
||||
lower: 0,
|
||||
upper: 21e17,
|
||||
expected: 1,
|
||||
scale: defaultScale,
|
||||
},
|
||||
{
|
||||
name: "large numbers, high lower",
|
||||
capacity: 21e17,
|
||||
lower: 21e17,
|
||||
upper: 21e17,
|
||||
scale: defaultScale,
|
||||
},
|
||||
{
|
||||
name: "same scale and capacity",
|
||||
capacity: 21e17,
|
||||
lower: 21e17,
|
||||
upper: 21e17,
|
||||
scale: 21e17,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
estimator := BimodalEstimator{
|
||||
BimodalConfig: BimodalConfig{
|
||||
BimodalScaleMsat: test.scale,
|
||||
},
|
||||
}
|
||||
|
||||
p := estimator.integral(
|
||||
test.capacity, test.lower, test.upper,
|
||||
)
|
||||
require.InDelta(t, test.expected, p, 0.001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanSend tests that the success amount drops to zero over time.
|
||||
func TestCanSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
successAmount := lnwire.MilliSatoshi(1_000_000)
|
||||
successTime := time.Unix(1_000, 0)
|
||||
now := time.Unix(2_000, 0)
|
||||
decayTime := time.Duration(1_000) * time.Second
|
||||
infinity := time.Unix(10_000_000_000, 0)
|
||||
|
||||
// Test an immediate retry.
|
||||
require.Equal(t, successAmount, canSend(
|
||||
successAmount, successTime, successTime, decayTime,
|
||||
))
|
||||
|
||||
// Test that after the decay time, the successAmount is 1/e of its
|
||||
// value.
|
||||
decayAmount := lnwire.MilliSatoshi(float64(successAmount) / math.E)
|
||||
require.Equal(t, decayAmount, canSend(
|
||||
successAmount, now, successTime, decayTime,
|
||||
))
|
||||
|
||||
// After a long time, we want the amount to approach 0.
|
||||
require.Equal(t, lnwire.MilliSatoshi(0), canSend(
|
||||
successAmount, infinity, successTime, decayTime,
|
||||
))
|
||||
}
|
||||
|
||||
// TestCannotSend tests that the fail amount approaches the capacity over time.
|
||||
func TestCannotSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
failAmount := lnwire.MilliSatoshi(1_000_000)
|
||||
failTime := time.Unix(1_000, 0)
|
||||
now := time.Unix(2_000, 0)
|
||||
decayTime := time.Duration(1_000) * time.Second
|
||||
infinity := time.Unix(10_000_000_000, 0)
|
||||
capacity := lnwire.MilliSatoshi(3_000_000)
|
||||
|
||||
// Test immediate retry.
|
||||
require.EqualValues(t, failAmount, cannotSend(
|
||||
failAmount, capacity, failTime, failTime, decayTime,
|
||||
))
|
||||
|
||||
// After the decay time we want to be between the fail amount and
|
||||
// the capacity.
|
||||
summand := lnwire.MilliSatoshi(float64(capacity-failAmount) / math.E)
|
||||
expected := capacity - summand
|
||||
require.Equal(t, expected, cannotSend(
|
||||
failAmount, capacity, now, failTime, decayTime,
|
||||
))
|
||||
|
||||
// After a long time, we want the amount to approach the capacity.
|
||||
require.Equal(t, capacity, cannotSend(
|
||||
failAmount, capacity, infinity, failTime, decayTime,
|
||||
))
|
||||
}
|
||||
|
||||
// TestComputeProbability tests the inclusion of previous forwarding results of
|
||||
// other channels of the node into the total probability.
|
||||
func TestComputeProbability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodeWeight := 1 / 5.
|
||||
toNode := route.Vertex{10}
|
||||
tolerance := 0.01
|
||||
decayTime := time.Duration(1) * time.Hour * 24
|
||||
|
||||
// makeNodeResults prepares forwarding data for the other channels of
|
||||
// the node.
|
||||
makeNodeResults := func(successes []bool, now time.Time) NodeResults {
|
||||
results := make(NodeResults, len(successes))
|
||||
|
||||
for i, s := range successes {
|
||||
vertex := route.Vertex{byte(i)}
|
||||
|
||||
results[vertex] = TimedPairResult{
|
||||
FailTime: now, FailAmt: 1,
|
||||
}
|
||||
if s {
|
||||
results[vertex] = TimedPairResult{
|
||||
SuccessTime: now, SuccessAmt: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
directProbability float64
|
||||
otherResults []bool
|
||||
expectedProbability float64
|
||||
delay time.Duration
|
||||
}{
|
||||
// If no other information is available, use the direct
|
||||
// probability.
|
||||
{
|
||||
name: "unknown, only direct",
|
||||
directProbability: 0.5,
|
||||
expectedProbability: 0.5,
|
||||
},
|
||||
// If there was a single success, expect increased success
|
||||
// probability.
|
||||
{
|
||||
name: "unknown, single success",
|
||||
directProbability: 0.5,
|
||||
otherResults: []bool{true},
|
||||
expectedProbability: 0.583,
|
||||
},
|
||||
// If there were many successes, expect even higher success
|
||||
// probability.
|
||||
{
|
||||
name: "unknown, many successes",
|
||||
directProbability: 0.5,
|
||||
otherResults: []bool{
|
||||
true, true, true, true, true,
|
||||
},
|
||||
expectedProbability: 0.75,
|
||||
},
|
||||
// If there was a single failure, we expect a slightly decreased
|
||||
// probability.
|
||||
{
|
||||
name: "unknown, single failure",
|
||||
directProbability: 0.5,
|
||||
otherResults: []bool{false},
|
||||
expectedProbability: 0.416,
|
||||
},
|
||||
// If there were many failures, we expect a strongly decreased
|
||||
// probability.
|
||||
{
|
||||
name: "unknown, many failures",
|
||||
directProbability: 0.5,
|
||||
otherResults: []bool{
|
||||
false, false, false, false, false,
|
||||
},
|
||||
expectedProbability: 0.25,
|
||||
},
|
||||
// A success and a failure neutralize themselves.
|
||||
{
|
||||
name: "unknown, mixed even",
|
||||
directProbability: 0.5,
|
||||
otherResults: []bool{true, false},
|
||||
expectedProbability: 0.5,
|
||||
},
|
||||
// A mixed result history leads to increase/decrease of the most
|
||||
// experienced successes/failures.
|
||||
{
|
||||
name: "unknown, mixed uneven",
|
||||
directProbability: 0.5,
|
||||
otherResults: []bool{
|
||||
true, true, false, false, false,
|
||||
},
|
||||
expectedProbability: 0.45,
|
||||
},
|
||||
// Many successes don't elevate the probability above 1.
|
||||
{
|
||||
name: "success, successes",
|
||||
directProbability: 1.0,
|
||||
otherResults: []bool{
|
||||
true, true, true, true, true,
|
||||
},
|
||||
expectedProbability: 1.0,
|
||||
},
|
||||
// Five failures on a very certain channel will lower its
|
||||
// success probability to the unknown probability.
|
||||
{
|
||||
name: "success, failures",
|
||||
directProbability: 1.0,
|
||||
otherResults: []bool{
|
||||
false, false, false, false, false,
|
||||
},
|
||||
expectedProbability: 0.5,
|
||||
},
|
||||
// If we are sure that the channel can send, a single failure
|
||||
// will not decrease the outcome significantly.
|
||||
{
|
||||
name: "success, single failure",
|
||||
directProbability: 1.0,
|
||||
otherResults: []bool{false},
|
||||
expectedProbability: 0.8333,
|
||||
},
|
||||
{
|
||||
name: "success, many failures",
|
||||
directProbability: 1.0,
|
||||
otherResults: []bool{
|
||||
false, false, false, false, false, false, false,
|
||||
},
|
||||
expectedProbability: 0.416,
|
||||
},
|
||||
// Failures won't decrease the probability below zero.
|
||||
{
|
||||
name: "fail, failures",
|
||||
directProbability: 0.0,
|
||||
otherResults: []bool{false, false, false},
|
||||
expectedProbability: 0.0,
|
||||
},
|
||||
{
|
||||
name: "fail, successes",
|
||||
directProbability: 0.0,
|
||||
otherResults: []bool{
|
||||
true, true, true, true, true,
|
||||
},
|
||||
expectedProbability: 0.5,
|
||||
},
|
||||
// We test forgetting information with the time decay.
|
||||
// A past success won't alter the certain success probability.
|
||||
{
|
||||
name: "success, single success, decay " +
|
||||
"time",
|
||||
directProbability: 1.0,
|
||||
otherResults: []bool{true},
|
||||
delay: decayTime,
|
||||
expectedProbability: 1.00,
|
||||
},
|
||||
// A failure that was experienced some time ago won't influence
|
||||
// as much as a recent one.
|
||||
{
|
||||
name: "success, single fail, decay time",
|
||||
directProbability: 1.0,
|
||||
otherResults: []bool{false},
|
||||
delay: decayTime,
|
||||
expectedProbability: 0.9314,
|
||||
},
|
||||
// Information from a long time ago doesn't have any effect.
|
||||
{
|
||||
name: "success, single fail, long ago",
|
||||
directProbability: 1.0,
|
||||
otherResults: []bool{false},
|
||||
delay: 10 * decayTime,
|
||||
expectedProbability: 1.0,
|
||||
},
|
||||
{
|
||||
name: "fail, successes decay time",
|
||||
directProbability: 0.0,
|
||||
otherResults: []bool{
|
||||
true, true, true, true, true,
|
||||
},
|
||||
delay: decayTime,
|
||||
expectedProbability: 0.269,
|
||||
},
|
||||
// Very recent info approaches the case with no time decay.
|
||||
{
|
||||
name: "unknown, successes close",
|
||||
directProbability: 0.5,
|
||||
otherResults: []bool{
|
||||
true, true, true, true, true,
|
||||
},
|
||||
delay: decayTime / 10,
|
||||
expectedProbability: 0.741,
|
||||
},
|
||||
}
|
||||
|
||||
estimator := BimodalEstimator{
|
||||
BimodalConfig: BimodalConfig{
|
||||
BimodalScaleMsat: scale, BimodalNodeWeight: nodeWeight,
|
||||
BimodalDecayTime: decayTime,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
then := time.Unix(0, 0)
|
||||
results := makeNodeResults(test.otherResults, then)
|
||||
now := then.Add(test.delay)
|
||||
|
||||
p := estimator.calculateProbability(
|
||||
test.directProbability, now, results, toNode,
|
||||
)
|
||||
|
||||
require.InDelta(t, test.expectedProbability, p,
|
||||
tolerance)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalPairProbability tests that we reduce probability for failed direct
|
||||
// neighbors.
|
||||
func TestLocalPairProbability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
decayTime := time.Hour
|
||||
now := time.Unix(1000000000, 0)
|
||||
toNode := route.Vertex{1}
|
||||
|
||||
createFailedResult := func(timeAgo time.Duration) NodeResults {
|
||||
return NodeResults{
|
||||
toNode: TimedPairResult{
|
||||
FailTime: now.Add(-timeAgo),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expectedProbability float64
|
||||
results NodeResults
|
||||
}{
|
||||
{
|
||||
name: "no results",
|
||||
expectedProbability: 1.0,
|
||||
},
|
||||
{
|
||||
name: "recent failure",
|
||||
results: createFailedResult(0),
|
||||
expectedProbability: 0.0,
|
||||
},
|
||||
{
|
||||
name: "after decay time",
|
||||
results: createFailedResult(decayTime),
|
||||
expectedProbability: 1 - 1/math.E,
|
||||
},
|
||||
{
|
||||
name: "long ago",
|
||||
results: createFailedResult(10 * decayTime),
|
||||
expectedProbability: 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
estimator := BimodalEstimator{
|
||||
BimodalConfig: BimodalConfig{BimodalDecayTime: decayTime},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := estimator.LocalPairProbability(
|
||||
now, test.results, toNode,
|
||||
)
|
||||
require.InDelta(t, test.expectedProbability, p, 0.001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzProbability checks that we don't encounter errors related to NaNs.
|
||||
func FuzzProbability(f *testing.F) {
|
||||
estimator := BimodalEstimator{
|
||||
BimodalConfig: BimodalConfig{BimodalScaleMsat: scale},
|
||||
}
|
||||
f.Add(uint64(0), uint64(0), uint64(0), uint64(0))
|
||||
|
||||
f.Fuzz(func(t *testing.T, capacity, successAmt, failAmt, amt uint64) {
|
||||
_, err := estimator.probabilityFormula(
|
||||
lnwire.MilliSatoshi(capacity),
|
||||
lnwire.MilliSatoshi(successAmt),
|
||||
lnwire.MilliSatoshi(failAmt), lnwire.MilliSatoshi(amt),
|
||||
)
|
||||
|
||||
require.NoError(t, err, "c: %v s: %v f: %v a: %v", capacity,
|
||||
successAmt, failAmt, amt)
|
||||
})
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
package routing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
|
@ -10,285 +8,30 @@ import (
|
|||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
)
|
||||
|
||||
const (
|
||||
// capacityCutoffFraction and capacitySmearingFraction define how
|
||||
// capacity-related probability reweighting works.
|
||||
// capacityCutoffFraction defines the fraction of the channel capacity
|
||||
// at which the effect roughly sets in and capacitySmearingFraction
|
||||
// defines over which range the factor changes from 1 to 0.
|
||||
//
|
||||
// We may fall below the minimum required probability
|
||||
// (DefaultMinRouteProbability) when the amount comes close to the
|
||||
// available capacity of a single channel of the route in case of no
|
||||
// prior knowledge about the channels. We want such routes still to be
|
||||
// available and therefore a probability reduction should not completely
|
||||
// drop the total probability below DefaultMinRouteProbability.
|
||||
// For this to hold for a three-hop route we require:
|
||||
// (DefaultAprioriHopProbability)^3 * minCapacityFactor >
|
||||
// DefaultMinRouteProbability
|
||||
//
|
||||
// For DefaultAprioriHopProbability = 0.6 and
|
||||
// DefaultMinRouteProbability = 0.01 this results in
|
||||
// minCapacityFactor ~ 0.05. The following combination of parameters
|
||||
// fulfill the requirement with capacityFactor(cap, cap) ~ 0.076 (see
|
||||
// tests).
|
||||
// Estimator estimates the probability to reach a node.
|
||||
type Estimator interface {
|
||||
// PairProbability estimates the probability of successfully traversing
|
||||
// to toNode based on historical payment outcomes for the from node.
|
||||
// Those outcomes are passed in via the results parameter.
|
||||
PairProbability(now time.Time, results NodeResults,
|
||||
toNode route.Vertex, amt lnwire.MilliSatoshi,
|
||||
capacity btcutil.Amount) float64
|
||||
|
||||
// The capacityCutoffFraction is a trade-off between usage of the
|
||||
// provided capacity and expected probability reduction when we send the
|
||||
// full amount. The success probability in the random balance model can
|
||||
// be approximated with P(a) = 1 - a/c, for amount a and capacity c. If
|
||||
// we require a probability P(a) > 0.25, this translates into a value of
|
||||
// 0.75 for a/c.
|
||||
capacityCutoffFraction = 0.75
|
||||
// LocalPairProbability estimates the probability of successfully
|
||||
// traversing our own local channels to toNode.
|
||||
LocalPairProbability(now time.Time, results NodeResults,
|
||||
toNode route.Vertex) float64
|
||||
|
||||
// We don't want to have a sharp drop of the capacity factor to zero at
|
||||
// capacityCutoffFraction, but a smooth smearing such that some residual
|
||||
// probability is left when spending the whole amount, see above.
|
||||
capacitySmearingFraction = 0.1
|
||||
)
|
||||
// Config returns the estimator's configuration.
|
||||
Config() estimatorConfig
|
||||
|
||||
var (
|
||||
// ErrInvalidHalflife is returned when we get an invalid half life.
|
||||
ErrInvalidHalflife = errors.New("penalty half life must be >= 0")
|
||||
|
||||
// ErrInvalidHopProbability is returned when we get an invalid hop
|
||||
// probability.
|
||||
ErrInvalidHopProbability = errors.New("hop probability must be in [0;1]")
|
||||
|
||||
// ErrInvalidAprioriWeight is returned when we get an apriori weight
|
||||
// that is out of range.
|
||||
ErrInvalidAprioriWeight = errors.New("apriori weight must be in [0;1]")
|
||||
)
|
||||
|
||||
// ProbabilityEstimatorCfg contains configuration for our probability estimator.
|
||||
type ProbabilityEstimatorCfg struct {
|
||||
// PenaltyHalfLife defines after how much time a penalized node or
|
||||
// channel is back at 50% probability.
|
||||
PenaltyHalfLife time.Duration
|
||||
|
||||
// AprioriHopProbability is the assumed success probability of a hop in
|
||||
// a route when no other information is available.
|
||||
AprioriHopProbability float64
|
||||
|
||||
// AprioriWeight is a value in the range [0, 1] that defines to what
|
||||
// extent historical results should be extrapolated to untried
|
||||
// connections. Setting it to one will completely ignore historical
|
||||
// results and always assume the configured a priori probability for
|
||||
// untried connections. A value of zero will ignore the a priori
|
||||
// probability completely and only base the probability on historical
|
||||
// results, unless there are none available.
|
||||
AprioriWeight float64
|
||||
// String returns the string representation of the estimator's
|
||||
// configuration.
|
||||
String() string
|
||||
}
|
||||
|
||||
func (p ProbabilityEstimatorCfg) validate() error {
|
||||
if p.PenaltyHalfLife < 0 {
|
||||
return ErrInvalidHalflife
|
||||
}
|
||||
|
||||
if p.AprioriHopProbability < 0 || p.AprioriHopProbability > 1 {
|
||||
return ErrInvalidHopProbability
|
||||
}
|
||||
|
||||
if p.AprioriWeight < 0 || p.AprioriWeight > 1 {
|
||||
return ErrInvalidAprioriWeight
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// probabilityEstimator returns node and pair probabilities based on historical
|
||||
// payment results.
|
||||
type probabilityEstimator struct {
|
||||
// ProbabilityEstimatorCfg contains configuration options for our
|
||||
// estimator.
|
||||
ProbabilityEstimatorCfg
|
||||
|
||||
// prevSuccessProbability is the assumed probability for node pairs that
|
||||
// successfully relayed the previous attempt.
|
||||
prevSuccessProbability float64
|
||||
}
|
||||
|
||||
// getNodeProbability calculates the probability for connections from a node
|
||||
// that have not been tried before. The results parameter is a list of last
|
||||
// payment results for that node.
|
||||
func (p *probabilityEstimator) getNodeProbability(now time.Time,
|
||||
results NodeResults, amt lnwire.MilliSatoshi,
|
||||
capacity btcutil.Amount) float64 {
|
||||
|
||||
// We reduce the apriori hop probability if the amount comes close to
|
||||
// the capacity.
|
||||
apriori := p.AprioriHopProbability * capacityFactor(amt, capacity)
|
||||
|
||||
// If the channel history is not to be taken into account, we can return
|
||||
// early here with the configured a priori probability.
|
||||
if p.AprioriWeight == 1 {
|
||||
return apriori
|
||||
}
|
||||
|
||||
// If there is no channel history, our best estimate is still the a
|
||||
// priori probability.
|
||||
if len(results) == 0 {
|
||||
return apriori
|
||||
}
|
||||
|
||||
// The value of the apriori weight is in the range [0, 1]. Convert it to
|
||||
// a factor that properly expresses the intention of the weight in the
|
||||
// following weight average calculation. When the apriori weight is 0,
|
||||
// the apriori factor is also 0. This means it won't have any effect on
|
||||
// the weighted average calculation below. When the apriori weight
|
||||
// approaches 1, the apriori factor goes to infinity. It will heavily
|
||||
// outweigh any observations that have been collected.
|
||||
aprioriFactor := 1/(1-p.AprioriWeight) - 1
|
||||
|
||||
// Calculate a weighted average consisting of the apriori probability
|
||||
// and historical observations. This is the part that incentivizes nodes
|
||||
// to make sure that all (not just some) of their channels are in good
|
||||
// shape. Senders will steer around nodes that have shown a few
|
||||
// failures, even though there may be many channels still untried.
|
||||
//
|
||||
// If there is just a single observation and the apriori weight is 0,
|
||||
// this single observation will totally determine the node probability.
|
||||
// The node probability is returned for all other channels of the node.
|
||||
// This means that one failure will lead to the success probability
|
||||
// estimates for all other channels being 0 too. The probability for the
|
||||
// channel that was tried will not even recover, because it is
|
||||
// recovering to the node probability (which is zero). So one failure
|
||||
// effectively prunes all channels of the node forever. This is the most
|
||||
// aggressive way in which we can penalize nodes and unlikely to yield
|
||||
// good results in a real network.
|
||||
probabilitiesTotal := apriori * aprioriFactor
|
||||
totalWeight := aprioriFactor
|
||||
|
||||
for _, result := range results {
|
||||
switch {
|
||||
// Weigh success with a constant high weight of 1. There is no
|
||||
// decay. Amt is never zero, so this clause is never executed
|
||||
// when result.SuccessAmt is zero.
|
||||
case amt <= result.SuccessAmt:
|
||||
totalWeight++
|
||||
probabilitiesTotal += p.prevSuccessProbability
|
||||
|
||||
// Weigh failures in accordance with their age. The base
|
||||
// probability of a failure is considered zero, so nothing needs
|
||||
// to be added to probabilitiesTotal.
|
||||
case !result.FailTime.IsZero() && amt >= result.FailAmt:
|
||||
age := now.Sub(result.FailTime)
|
||||
totalWeight += p.getWeight(age)
|
||||
}
|
||||
}
|
||||
|
||||
return probabilitiesTotal / totalWeight
|
||||
}
|
||||
|
||||
// getWeight calculates a weight in the range [0, 1] that should be assigned to
|
||||
// a payment result. Weight follows an exponential curve that starts at 1 when
|
||||
// the result is fresh and asymptotically approaches zero over time. The rate at
|
||||
// which this happens is controlled by the penaltyHalfLife parameter.
|
||||
func (p *probabilityEstimator) getWeight(age time.Duration) float64 {
|
||||
exp := -age.Hours() / p.PenaltyHalfLife.Hours()
|
||||
return math.Pow(2, exp)
|
||||
}
|
||||
|
||||
// capacityFactor is a multiplier that can be used to reduce the probability
|
||||
// depending on how much of the capacity is sent. The limits are 1 for amt == 0
|
||||
// and 0 for amt >> cutoffMsat. The function drops significantly when amt
|
||||
// reaches cutoffMsat. smearingMsat determines over which scale the reduction
|
||||
// takes place.
|
||||
func capacityFactor(amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 {
|
||||
// If we don't have information about the capacity, which can be the
|
||||
// case for hop hints or local channels, we return unity to not alter
|
||||
// anything.
|
||||
if capacity == 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
capMsat := float64(lnwire.NewMSatFromSatoshis(capacity))
|
||||
amtMsat := float64(amt)
|
||||
|
||||
if amtMsat > capMsat {
|
||||
return 0
|
||||
}
|
||||
|
||||
cutoffMsat := capacityCutoffFraction * capMsat
|
||||
smearingMsat := capacitySmearingFraction * capMsat
|
||||
|
||||
// We compute a logistic function mirrored around the y axis, centered
|
||||
// at cutoffMsat, decaying over the smearingMsat scale.
|
||||
denominator := 1 + math.Exp(-(amtMsat-cutoffMsat)/smearingMsat)
|
||||
|
||||
return 1 - 1/denominator
|
||||
}
|
||||
|
||||
// getPairProbability estimates the probability of successfully traversing to
|
||||
// toNode based on historical payment outcomes for the from node. Those outcomes
|
||||
// are passed in via the results parameter.
|
||||
func (p *probabilityEstimator) getPairProbability(
|
||||
now time.Time, results NodeResults, toNode route.Vertex,
|
||||
amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 {
|
||||
|
||||
nodeProbability := p.getNodeProbability(now, results, amt, capacity)
|
||||
|
||||
return p.calculateProbability(
|
||||
now, results, nodeProbability, toNode, amt,
|
||||
)
|
||||
}
|
||||
|
||||
// getLocalPairProbability estimates the probability of successfully traversing
|
||||
// our own local channels to toNode.
|
||||
func (p *probabilityEstimator) getLocalPairProbability(
|
||||
now time.Time, results NodeResults, toNode route.Vertex) float64 {
|
||||
|
||||
// For local channels that have never been tried before, we assume them
|
||||
// to be successful. We have accurate balance and online status
|
||||
// information on our own channels, so when we select them in a route it
|
||||
// is close to certain that those channels will work.
|
||||
nodeProbability := p.prevSuccessProbability
|
||||
|
||||
return p.calculateProbability(
|
||||
now, results, nodeProbability, toNode, lnwire.MaxMilliSatoshi,
|
||||
)
|
||||
}
|
||||
|
||||
// calculateProbability estimates the probability of successfully traversing to
|
||||
// toNode based on historical payment outcomes and a fall-back node probability.
|
||||
func (p *probabilityEstimator) calculateProbability(
|
||||
now time.Time, results NodeResults,
|
||||
nodeProbability float64, toNode route.Vertex,
|
||||
amt lnwire.MilliSatoshi) float64 {
|
||||
|
||||
// Retrieve the last pair outcome.
|
||||
lastPairResult, ok := results[toNode]
|
||||
|
||||
// If there is no history for this pair, return the node probability
|
||||
// that is a probability estimate for untried channel.
|
||||
if !ok {
|
||||
return nodeProbability
|
||||
}
|
||||
|
||||
// For successes, we have a fixed (high) probability. Those pairs will
|
||||
// be assumed good until proven otherwise. Amt is never zero, so this
|
||||
// clause is never executed when lastPairResult.SuccessAmt is zero.
|
||||
if amt <= lastPairResult.SuccessAmt {
|
||||
return p.prevSuccessProbability
|
||||
}
|
||||
|
||||
// Take into account a minimum penalize amount. For balance errors, a
|
||||
// failure may be reported with such a minimum to prevent too aggressive
|
||||
// penalization. If the current amount is smaller than the amount that
|
||||
// previously triggered a failure, we act as if this is an untried
|
||||
// channel.
|
||||
if lastPairResult.FailTime.IsZero() || amt < lastPairResult.FailAmt {
|
||||
return nodeProbability
|
||||
}
|
||||
|
||||
timeSinceLastFailure := now.Sub(lastPairResult.FailTime)
|
||||
|
||||
// Calculate success probability based on the weight of the last
|
||||
// failure. When the failure is fresh, its weight is 1 and we'll return
|
||||
// probability 0. Over time the probability recovers to the node
|
||||
// probability. It would be as if this channel was never tried before.
|
||||
weight := p.getWeight(timeSinceLastFailure)
|
||||
probability := nodeProbability * (1 - weight)
|
||||
|
||||
return probability
|
||||
// estimatorConfig represents a configuration for a probability estimator.
|
||||
type estimatorConfig interface {
|
||||
// validate checks that all configuration parameters are sane.
|
||||
validate() error
|
||||
}
|
||||
|
|
|
@ -1,281 +1,95 @@
|
|||
package routing
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
// Define node identifiers.
|
||||
node1 = 1
|
||||
node2 = 2
|
||||
node3 = 3
|
||||
|
||||
// untriedNode is a node id for which we don't record any results in
|
||||
// this test. This can be used to assert the probability for untried
|
||||
// ndoes.
|
||||
untriedNode = 255
|
||||
|
||||
// Define test estimator parameters.
|
||||
aprioriHopProb = 0.6
|
||||
aprioriWeight = 0.75
|
||||
aprioriPrevSucProb = 0.95
|
||||
|
||||
// testCapacity is used to define a capacity for some channels.
|
||||
testCapacity = btcutil.Amount(100_000)
|
||||
testAmount = lnwire.MilliSatoshi(50_000_000)
|
||||
|
||||
// Defines the capacityFactor for testAmount and testCapacity.
|
||||
capFactor = 0.9241
|
||||
)
|
||||
|
||||
type estimatorTestContext struct {
|
||||
t *testing.T
|
||||
estimator *probabilityEstimator
|
||||
|
||||
// results contains a list of last results. Every element in the list
|
||||
// corresponds to the last result towards a node. The list index equals
|
||||
// the node id. So the first element in the list is the result towards
|
||||
// node 0.
|
||||
results map[int]TimedPairResult
|
||||
// Create a set of test results.
|
||||
var resultTime = time.Unix(1674169200, 0) // 20.01.2023
|
||||
var now = time.Unix(1674190800, 0) // 6 hours later
|
||||
var results = NodeResults{
|
||||
route.Vertex{byte(0)}: TimedPairResult{
|
||||
FailAmt: 200_000_000,
|
||||
FailTime: resultTime,
|
||||
SuccessAmt: 100_000_000,
|
||||
SuccessTime: resultTime,
|
||||
},
|
||||
route.Vertex{byte(1)}: TimedPairResult{
|
||||
FailAmt: 200_000_000,
|
||||
FailTime: resultTime,
|
||||
SuccessAmt: 100_000_000,
|
||||
SuccessTime: resultTime,
|
||||
},
|
||||
route.Vertex{byte(2)}: TimedPairResult{
|
||||
FailAmt: 200_000_000,
|
||||
FailTime: resultTime,
|
||||
SuccessAmt: 100_000_000,
|
||||
SuccessTime: resultTime,
|
||||
},
|
||||
route.Vertex{byte(3)}: TimedPairResult{
|
||||
FailAmt: 200_000_000,
|
||||
FailTime: resultTime,
|
||||
SuccessAmt: 100_000_000,
|
||||
SuccessTime: resultTime,
|
||||
},
|
||||
route.Vertex{byte(4)}: TimedPairResult{
|
||||
FailAmt: 200_000_000,
|
||||
FailTime: resultTime,
|
||||
SuccessAmt: 100_000_000,
|
||||
SuccessTime: resultTime,
|
||||
},
|
||||
}
|
||||
|
||||
func newEstimatorTestContext(t *testing.T) *estimatorTestContext {
|
||||
return &estimatorTestContext{
|
||||
t: t,
|
||||
estimator: &probabilityEstimator{
|
||||
ProbabilityEstimatorCfg: ProbabilityEstimatorCfg{
|
||||
AprioriHopProbability: aprioriHopProb,
|
||||
AprioriWeight: aprioriWeight,
|
||||
PenaltyHalfLife: time.Hour,
|
||||
},
|
||||
prevSuccessProbability: aprioriPrevSucProb,
|
||||
},
|
||||
}
|
||||
}
|
||||
// probability is a package level variable to prevent the compiler from
|
||||
// optimizing the benchmark.
|
||||
var probability float64
|
||||
|
||||
// assertPairProbability asserts that the calculated success probability is
|
||||
// correct.
|
||||
func (c *estimatorTestContext) assertPairProbability(now time.Time,
|
||||
toNode byte, amt lnwire.MilliSatoshi, capacity btcutil.Amount,
|
||||
expectedProb float64) {
|
||||
|
||||
c.t.Helper()
|
||||
|
||||
results := make(NodeResults)
|
||||
for i, r := range c.results {
|
||||
results[route.Vertex{byte(i)}] = r
|
||||
}
|
||||
|
||||
const tolerance = 0.01
|
||||
|
||||
p := c.estimator.getPairProbability(
|
||||
now, results, route.Vertex{toNode}, amt, capacity,
|
||||
)
|
||||
diff := p - expectedProb
|
||||
if diff > tolerance || diff < -tolerance {
|
||||
c.t.Fatalf("expected probability %v for node %v, but got %v",
|
||||
expectedProb, toNode, p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbabilityEstimatorNoResults tests the probability estimation when no
|
||||
// results are available.
|
||||
func TestProbabilityEstimatorNoResults(t *testing.T) {
|
||||
ctx := newEstimatorTestContext(t)
|
||||
|
||||
// A zero amount does not trigger capacity rescaling.
|
||||
ctx.assertPairProbability(
|
||||
testTime, 0, 0, testCapacity, aprioriHopProb,
|
||||
)
|
||||
|
||||
// We expect a reduced probability when a higher amount is used.
|
||||
expected := aprioriHopProb * capFactor
|
||||
ctx.assertPairProbability(
|
||||
testTime, 0, testAmount, testCapacity, expected,
|
||||
)
|
||||
}
|
||||
|
||||
// TestProbabilityEstimatorOneSuccess tests the probability estimation for nodes
|
||||
// that have a single success result.
|
||||
func TestProbabilityEstimatorOneSuccess(t *testing.T) {
|
||||
ctx := newEstimatorTestContext(t)
|
||||
|
||||
ctx.results = map[int]TimedPairResult{
|
||||
node1: {
|
||||
SuccessAmt: testAmount,
|
||||
// BenchmarkBimodalPairProbability benchmarks the probability calculation.
|
||||
func BenchmarkBimodalPairProbability(b *testing.B) {
|
||||
estimator := BimodalEstimator{
|
||||
BimodalConfig: BimodalConfig{
|
||||
BimodalScaleMsat: scale,
|
||||
BimodalNodeWeight: 0.2,
|
||||
BimodalDecayTime: 48 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
// Because of the previous success, this channel keep reporting a high
|
||||
// probability.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node1, 100, testCapacity, aprioriPrevSucProb,
|
||||
)
|
||||
|
||||
// The apriori success probability indicates that in the past we were
|
||||
// able to send the full amount. We don't want to reduce this
|
||||
// probability with the capacity factor, which is tested here.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node1, testAmount, testCapacity, aprioriPrevSucProb,
|
||||
)
|
||||
|
||||
// Untried channels are also influenced by the success. With a
|
||||
// aprioriWeight of 0.75, the a priori probability is assigned weight 3.
|
||||
expectedP := (3*aprioriHopProb + 1*aprioriPrevSucProb) / 4
|
||||
ctx.assertPairProbability(
|
||||
testTime, untriedNode, 100, testCapacity, expectedP,
|
||||
)
|
||||
|
||||
// Check that the correct probability is computed for larger amounts.
|
||||
apriori := aprioriHopProb * capFactor
|
||||
|
||||
expectedP = (3*apriori + 1*aprioriPrevSucProb) / 4
|
||||
ctx.assertPairProbability(
|
||||
testTime, untriedNode, testAmount, testCapacity, expectedP,
|
||||
)
|
||||
toNode := route.Vertex{byte(0)}
|
||||
var p float64
|
||||
for i := 0; i < b.N; i++ {
|
||||
p = estimator.PairProbability(now, results, toNode,
|
||||
150_000_000, 300_000)
|
||||
}
|
||||
probability = p
|
||||
}
|
||||
|
||||
// TestProbabilityEstimatorOneFailure tests the probability estimation for nodes
|
||||
// that have a single failure.
|
||||
func TestProbabilityEstimatorOneFailure(t *testing.T) {
|
||||
ctx := newEstimatorTestContext(t)
|
||||
|
||||
ctx.results = map[int]TimedPairResult{
|
||||
node1: {
|
||||
FailTime: testTime.Add(-time.Hour),
|
||||
FailAmt: lnwire.MilliSatoshi(50),
|
||||
// BenchmarkAprioriPairProbability benchmarks the probability calculation.
|
||||
func BenchmarkAprioriPairProbability(b *testing.B) {
|
||||
estimator := AprioriEstimator{
|
||||
AprioriConfig: AprioriConfig{
|
||||
AprioriWeight: 0.2,
|
||||
PenaltyHalfLife: 48 * time.Hour,
|
||||
AprioriHopProbability: 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
// For an untried node, we expected the node probability. The weight for
|
||||
// the failure after one hour is 0.5. This makes the node probability
|
||||
// 0.51:
|
||||
expectedNodeProb := (3*aprioriHopProb + 0.5*0) / 3.5
|
||||
ctx.assertPairProbability(
|
||||
testTime, untriedNode, 100, testCapacity, expectedNodeProb,
|
||||
)
|
||||
|
||||
// The pair probability decays back to the node probability. With the
|
||||
// weight at 0.5, we expected a pair probability of 0.5 * 0.51 = 0.25.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node1, 100, testCapacity, expectedNodeProb/2,
|
||||
)
|
||||
toNode := route.Vertex{byte(0)}
|
||||
var p float64
|
||||
for i := 0; i < b.N; i++ {
|
||||
p = estimator.PairProbability(now, results, toNode,
|
||||
150_000_000, 300_000)
|
||||
}
|
||||
probability = p
|
||||
}
|
||||
|
||||
// TestProbabilityEstimatorMix tests the probability estimation for nodes for
|
||||
// which a mix of successes and failures is recorded.
|
||||
func TestProbabilityEstimatorMix(t *testing.T) {
|
||||
ctx := newEstimatorTestContext(t)
|
||||
|
||||
ctx.results = map[int]TimedPairResult{
|
||||
node1: {
|
||||
SuccessAmt: lnwire.MilliSatoshi(1000),
|
||||
},
|
||||
node2: {
|
||||
FailTime: testTime.Add(-2 * time.Hour),
|
||||
FailAmt: lnwire.MilliSatoshi(50),
|
||||
},
|
||||
node3: {
|
||||
FailTime: testTime.Add(-3 * time.Hour),
|
||||
FailAmt: lnwire.MilliSatoshi(50),
|
||||
},
|
||||
}
|
||||
|
||||
// We expect the probability for a previously successful channel to
|
||||
// remain high.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node1, 100, testCapacity, prevSuccessProbability,
|
||||
)
|
||||
|
||||
// For an untried node, we expected the node probability to be returned.
|
||||
// This is a weighted average of the results above and the a priori
|
||||
// probability: 0.62.
|
||||
expectedNodeProb := (3*aprioriHopProb + 1*prevSuccessProbability) /
|
||||
(3 + 1 + 0.25 + 0.125)
|
||||
|
||||
ctx.assertPairProbability(
|
||||
testTime, untriedNode, 100, testCapacity, expectedNodeProb,
|
||||
)
|
||||
|
||||
// For the previously failed connection with node 1, we expect 0.75 *
|
||||
// the node probability = 0.47.
|
||||
ctx.assertPairProbability(
|
||||
testTime, node2, 100, testCapacity, expectedNodeProb*0.75,
|
||||
)
|
||||
}
|
||||
|
||||
// TestCapacityCutoff tests the mathematical expression and limits for the
|
||||
// capacity factor.
|
||||
func TestCapacityCutoff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
capacitySat := 1_000_000
|
||||
capacityMSat := capacitySat * 1000
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
amountMsat int
|
||||
expectedFactor float64
|
||||
}{
|
||||
{
|
||||
name: "zero amount",
|
||||
expectedFactor: 1,
|
||||
},
|
||||
{
|
||||
name: "low amount",
|
||||
amountMsat: capacityMSat / 10,
|
||||
expectedFactor: 0.998,
|
||||
},
|
||||
{
|
||||
name: "half amount",
|
||||
amountMsat: capacityMSat / 2,
|
||||
expectedFactor: 0.924,
|
||||
},
|
||||
{
|
||||
name: "cutoff amount",
|
||||
amountMsat: int(
|
||||
capacityCutoffFraction * float64(capacityMSat),
|
||||
),
|
||||
expectedFactor: 0.5,
|
||||
},
|
||||
{
|
||||
name: "high amount",
|
||||
amountMsat: capacityMSat * 80 / 100,
|
||||
expectedFactor: 0.377,
|
||||
},
|
||||
{
|
||||
// Even when we spend the full capacity, we still want
|
||||
// to have some residual probability to not throw away
|
||||
// routes due to a min probability requirement of the
|
||||
// whole path.
|
||||
name: "full amount",
|
||||
amountMsat: capacityMSat,
|
||||
expectedFactor: 0.076,
|
||||
},
|
||||
{
|
||||
name: "more than capacity",
|
||||
amountMsat: capacityMSat + 1,
|
||||
expectedFactor: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := capacityFactor(
|
||||
lnwire.MilliSatoshi(test.amountMsat),
|
||||
btcutil.Amount(capacitySat),
|
||||
)
|
||||
require.InDelta(t, test.expectedFactor, got, 0.001)
|
||||
})
|
||||
// BenchmarkExp benchmarks the exponential function as provided by the math
|
||||
// library.
|
||||
func BenchmarkExp(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
math.Exp(0.1)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -119,13 +119,15 @@ func createTestCtxFromGraphInstanceAssumeValid(t *testing.T,
|
|||
AttemptCost: 100,
|
||||
}
|
||||
|
||||
mcConfig := &MissionControlConfig{
|
||||
ProbabilityEstimatorCfg: ProbabilityEstimatorCfg{
|
||||
PenaltyHalfLife: time.Hour,
|
||||
AprioriHopProbability: 0.9,
|
||||
AprioriWeight: 0.5,
|
||||
},
|
||||
aCfg := AprioriConfig{
|
||||
PenaltyHalfLife: time.Hour,
|
||||
AprioriHopProbability: 0.9,
|
||||
AprioriWeight: 0.5,
|
||||
}
|
||||
estimator, err := NewAprioriEstimator(aCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
mcConfig := &MissionControlConfig{Estimator: estimator}
|
||||
|
||||
mc, err := NewMissionControl(
|
||||
graphInstance.graphBackend, route.Vertex{}, mcConfig,
|
||||
|
|
|
@ -1105,21 +1105,23 @@ litecoin.node=ltcd
|
|||
|
||||
[routerrpc]
|
||||
|
||||
; Probability estimator used for pathfinding. (default: apriori)
|
||||
; routerrpc.estimator=[apriori|bimodal]
|
||||
|
||||
; Minimum required route success probability to attempt the payment (default:
|
||||
; 0.01)
|
||||
; routerrpc.minrtprob=1
|
||||
|
||||
; Assumed success probability of a hop in a route when no other information is
|
||||
; available. (default: 0.6)
|
||||
; routerrpc.apriorihopprob=0.2
|
||||
; The maximum number of payment results that are held on disk by mission control
|
||||
; (default: 1000)
|
||||
; routerrpc.maxmchistory=900
|
||||
|
||||
; Weight of the a priori probability in success probability estimation. Valid
|
||||
; values are in [0, 1]. (default: 0.5)
|
||||
; routerrpc.aprioriweight=0.3
|
||||
; The time interval with which the MC store state is flushed to the database
|
||||
; (default: 1s)
|
||||
; routerrpc.mcflushinterval=1m
|
||||
|
||||
; Defines the duration after which a penalized node or channel is back at 50%
|
||||
; probability (default: 1h0m0s)
|
||||
; routerrpc.penaltyhalflife=2h
|
||||
; Path to the router macaroon
|
||||
; routerrpc.routermacaroonpath=~/.lnd/data/chain/bitcoin/simnet/router.macaroon
|
||||
|
||||
; The (virtual) fixed cost in sats of a failed payment attempt (default: 100)
|
||||
; routerrpc.attemptcost=90
|
||||
|
@ -1128,15 +1130,32 @@ litecoin.node=ltcd
|
|||
; attempt (default: 1000)
|
||||
; routerrpc.attemptcostppm=900
|
||||
|
||||
; The maximum number of payment results that are held on disk by mission control
|
||||
; (default: 1000)
|
||||
; routerrpc.maxmchistory=900
|
||||
; Assumed success probability of a hop in a route when no other information is
|
||||
; available. (default: 0.6)
|
||||
; routerrpc.apriori.hopprob=0.2
|
||||
|
||||
; The time interval with which the MC store state is flushed to the DB.
|
||||
; routerrpc.mcflushinterval=1m
|
||||
; Weight of the a priori probability in success probability estimation. Valid
|
||||
; values are in [0, 1]. (default: 0.5)
|
||||
; routerrpc.apriori.weight=0.3
|
||||
|
||||
; Path to the router macaroon
|
||||
; routerrpc.routermacaroonpath=~/.lnd/data/chain/bitcoin/simnet/router.macaroon
|
||||
; Defines the duration after which a penalized node or channel is back at 50%
|
||||
; probability (default: 1h0m0s)
|
||||
; routerrpc.apriori.penaltyhalflife=2h
|
||||
|
||||
; Describes the scale over which channels still have some liquidity left on
|
||||
; both channel ends. A very low value (compared to typical channel capacities)
|
||||
; means that we assume unbalanced channels, a very high value means randomly
|
||||
; balanced channels. Value in msat. (default: 300000000)
|
||||
; routerrpc.bimodal.scale=1000000000
|
||||
|
||||
; Defines how strongly non-routed channels of forwarders should be taken into
|
||||
; account for probability estimation. A weight of zero disables this feature.
|
||||
; Valid values are in [0, 1]. (default: 0.2)
|
||||
; routerrpc.bimodal.nodeweight=0.3
|
||||
|
||||
; Defines the information decay of knowledge about previous successes and
|
||||
; failures in channels. (default: 168h0m0s)
|
||||
; routerrpc.bimodal.decaytime=72h
|
||||
|
||||
|
||||
[workers]
|
||||
|
|
60
server.go
60
server.go
|
@ -868,20 +868,58 @@ func newServer(cfg *Config, listenAddrs []net.Addr,
|
|||
// servers, the mission control instance itself can be moved there too.
|
||||
routingConfig := routerrpc.GetRoutingConfig(cfg.SubRPCServers.RouterRPC)
|
||||
|
||||
estimatorCfg := routing.ProbabilityEstimatorCfg{
|
||||
AprioriHopProbability: routingConfig.AprioriHopProbability,
|
||||
PenaltyHalfLife: routingConfig.PenaltyHalfLife,
|
||||
AprioriWeight: routingConfig.AprioriWeight,
|
||||
// We only initialize a probability estimator if there's no custom one.
|
||||
var estimator routing.Estimator
|
||||
if cfg.Estimator != nil {
|
||||
estimator = cfg.Estimator
|
||||
} else {
|
||||
switch routingConfig.ProbabilityEstimatorType {
|
||||
case routing.AprioriEstimatorName:
|
||||
aCfg := routingConfig.AprioriConfig
|
||||
aprioriConfig := routing.AprioriConfig{
|
||||
AprioriHopProbability: aCfg.HopProbability,
|
||||
PenaltyHalfLife: aCfg.PenaltyHalfLife,
|
||||
AprioriWeight: aCfg.Weight,
|
||||
}
|
||||
|
||||
estimator, err = routing.NewAprioriEstimator(
|
||||
aprioriConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case routing.BimodalEstimatorName:
|
||||
bCfg := routingConfig.BimodalConfig
|
||||
bimodalConfig := routing.BimodalConfig{
|
||||
BimodalNodeWeight: bCfg.NodeWeight,
|
||||
BimodalScaleMsat: lnwire.MilliSatoshi(
|
||||
bCfg.Scale,
|
||||
),
|
||||
BimodalDecayTime: bCfg.DecayTime,
|
||||
}
|
||||
|
||||
estimator, err = routing.NewBimodalEstimator(
|
||||
bimodalConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown estimator type %v",
|
||||
routingConfig.ProbabilityEstimatorType)
|
||||
}
|
||||
}
|
||||
|
||||
mcCfg := &routing.MissionControlConfig{
|
||||
Estimator: estimator,
|
||||
MaxMcHistory: routingConfig.MaxMcHistory,
|
||||
McFlushInterval: routingConfig.McFlushInterval,
|
||||
MinFailureRelaxInterval: routing.DefaultMinFailureRelaxInterval,
|
||||
}
|
||||
s.missionControl, err = routing.NewMissionControl(
|
||||
dbs.ChanStateDB, selfNode.PubKeyBytes,
|
||||
&routing.MissionControlConfig{
|
||||
ProbabilityEstimatorCfg: estimatorCfg,
|
||||
MaxMcHistory: routingConfig.MaxMcHistory,
|
||||
McFlushInterval: routingConfig.McFlushInterval,
|
||||
MinFailureRelaxInterval: routing.DefaultMinFailureRelaxInterval,
|
||||
},
|
||||
dbs.ChanStateDB, selfNode.PubKeyBytes, mcCfg,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't create mission control: %v", err)
|
||||
|
|
Loading…
Add table
Reference in a new issue