github.com/onflow/flow-go@v0.35.7-crescendo-preview.23-atree-inlining/consensus/hotstuff/committees/leader/leader_selection_test.go (about)

     1  package leader
     2  
     3  import (
     4  	"fmt"
     5  	"math/rand"
     6  	"sort"
     7  	"testing"
     8  
     9  	"github.com/onflow/crypto/random"
    10  	"github.com/stretchr/testify/assert"
    11  	"github.com/stretchr/testify/require"
    12  
    13  	"github.com/onflow/flow-go/model/flow"
    14  	"github.com/onflow/flow-go/model/flow/filter"
    15  	"github.com/onflow/flow-go/utils/unittest"
    16  )
    17  
    18  var someSeed = []uint8{0x6A, 0x23, 0x41, 0xB7, 0x80, 0xE1, 0x64, 0x59,
    19  	0x6A, 0x53, 0x40, 0xB7, 0x80, 0xE4, 0x64, 0x5C,
    20  	0x66, 0x53, 0x41, 0xB7, 0x80, 0xE1, 0x64, 0x51,
    21  	0xAA, 0x53, 0x40, 0xB7, 0x80, 0xE4, 0x64, 0x50}
    22  
    23  // We test that leader selection works for a committee of size one
    24  func TestSingleConsensusNode(t *testing.T) {
    25  
    26  	identity := unittest.IdentityFixture(unittest.WithInitialWeight(8))
    27  	rng := getPRG(t, someSeed)
    28  	selection, err := ComputeLeaderSelection(0, rng, 10, flow.IdentitySkeletonList{&identity.IdentitySkeleton})
    29  	require.NoError(t, err)
    30  	for i := uint64(0); i < 10; i++ {
    31  		leaderID, err := selection.LeaderForView(i)
    32  		require.NoError(t, err)
    33  		require.Equal(t, identity.NodeID, leaderID)
    34  	}
    35  }
    36  
    37  // compare this binary search method with sort.Search()
    38  func TestBsearchVSsortSearch(t *testing.T) {
    39  	weights := []uint64{1, 2, 3, 4, 5, 6, 7, 9, 12, 21, 32}
    40  	weights2 := []int{1, 2, 3, 4, 5, 6, 7, 9, 12, 21, 32}
    41  	var sum uint64
    42  	var sum2 int
    43  	sums := make([]uint64, 0)
    44  	sums2 := make([]int, 0)
    45  	for i := 0; i < len(weights); i++ {
    46  		sum += weights[i]
    47  		sum2 += weights2[i]
    48  		sums = append(sums, sum)
    49  		sums2 = append(sums2, sum2)
    50  	}
    51  	sel := make([]int, 0, 10)
    52  	for i := 0; i < 10; i++ {
    53  		index := binarySearchStrictlyBigger(uint64(i), sums)
    54  		sel = append(sel, index)
    55  	}
    56  
    57  	sel2 := make([]int, 0, 10)
    58  	for i2 := 1; i2 < 11; i2++ {
    59  		index := sort.SearchInts(sums2, i2)
    60  		sel2 = append(sel2, index)
    61  	}
    62  
    63  	require.Equal(t, sel, sel2)
    64  }
    65  
    66  // Test binary search implementation
    67  func TestBsearch(t *testing.T) {
    68  	weights := []uint64{1, 2, 3, 4}
    69  	var sum uint64
    70  	sums := make([]uint64, 0)
    71  	for i := 0; i < len(weights); i++ {
    72  		sum += weights[i]
    73  		sums = append(sums, sum)
    74  	}
    75  	sel := make([]int, 0, 10)
    76  	for i := 0; i < 10; i++ {
    77  		index := binarySearchStrictlyBigger(uint64(i), sums)
    78  		sel = append(sel, index)
    79  	}
    80  	require.Equal(t, []int{0, 1, 1, 2, 2, 2, 3, 3, 3, 3}, sel)
    81  }
    82  
    83  // compare the result of binary search with the brute force search,
    84  // should be the same.
    85  func TestBsearchWithNormalSearch(t *testing.T) {
    86  	count := 100
    87  	sums := make([]uint64, 0, count)
    88  	sum := 0
    89  	for i := 0; i < count; i++ {
    90  		sum += i
    91  		sums = append(sums, uint64(sum))
    92  	}
    93  
    94  	var value uint64
    95  	total := sums[len(sums)-1]
    96  	for value = 0; value < total; value++ {
    97  		expected, err := bruteSearch(value, sums)
    98  		require.NoError(t, err)
    99  
   100  		actual := binarySearchStrictlyBigger(value, sums)
   101  		require.NoError(t, err)
   102  
   103  		require.Equal(t, expected, actual)
   104  	}
   105  }
   106  
   107  func bruteSearch(value uint64, arr []uint64) (int, error) {
   108  	// value ranges from [arr[0], arr[len(arr) -1 ]) exclusive
   109  	for i, a := range arr {
   110  		if a > value {
   111  			return i, nil
   112  		}
   113  	}
   114  	return 0, fmt.Errorf("not found")
   115  }
   116  
   117  func getPRG(t testing.TB, seed []byte) random.Rand {
   118  	rng, err := random.NewChacha20PRG(seed, []byte("random"))
   119  	require.NoError(t, err)
   120  	return rng
   121  }
   122  
   123  // Test given the same seed, the leader selection will produce the same selection
   124  func TestDeterministic(t *testing.T) {
   125  
   126  	const N_VIEWS = 100
   127  	const N_NODES = 4
   128  
   129  	identities := unittest.IdentityListFixture(N_NODES).ToSkeleton()
   130  	for i, identity := range identities {
   131  		identity.InitialWeight = uint64(i + 1)
   132  	}
   133  	rng := getPRG(t, someSeed)
   134  
   135  	leaders1, err := ComputeLeaderSelection(0, rng, N_VIEWS, identities)
   136  	require.NoError(t, err)
   137  
   138  	rng = getPRG(t, someSeed)
   139  
   140  	leaders2, err := ComputeLeaderSelection(0, rng, N_VIEWS, identities)
   141  	require.NoError(t, err)
   142  
   143  	for i := 0; i < N_VIEWS; i++ {
   144  		l1, err := leaders1.LeaderForView(uint64(i))
   145  		require.NoError(t, err)
   146  
   147  		l2, err := leaders2.LeaderForView(uint64(i))
   148  		require.NoError(t, err)
   149  
   150  		require.Equal(t, l1, l2)
   151  	}
   152  }
   153  
   154  func TestInputValidation(t *testing.T) {
   155  
   156  	rng := getPRG(t, someSeed)
   157  
   158  	// should return an error if we request to compute leader selection for <1 views
   159  	t.Run("epoch containing no views", func(t *testing.T) {
   160  		count := 0
   161  		_, err := ComputeLeaderSelection(0, rng, count, unittest.IdentityListFixture(4).ToSkeleton())
   162  		assert.Error(t, err)
   163  		count = -1
   164  		_, err = ComputeLeaderSelection(0, rng, count, unittest.IdentityListFixture(4).ToSkeleton())
   165  		assert.Error(t, err)
   166  	})
   167  
   168  	// epoch with no possible leaders should return an error
   169  	t.Run("epoch without participants", func(t *testing.T) {
   170  		identities := unittest.IdentityListFixture(0).ToSkeleton()
   171  		_, err := ComputeLeaderSelection(0, rng, 100, identities)
   172  		assert.Error(t, err)
   173  	})
   174  }
   175  
   176  // test that requesting a view outside the given range returns an error
   177  func TestViewOutOfRange(t *testing.T) {
   178  
   179  	rng := getPRG(t, someSeed)
   180  
   181  	firstView := uint64(100)
   182  	finalView := uint64(200)
   183  
   184  	identities := unittest.IdentityListFixture(4).ToSkeleton()
   185  	leaders, err := ComputeLeaderSelection(firstView, rng, int(finalView-firstView+1), identities)
   186  	require.Nil(t, err)
   187  
   188  	// confirm the selection has first/final view we expect
   189  	assert.Equal(t, firstView, leaders.FirstView())
   190  	assert.Equal(t, finalView, leaders.FinalView())
   191  
   192  	// boundary views should not return error
   193  	t.Run("boundary views", func(t *testing.T) {
   194  		_, err = leaders.LeaderForView(firstView)
   195  		assert.Nil(t, err)
   196  		_, err = leaders.LeaderForView(finalView)
   197  		assert.Nil(t, err)
   198  	})
   199  
   200  	// views before first view should return error
   201  	t.Run("before first view", func(t *testing.T) {
   202  		before := firstView - 1 // 1 before first view
   203  		_, err = leaders.LeaderForView(before)
   204  		assert.Error(t, err)
   205  
   206  		before = uint64(rand.Intn(int(firstView))) // random view before first view
   207  		_, err = leaders.LeaderForView(before)
   208  		assert.Error(t, err)
   209  	})
   210  
   211  	// views after final view should return error
   212  	t.Run("after final view", func(t *testing.T) {
   213  		after := finalView + 1 // 1 after final view
   214  		_, err = leaders.LeaderForView(after)
   215  		assert.Error(t, err)
   216  
   217  		after = finalView + uint64(rand.Uint32()) + 1 // random view after final view
   218  		_, err = leaders.LeaderForView(after)
   219  		assert.Error(t, err)
   220  	})
   221  }
   222  
   223  func TestDifferentSeedWillProduceDifferentSelection(t *testing.T) {
   224  
   225  	const N_VIEWS = 100
   226  	const N_NODES = 4
   227  
   228  	identities := unittest.IdentityListFixture(N_NODES)
   229  	for i, identity := range identities {
   230  		identity.InitialWeight = uint64(i)
   231  	}
   232  
   233  	rng1 := getPRG(t, someSeed)
   234  
   235  	seed2 := make([]byte, 32)
   236  	seed2[0] = 8
   237  	rng2 := getPRG(t, seed2)
   238  
   239  	leaders1, err := ComputeLeaderSelection(0, rng1, N_VIEWS, identities.ToSkeleton())
   240  	require.NoError(t, err)
   241  
   242  	leaders2, err := ComputeLeaderSelection(0, rng2, N_VIEWS, identities.ToSkeleton())
   243  	require.NoError(t, err)
   244  
   245  	diff := 0
   246  	for view := 0; view < N_VIEWS; view++ {
   247  		l1, err := leaders1.LeaderForView(uint64(view))
   248  		require.NoError(t, err)
   249  
   250  		l2, err := leaders2.LeaderForView(uint64(view))
   251  		require.NoError(t, err)
   252  
   253  		if l1 != l2 {
   254  			diff++
   255  		}
   256  	}
   257  
   258  	require.True(t, diff > 0)
   259  }
   260  
   261  // given a random seed and certain weights, measure the chance each identity selected as leader.
   262  // The number of time being selected as leader might not exactly match their weight, but also
   263  // won't go too far from that.
   264  func TestLeaderSelectionAreWeighted(t *testing.T) {
   265  	rng := getPRG(t, someSeed)
   266  
   267  	const N_VIEWS = 100000
   268  	const N_NODES = 4
   269  
   270  	identities := unittest.IdentityListFixture(N_NODES).ToSkeleton()
   271  	for i, identity := range identities {
   272  		identity.InitialWeight = uint64(i + 1)
   273  	}
   274  
   275  	leaders, err := ComputeLeaderSelection(0, rng, N_VIEWS, identities)
   276  	require.NoError(t, err)
   277  
   278  	selected := make(map[flow.Identifier]uint64)
   279  	for view := 0; view < N_VIEWS; view++ {
   280  		nodeID, err := leaders.LeaderForView(uint64(view))
   281  		require.NoError(t, err)
   282  
   283  		selected[nodeID]++
   284  	}
   285  
   286  	fmt.Printf("selected for weights [1,2,3,4]: %v\n", selected)
   287  	for nodeID, selectedCount := range selected {
   288  		identity, ok := identities.ByNodeID(nodeID)
   289  		require.True(t, ok)
   290  		target := uint64(N_VIEWS) * identity.InitialWeight / 10
   291  
   292  		var diff uint64
   293  		if selectedCount > target {
   294  			diff = selectedCount - target
   295  		} else {
   296  			diff = target - selectedCount
   297  		}
   298  
   299  		// difference should be less than 2%
   300  		stdDiff := N_VIEWS / 10 * 2 / 100
   301  		require.Less(t, diff, uint64(stdDiff))
   302  	}
   303  }
   304  
   305  func BenchmarkLeaderSelection(b *testing.B) {
   306  
   307  	const N_VIEWS = 15000000
   308  	const N_NODES = 20
   309  
   310  	identities := make(flow.IdentityList, 0, N_NODES)
   311  	for i := 0; i < N_NODES; i++ {
   312  		identities = append(identities, unittest.IdentityFixture(unittest.WithInitialWeight(uint64(i))))
   313  	}
   314  	skeletonIdentities := identities.ToSkeleton()
   315  	rng := getPRG(b, someSeed)
   316  
   317  	for n := 0; n < b.N; n++ {
   318  		_, err := ComputeLeaderSelection(0, rng, N_VIEWS, skeletonIdentities)
   319  
   320  		require.NoError(b, err)
   321  	}
   322  }
   323  
   324  func TestInvalidTotalWeight(t *testing.T) {
   325  	rng := getPRG(t, someSeed)
   326  	identities := unittest.IdentityListFixture(4, unittest.WithInitialWeight(0))
   327  	_, err := ComputeLeaderSelection(0, rng, 10, identities.ToSkeleton())
   328  	require.Error(t, err)
   329  }
   330  
   331  func TestZeroWeightNodeWillNotBeSelected(t *testing.T) {
   332  
   333  	// create 2 RNGs from the same seed
   334  	rng := getPRG(t, someSeed)
   335  	rng_copy := getPRG(t, someSeed)
   336  
   337  	// check that if there is some node with 0 weight, the selections for each view should be the same as
   338  	// with no zero-weight nodes.
   339  	t.Run("small dataset", func(t *testing.T) {
   340  		const N_VIEWS = 100
   341  
   342  		weightless := unittest.IdentityListFixture(5, unittest.WithInitialWeight(0)).ToSkeleton()
   343  		weightful := unittest.IdentityListFixture(5).ToSkeleton()
   344  		for i, identity := range weightful {
   345  			identity.InitialWeight = uint64(i + 1)
   346  		}
   347  
   348  		identities := append(weightless, weightful...)
   349  
   350  		selectionFromAll, err := ComputeLeaderSelection(0, rng, N_VIEWS, identities)
   351  		require.NoError(t, err)
   352  
   353  		selectionFromWeightful, err := ComputeLeaderSelection(0, rng_copy, N_VIEWS, weightful)
   354  		require.NoError(t, err)
   355  
   356  		for i := 0; i < N_VIEWS; i++ {
   357  			nodeIDFromAll, err := selectionFromAll.LeaderForView(uint64(i))
   358  			require.NoError(t, err)
   359  
   360  			nodeIDFromWeightful, err := selectionFromWeightful.LeaderForView(uint64(i))
   361  			require.NoError(t, err)
   362  
   363  			// the selection should be the same
   364  			require.Equal(t, nodeIDFromAll, nodeIDFromWeightful)
   365  		}
   366  	})
   367  
   368  	t.Run("fuzzy set", func(t *testing.T) {
   369  		toolRng := getPRG(t, someSeed)
   370  
   371  		// create 1002 nodes with all 0 weight
   372  		fullIdentities := unittest.IdentityListFixture(1002, unittest.WithInitialWeight(0))
   373  
   374  		// create 2 nodes with 1 weight, and place them in between
   375  		// index 233-777
   376  		n := toolRng.UintN(777-233) + 233
   377  		m := toolRng.UintN(777-233) + 233
   378  		fullIdentities[n].InitialWeight = 1
   379  		fullIdentities[m].InitialWeight = 1
   380  
   381  		// the following code checks that zero-weight nodes are not selected (selection probability is proportional to weight)
   382  		votingConsensusNodes := fullIdentities.Filter(filter.HasInitialWeight[flow.Identity](true)).ToSkeleton()
   383  		allEpochConsensusNodes := fullIdentities.ToSkeleton() // including zero-weight nodes
   384  
   385  		count := 1000
   386  		selectionFromAll, err := ComputeLeaderSelection(0, rng, count, allEpochConsensusNodes)
   387  		require.NoError(t, err)
   388  
   389  		selectionFromWeightful, err := ComputeLeaderSelection(0, rng_copy, count, votingConsensusNodes)
   390  		require.NoError(t, err)
   391  
   392  		for i := 0; i < count; i++ {
   393  			nodeIDFromAll, err := selectionFromAll.LeaderForView(uint64(i))
   394  			require.NoError(t, err)
   395  
   396  			nodeIDFromWeightful, err := selectionFromWeightful.LeaderForView(uint64(i))
   397  			require.NoError(t, err)
   398  
   399  			// the selection should be the same
   400  			require.Equal(t, nodeIDFromWeightful, nodeIDFromAll)
   401  		}
   402  
   403  		t.Run("if there is only 1 node has weight, then it will be always be the leader and the only leader", func(t *testing.T) {
   404  			toolRng := getPRG(t, someSeed)
   405  
   406  			identities := unittest.IdentityListFixture(1000, unittest.WithInitialWeight(0)).ToSkeleton()
   407  
   408  			n := rng.UintN(1000)
   409  			weight := n + 1
   410  			identities[n].InitialWeight = weight
   411  			onlyNodeWithWeight := identities[n]
   412  
   413  			selections, err := ComputeLeaderSelection(0, toolRng, 1000, identities)
   414  			require.NoError(t, err)
   415  
   416  			for i := 0; i < 1000; i++ {
   417  				nodeID, err := selections.LeaderForView(uint64(i))
   418  				require.NoError(t, err)
   419  				require.Equal(t, onlyNodeWithWeight.NodeID, nodeID)
   420  			}
   421  		})
   422  	})
   423  }