Files
consensus/engine/chain/convergence_bound_red_test.go
zeekayandHanzo Dev 9a0cfbb0d1 engine/chain: committee-sizer drift-guard (RED #3) + -race scale cap for the convergence storm
RED #3 — eliminate latent divergence between the two committee sizers by proving they are
EQUIVALENT on the clamp range: the ACTIVE path (MainnetParams K=21 → bftCommittee clamp,
deployed) yields the IDENTICAL (K,α) the REFERENCE config.FeasibleParams emits for every live
n ≤ presetK, both flooring a sub-BFT set at K=4/α=3. Above the preset the two intentionally
differ (active caps at the sample size; reference is unbounded) — locked as deliberate.
Decision: keep the ACTIVE path canonical; FeasibleParams is the analytical reference the tests
check against (one behavior, two proven-equal expressions), so neither can silently drift.

Also cap TestRedConvergence_SiblingStormBoundedAcrossScale's in-process scale at N=32 under
-race (non-race keeps N=64): -race's ~3x slowdown makes the O(N²) in-process gossip at N=64
exceed the flat wall-clock bound — a harness artifact, not a protocol round-count change (the
round schedule this test measures is unaffected by -race). Keeps the full suite -race-clean.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-09 11:02:44 -07:00

109 lines
4.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// convergence_bound_red_test.go — RED attack on choke #2 at SCALE: when pChainHeight=0
// makes EVERY validator propose a sibling at each height, the view-change must collapse
// the N-way split to ONE finalized head in a BOUNDED number of rounds — and that bound
// must NOT grow pathologically with N. This is the "can it choke at 1M?" question posed
// to the round machine (the sizer is proven analytically in scale_invariance_red_test.go).
//
// Method: run a full N-way sibling storm (every node builds a distinct competing block at
// height 1, view-change ON, committee sized from the live set) at the LARGEST FEASIBLE
// live sizes and assert, at each N: (a) exactly ONE head finalizes across all nodes (no
// fork), (b) convergence completes within a FIXED wall-clock bound that is the SAME for
// every N (the settle schedule is round-count-based and N-independent). A convergence time
// that climbs with N — or a fork — is the regression this gate catches. Times are logged
// so the trend is visible in CI output.
package chain
import (
"testing"
"time"
"github.com/luxfi/ids"
)
// TestRedConvergence_SiblingStormBoundedAcrossScale drives the storm at increasing N and
// asserts a flat convergence bound + single-head safety at each scale.
func TestRedConvergence_SiblingStormBoundedAcrossScale(t *testing.T) {
if testing.Short() {
t.Skip("scale storm sim is heavy; skipped in -short")
}
// Largest feasible in-process (each message fans out O(N) with real ed25519 verify, so
// a round is O(N²) crypto ops). 64 nodes is the non-race ceiling; the analytical proof
// (scale_invariance_red_test.go) covers 1M. Under -race the ~3x slowdown makes the O(N²)
// gossip at N=64 exceed the flat perScaleBound (a HARNESS artifact, not a protocol
// round-count property — the very thing this test measures is round-count, which -race does
// not change), so cap the in-process scale at 32 under -race; the N-independence of the
// round schedule is still exercised across 4→32, and non-race still covers 64.
scales := []int{4, 7, 16, 32, 64}
if underRace {
scales = []int{4, 7, 16, 32}
}
// A FIXED bound for EVERY N: the view-change settle schedule is counted in ROUNDS
// (viewSettleTicks), independent of N, so convergence time must not scale with N. We
// allow generous slack for in-process O(N²) gossip + -race overhead, but the SAME
// ceiling applies at N=4 and N=64 — the invariant is that the ceiling does not have to
// grow with N.
const perScaleBound = 25 * time.Second
times := make(map[int]time.Duration, len(scales))
for _, n := range scales {
n := n
t.Run("N="+itoaBig(n), func(t *testing.T) {
params := mainnetStormParams5VC() // K=21/α=15 preset + view-change ON; committee auto-sizes to the live n
net := newSimNet(t, n, params)
// Full storm: every node builds its OWN distinct sibling on genesis at height 1.
built := make(map[ids.ID]struct{}, n)
for i := 0; i < n; i++ {
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "storm-n"+itoaBig(n)+"-"+itoaBig(i))
built[blk.ID()] = struct{}{}
net.build(i, blk)
}
start := time.Now()
deadline := start.Add(perScaleBound)
var converged bool
for time.Now().Before(deadline) {
heads := net.headsAtHeight(1)
if len(heads) > 1 {
t.Fatalf("SAFETY: DOUBLE-FINALIZATION at N=%d — divergent heads %v", n, heads)
}
if len(heads) == 1 {
// Require EVERY node to have finalized the single head (full convergence).
for id, c := range heads {
if c >= net.upCount() {
if _, ok := built[id]; !ok {
t.Fatalf("N=%d: finalized head %s is not one of the built siblings", n, id)
}
converged = true
}
}
}
if converged {
break
}
time.Sleep(20 * time.Millisecond)
}
elapsed := time.Since(start)
times[n] = elapsed
if !converged {
t.Fatalf("CONVERGENCE: N=%d storm did NOT collapse to one finalized head within %s "+
"(heads=%v up=%d) — the split failed to converge at this scale",
n, perScaleBound, net.headsAtHeight(1), net.upCount())
}
t.Logf("N=%d converged to a single finalized head in %s", n, elapsed)
})
}
// The bound is FLAT: report the trend so a super-linear blow-up is visible. We do not
// hard-fail on a ratio (in-process O(N²) gossip inflates wall-clock super-linearly by
// construction — that is a HARNESS artifact, not a protocol round-count property); the
// protocol invariant proven above is that a FIXED per-scale bound suffices at every N.
if len(times) == len(scales) {
t.Logf("convergence-time trend (round schedule is N-independent): %v", times)
}
}