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]>
207 lines
5.5 KiB
Go
207 lines
5.5 KiB
Go
package field
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/luxfi/consensus/core/types"
|
|
"github.com/luxfi/consensus/protocol/prism"
|
|
"github.com/luxfi/consensus/protocol/wave"
|
|
)
|
|
|
|
type VID interface{ comparable } // vertex id
|
|
|
|
// BlockView is enough for order-theory (parents + metadata).
|
|
type BlockView[V VID] interface {
|
|
ID() V
|
|
Parents() []V
|
|
Author() types.NodeID
|
|
Round() uint64
|
|
}
|
|
|
|
// Store exposes local DAG state for cert/skip scans and causal reads.
|
|
type Store[V VID] interface {
|
|
Head() []V
|
|
Get(V) (BlockView[V], bool)
|
|
Children(V) []V
|
|
}
|
|
|
|
// Proposer builds a new vertex from frontier + sidecar votes, etc.
|
|
type Proposer[V VID] interface {
|
|
Propose(ctx context.Context, parents []V) (V, error)
|
|
}
|
|
|
|
// Committer applies an ordered prefix decided by Field.
|
|
type Committer[V VID] interface {
|
|
Commit(ctx context.Context, ordered []V) error
|
|
}
|
|
|
|
type Config struct {
|
|
PollSize int
|
|
Alpha float64
|
|
Beta uint32
|
|
RoundTO time.Duration
|
|
}
|
|
|
|
type Driver[V VID] struct {
|
|
cfg Config
|
|
wv *wave.Wave[V]
|
|
cut prism.Cut[V]
|
|
str Store[V]
|
|
prop Proposer[V]
|
|
com Committer[V]
|
|
committed []V // Track committed vertices in order
|
|
finalizedCache map[V]bool
|
|
}
|
|
|
|
func NewDriver[V VID](cfg Config, cut prism.Cut[V], tx wave.Transport[V], store Store[V], prop Proposer[V], com Committer[V]) *Driver[V] {
|
|
// Normalise the whole invalid range, not just the zero value. wave.New
|
|
// refuses K < 1, Beta < 1 and Alpha outside (0, 1]; a negative or
|
|
// out-of-range field passes an `== 0` check, reaches that refusal, and —
|
|
// since the error is not returned from here — leaves a zero Wave whose nil
|
|
// state map panics on the first Tick.
|
|
if cfg.PollSize < 1 {
|
|
cfg.PollSize = 20
|
|
}
|
|
if cfg.Alpha <= 0 || cfg.Alpha > 1 {
|
|
cfg.Alpha = 0.8
|
|
}
|
|
if cfg.Beta < 1 {
|
|
cfg.Beta = 15
|
|
}
|
|
if cfg.RoundTO == 0 {
|
|
cfg.RoundTO = 250 * time.Millisecond
|
|
}
|
|
|
|
wvVal, _ := wave.New[V](wave.Config{K: cfg.PollSize, Alpha: cfg.Alpha, Beta: cfg.Beta, RoundTO: cfg.RoundTO}, cut, tx)
|
|
return &Driver[V]{
|
|
cfg: cfg,
|
|
wv: &wvVal,
|
|
cut: cut,
|
|
str: store,
|
|
prop: prop,
|
|
com: com,
|
|
committed: make([]V, 0),
|
|
finalizedCache: make(map[V]bool),
|
|
}
|
|
}
|
|
|
|
// OnObserve should be called by your networking layer as new vertices arrive.
|
|
// You can also plug DAG fast-path voting (flare) here if you embed it in vertex payloads.
|
|
func (d *Driver[V]) OnObserve(ctx context.Context, v V) {
|
|
// optional: run local checks, update sampler health, etc.
|
|
_ = ctx
|
|
_ = v
|
|
}
|
|
|
|
// Tick runs one poll round over DAG heads, looks for cert/skip and commits the safe prefix.
|
|
func (d *Driver[V]) Tick(ctx context.Context) error {
|
|
frontier := d.str.Head()
|
|
if len(frontier) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Drive thresholding on frontier candidates
|
|
for _, v := range frontier {
|
|
d.wv.Tick(ctx, v)
|
|
}
|
|
|
|
// Compute safe prefix: vertices that are finalized (decided accept) with all ancestors also finalized
|
|
ordered := d.computeSafePrefix(frontier)
|
|
if len(ordered) > 0 {
|
|
if err := d.com.Commit(ctx, ordered); err != nil {
|
|
return err
|
|
}
|
|
// Track committed vertices
|
|
d.committed = append(d.committed, ordered...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// computeSafePrefix returns vertices from the frontier that are finalized with all ancestors also finalized.
|
|
// This ensures we only commit vertices whose causal history is completely decided.
|
|
func (d *Driver[V]) computeSafePrefix(frontier []V) []V {
|
|
var safe []V
|
|
for _, v := range frontier {
|
|
if d.isFullyFinalized(v) {
|
|
safe = append(safe, v)
|
|
}
|
|
}
|
|
return safe
|
|
}
|
|
|
|
// isFullyFinalized checks if a vertex and all its ancestors are finalized.
|
|
// Results are memoized in finalizedCache to avoid O(n^2) repeated DFS.
|
|
func (d *Driver[V]) isFullyFinalized(v V) bool {
|
|
if cached, ok := d.finalizedCache[v]; ok {
|
|
return cached
|
|
}
|
|
|
|
// Check this vertex is finalized
|
|
state, exists := d.wv.State(v)
|
|
if !exists || !state.Decided || state.Result != types.DecideAccept {
|
|
d.finalizedCache[v] = false
|
|
return false
|
|
}
|
|
|
|
// Check all parents are finalized (recursively)
|
|
block, exists := d.str.Get(v)
|
|
if !exists {
|
|
d.finalizedCache[v] = false
|
|
return false
|
|
}
|
|
for _, parent := range block.Parents() {
|
|
if !d.isFullyFinalized(parent) {
|
|
d.finalizedCache[v] = false
|
|
return false
|
|
}
|
|
}
|
|
|
|
d.finalizedCache[v] = true
|
|
return true
|
|
}
|
|
|
|
// ClearFinalizedCache resets the finalization memoization cache.
|
|
// Call this on epoch rotation so stale entries do not persist.
|
|
func (d *Driver[V]) ClearFinalizedCache() {
|
|
d.finalizedCache = make(map[V]bool)
|
|
}
|
|
|
|
// Start begins the Field engine operation
|
|
func (d *Driver[V]) Start(ctx context.Context) error {
|
|
// Field engine is stateless and starts immediately
|
|
return nil
|
|
}
|
|
|
|
// Stop ends the Field engine operation
|
|
func (d *Driver[V]) Stop(ctx context.Context) error {
|
|
// Field engine is stateless and stops immediately
|
|
return nil
|
|
}
|
|
|
|
// Propose proposes a new vertex with given parents (Nebula will use this)
|
|
func (d *Driver[V]) Propose(ctx context.Context, parents []V) (V, error) {
|
|
return d.prop.Propose(ctx, parents)
|
|
}
|
|
|
|
// GetFrontier returns the current DAG frontier (tips)
|
|
func (d *Driver[V]) GetFrontier() []V {
|
|
return d.str.Head()
|
|
}
|
|
|
|
// IsFinalized checks if a vertex is finalized
|
|
func (d *Driver[V]) IsFinalized(vertex V) bool {
|
|
if state, exists := d.wv.State(vertex); exists {
|
|
return state.Decided && state.Result == types.DecideAccept
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetCommittedVertices returns vertices that have been committed in order.
|
|
func (d *Driver[V]) GetCommittedVertices() []V {
|
|
result := make([]V, len(d.committed))
|
|
copy(result, d.committed)
|
|
return result
|
|
}
|