github.com/decred/dcrlnd@v0.7.6/autopilot/choice_test.go (about)

     1  package autopilot
     2  
     3  import (
     4  	"encoding/binary"
     5  	"math/rand"
     6  	"reflect"
     7  	"testing"
     8  	"testing/quick"
     9  )
    10  
    11  // TestWeightedChoiceEmptyMap tests that passing in an empty slice of weights
    12  // returns an error.
    13  func TestWeightedChoiceEmptyMap(t *testing.T) {
    14  	t.Parallel()
    15  
    16  	var w []float64
    17  	_, err := weightedChoice(w)
    18  	if err != ErrNoPositive {
    19  		t.Fatalf("expected ErrNoPositive when choosing in "+
    20  			"empty map, instead got %v", err)
    21  	}
    22  }
    23  
    24  // singeNonZero is a type used to generate float64 slices with one non-zero
    25  // element.
    26  type singleNonZero []float64
    27  
    28  // Generate generates a value of type sinelNonZero to be used during
    29  // QuickTests.
    30  func (singleNonZero) Generate(rand *rand.Rand, size int) reflect.Value {
    31  	w := make([]float64, size)
    32  
    33  	// Pick a random index and set it to a random float.
    34  	i := rand.Intn(size)
    35  	w[i] = rand.Float64()
    36  
    37  	return reflect.ValueOf(w)
    38  }
    39  
    40  // TestWeightedChoiceSingleIndex tests that choosing randomly in a slice with
    41  // one positive element always returns that one index.
    42  func TestWeightedChoiceSingleIndex(t *testing.T) {
    43  	t.Parallel()
    44  
    45  	// Helper that returns the index of the non-zero element.
    46  	allButOneZero := func(weights []float64) (bool, int) {
    47  		var (
    48  			numZero   uint32
    49  			nonZeroEl int
    50  		)
    51  
    52  		for i, w := range weights {
    53  			if w != 0 {
    54  				numZero++
    55  				nonZeroEl = i
    56  			}
    57  		}
    58  
    59  		return numZero == 1, nonZeroEl
    60  	}
    61  
    62  	property := func(weights singleNonZero) bool {
    63  		// Make sure the generated slice has exactly one non-zero
    64  		// element.
    65  		conditionMet, nonZeroElem := allButOneZero(weights[:])
    66  		if !conditionMet {
    67  			return false
    68  		}
    69  
    70  		// Call weightedChoice and assert it picks the non-zero
    71  		// element.
    72  		choice, err := weightedChoice(weights[:])
    73  		if err != nil {
    74  			return false
    75  		}
    76  		return choice == nonZeroElem
    77  	}
    78  
    79  	if err := quick.Check(property, nil); err != nil {
    80  		t.Fatal(err)
    81  	}
    82  }
    83  
    84  // nonNegative is a type used to generate float64 slices with non-negative
    85  // elements.
    86  type nonNegative []float64
    87  
    88  // Generate generates a value of type nonNegative to be used during
    89  // QuickTests.
    90  func (nonNegative) Generate(rand *rand.Rand, size int) reflect.Value {
    91  	w := make([]float64, size)
    92  
    93  	for i := range w {
    94  		r := rand.Float64()
    95  
    96  		// For very small weights it won't work to check deviation from
    97  		// expected value, so we set them to zero.
    98  		if r < 0.01*float64(size) {
    99  			r = 0
   100  		}
   101  		w[i] = r
   102  	}
   103  	return reflect.ValueOf(w)
   104  }
   105  
   106  func assertChoice(w []float64, iterations int) bool {
   107  	var sum float64
   108  	for _, v := range w {
   109  		sum += v
   110  	}
   111  
   112  	// Calculate the expected frequency of each choice.
   113  	expFrequency := make([]float64, len(w))
   114  	for i, ww := range w {
   115  		expFrequency[i] = ww / sum
   116  	}
   117  
   118  	chosen := make(map[int]int)
   119  	for i := 0; i < iterations; i++ {
   120  		res, err := weightedChoice(w)
   121  		if err != nil {
   122  			return false
   123  		}
   124  		chosen[res]++
   125  	}
   126  
   127  	// Since this is random we check that the number of times chosen is
   128  	// within 20% of the expected value.
   129  	totalChoices := 0
   130  	for i, f := range expFrequency {
   131  		exp := float64(iterations) * f
   132  		v := float64(chosen[i])
   133  		totalChoices += chosen[i]
   134  		expHigh := exp + exp/5
   135  		expLow := exp - exp/5
   136  		if v < expLow || v > expHigh {
   137  			return false
   138  		}
   139  	}
   140  
   141  	// The sum of choices must be exactly iterations of course.
   142  	return totalChoices == iterations
   143  }
   144  
   145  // TestWeightedChoiceDistribution asserts that the weighted choice algorithm
   146  // chooses among indexes according to their scores.
   147  func TestWeightedChoiceDistribution(t *testing.T) {
   148  	const iterations = 100000
   149  
   150  	property := func(weights nonNegative) bool {
   151  		return assertChoice(weights, iterations)
   152  	}
   153  
   154  	if err := quick.Check(property, nil); err != nil {
   155  		t.Fatal(err)
   156  	}
   157  }
   158  
   159  // TestChooseNEmptyMap checks that chooseN returns an empty result when no
   160  // nodes are chosen among.
   161  func TestChooseNEmptyMap(t *testing.T) {
   162  	t.Parallel()
   163  
   164  	nodes := map[NodeID]*NodeScore{}
   165  	property := func(n uint32) bool {
   166  		res, err := chooseN(n, nodes)
   167  		if err != nil {
   168  			return false
   169  		}
   170  
   171  		// Result should always be empty.
   172  		return len(res) == 0
   173  	}
   174  
   175  	if err := quick.Check(property, nil); err != nil {
   176  		t.Fatal(err)
   177  	}
   178  }
   179  
   180  // candidateMapVarLen is a type we'll use to generate maps of various lengths
   181  // up to 255 to be used during QuickTests.
   182  type candidateMapVarLen map[NodeID]*NodeScore
   183  
   184  // Generate generates a value of type candidateMapVarLen to be used during
   185  // QuickTests.
   186  func (candidateMapVarLen) Generate(rand *rand.Rand, size int) reflect.Value {
   187  	nodes := make(map[NodeID]*NodeScore)
   188  
   189  	// To avoid creating huge maps, we restrict them to max uint8 len.
   190  	n := uint8(rand.Uint32())
   191  
   192  	for i := uint8(0); i < n; i++ {
   193  		s := rand.Float64()
   194  
   195  		// We set small values to zero, to ensure we handle these
   196  		// correctly.
   197  		if s < 0.01 {
   198  			s = 0
   199  		}
   200  
   201  		var nID [33]byte
   202  		binary.BigEndian.PutUint32(nID[:], uint32(i))
   203  		nodes[nID] = &NodeScore{
   204  			Score: s,
   205  		}
   206  	}
   207  
   208  	return reflect.ValueOf(nodes)
   209  }
   210  
   211  // TestChooseNMinimum test that chooseN returns the minimum of the number of
   212  // nodes we request and the number of positively scored nodes in the given map.
   213  func TestChooseNMinimum(t *testing.T) {
   214  	t.Parallel()
   215  
   216  	// Helper to count the number of positive scores in the given map.
   217  	numPositive := func(nodes map[NodeID]*NodeScore) int {
   218  		cnt := 0
   219  		for _, v := range nodes {
   220  			if v.Score > 0 {
   221  				cnt++
   222  			}
   223  		}
   224  		return cnt
   225  	}
   226  
   227  	// We use let the type of n be uint8 to avoid generating huge numbers.
   228  	property := func(nodes candidateMapVarLen, n uint8) bool {
   229  		res, err := chooseN(uint32(n), nodes)
   230  		if err != nil {
   231  			return false
   232  		}
   233  
   234  		positive := numPositive(nodes)
   235  
   236  		// Result should always be the minimum of the number of nodes
   237  		// we wanted to select and the number of positively scored
   238  		// nodes in the map.
   239  		min := positive
   240  		if int(n) < min {
   241  			min = int(n)
   242  		}
   243  
   244  		if len(res) != min {
   245  			return false
   246  
   247  		}
   248  		return true
   249  	}
   250  
   251  	if err := quick.Check(property, nil); err != nil {
   252  		t.Fatal(err)
   253  	}
   254  }
   255  
   256  // TestChooseNSample sanity checks that nodes are picked by chooseN according
   257  // to their scores.
   258  func TestChooseNSample(t *testing.T) {
   259  	t.Parallel()
   260  
   261  	const numNodes = 500
   262  	const maxIterations = 100000
   263  	fifth := uint32(numNodes / 5)
   264  
   265  	nodes := make(map[NodeID]*NodeScore)
   266  
   267  	// we make 5 buckets of nodes: 0, 0.1, 0.2, 0.4 and 0.8 score. We want
   268  	// to check that zero scores never gets chosen, while a doubling the
   269  	// score makes a node getting chosen about double the amount (this is
   270  	// true only when n <<< numNodes).
   271  	j := 2 * fifth
   272  	score := 0.1
   273  	for i := uint32(0); i < numNodes; i++ {
   274  
   275  		// Each time i surpasses j we double the score we give to the
   276  		// next fifth of nodes.
   277  		if i >= j {
   278  			score *= 2
   279  			j += fifth
   280  		}
   281  		s := score
   282  
   283  		// The first 1/5 of nodes we give a score of 0.
   284  		if i < fifth {
   285  			s = 0
   286  		}
   287  
   288  		var nID [33]byte
   289  		binary.BigEndian.PutUint32(nID[:], i)
   290  		nodes[nID] = &NodeScore{
   291  			Score: s,
   292  		}
   293  	}
   294  
   295  	// For each value of N we'll check that the nodes are picked the
   296  	// expected number of times over time.
   297  	for _, n := range []uint32{1, 5, 10, 20, 50} {
   298  		// Since choosing more nodes will result in chooseN getting
   299  		// slower we decrease the number of iterations. This is okay
   300  		// since the variance in the total picks for a node will be
   301  		// lower when choosing more nodes each time.
   302  		iterations := maxIterations / n
   303  		count := make(map[NodeID]int)
   304  		for i := 0; i < int(iterations); i++ {
   305  			res, err := chooseN(n, nodes)
   306  			if err != nil {
   307  				t.Fatalf("failed choosing nodes: %v", err)
   308  			}
   309  
   310  			for nID := range res {
   311  				count[nID]++
   312  			}
   313  		}
   314  
   315  		// Sum the number of times a node in each score bucket was
   316  		// picked.
   317  		sums := make(map[float64]int)
   318  		for nID, s := range nodes {
   319  			sums[s.Score] += count[nID]
   320  		}
   321  
   322  		// The count of each bucket should be about double of the
   323  		// previous bucket.  Since this is all random, we check that
   324  		// the result is within 20% of the expected value.
   325  		for _, score := range []float64{0.2, 0.4, 0.8} {
   326  			cnt := sums[score]
   327  			half := cnt / 2
   328  			expLow := half - half/5
   329  			expHigh := half + half/5
   330  			if sums[score/2] < expLow || sums[score/2] > expHigh {
   331  				t.Fatalf("expected the nodes with score %v "+
   332  					"to be chosen about %v times, instead "+
   333  					"was %v", score/2, half, sums[score/2])
   334  			}
   335  		}
   336  	}
   337  }