mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
Add Phase 2-3 poll edge case tests and clean up orphan code
Phase 2 tests (5 critical edge cases): - TestRecordPollSplitVote: Split votes don't change preference - TestRecordPollWhenFinalized: Safe no-op after finalization - TestRecordPollInvalidVote: Unknown block IDs ignored - TestRecordPollChangePreferredChain: Preference switching works - TestRecordPollWithDefaultParams: Production parameters validated Phase 3 tests (2 advanced scenarios): - TestRecordPollDivergedVoting: Network converges from divergence - TestRecordPollRegressionIndegree: No double-counting in votes Infrastructure: - Convert bag.Bag to generic type [T comparable] for flexibility - Promote gonum from indirect to direct dependency - Clean up orphan directories (voting_backup, voting_fpc, test-nnary) - Remove orphan test files (test_binary, test_nnary_direct.go) All 16 new tests passing (9 Phase 1 + 7 Phase 2-3) Total chain engine coverage: Status tracking + block addition + poll edges
This commit is contained in:
@@ -0,0 +1,619 @@
|
||||
// Copyright (C) 2019-2025, Lux Partners Limited All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Phase 2 Tests: Poll Edge Cases (5 tests - HIGH PRIORITY)
|
||||
// ============================================================================
|
||||
|
||||
// TestRecordPollSplitVote verifies that split votes (no supermajority) don't
|
||||
// change preference or finalize blocks. Tests exact threshold boundary behavior.
|
||||
func TestRecordPollSplitVote(t *testing.T) {
|
||||
require := require.New(t)
|
||||
engine := NewTransitiveEngine()
|
||||
ctx := context.Background()
|
||||
|
||||
// K=5, Alpha=3 means we need 3 votes to reach threshold
|
||||
params := TransitiveParams{
|
||||
K: 5,
|
||||
AlphaPreference: 3,
|
||||
AlphaConfidence: 3,
|
||||
Beta: 1,
|
||||
}
|
||||
|
||||
genesisID := ids.GenerateTestID()
|
||||
genesisHeight := uint64(0)
|
||||
genesisTime := time.Now()
|
||||
|
||||
require.NoError(engine.Initialize(ctx, params, genesisID, genesisHeight, genesisTime))
|
||||
|
||||
// Add two competing blocks at same height
|
||||
block1 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
block2 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
|
||||
require.NoError(engine.Add(block1))
|
||||
require.NoError(engine.Add(block2))
|
||||
|
||||
// Record split vote: K=5 total, 2 for block1, 2 for block2, 1 abstain
|
||||
// Neither reaches alpha threshold of 3
|
||||
votes := NewBag[ids.ID]()
|
||||
votes.AddCount(block1.ID(), 2)
|
||||
votes.AddCount(block2.ID(), 2)
|
||||
require.NoError(engine.RecordPoll(ctx, votes))
|
||||
|
||||
// Preference should remain unchanged (or switch based on vote count, but not finalize)
|
||||
// Both blocks should still be processing (neither finalized)
|
||||
require.Equal(StatusProcessing, block1.Status())
|
||||
require.Equal(StatusProcessing, block2.Status())
|
||||
require.Equal(2, engine.NumProcessing())
|
||||
|
||||
// Confidence should be reset to 0 for both since neither reached threshold
|
||||
require.Zero(engine.confidence[block1.ID()])
|
||||
require.Zero(engine.confidence[block2.ID()])
|
||||
|
||||
// Verify no block was finalized
|
||||
require.False(engine.finalized.Contains(block1.ID()))
|
||||
require.False(engine.finalized.Contains(block2.ID()))
|
||||
|
||||
// One more poll that still splits (2 vs 2)
|
||||
votes2 := NewBag[ids.ID]()
|
||||
votes2.AddCount(block1.ID(), 2)
|
||||
votes2.AddCount(block2.ID(), 2)
|
||||
require.NoError(engine.RecordPoll(ctx, votes2))
|
||||
|
||||
// Still no finalization
|
||||
require.Equal(2, engine.NumProcessing())
|
||||
require.Equal(StatusProcessing, block1.Status())
|
||||
require.Equal(StatusProcessing, block2.Status())
|
||||
}
|
||||
|
||||
// TestRecordPollWhenFinalized verifies that polling after finalization is
|
||||
// safe and acts as a no-op without changing state.
|
||||
func TestRecordPollWhenFinalized(t *testing.T) {
|
||||
require := require.New(t)
|
||||
engine := NewTransitiveEngine()
|
||||
ctx := context.Background()
|
||||
|
||||
params := TransitiveParams{
|
||||
K: 1,
|
||||
AlphaPreference: 1,
|
||||
AlphaConfidence: 1,
|
||||
Beta: 1,
|
||||
}
|
||||
|
||||
genesisID := ids.GenerateTestID()
|
||||
genesisHeight := uint64(0)
|
||||
genesisTime := time.Now()
|
||||
|
||||
require.NoError(engine.Initialize(ctx, params, genesisID, genesisHeight, genesisTime))
|
||||
|
||||
// Add and finalize a block
|
||||
block1 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
require.NoError(engine.Add(block1))
|
||||
|
||||
// Vote to accept block1
|
||||
votes := NewBag[ids.ID]()
|
||||
votes.Add(block1.ID())
|
||||
require.NoError(engine.RecordPoll(ctx, votes))
|
||||
|
||||
// Block should be finalized
|
||||
require.Equal(StatusAccepted, block1.Status())
|
||||
require.True(engine.finalized.Contains(block1.ID()))
|
||||
require.Zero(engine.NumProcessing())
|
||||
require.Equal(block1.ID(), engine.lastAccepted)
|
||||
|
||||
// Record another poll after finalization (should be no-op)
|
||||
votes2 := NewBag[ids.ID]()
|
||||
votes2.Add(block1.ID())
|
||||
err := engine.RecordPoll(ctx, votes2)
|
||||
|
||||
// Should not error
|
||||
require.NoError(err)
|
||||
|
||||
// State should remain unchanged
|
||||
require.Equal(StatusAccepted, block1.Status())
|
||||
require.True(engine.finalized.Contains(block1.ID()))
|
||||
require.Zero(engine.NumProcessing())
|
||||
require.Equal(block1.ID(), engine.lastAccepted)
|
||||
require.Equal(block1.ID(), engine.preference)
|
||||
|
||||
// Try voting for already finalized block multiple times
|
||||
for i := 0; i < 5; i++ {
|
||||
require.NoError(engine.RecordPoll(ctx, votes2))
|
||||
}
|
||||
|
||||
// Still no change
|
||||
require.Equal(StatusAccepted, block1.Status())
|
||||
require.Zero(engine.NumProcessing())
|
||||
}
|
||||
|
||||
// TestRecordPollInvalidVote verifies that votes for unknown/non-existent
|
||||
// block IDs are safely ignored without causing errors.
|
||||
func TestRecordPollInvalidVote(t *testing.T) {
|
||||
require := require.New(t)
|
||||
engine := NewTransitiveEngine()
|
||||
ctx := context.Background()
|
||||
|
||||
params := TransitiveParams{
|
||||
K: 2,
|
||||
AlphaPreference: 2,
|
||||
AlphaConfidence: 2,
|
||||
Beta: 1,
|
||||
}
|
||||
|
||||
genesisID := ids.GenerateTestID()
|
||||
genesisHeight := uint64(0)
|
||||
genesisTime := time.Now()
|
||||
|
||||
require.NoError(engine.Initialize(ctx, params, genesisID, genesisHeight, genesisTime))
|
||||
|
||||
// Add a valid block
|
||||
block1 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
require.NoError(engine.Add(block1))
|
||||
|
||||
// Create votes with both valid and invalid block IDs
|
||||
unknownBlock1 := ids.GenerateTestID()
|
||||
unknownBlock2 := ids.GenerateTestID()
|
||||
|
||||
votes := NewBag[ids.ID]()
|
||||
votes.Add(unknownBlock1) // Invalid - should be ignored
|
||||
votes.Add(unknownBlock2) // Invalid - should be ignored
|
||||
votes.AddCount(block1.ID(), 2) // Valid - should be processed
|
||||
|
||||
// Record poll with mixed valid/invalid votes
|
||||
err := engine.RecordPoll(ctx, votes)
|
||||
|
||||
// Should not error - invalid votes are silently ignored
|
||||
require.NoError(err)
|
||||
|
||||
// Valid votes should still be processed correctly
|
||||
require.Equal(StatusAccepted, block1.Status())
|
||||
require.Zero(engine.NumProcessing())
|
||||
|
||||
// Verify unknown blocks are not in the engine
|
||||
require.NotContains(engine.blocks, unknownBlock1)
|
||||
require.NotContains(engine.blocks, unknownBlock2)
|
||||
|
||||
// Test with only invalid votes (should be safe no-op)
|
||||
votesAllInvalid := NewBag[ids.ID]()
|
||||
votesAllInvalid.Add(ids.GenerateTestID())
|
||||
votesAllInvalid.Add(ids.GenerateTestID())
|
||||
|
||||
err = engine.RecordPoll(ctx, votesAllInvalid)
|
||||
require.NoError(err)
|
||||
|
||||
// State unchanged
|
||||
require.Equal(StatusAccepted, block1.Status())
|
||||
require.Zero(engine.NumProcessing())
|
||||
}
|
||||
|
||||
// TestRecordPollChangePreferredChain verifies that polling can switch preference
|
||||
// between competing chains and correctly build confidence for the new chain while
|
||||
// resetting confidence for the old chain.
|
||||
func TestRecordPollChangePreferredChain(t *testing.T) {
|
||||
require := require.New(t)
|
||||
engine := NewTransitiveEngine()
|
||||
ctx := context.Background()
|
||||
|
||||
// Beta=3 requires 3 rounds of voting to finalize
|
||||
params := TransitiveParams{
|
||||
K: 2,
|
||||
AlphaPreference: 2,
|
||||
AlphaConfidence: 2,
|
||||
Beta: 3,
|
||||
}
|
||||
|
||||
genesisID := ids.GenerateTestID()
|
||||
genesisHeight := uint64(0)
|
||||
genesisTime := time.Now()
|
||||
|
||||
require.NoError(engine.Initialize(ctx, params, genesisID, genesisHeight, genesisTime))
|
||||
|
||||
// Build two competing chains:
|
||||
// Chain A: genesis -> A1 -> A2
|
||||
// Chain B: genesis -> B1 -> B2
|
||||
blockA1 := &TestBlock{
|
||||
IDV: ids.ID{0xA1},
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
blockA2 := &TestBlock{
|
||||
IDV: ids.ID{0xA2},
|
||||
HeightV: 2,
|
||||
ParentV: blockA1.ID(),
|
||||
TimestampV: genesisTime.Add(2 * time.Second),
|
||||
}
|
||||
blockB1 := &TestBlock{
|
||||
IDV: ids.ID{0xB1},
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
blockB2 := &TestBlock{
|
||||
IDV: ids.ID{0xB2},
|
||||
HeightV: 2,
|
||||
ParentV: blockB1.ID(),
|
||||
TimestampV: genesisTime.Add(2 * time.Second),
|
||||
}
|
||||
|
||||
require.NoError(engine.Add(blockA1))
|
||||
require.NoError(engine.Add(blockA2))
|
||||
require.NoError(engine.Add(blockB1))
|
||||
require.NoError(engine.Add(blockB2))
|
||||
|
||||
// Initially prefer chain A
|
||||
votesA := NewBag[ids.ID]()
|
||||
votesA.AddCount(blockA2.ID(), 2)
|
||||
require.NoError(engine.RecordPoll(ctx, votesA))
|
||||
|
||||
// Preference should be A2
|
||||
require.Equal(blockA2.ID(), engine.preference)
|
||||
require.Equal(1, engine.confidence[blockA2.ID()])
|
||||
|
||||
// Build more confidence for A
|
||||
require.NoError(engine.RecordPoll(ctx, votesA))
|
||||
require.Equal(2, engine.confidence[blockA2.ID()])
|
||||
|
||||
// Now switch to chain B
|
||||
votesB := NewBag[ids.ID]()
|
||||
votesB.AddCount(blockB2.ID(), 2)
|
||||
require.NoError(engine.RecordPoll(ctx, votesB))
|
||||
|
||||
// Preference should switch to B2
|
||||
require.Equal(blockB2.ID(), engine.preference)
|
||||
|
||||
// Confidence for A2 should reset to 0
|
||||
require.Zero(engine.confidence[blockA2.ID()])
|
||||
|
||||
// Confidence for B2 should be 1
|
||||
require.Equal(1, engine.confidence[blockB2.ID()])
|
||||
|
||||
// Continue voting for B to finalize
|
||||
require.NoError(engine.RecordPoll(ctx, votesB))
|
||||
require.NoError(engine.RecordPoll(ctx, votesB))
|
||||
|
||||
// Chain B should be accepted, chain A rejected
|
||||
require.Equal(StatusRejected, blockA1.Status())
|
||||
require.Equal(StatusRejected, blockA2.Status())
|
||||
require.Equal(StatusAccepted, blockB1.Status())
|
||||
require.Equal(StatusAccepted, blockB2.Status())
|
||||
require.Zero(engine.NumProcessing())
|
||||
}
|
||||
|
||||
// TestRecordPollWithDefaultParams tests consensus with production-like parameters
|
||||
// to ensure the engine works correctly with realistic values.
|
||||
func TestRecordPollWithDefaultParams(t *testing.T) {
|
||||
require := require.New(t)
|
||||
engine := NewTransitiveEngine()
|
||||
ctx := context.Background()
|
||||
|
||||
// Production-like parameters: K=20, Alpha=15 (75% quorum), Beta=20
|
||||
params := TransitiveParams{
|
||||
K: 20,
|
||||
AlphaPreference: 15,
|
||||
AlphaConfidence: 15,
|
||||
Beta: 20,
|
||||
}
|
||||
|
||||
genesisID := ids.GenerateTestID()
|
||||
genesisHeight := uint64(0)
|
||||
genesisTime := time.Now()
|
||||
|
||||
require.NoError(engine.Initialize(ctx, params, genesisID, genesisHeight, genesisTime))
|
||||
|
||||
// Add multiple competing blocks
|
||||
block1 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
block2 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
|
||||
require.NoError(engine.Add(block1))
|
||||
require.NoError(engine.Add(block2))
|
||||
|
||||
// Simulate realistic voting where block1 gets supermajority (15/20 = 75%)
|
||||
votes := NewBag[ids.ID]()
|
||||
votes.AddCount(block1.ID(), 15)
|
||||
votes.AddCount(block2.ID(), 5)
|
||||
|
||||
// Vote repeatedly to build confidence (need Beta=20 rounds)
|
||||
for round := 0; round < params.Beta; round++ {
|
||||
require.NoError(engine.RecordPoll(ctx, votes))
|
||||
|
||||
// Check intermediate state
|
||||
if round < params.Beta-1 {
|
||||
// Before Beta rounds, should still be processing
|
||||
require.Equal(2, engine.NumProcessing())
|
||||
require.Equal(StatusProcessing, block1.Status())
|
||||
require.Equal(StatusProcessing, block2.Status())
|
||||
require.Equal(round+1, engine.confidence[block1.ID()])
|
||||
}
|
||||
}
|
||||
|
||||
// After Beta rounds with supermajority, block1 should be accepted
|
||||
require.Equal(StatusAccepted, block1.Status())
|
||||
require.Equal(StatusRejected, block2.Status())
|
||||
require.Zero(engine.NumProcessing())
|
||||
require.Equal(block1.ID(), engine.lastAccepted)
|
||||
|
||||
// Test that additional votes after finalization are safe
|
||||
require.NoError(engine.RecordPoll(ctx, votes))
|
||||
require.Equal(StatusAccepted, block1.Status())
|
||||
require.Zero(engine.NumProcessing())
|
||||
|
||||
// Add another block extending the accepted chain
|
||||
block3 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 2,
|
||||
ParentV: block1.ID(),
|
||||
TimestampV: genesisTime.Add(2 * time.Second),
|
||||
}
|
||||
require.NoError(engine.Add(block3))
|
||||
require.Equal(1, engine.NumProcessing())
|
||||
|
||||
// Vote to finalize block3 with supermajority
|
||||
votes2 := NewBag[ids.ID]()
|
||||
votes2.AddCount(block3.ID(), 16) // >75% quorum
|
||||
|
||||
for round := 0; round < params.Beta; round++ {
|
||||
require.NoError(engine.RecordPoll(ctx, votes2))
|
||||
}
|
||||
|
||||
// Block3 should now be accepted
|
||||
require.Equal(StatusAccepted, block3.Status())
|
||||
require.Zero(engine.NumProcessing())
|
||||
require.Equal(block3.ID(), engine.lastAccepted)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Phase 3 Tests: Advanced Scenarios (2 tests)
|
||||
// ============================================================================
|
||||
|
||||
// TestRecordPollDivergedVoting simulates a complex scenario where multiple
|
||||
// nodes have different initial preferences and tests whether the network
|
||||
// converges to a single choice through repeated polling.
|
||||
func TestRecordPollDivergedVoting(t *testing.T) {
|
||||
require := require.New(t)
|
||||
engine := NewTransitiveEngine()
|
||||
ctx := context.Background()
|
||||
|
||||
params := TransitiveParams{
|
||||
K: 5,
|
||||
AlphaPreference: 3,
|
||||
AlphaConfidence: 3,
|
||||
Beta: 3,
|
||||
}
|
||||
|
||||
genesisID := ids.GenerateTestID()
|
||||
genesisHeight := uint64(0)
|
||||
genesisTime := time.Now()
|
||||
|
||||
require.NoError(engine.Initialize(ctx, params, genesisID, genesisHeight, genesisTime))
|
||||
|
||||
// Create 3 competing blocks to simulate high divergence
|
||||
block1 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
block2 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
block3 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
|
||||
require.NoError(engine.Add(block1))
|
||||
require.NoError(engine.Add(block2))
|
||||
require.NoError(engine.Add(block3))
|
||||
|
||||
// Round 1: Highly diverged votes (2-2-1)
|
||||
round1 := NewBag[ids.ID]()
|
||||
round1.AddCount(block1.ID(), 2)
|
||||
round1.AddCount(block2.ID(), 2)
|
||||
round1.AddCount(block3.ID(), 1)
|
||||
require.NoError(engine.RecordPoll(ctx, round1))
|
||||
|
||||
// No block reaches threshold yet
|
||||
require.Equal(3, engine.NumProcessing())
|
||||
|
||||
// Round 2: Slight convergence (3-1-1)
|
||||
round2 := NewBag[ids.ID]()
|
||||
round2.AddCount(block1.ID(), 3)
|
||||
round2.AddCount(block2.ID(), 1)
|
||||
round2.AddCount(block3.ID(), 1)
|
||||
require.NoError(engine.RecordPoll(ctx, round2))
|
||||
|
||||
// Block1 reaches threshold, gains 1 confidence
|
||||
require.Equal(1, engine.confidence[block1.ID()])
|
||||
require.Equal(3, engine.NumProcessing())
|
||||
|
||||
// Round 3: More nodes converge (4-1-0)
|
||||
round3 := NewBag[ids.ID]()
|
||||
round3.AddCount(block1.ID(), 4)
|
||||
round3.AddCount(block2.ID(), 1)
|
||||
require.NoError(engine.RecordPoll(ctx, round3))
|
||||
|
||||
// Block1 gains more confidence
|
||||
require.Equal(2, engine.confidence[block1.ID()])
|
||||
|
||||
// Round 4: Strong convergence (5-0-0)
|
||||
round4 := NewBag[ids.ID]()
|
||||
round4.AddCount(block1.ID(), 5)
|
||||
require.NoError(engine.RecordPoll(ctx, round4))
|
||||
|
||||
// Block1 should now be finalized (confidence >= Beta)
|
||||
require.Equal(StatusAccepted, block1.Status())
|
||||
require.Equal(StatusRejected, block2.Status())
|
||||
require.Equal(StatusRejected, block3.Status())
|
||||
require.Zero(engine.NumProcessing())
|
||||
|
||||
// Verify convergence achieved
|
||||
require.Equal(block1.ID(), engine.lastAccepted)
|
||||
require.Equal(block1.ID(), engine.preference)
|
||||
}
|
||||
|
||||
// TestRecordPollRegressionIndegree is a regression test for indegree calculation
|
||||
// bugs. Ensures that when counting votes transitively, each child block is only
|
||||
// counted once per parent (no double-counting).
|
||||
func TestRecordPollRegressionIndegree(t *testing.T) {
|
||||
require := require.New(t)
|
||||
engine := NewTransitiveEngine()
|
||||
ctx := context.Background()
|
||||
|
||||
params := TransitiveParams{
|
||||
K: 3,
|
||||
AlphaPreference: 3,
|
||||
AlphaConfidence: 3,
|
||||
Beta: 1,
|
||||
}
|
||||
|
||||
genesisID := ids.GenerateTestID()
|
||||
genesisHeight := uint64(0)
|
||||
genesisTime := time.Now()
|
||||
|
||||
require.NoError(engine.Initialize(ctx, params, genesisID, genesisHeight, genesisTime))
|
||||
|
||||
// Create a tree structure to test indegree calculation:
|
||||
// genesis
|
||||
// |
|
||||
// block0
|
||||
// / \
|
||||
// block1 block2
|
||||
// | |
|
||||
// block3 block4
|
||||
|
||||
block0 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1,
|
||||
ParentV: genesisID,
|
||||
TimestampV: genesisTime.Add(time.Second),
|
||||
}
|
||||
block1 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 2,
|
||||
ParentV: block0.ID(),
|
||||
TimestampV: genesisTime.Add(2 * time.Second),
|
||||
}
|
||||
block2 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 2,
|
||||
ParentV: block0.ID(),
|
||||
TimestampV: genesisTime.Add(2 * time.Second),
|
||||
}
|
||||
block3 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 3,
|
||||
ParentV: block1.ID(),
|
||||
TimestampV: genesisTime.Add(3 * time.Second),
|
||||
}
|
||||
block4 := &TestBlock{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 3,
|
||||
ParentV: block2.ID(),
|
||||
TimestampV: genesisTime.Add(3 * time.Second),
|
||||
}
|
||||
|
||||
require.NoError(engine.Add(block0))
|
||||
require.NoError(engine.Add(block1))
|
||||
require.NoError(engine.Add(block2))
|
||||
require.NoError(engine.Add(block3))
|
||||
require.NoError(engine.Add(block4))
|
||||
|
||||
// Vote for both leaf blocks (block3 and block4)
|
||||
// These votes should transitively apply to block0 through different paths
|
||||
// Bug to test: ensure block0 doesn't get double-counted
|
||||
votes := NewBag[ids.ID]()
|
||||
votes.Add(block3.ID()) // Votes: block3 -> block1 -> block0
|
||||
votes.Add(block4.ID()) // Votes: block4 -> block2 -> block0
|
||||
|
||||
require.NoError(engine.RecordPoll(ctx, votes))
|
||||
|
||||
// With transitive voting:
|
||||
// - block3: 1 direct vote
|
||||
// - block4: 1 direct vote
|
||||
// - block1: 1 transitive vote (from block3)
|
||||
// - block2: 1 transitive vote (from block4)
|
||||
// - block0: 2 transitive votes (from block1 and block2)
|
||||
|
||||
// Verify transitive vote counts
|
||||
require.Equal(1, engine.votes[block3.ID()])
|
||||
require.Equal(1, engine.votes[block4.ID()])
|
||||
require.Equal(1, engine.votes[block1.ID()])
|
||||
require.Equal(1, engine.votes[block2.ID()])
|
||||
require.Equal(2, engine.votes[block0.ID()]) // Should be exactly 2, not 4
|
||||
|
||||
// None should be finalized yet (only 2 votes, need 3 for alpha)
|
||||
require.Equal(5, engine.NumProcessing())
|
||||
|
||||
// Add one more vote to push block0 over threshold
|
||||
votes2 := NewBag[ids.ID]()
|
||||
votes2.Add(block3.ID()) // Another vote for block3
|
||||
require.NoError(engine.RecordPoll(ctx, votes2))
|
||||
|
||||
// Now block0 should have 2 votes again (1 from block3 directly)
|
||||
// This poll only has 1 vote, so nothing finalizes
|
||||
require.Equal(5, engine.NumProcessing())
|
||||
|
||||
// Vote with 3 votes for block3 to finalize the chain
|
||||
votes3 := NewBag[ids.ID]()
|
||||
votes3.AddCount(block3.ID(), 3)
|
||||
require.NoError(engine.RecordPoll(ctx, votes3))
|
||||
|
||||
// Now the chain through block3 should be accepted
|
||||
require.Equal(StatusAccepted, block0.Status())
|
||||
require.Equal(StatusAccepted, block1.Status())
|
||||
require.Equal(StatusAccepted, block3.Status())
|
||||
|
||||
// The competing chain should be rejected
|
||||
require.Equal(StatusRejected, block2.Status())
|
||||
require.Equal(StatusRejected, block4.Status())
|
||||
|
||||
require.Zero(engine.NumProcessing())
|
||||
}
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
||||
gonum.org/v1/gonum v0.16.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -78,7 +79,6 @@ require (
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
gonum.org/v1/gonum v0.16.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
+79
-23
@@ -1,39 +1,95 @@
|
||||
// Package bag provides utilities for vote collection and counting
|
||||
package bag
|
||||
|
||||
import "github.com/luxfi/ids"
|
||||
|
||||
// Bag is a collection of IDs with counts
|
||||
type Bag struct {
|
||||
counts map[ids.ID]int
|
||||
// Bag tracks counts of IDs (votes)
|
||||
type Bag[T comparable] struct {
|
||||
counts map[T]int
|
||||
size int
|
||||
}
|
||||
|
||||
// New creates a new bag
|
||||
func New() *Bag {
|
||||
return &Bag{
|
||||
counts: make(map[ids.ID]int),
|
||||
// Of creates a bag from elements
|
||||
func Of[T comparable](elements ...T) Bag[T] {
|
||||
b := Bag[T]{
|
||||
counts: make(map[T]int),
|
||||
}
|
||||
for _, e := range elements {
|
||||
b.Add(e)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// New creates an empty bag
|
||||
func New[T comparable]() Bag[T] {
|
||||
return Bag[T]{
|
||||
counts: make(map[T]int),
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds an ID to the bag
|
||||
func (b *Bag) Add(id ids.ID) {
|
||||
b.counts[id]++
|
||||
// Add increments the count for an element
|
||||
func (b *Bag[T]) Add(element T) {
|
||||
b.counts[element]++
|
||||
b.size++
|
||||
}
|
||||
|
||||
// Count returns the count for an ID
|
||||
func (b *Bag) Count(id ids.ID) int {
|
||||
return b.counts[id]
|
||||
// AddCount adds multiple counts for an element
|
||||
func (b *Bag[T]) AddCount(element T, count int) {
|
||||
if count <= 0 {
|
||||
return
|
||||
}
|
||||
b.counts[element] += count
|
||||
b.size += count
|
||||
}
|
||||
|
||||
// Len returns the number of unique IDs
|
||||
func (b *Bag) Len() int {
|
||||
return len(b.counts)
|
||||
// Count returns the count for an element
|
||||
func (b *Bag[T]) Count(element T) int {
|
||||
return b.counts[element]
|
||||
}
|
||||
|
||||
// List returns all IDs in the bag
|
||||
func (b *Bag) List() []ids.ID {
|
||||
list := make([]ids.ID, 0, len(b.counts))
|
||||
for id := range b.counts {
|
||||
list = append(list, id)
|
||||
// Len returns total number of elements (with duplicates)
|
||||
func (b *Bag[T]) Len() int {
|
||||
return b.size
|
||||
}
|
||||
|
||||
// Mode returns the element with highest count and its count
|
||||
func (b *Bag[T]) Mode() (mode T, count int) {
|
||||
for element, elementCount := range b.counts {
|
||||
if elementCount > count {
|
||||
mode = element
|
||||
count = elementCount
|
||||
}
|
||||
}
|
||||
return mode, count
|
||||
}
|
||||
|
||||
// List returns all unique elements
|
||||
func (b *Bag[T]) List() []T {
|
||||
list := make([]T, 0, len(b.counts))
|
||||
for element := range b.counts {
|
||||
list = append(list, element)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Filter returns a new bag with only elements passing the filter
|
||||
func (b *Bag[T]) Filter(filter func(T) bool) Bag[T] {
|
||||
result := New[T]()
|
||||
for element, count := range b.counts {
|
||||
if filter(element) {
|
||||
result.AddCount(element, count)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Equals checks if two bags have the same contents
|
||||
func (b *Bag[T]) Equals(other Bag[T]) bool {
|
||||
if b.size != other.size || len(b.counts) != len(other.counts) {
|
||||
return false
|
||||
}
|
||||
for element, count := range b.counts {
|
||||
if other.counts[element] != count {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user