Skip to content

Commit d9d64f0

Browse files
authored
test(frost/roast): 600-case cross-language differential corpus for the coordinator shuffle (#4034)
Stacked on #3866 (base: `feat/frost-schnorr-migration-scaffold`). Implements the review item "widen the Go↔Rust math/rand parity from a handful of pinned vectors to corpus-based differential fuzzing" — Go half; the Rust consumer is the paired PR stacked on #4005. ## What A generated 600-case differential corpus over `SelectCoordinator` (`testdata/coordinator_shuffle_corpus.json`, 176 KB), replayed by `TestCoordinatorShuffle_DifferentialCorpus` here and by the identical byte-for-byte copy in the Rust signer's `go_math_rand` tests: - **216 boundary cases**: seeds {0, ±1, `i64::MIN/MAX`, `MIN+3`/`MAX−3`, the #4026 pin seed and its negation} × attempts {0, 1, 7, `u32::MAX`} — exercising the two's-complement wrapping `seed + attempt` composition — × six member sets including unsorted and reversed inputs (pinning the internal sort both implementations perform). - **384 generated cases**: fixed-seed generator sweeping set sizes 1..255 (the full `group.MemberIndex` range), full-range `int64` seeds, and small/large/extreme attempt numbers. Regeneration is deterministic and gated (`ROAST_SHUFFLE_CORPUS_REGEN=1`), so the corpus provably comes from the documented case matrix rather than hand-pinning. This complements #4030's Annex-A seed-derivation vectors: those pin the *derivation* end-to-end on 10 vectors; this corpus stress-pins the *shuffle port itself* — the actual cross-language landmine — at volume, including the integer-boundary regions where a port diverges first. Not full continuous fuzzing (no coverage-guided harness); it's the pragmatic corpus-differential version that rides the existing unit-test CI on both sides at negligible cost. A coverage-guided Go-oracle harness can layer on later if desired. ## Tests `go test ./pkg/frost/roast/...` passes (corpus replay + regeneration roundtrip verified). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 09f61d3 + 3651a3a commit d9d64f0

2 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
package roast
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"math"
7+
"math/rand"
8+
"os"
9+
"strconv"
10+
"testing"
11+
12+
"github.com/keep-network/keep-core/pkg/protocol/group"
13+
)
14+
15+
// coordinatorShuffleCorpusPath is the cross-language differential
16+
// corpus for the legacy Go math/rand coordinator shuffle. Where
17+
// testdata/coordinator_seed_vectors.json pins the full RFC-21 Annex A
18+
// derivation on a handful of hand-picked vectors, this corpus pins
19+
// the shuffle itself -- SelectCoordinator semantics over explicit
20+
// int64 seeds -- across hundreds of generated cases and the integer
21+
// boundary values where a port is most likely to diverge (negative
22+
// seeds, i64 extremes, wrapping seed+attempt composition, unsorted
23+
// inputs, 1..255-member sets).
24+
//
25+
// The Rust signer carries a byte-identical copy at
26+
// pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json consumed
27+
// by go_math_rand.rs tests; either side drifting fails its own suite.
28+
// Regenerate here with ROAST_SHUFFLE_CORPUS_REGEN=1 and re-copy.
29+
const coordinatorShuffleCorpusPath = "testdata/coordinator_shuffle_corpus.json"
30+
31+
type coordinatorShuffleCorpusFile struct {
32+
Description string `json:"description"`
33+
Cases []coordinatorShuffleCase `json:"cases"`
34+
}
35+
36+
type coordinatorShuffleCase struct {
37+
Name string `json:"name"`
38+
// SeedInt64 is the legacy shuffle seed as a decimal string so
39+
// JSON number precision cannot corrupt it.
40+
SeedInt64 string `json:"seedInt64"`
41+
// AttemptNumber is the 0-based attempt composed into the
42+
// rand.Source seed as seed + int64(attemptNumber) (two's-
43+
// complement wrapping).
44+
AttemptNumber uint32 `json:"attemptNumber"`
45+
// Members is the included set in the order passed to the
46+
// shuffle. Deliberately not always sorted: both implementations
47+
// must sort internally, and unsorted cases pin that.
48+
Members []uint16 `json:"members"`
49+
ExpectedCoordinator uint16 `json:"expectedCoordinator"`
50+
}
51+
52+
func loadCoordinatorShuffleCorpus(t *testing.T) coordinatorShuffleCorpusFile {
53+
t.Helper()
54+
raw, err := os.ReadFile(coordinatorShuffleCorpusPath)
55+
if err != nil {
56+
t.Fatalf(
57+
"cannot read %s (regenerate with ROAST_SHUFFLE_CORPUS_REGEN=1): %v",
58+
coordinatorShuffleCorpusPath,
59+
err,
60+
)
61+
}
62+
var file coordinatorShuffleCorpusFile
63+
if err := json.Unmarshal(raw, &file); err != nil {
64+
t.Fatalf("cannot parse %s: %v", coordinatorShuffleCorpusPath, err)
65+
}
66+
if len(file.Cases) == 0 {
67+
t.Fatalf("%s contains no cases", coordinatorShuffleCorpusPath)
68+
}
69+
return file
70+
}
71+
72+
func runCoordinatorShuffleCase(
73+
t *testing.T,
74+
shuffleCase coordinatorShuffleCase,
75+
) group.MemberIndex {
76+
t.Helper()
77+
seed, err := strconv.ParseInt(shuffleCase.SeedInt64, 10, 64)
78+
if err != nil {
79+
t.Fatalf(
80+
"case %q: seedInt64 %q is not a valid int64: %v",
81+
shuffleCase.Name, shuffleCase.SeedInt64, err,
82+
)
83+
}
84+
members := make([]group.MemberIndex, 0, len(shuffleCase.Members))
85+
for _, m := range shuffleCase.Members {
86+
if m == 0 || m > math.MaxUint8 {
87+
t.Fatalf(
88+
"case %q: member %d outside group.MemberIndex range",
89+
shuffleCase.Name, m,
90+
)
91+
}
92+
members = append(members, group.MemberIndex(m))
93+
}
94+
coordinator, err := SelectCoordinator(
95+
members,
96+
seed,
97+
uint(shuffleCase.AttemptNumber),
98+
)
99+
if err != nil {
100+
t.Fatalf("case %q: SelectCoordinator: %v", shuffleCase.Name, err)
101+
}
102+
return coordinator
103+
}
104+
105+
// TestCoordinatorShuffle_DifferentialCorpus replays the generated
106+
// corpus against SelectCoordinator. The Rust go_math_rand port runs
107+
// the identical corpus, so any semantic drift in either shuffle --
108+
// source seeding, Fisher-Yates order, int31n bounds, sign handling,
109+
// wrapping composition, internal sorting -- fails the drifting side's
110+
// own unit suite instead of fracturing coordinator agreement in a
111+
// mixed deployment.
112+
func TestCoordinatorShuffle_DifferentialCorpus(t *testing.T) {
113+
file := loadCoordinatorShuffleCorpus(t)
114+
115+
for _, shuffleCase := range file.Cases {
116+
coordinator := runCoordinatorShuffleCase(t, shuffleCase)
117+
if coordinator != group.MemberIndex(shuffleCase.ExpectedCoordinator) {
118+
t.Fatalf(
119+
"case %q: coordinator mismatch: derived %d, corpus pins %d",
120+
shuffleCase.Name, coordinator, shuffleCase.ExpectedCoordinator,
121+
)
122+
}
123+
}
124+
}
125+
126+
// TestRegenerateCoordinatorShuffleCorpus rewrites the corpus from the
127+
// deterministic case matrix below using the current Go
128+
// implementation. Guarded behind an env flag so it never rewrites
129+
// during normal CI:
130+
//
131+
// ROAST_SHUFFLE_CORPUS_REGEN=1 go test ./pkg/frost/roast -run TestRegenerateCoordinatorShuffleCorpus
132+
//
133+
// After regenerating, copy the file byte-identically to
134+
// pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json on the
135+
// signer branch.
136+
//
137+
// Regenerating is a protocol-change event, not a refresh: the corpus
138+
// pins the legacy math/rand shuffle semantics shared with the Rust
139+
// port, so a run that produces different bytes means the shuffle
140+
// changed and deployed engines would disagree on coordinator
141+
// rotation. Both language suites passing after a dual regen is NOT
142+
// evidence of compatibility with already-deployed engines.
143+
func TestRegenerateCoordinatorShuffleCorpus(t *testing.T) {
144+
if os.Getenv("ROAST_SHUFFLE_CORPUS_REGEN") != "1" {
145+
t.Skip("set ROAST_SHUFFLE_CORPUS_REGEN=1 to regenerate the corpus file")
146+
}
147+
148+
cases := make([]coordinatorShuffleCase, 0, 600)
149+
150+
// Boundary block: integer extremes, sign boundaries, the
151+
// wrapping seed+attempt composition, and the historical
152+
// cross-language pin seed from PR #4026.
153+
//
154+
// +/-MaxInt32 are the rand.NewSource seed-normalization collision:
155+
// Go reduces the source seed mod (2^31 - 1) and maps 0 to 89482311,
156+
// so +/-(2^31 - 1) seed the generator identically to 0. Pinning them
157+
// catches a port that special-cases literal 0 but skips the modulo.
158+
boundarySeeds := []int64{
159+
0,
160+
1,
161+
-1,
162+
math.MaxInt64,
163+
math.MinInt64,
164+
math.MaxInt64 - 3,
165+
math.MinInt64 + 3,
166+
math.MaxInt32, // == 2^31 - 1; normalizes to the seed-0 state
167+
-math.MaxInt32, // negative wrap then the same collision
168+
6879463052285329321,
169+
-6879463052285329321,
170+
}
171+
boundaryAttempts := []uint32{0, 1, 7, math.MaxUint32}
172+
boundarySets := [][]uint16{
173+
{1},
174+
{1, 2},
175+
{2, 1}, // unsorted: pins internal sorting
176+
{5, 4, 3, 2, 1}, // reverse order
177+
{1, 2, 3, 4, 5},
178+
{7, 2, 11, 9}, // sparse, unsorted
179+
}
180+
for _, seed := range boundarySeeds {
181+
for _, attemptNumber := range boundaryAttempts {
182+
for setIndex, members := range boundarySets {
183+
cases = append(cases, coordinatorShuffleCase{
184+
Name: fmt.Sprintf(
185+
"boundary-seed-%d-attempt-%d-set-%d",
186+
seed, attemptNumber, setIndex,
187+
),
188+
SeedInt64: strconv.FormatInt(seed, 10),
189+
AttemptNumber: attemptNumber,
190+
Members: members,
191+
})
192+
}
193+
}
194+
}
195+
196+
// Generated block: deterministic pseudo-random sweep over set
197+
// sizes 1..255 (every member index value reachable), seeds across
198+
// the full int64 range, attempts across realistic and large
199+
// values. The generator RNG seed is fixed; it only chooses the
200+
// case inputs and has no parity significance itself.
201+
generatorRng := rand.New(rand.NewSource(0x5EED_C0DE))
202+
memberPool := make([]uint16, 255)
203+
for i := range memberPool {
204+
memberPool[i] = uint16(i + 1)
205+
}
206+
for caseIndex := 0; caseIndex < 384; caseIndex++ {
207+
// Bias towards small sets (production-realistic) while still
208+
// sweeping up to the full 255.
209+
var setSize int
210+
switch caseIndex % 4 {
211+
case 0:
212+
setSize = 1 + generatorRng.Intn(5)
213+
case 1:
214+
setSize = 6 + generatorRng.Intn(46)
215+
case 2:
216+
setSize = 51 + generatorRng.Intn(50)
217+
default:
218+
setSize = 101 + generatorRng.Intn(155)
219+
}
220+
221+
generatorRng.Shuffle(len(memberPool), func(i, j int) {
222+
memberPool[i], memberPool[j] = memberPool[j], memberPool[i]
223+
})
224+
members := make([]uint16, setSize)
225+
copy(members, memberPool[:setSize])
226+
227+
seed := int64(generatorRng.Uint64())
228+
var attemptNumber uint32
229+
switch caseIndex % 3 {
230+
case 0:
231+
attemptNumber = uint32(generatorRng.Intn(8))
232+
case 1:
233+
attemptNumber = uint32(generatorRng.Intn(1 << 16))
234+
default:
235+
attemptNumber = generatorRng.Uint32()
236+
}
237+
238+
cases = append(cases, coordinatorShuffleCase{
239+
Name: fmt.Sprintf("generated-%03d-size-%d", caseIndex, setSize),
240+
SeedInt64: strconv.FormatInt(seed, 10),
241+
AttemptNumber: attemptNumber,
242+
Members: members,
243+
})
244+
}
245+
246+
for caseIndex := range cases {
247+
coordinator := runCoordinatorShuffleCase(t, cases[caseIndex])
248+
cases[caseIndex].ExpectedCoordinator = uint16(coordinator)
249+
}
250+
251+
file := coordinatorShuffleCorpusFile{
252+
Description: "Cross-language differential corpus for the legacy Go math/rand " +
253+
"coordinator shuffle (SelectCoordinator / go_math_rand.rs " +
254+
"select_coordinator_identifier): source seed = seedInt64 + " +
255+
"int64(attemptNumber) with two's-complement wrapping; members sorted " +
256+
"ascending internally before the Fisher-Yates shuffle; first element " +
257+
"after shuffling is the coordinator. Canonical copy: " +
258+
"pkg/frost/roast/testdata/coordinator_shuffle_corpus.json (Go); " +
259+
"mirrored byte-identically to " +
260+
"pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json (Rust). " +
261+
"Regenerate with ROAST_SHUFFLE_CORPUS_REGEN=1.",
262+
Cases: cases,
263+
}
264+
265+
encoded, err := json.Marshal(file)
266+
if err != nil {
267+
t.Fatalf("encode corpus file: %v", err)
268+
}
269+
encoded = append(encoded, '\n')
270+
if err := os.MkdirAll("testdata", 0o755); err != nil {
271+
t.Fatalf("create testdata dir: %v", err)
272+
}
273+
if err := os.WriteFile(coordinatorShuffleCorpusPath, encoded, 0o644); err != nil {
274+
t.Fatalf("write corpus file: %v", err)
275+
}
276+
t.Logf("regenerated %s with %d cases", coordinatorShuffleCorpusPath, len(cases))
277+
}

pkg/frost/roast/testdata/coordinator_shuffle_corpus.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)