mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
Five defects. The first four let a decision be reached on evidence never
gathered; the fifth is that main does not build.
**wave.Tick rounded the vote threshold down.** `int(float64(K) * Alpha)`
truncates: K=20 Alpha=0.69 is 13.8, so 13 votes of 20 cleared a threshold that
config.DefaultParams declares as AlphaPreference 14 — 65.0% on a chain
configured for 69%. Mainnet gave 14 where 15 is declared, testnet 7 where 8 is.
config.AlphaForK and fpc.Selector.SelectThreshold both round up; only this path
did not. Now math.Ceil.
**wave.New validated nothing.** Beta = 0 makes `Count >= Beta` in Tick true
before a vote is counted; K = 0 or Alpha = 0 puts the threshold at 0, which
yesVotes = 0 already meets. All three are zero values, so a Config missing a
field decided immediately. New refuses each. Alpha is checked only when FPC is
disabled, since the selector derives the threshold from ThetaMin/ThetaMax
otherwise — an existing FPC test passes Alpha 0 legitimately and caught the
first, over-strict version of this check.
**ray and field discarded that error.** Both NewDriver functions defaulted on
`== 0`, so a negative PollSize or Beta, or an Alpha outside (0, 1], reached
wave.New's refusal — and because the error is dropped, the Driver kept a zero
Wave whose nil state map panics on the first Tick. Verified: with the old
defaulting the new test panics with "assignment to entry in nil map". Both now
normalise the whole invalid range.
**engine.NewLuxConsensus clamped a negative beta to 0.** The comment said
"ensure beta is non-negative for uint32 conversion" — that fixes the
representation and breaks the meaning. NewLuxConsensus(20, 15, -5) built a
driver whose wave decides with Count = 0, and a test asserted `beta == 0`. Beta
now clamps to 1. k < 1 clamps too: it was a division by zero in
`float64(alpha)/float64(k)`. An alpha ratio outside (0, 1] falls back to
ConsensusSuperMajority, since above 1 no vote count reaches it and nothing ever
decides.
The chain layer is unaffected in safety: engine/chain gates acceptance on its
own distinct-signer count (`block.acceptVotes >= c.alpha`) as well as the
driver's decision, so wave's verdict is a liveness trigger there, not the safety
boundary. Its partition matrix stays green.
**go.sum recorded a hash luxfi/pq v1.0.3 no longer resolves to**, so main did
not build at all: "checksum mismatch, downloaded pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=,
go.sum ksw1dmfTR0dqqNMRS7BjGcprCO2Fhc+3Iiq2/NMuONw=". Reconciled to the hash the
tag resolves to now.
Tests: protocol/wave/threshold_rounding_test.go pins each shipped profile's
threshold to its declared AlphaPreference and checks the boundary from both
sides — one vote below must not decide, exactly at must, on accept and reject —
plus every zero and out-of-range Config New must refuse.
protocol/{ray,field}/config_floor_test.go assert no malformed Config yields a
Driver that panics on Tick. engine/lux_consensus_test.go asserts beta >= 1, a
positive finite alpha ratio for k <= 0, and a clamped ratio for alpha > k.
go vet clean, full suite green.
Co-authored-by: Hanzo Dev <[email protected]>
234 lines
5.8 KiB
Go
234 lines
5.8 KiB
Go
package wave
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/consensus/core/types"
|
|
"github.com/luxfi/consensus/protocol/prism"
|
|
"github.com/luxfi/consensus/protocol/wave/fpc"
|
|
)
|
|
|
|
// Photon represents a vote message in the consensus protocol
|
|
type Photon[T comparable] struct {
|
|
Item T
|
|
Prefer bool
|
|
Sender types.NodeID
|
|
Timestamp time.Time
|
|
}
|
|
|
|
// Transport handles network communication for voting
|
|
type Transport[T comparable] interface {
|
|
RequestVotes(ctx context.Context, peers []types.NodeID, item T) <-chan Photon[T]
|
|
MakeLocalPhoton(item T, prefer bool) Photon[T]
|
|
}
|
|
|
|
// Config holds configuration for wave consensus
|
|
type Config struct {
|
|
K int // Sample size
|
|
Alpha float64 // Threshold ratio (used when FPC disabled)
|
|
Beta uint32 // Confidence threshold
|
|
RoundTO time.Duration // Round timeout
|
|
EnableFPC bool // Enable Fast Probabilistic Consensus
|
|
ThetaMin float64 // FPC minimum threshold (default: 0.5)
|
|
ThetaMax float64 // FPC maximum threshold (default: 0.8)
|
|
FPCSeed []byte // FPC seed (required when EnableFPC=true); use fpc.DeriveEpochSeed
|
|
}
|
|
|
|
// WaveState represents the polling state of an item in wave consensus
|
|
type WaveState struct {
|
|
Decided bool
|
|
Result types.Decision
|
|
Count uint32
|
|
}
|
|
|
|
// Wave manages threshold voting and confidence building
|
|
type Wave[T comparable] struct {
|
|
cfg Config
|
|
cut prism.Cut[T]
|
|
tx Transport[T]
|
|
|
|
// FPC support
|
|
fpcSelector *fpc.Selector
|
|
phase uint64 // Current phase for FPC threshold selection
|
|
|
|
// State tracking
|
|
mu sync.RWMutex
|
|
states map[T]*WaveState
|
|
prefs map[T]bool // current preferences
|
|
}
|
|
|
|
// New creates a new Wave instance.
|
|
// When cfg.EnableFPC is true, cfg.FPCSeed must be non-empty.
|
|
func New[T comparable](cfg Config, cut prism.Cut[T], tx Transport[T]) (Wave[T], error) {
|
|
// A zero value in any of these decides with no evidence. Beta = 0 makes the
|
|
// Count >= Beta test in Tick true before a single vote is counted; K = 0 or
|
|
// Alpha = 0 puts the vote threshold at 0, which yesVotes = 0 already meets.
|
|
// Config arrives from callers and files, so it is checked here rather than
|
|
// assumed.
|
|
if cfg.K < 1 {
|
|
return Wave[T]{}, fmt.Errorf("wave: K must be at least 1, got %d", cfg.K)
|
|
}
|
|
if cfg.Beta < 1 {
|
|
return Wave[T]{}, fmt.Errorf("wave: Beta must be at least 1, got %d", cfg.Beta)
|
|
}
|
|
// Alpha is the fixed-threshold ratio and is unused under FPC, where the
|
|
// selector derives the threshold from ThetaMin/ThetaMax instead.
|
|
if !cfg.EnableFPC && (cfg.Alpha <= 0 || cfg.Alpha > 1) {
|
|
return Wave[T]{}, fmt.Errorf("wave: Alpha must be in (0, 1], got %v", cfg.Alpha)
|
|
}
|
|
|
|
// Initialize FPC selector if enabled
|
|
var fpcSel *fpc.Selector
|
|
if cfg.EnableFPC {
|
|
thetaMin := cfg.ThetaMin
|
|
if thetaMin == 0 {
|
|
thetaMin = 0.5 // Default
|
|
}
|
|
thetaMax := cfg.ThetaMax
|
|
if thetaMax == 0 {
|
|
thetaMax = 0.8 // Default
|
|
}
|
|
var err error
|
|
fpcSel, err = fpc.NewSelector(thetaMin, thetaMax, cfg.FPCSeed)
|
|
if err != nil {
|
|
return Wave[T]{}, err
|
|
}
|
|
}
|
|
|
|
return Wave[T]{
|
|
cfg: cfg,
|
|
cut: cut,
|
|
tx: tx,
|
|
fpcSelector: fpcSel,
|
|
phase: 0,
|
|
states: make(map[T]*WaveState),
|
|
prefs: make(map[T]bool),
|
|
}, nil
|
|
}
|
|
|
|
// Tick performs one round of sampling and threshold checking for an item
|
|
func (w *Wave[T]) Tick(ctx context.Context, item T) {
|
|
// Get current state or create new one
|
|
w.mu.Lock()
|
|
state, exists := w.states[item]
|
|
if !exists {
|
|
state = &WaveState{Decided: false, Result: types.DecideUndecided, Count: 0}
|
|
w.states[item] = state
|
|
}
|
|
|
|
// Skip if already decided
|
|
if state.Decided {
|
|
w.mu.Unlock()
|
|
return
|
|
}
|
|
w.mu.Unlock()
|
|
|
|
// Cut light rays (sample peers) and request votes
|
|
peers := w.cut.Sample(w.cfg.K)
|
|
votes := w.tx.RequestVotes(ctx, peers, item)
|
|
|
|
// Count votes
|
|
yesVotes := 0
|
|
totalVotes := 0
|
|
|
|
// Collect votes with timeout
|
|
timeout := time.After(w.cfg.RoundTO)
|
|
for {
|
|
select {
|
|
case vote := <-votes:
|
|
totalVotes++
|
|
if vote.Prefer {
|
|
yesVotes++
|
|
}
|
|
// Break if we have enough votes
|
|
if totalVotes >= w.cfg.K {
|
|
goto countVotes
|
|
}
|
|
case <-timeout:
|
|
goto countVotes
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
|
|
countVotes:
|
|
if totalVotes == 0 {
|
|
return
|
|
}
|
|
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
// Increment phase for FPC
|
|
w.phase++
|
|
|
|
// Calculate threshold using FPC or fixed Alpha
|
|
var threshold int
|
|
if w.fpcSelector != nil {
|
|
threshold = w.fpcSelector.SelectThreshold(w.phase, w.cfg.K)
|
|
} else {
|
|
// Ceil, matching config.AlphaForK and fpc.Selector.SelectThreshold.
|
|
// Truncating put every profile one vote under its declared
|
|
// AlphaPreference: K=20 Alpha=0.69 gave 13 where 14 is declared, K=21
|
|
// gave 14 for 15, K=11 gave 7 for 8 — a 65.0% threshold on a chain
|
|
// configured for 69%.
|
|
threshold = int(math.Ceil(float64(w.cfg.K) * w.cfg.Alpha))
|
|
}
|
|
|
|
currentPref := w.prefs[item]
|
|
|
|
if yesVotes >= threshold {
|
|
// Strong preference for yes
|
|
w.prefs[item] = true
|
|
if currentPref {
|
|
// Consecutive confirmation
|
|
state.Count++
|
|
} else {
|
|
// Preference switch
|
|
state.Count = 1
|
|
}
|
|
} else if (totalVotes - yesVotes) >= threshold {
|
|
// Strong preference for no
|
|
w.prefs[item] = false
|
|
if !currentPref {
|
|
// Consecutive confirmation
|
|
state.Count++
|
|
} else {
|
|
// Preference switch
|
|
state.Count = 1
|
|
}
|
|
} else {
|
|
// No strong preference, reset count
|
|
state.Count = 0
|
|
}
|
|
|
|
// Check for decision
|
|
if state.Count >= w.cfg.Beta {
|
|
state.Decided = true
|
|
if w.prefs[item] {
|
|
state.Result = types.DecideAccept
|
|
} else {
|
|
state.Result = types.DecideReject
|
|
}
|
|
}
|
|
}
|
|
|
|
// State returns the current polling state of an item
|
|
func (w *Wave[T]) State(item T) (*WaveState, bool) {
|
|
w.mu.RLock()
|
|
defer w.mu.RUnlock()
|
|
state, exists := w.states[item]
|
|
return state, exists
|
|
}
|
|
|
|
// Preference returns the current preference for an item
|
|
func (w *Wave[T]) Preference(item T) bool {
|
|
w.mu.RLock()
|
|
defer w.mu.RUnlock()
|
|
return w.prefs[item]
|
|
}
|