lnd/autopilot/choice_test.go
2020-04-24 19:15:08 +02:00

339 lines
8.0 KiB
Go

package autopilot
import (
"encoding/binary"
"math/rand"
"reflect"
"testing"
"testing/quick"
)
// TestWeightedChoiceEmptyMap tests that passing in an empty slice of weights
// returns an error.
func TestWeightedChoiceEmptyMap(t *testing.T) {
t.Parallel()
var w []float64
_, err := weightedChoice(w)
if err != ErrNoPositive {
t.Fatalf("expected ErrNoPositive when choosing in "+
"empty map, instead got %v", err)
}
}
// singeNonZero is a type used to generate float64 slices with one non-zero
// element.
type singleNonZero []float64
// Generate generates a value of type sinelNonZero to be used during
// QuickTests.
func (singleNonZero) Generate(rand *rand.Rand, size int) reflect.Value {
w := make([]float64, size)
// Pick a random index and set it to a random float.
i := rand.Intn(size)
w[i] = rand.Float64()
return reflect.ValueOf(w)
}
// TestWeightedChoiceSingleIndex tests that choosing randomly in a slice with
// one positive element always returns that one index.
func TestWeightedChoiceSingleIndex(t *testing.T) {
t.Parallel()
// Helper that returns the index of the non-zero element.
allButOneZero := func(weights []float64) (bool, int) {
var (
numZero uint32
nonZeroEl int
)
for i, w := range weights {
if w != 0 {
numZero++
nonZeroEl = i
}
}
return numZero == 1, nonZeroEl
}
property := func(weights singleNonZero) bool {
// Make sure the generated slice has exactly one non-zero
// element.
conditionMet, nonZeroElem := allButOneZero(weights[:])
if !conditionMet {
return false
}
// Call weightedChoice and assert it picks the non-zero
// element.
choice, err := weightedChoice(weights[:])
if err != nil {
return false
}
return choice == nonZeroElem
}
if err := quick.Check(property, nil); err != nil {
t.Fatal(err)
}
}
// nonNegative is a type used to generate float64 slices with non-negative
// elements.
type nonNegative []float64
// Generate generates a value of type nonNegative to be used during
// QuickTests.
func (nonNegative) Generate(rand *rand.Rand, size int) reflect.Value {
w := make([]float64, size)
for i := range w {
r := rand.Float64()
// For very small weights it won't work to check deviation from
// expected value, so we set them to zero.
if r < 0.01*float64(size) {
r = 0
}
w[i] = float64(r)
}
return reflect.ValueOf(w)
}
func assertChoice(w []float64, iterations int) bool {
var sum float64
for _, v := range w {
sum += v
}
// Calculate the expected frequency of each choice.
expFrequency := make([]float64, len(w))
for i, ww := range w {
expFrequency[i] = ww / sum
}
chosen := make(map[int]int)
for i := 0; i < iterations; i++ {
res, err := weightedChoice(w)
if err != nil {
return false
}
chosen[res]++
}
// Since this is random we check that the number of times chosen is
// within 20% of the expected value.
totalChoices := 0
for i, f := range expFrequency {
exp := float64(iterations) * f
v := float64(chosen[i])
totalChoices += chosen[i]
expHigh := exp + exp/5
expLow := exp - exp/5
if v < expLow || v > expHigh {
return false
}
}
// The sum of choices must be exactly iterations of course.
return totalChoices == iterations
}
// TestWeightedChoiceDistribution asserts that the weighted choice algorithm
// chooses among indexes according to their scores.
func TestWeightedChoiceDistribution(t *testing.T) {
const iterations = 100000
property := func(weights nonNegative) bool {
return assertChoice(weights, iterations)
}
if err := quick.Check(property, nil); err != nil {
t.Fatal(err)
}
}
// TestChooseNEmptyMap checks that chooseN returns an empty result when no
// nodes are chosen among.
func TestChooseNEmptyMap(t *testing.T) {
t.Parallel()
nodes := map[NodeID]*NodeScore{}
property := func(n uint32) bool {
res, err := chooseN(n, nodes)
if err != nil {
return false
}
// Result should always be empty.
return len(res) == 0
}
if err := quick.Check(property, nil); err != nil {
t.Fatal(err)
}
}
// candidateMapVarLen is a type we'll use to generate maps of various lengths
// up to 255 to be used during QuickTests.
type candidateMapVarLen map[NodeID]*NodeScore
// Generate generates a value of type candidateMapVarLen to be used during
// QuickTests.
func (candidateMapVarLen) Generate(rand *rand.Rand, size int) reflect.Value {
nodes := make(map[NodeID]*NodeScore)
// To avoid creating huge maps, we restrict them to max uint8 len.
n := uint8(rand.Uint32())
for i := uint8(0); i < n; i++ {
s := rand.Float64()
// We set small values to zero, to ensure we handle these
// correctly.
if s < 0.01 {
s = 0
}
var nID [33]byte
binary.BigEndian.PutUint32(nID[:], uint32(i))
nodes[nID] = &NodeScore{
Score: s,
}
}
return reflect.ValueOf(nodes)
}
// TestChooseNMinimum test that chooseN returns the minimum of the number of
// nodes we request and the number of positively scored nodes in the given map.
func TestChooseNMinimum(t *testing.T) {
t.Parallel()
// Helper to count the number of positive scores in the given map.
numPositive := func(nodes map[NodeID]*NodeScore) int {
cnt := 0
for _, v := range nodes {
if v.Score > 0 {
cnt++
}
}
return cnt
}
// We use let the type of n be uint8 to avoid generating huge numbers.
property := func(nodes candidateMapVarLen, n uint8) bool {
res, err := chooseN(uint32(n), nodes)
if err != nil {
return false
}
positive := numPositive(nodes)
// Result should always be the minimum of the number of nodes
// we wanted to select and the number of positively scored
// nodes in the map.
min := positive
if int(n) < min {
min = int(n)
}
if len(res) != min {
return false
}
return true
}
if err := quick.Check(property, nil); err != nil {
t.Fatal(err)
}
}
// TestChooseNSample sanity checks that nodes are picked by chooseN according
// to their scores.
func TestChooseNSample(t *testing.T) {
t.Parallel()
const numNodes = 500
const maxIterations = 100000
fifth := uint32(numNodes / 5)
nodes := make(map[NodeID]*NodeScore)
// we make 5 buckets of nodes: 0, 0.1, 0.2, 0.4 and 0.8 score. We want
// to check that zero scores never gets chosen, while a doubling the
// score makes a node getting chosen about double the amount (this is
// true only when n <<< numNodes).
j := 2 * fifth
score := 0.1
for i := uint32(0); i < numNodes; i++ {
// Each time i surpasses j we double the score we give to the
// next fifth of nodes.
if i >= j {
score *= 2
j += fifth
}
s := score
// The first 1/5 of nodes we give a score of 0.
if i < fifth {
s = 0
}
var nID [33]byte
binary.BigEndian.PutUint32(nID[:], i)
nodes[nID] = &NodeScore{
Score: s,
}
}
// For each value of N we'll check that the nodes are picked the
// expected number of times over time.
for _, n := range []uint32{1, 5, 10, 20, 50} {
// Since choosing more nodes will result in chooseN getting
// slower we decrease the number of iterations. This is okay
// since the variance in the total picks for a node will be
// lower when choosing more nodes each time.
iterations := maxIterations / n
count := make(map[NodeID]int)
for i := 0; i < int(iterations); i++ {
res, err := chooseN(n, nodes)
if err != nil {
t.Fatalf("failed choosing nodes: %v", err)
}
for nID := range res {
count[nID]++
}
}
// Sum the number of times a node in each score bucket was
// picked.
sums := make(map[float64]int)
for nID, s := range nodes {
sums[s.Score] += count[nID]
}
// The count of each bucket should be about double of the
// previous bucket. Since this is all random, we check that
// the result is within 20% of the expected value.
for _, score := range []float64{0.2, 0.4, 0.8} {
cnt := sums[score]
half := cnt / 2
expLow := half - half/5
expHigh := half + half/5
if sums[score/2] < expLow || sums[score/2] > expHigh {
t.Fatalf("expected the nodes with score %v "+
"to be chosen about %v times, instead "+
"was %v", score/2, half, sums[score/2])
}
}
}
}