diff --git a/config/config.go b/config/config.go index 74105cf..a898801 100644 --- a/config/config.go +++ b/config/config.go @@ -19,6 +19,15 @@ type LoadConfig struct { Settings *Settings `json:"settings,omitempty"` // Path to write a JSON report of the load test. ReportPath string `json:"reportPath,omitempty"` + // Seed roots the deterministic PRNG sub-streams that drive the run. Same + // seed + config reproduces the per-stream draw multiset, so the workload + // (the distribution of keys, sizes, gas, and accounts) is statistically + // reproducible for fair A/B comparison. Per-tx emission ordering is + // reproducible only at a single worker; above one worker the multiset still + // matches but ordering does not, and on-chain arrival order is concurrent + // regardless. A nil Seed means "unseeded": the generator resolves a random + // one and records it for after-the-fact replay. + Seed *uint64 `json:"seed,omitempty"` } // Duration wraps time.Duration to provide JSON unmarshaling support diff --git a/config/gas.go b/config/gas.go index 81c75aa..a9410ad 100644 --- a/config/gas.go +++ b/config/gas.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "math/rand/v2" + + "github.com/sei-protocol/sei-load/utils/rng" ) var ( @@ -23,6 +25,18 @@ type GasPicker struct { func (g *GasPicker) Name() string { return g.name } +// SetStream binds the picker's random delegate to a deterministic sub-stream. A +// nil stream leaves the picker on the unseeded global RNG. +// +// Only a random delegate has anything to seed: fixed and empty pickers draw no +// randomness, so the type assertion intentionally no-ops for them rather than +// erroring. +func (g *GasPicker) SetStream(s *rng.Stream) { + if r, ok := g.delegate.(*RandomGasGenerator); ok { + r.stream = s + } +} + func (g *GasPicker) GenerateGas() (uint64, error) { if g.delegate == nil { return 0, nil @@ -71,11 +85,17 @@ func (f *FixedGasGenerator) GenerateGas() (uint64, error) { type RandomGasGenerator struct { Min uint64 `json:"Min"` Max uint64 `json:"Max"` + + stream *rng.Stream } func (r *RandomGasGenerator) GenerateGas() (uint64, error) { if r.Min >= r.Max { return 0, fmt.Errorf("invalid random gas range: min %d must be less than max %d", r.Min, r.Max) } - return r.Min + rand.Uint64N(r.Max-r.Min+1), nil + span := r.Max - r.Min + 1 + if r.stream != nil { + return r.Min + r.stream.Uint64N(span), nil + } + return r.Min + rand.Uint64N(span), nil } diff --git a/config/gas_test.go b/config/gas_test.go index 059a597..1ad98d8 100644 --- a/config/gas_test.go +++ b/config/gas_test.go @@ -1,9 +1,11 @@ package config_test import ( + "fmt" "testing" "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/utils/rng" "github.com/stretchr/testify/require" ) @@ -36,3 +38,60 @@ func TestGasPicker(t *testing.T) { require.Error(t, subject.UnmarshalJSON([]byte(`{"Name":"unknown"}`))) }) } + +// randomPicker unmarshals a fresh random gas picker over [min,max]. +func randomPicker(t *testing.T, min, max uint64) *config.GasPicker { + t.Helper() + var gp config.GasPicker + require.NoError(t, gp.UnmarshalJSON(fmt.Appendf(nil, `{"Name":"random","Min":%d,"Max":%d}`, min, max))) + return &gp +} + +// drawN binds the picker to the given stream and pulls n draws. +func drawN(t *testing.T, gp *config.GasPicker, s *rng.Stream, n int) []uint64 { + t.Helper() + gp.SetStream(s) + out := make([]uint64, n) + for i := range out { + v, err := gp.GenerateGas() + require.NoError(t, err) + out[i] = v + } + return out +} + +// TestRandomGasPickerStreamSeeds guards the binding contract: after SetStream a +// random picker draws seed-determined values. Two same-seed builds must match +// AND differ from an unseeded build. This fails loudly if a refactor (e.g. a +// deep copy of config.Scenario) breaks the pointer aliasing bindGasStreams +// relies on, so the binding silently reverting to the global RNG cannot pass. +func TestRandomGasPickerStreamSeeds(t *testing.T) { + const seed, n = 17, 64 + + seededA := drawN(t, randomPicker(t, 20000, 30000), rng.NewSource(seed).Stream("gas:0:base"), n) + seededB := drawN(t, randomPicker(t, 20000, 30000), rng.NewSource(seed).Stream("gas:0:base"), n) + require.Equal(t, seededA, seededB, "same seed must reproduce the draw sequence") + + unseeded := drawN(t, randomPicker(t, 20000, 30000), nil, n) + require.NotEqual(t, seededA, unseeded, "seeded draws must differ from the unseeded global RNG") +} + +// TestSetStreamNoOpsForFixedAndEmpty confirms fixed/empty pickers ignore +// SetStream (they have no randomness to seed) rather than erroring. +func TestSetStreamNoOpsForFixedAndEmpty(t *testing.T) { + stream := rng.NewSource(1).Stream("gas:0:base") + + var fixed config.GasPicker + require.NoError(t, fixed.UnmarshalJSON([]byte(`{"Name":"fixed","Gas":21000}`))) + fixed.SetStream(stream) + gas, err := fixed.GenerateGas() + require.NoError(t, err) + require.Equal(t, uint64(21000), gas) + + var empty config.GasPicker + require.NoError(t, empty.UnmarshalJSON([]byte(`{}`))) + empty.SetStream(stream) + gas, err = empty.GenerateGas() + require.NoError(t, err) + require.Zero(t, gas) +} diff --git a/generator/generator.go b/generator/generator.go index 324975c..b55c59a 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -11,6 +11,7 @@ import ( "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" + "github.com/sei-protocol/sei-load/utils/rng" ) // Generator interface defines the contract for transaction generators @@ -32,6 +33,7 @@ type scenarioInstance struct { // configBasedGenerator manages scenario creation and deployment from config type configBasedGenerator struct { config *config.LoadConfig + rng *rng.Source instances []*scenarioInstance deployer *types.Account sharedAccounts types.AccountPool // Shared account pool when using top-level config @@ -51,6 +53,7 @@ func (g *configBasedGenerator) createScenarios() error { g.sharedAccounts = types.NewAccountPool(&types.AccountConfig{ Accounts: accounts, NewAccountRate: g.config.Accounts.NewAccountRate, + Stream: g.rng.Stream(rng.StreamAccountsShared), }) g.accountPools = append(g.accountPools, g.sharedAccounts) } @@ -58,6 +61,7 @@ func (g *configBasedGenerator) createScenarios() error { for i, scenarioCfg := range g.config.Scenarios { // Create scenario instance using factory scenario := scenarios.CreateScenario(scenarioCfg) + g.bindGasStreams(i, scenarioCfg) // Determine account pool to use var accountPool types.AccountPool @@ -70,6 +74,7 @@ func (g *configBasedGenerator) createScenarios() error { accountPool = types.NewAccountPool(&types.AccountConfig{ Accounts: accounts, NewAccountRate: newAccountRate, + Stream: g.rng.Stream(rng.AccountsScenarioStream(i)), }) g.accountPools = append(g.accountPools, accountPool) } else if g.sharedAccounts != nil { @@ -111,6 +116,29 @@ func (g *configBasedGenerator) createScenarios() error { return nil } +// bindGasStreams binds each configured gas picker for a scenario to its own +// deterministic sub-stream. The stream ids are keyed by the scenario's config +// index so they stay stable across runs of the same config. +// +// cfg is a value copy, but its *GasPicker fields are pointers shared with the +// copy the scenario stores, so SetStream reaches the picker the scenario draws +// through. A shallow copy is safe precisely because GasPicker.delegate is a +// *RandomGasGenerator shared by both copies; only a copy that ALSO clones the +// gas delegate would break the aliasing silently — see +// TestRandomGasPickerStreamSeeds, which fails loudly if the binding stops +// reaching the live picker. +func (g *configBasedGenerator) bindGasStreams(i int, cfg config.Scenario) { + if cfg.GasPicker != nil { + cfg.GasPicker.SetStream(g.rng.Stream(rng.GasBaseStream(i))) + } + if cfg.GasTipCapPicker != nil { + cfg.GasTipCapPicker.SetStream(g.rng.Stream(rng.GasTipStream(i))) + } + if cfg.GasFeeCapPicker != nil { + cfg.GasFeeCapPicker.SetStream(g.rng.Stream(rng.GasFeeCapStream(i))) + } +} + // mockDeployAll deploys all scenario instances that require deployment (for unit tests). func (g *configBasedGenerator) mockDeployAll() error { for _, instance := range g.instances { @@ -181,7 +209,7 @@ func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) { } // Create and return the weighted scenarioGenerator - return NewWeightedGenerator(weightedConfigs...), nil + return NewWeightedGenerator(g.rng.Stream(rng.StreamWeightedShuffle), weightedConfigs...), nil } // GetAccountPools returns all account pools managed by this generator @@ -195,10 +223,24 @@ func (g *configBasedGenerator) GetAccountPools() []types.AccountPool { return pools } +// resolveSeed returns the run's PRNG source, defaulting an unseeded config to a +// random seed. The resolved seed is written back to cfg.Seed and logged so any +// run is replayable after the fact; the run summary (PLT-467) reads it there. +func resolveSeed(cfg *config.LoadConfig) *rng.Source { + if cfg.Seed != nil { + return rng.NewSource(*cfg.Seed) + } + src, seed := rng.NewRandomSource() + cfg.Seed = &seed + log.Printf("🎲 No seed configured; generated random seed %d (set \"seed\" to replay)", seed) + return src +} + // NewConfigBasedGenerator is a convenience method that combines all steps func NewConfigBasedGenerator(cfg *config.LoadConfig) (Generator, error) { generator := &configBasedGenerator{ config: cfg, + rng: resolveSeed(cfg), instances: make([]*scenarioInstance, 0), deployer: types.GenerateAccounts(1)[0], } diff --git a/generator/seed_test.go b/generator/seed_test.go new file mode 100644 index 0000000..2b9842b --- /dev/null +++ b/generator/seed_test.go @@ -0,0 +1,191 @@ +package generator_test + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +func seededConfig(t *testing.T, seed uint64) *config.LoadConfig { + t.Helper() + s := seed + return &config.LoadConfig{ + ChainID: 7777, + MockDeploy: true, + Endpoints: []string{"http://localhost:8545"}, + Seed: &s, + Scenarios: []config.Scenario{ + { + Name: scenarios.EVMTransfer, + Weight: 2, + Accounts: &config.AccountConfig{Accounts: 10, NewAccountRate: 0.5}, + GasPicker: randomGasPicker(t, 21000, 90000), + GasTipCapPicker: randomGasPicker(t, 1_000_000_000, 3_000_000_000), + GasFeeCapPicker: randomGasPicker(t, 100_000_000_000, 300_000_000_000), + }, + { + Name: scenarios.EVMTransferNoop, + Weight: 3, + Accounts: &config.AccountConfig{Accounts: 20, NewAccountRate: 0.5}, + GasPicker: randomGasPicker(t, 30000, 120000), + }, + }, + } +} + +func randomGasPicker(t *testing.T, min, max uint64) *config.GasPicker { + t.Helper() + var gp config.GasPicker + require.NoError(t, gp.UnmarshalJSON(fmt.Appendf(nil, `{"Name":"random","Min":%d,"Max":%d}`, min, max))) + return &gp +} + +// gasDraw is the seed-determined gas output of one tx across all three gas +// streams (base/tip/feecap), so reproducibility assertions cover every stream +// bindGasStreams binds, not just the base picker. +type gasDraw struct { + gas uint64 + tip int64 + feeCap int64 +} + +func draw(tx *types.LoadTx) gasDraw { + return gasDraw{ + gas: tx.EthTx.Gas(), + tip: tx.EthTx.GasTipCap().Int64(), + feeCap: tx.EthTx.GasFeeCap().Int64(), + } +} + +// gasSeq drains n txs from a freshly-built generator and returns each tx's +// seed-determined gas draw — the RNG-driven output we replay against. +func gasSeq(t *testing.T, seed uint64, n int) []gasDraw { + t.Helper() + gen, err := generator.NewConfigBasedGenerator(seededConfig(t, seed)) + require.NoError(t, err) + txs := gen.GenerateN(n) + require.Len(t, txs, n) + out := make([]gasDraw, n) + for i, tx := range txs { + out[i] = draw(tx) + } + return out +} + +// Same seed + config => identical ordered gas draw sequence at a single worker +// (GenerateN drains serially). This pins the ordered guarantee. +func TestSeededRunReplaysIdentically(t *testing.T) { + require.Equal(t, gasSeq(t, 123, 200), gasSeq(t, 123, 200)) +} + +// TestSingleWorkerOrderedReplay pins the real ordered guarantee: at one worker +// the ordered draw/tx sequence is reproducible across two same-seed runs. (The +// multiset — not the order — is what survives above one worker; see +// TestWorkerCountMultisetInvariant.) +func TestSingleWorkerOrderedReplay(t *testing.T) { + const seed, total = 55, 300 + a := gasSeq(t, seed, total) + b := gasSeq(t, seed, total) + require.Equal(t, a, b, "single-worker ordered draw sequence is not reproducible") +} + +// Different seeds must diverge (otherwise the seed is ignored). +func TestDifferentSeedsDiverge(t *testing.T) { + require.NotEqual(t, gasSeq(t, 1, 200), gasSeq(t, 2, 200)) +} + +// columns transposes a slice of per-tx gas draws into three per-stream column +// slices. The contract guarantees per-*stream* multisets, not per-tx tuples: +// concurrent txs interleave their base/tip/feecap draws across three +// independently-locked streams, so tuples reassemble differently while each +// stream's column multiset is unchanged. We assert columns, never tuples. +func columns(draws []gasDraw) (gas []uint64, tip, feeCap []int64) { + gas = make([]uint64, len(draws)) + tip = make([]int64, len(draws)) + feeCap = make([]int64, len(draws)) + for i, d := range draws { + gas[i] = d.gas + tip[i] = d.tip + feeCap[i] = d.feeCap + } + return gas, tip, feeCap +} + +// TestWorkerCountMultisetInvariant asserts the per-stream multiset guarantee, +// not ordered replay: each gas stream's column multiset that a seed yields does +// not depend on how many worker goroutines concurrently consume the generator. +// Streams are keyed by logical config id, not a live-goroutine counter, so the +// per-stream multiset is invariant to --workers. +// +// Two things make this test exercise the real contract rather than a stronger +// false one: +// +// - gen.Generate() runs OUTSIDE the worker lock, so workers genuinely draw +// concurrently and the three streams interleave. The lock guards only the +// work-claim bookkeeping. (Run under -race; -count=10 guards against flake.) +// - We assert each column's multiset independently via ElementsMatch, NOT the +// per-tx tuple. Tuples are not worker-invariant; columns are. +// +// Ordering within a column is deliberately NOT asserted; it is non-deterministic +// above one worker. +func TestWorkerCountMultisetInvariant(t *testing.T) { + const seed, total = 99, 600 + + serial := gasSeq(t, seed, total) + wantGas, wantTip, wantFeeCap := columns(serial) + + for _, workers := range []int{2, 4, 8} { + gen, err := generator.NewConfigBasedGenerator(seededConfig(t, seed)) + require.NoError(t, err) + + // Each worker collects into its own slice; we merge after the join so the + // only shared mutable state under lock is the work-claim counter, and + // Generate() itself runs unlocked and concurrently. + var mu sync.Mutex + remaining := total + perWorker := make([][]gasDraw, workers) + + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for { + mu.Lock() + if remaining <= 0 { + mu.Unlock() + return + } + remaining-- + mu.Unlock() + + tx, ok := gen.Generate() + require.True(t, ok) + perWorker[w] = append(perWorker[w], draw(tx)) + } + }(w) + } + wg.Wait() + + got := make([]gasDraw, 0, total) + for _, part := range perWorker { + got = append(got, part...) + } + require.Len(t, got, total) + + gotGas, gotTip, gotFeeCap := columns(got) + require.ElementsMatch(t, wantGas, gotGas, + "workers=%d: gas-stream column multiset diverged from serial", workers) + require.ElementsMatch(t, wantTip, gotTip, + "workers=%d: tip-stream column multiset diverged from serial", workers) + require.ElementsMatch(t, wantFeeCap, gotFeeCap, + "workers=%d: feecap-stream column multiset diverged from serial", workers) + } +} diff --git a/generator/weighted.go b/generator/weighted.go index c423826..c1c799d 100644 --- a/generator/weighted.go +++ b/generator/weighted.go @@ -2,10 +2,11 @@ package generator import ( "context" - "math/rand" + "math/rand/v2" "sync" "github.com/sei-protocol/sei-load/types" + "github.com/sei-protocol/sei-load/utils/rng" ) // WeightedCfg is a configuration for a weighted scenarioGenerator. @@ -101,8 +102,10 @@ func (w *weightedGenerator) GetAccountPools() []types.AccountPool { return allPools } -// NewWeightedGenerator creates a new scenarioGenerator that will randomly select from the provided generators. -func NewWeightedGenerator(cfgs ...*WeightedCfg) Generator { +// NewWeightedGenerator creates a new scenarioGenerator that will randomly select +// from the provided generators. A nil stream leaves the startup shuffle on the +// unseeded global RNG. +func NewWeightedGenerator(stream *rng.Stream, cfgs ...*WeightedCfg) Generator { // no need for clever weighting logic if we just have 1 scenarioGenerator anyway. if len(cfgs) == 1 { return cfgs[0].Generator @@ -114,7 +117,11 @@ func NewWeightedGenerator(cfgs ...*WeightedCfg) Generator { } } - rand.Shuffle(len(weighted), func(i, j int) { + shuffle := rand.Shuffle + if stream != nil { + shuffle = stream.Shuffle + } + shuffle(len(weighted), func(i, j int) { weighted[i], weighted[j] = weighted[j], weighted[i] }) diff --git a/types/account_pool.go b/types/account_pool.go index 0770f12..0ce8527 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -1,8 +1,10 @@ package types import ( - "math/rand" + "math/rand/v2" "sync" + + "github.com/sei-protocol/sei-load/utils/rng" ) // AccountPool returns a next account for load generation. @@ -14,6 +16,9 @@ type AccountPool interface { type AccountConfig struct { Accounts []*Account NewAccountRate float64 + // Stream, when non-nil, makes the new-account roll deterministic. A nil + // Stream leaves the pool on the unseeded global RNG. + Stream *rng.Stream } type accountPool struct { @@ -35,7 +40,12 @@ func (a *accountPool) nextIndex() int { // NextAccount returns the next account. func (a *accountPool) NextAccount() *Account { if a.cfg.NewAccountRate > 0 { - randomNumber := rand.Float64() + var randomNumber float64 + if a.cfg.Stream != nil { + randomNumber = a.cfg.Stream.Float64() + } else { + randomNumber = rand.Float64() + } if randomNumber <= a.cfg.NewAccountRate { return GenerateAccounts(1)[0] } diff --git a/types/types_test.go b/types/types_test.go index 54b48b3..33eb6a2 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -11,6 +11,8 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/utils/rng" ) func TestNewAccount(t *testing.T) { @@ -173,6 +175,7 @@ func TestAccountPoolMixedRate(t *testing.T) { config := &AccountConfig{ Accounts: accounts, NewAccountRate: 0.5, // 50% new accounts + Stream: rng.NewSource(1).Stream("accounts:test"), } pool := NewAccountPool(config) @@ -195,12 +198,11 @@ func TestAccountPoolMixedRate(t *testing.T) { } } - // With 50% rate, expect roughly equal distribution (allow 20% variance) - expectedNew := iterations / 2 - tolerance := expectedNew / 5 // 20% tolerance - - assert.InDelta(t, expectedNew, newCount, float64(tolerance), - "Expected ~%d new accounts, got %d (tolerance: ±%d)", expectedNew, newCount, tolerance) + // Seeded: the split is exact and reproducible, not probabilistic. Re-running + // the same seeded pool must reproduce these counts. If the frozen derivation + // changes, these expected values change with it. + const expectedNew = 51 + assert.Equal(t, expectedNew, newCount, "seeded new-account count is not reproducible") assert.Equal(t, iterations, originalCount+newCount, "Total accounts don't match iterations") } diff --git a/utils/rng/rng.go b/utils/rng/rng.go new file mode 100644 index 0000000..2ff9bf5 --- /dev/null +++ b/utils/rng/rng.go @@ -0,0 +1,142 @@ +// Package rng derives independent, reproducible pseudo-random sub-streams from +// a single run seed. +// +// Reproducibility contract — read precisely: +// +// Same seed + same config => identical per-stream draw multiset. The workload +// (the distribution of keys, sizes, gas, and accounts) is statistically +// reproducible, which is what fair A/B comparison of two runs requires. +// +// What is NOT guaranteed: per-tx emission ordering across runs is reproducible +// only at a single worker. Above one worker, workers interleave their draws +// into the shared streams non-deterministically, so the ordered tx sequence +// differs run to run even at the same seed (the multiset still matches). On-chain +// arrival order is concurrent regardless of worker count, so it is never +// reproducible. Nothing here makes individual transactions byte-identical. +// +// Sub-streams are keyed by a *logical* stream id (a string naming the +// consumer/purpose), never by a live-goroutine counter, so the per-stream draw +// multiset a seed yields is invariant to --workers. +package rng + +import ( + "crypto/rand" + "encoding/binary" + mrand "math/rand/v2" + "sync" +) + +// FROZEN DERIVATION — DO NOT CHANGE. +// +// substream(seed, streamID) = NewPCG(seed, splitmix64(fnv1a64(streamID))) +// +// where seed is the run seed and streamID is the logical consumer name. The +// FNV-1a hash maps the name to a uint64; splitmix64 diffuses it so that +// near-identical names (e.g. "gas:0" / "gas:1") seed well-separated PCG states. +// +// Four inputs are FROZEN, not just this formula. Each perturbs the draw +// sequence with no formula change, so each is a one-way door requiring a +// config_sha256 version bump: +// +// 1. The derivation formula above (hash, diffusion, PCG argument order). +// 2. The set of stream-id strings (defined as constants in streams.go). The +// streamID feeds fnv1a64, so renaming "gas:0:base" reseeds that stream. +// 3. The per-stream draw order. Each stream is a sequence; drawing base before +// tip before feecap is part of the contract — reordering draws within a +// stream shifts every downstream value. +// 4. The per-tx account draw cadence: sender then receiver NextAccount() per tx +// (generator/scenario.go), each consuming the account stream. This is a +// draw-order on the account stream just like #3 is for the gas streams — +// reordering or adding an account draw per tx shifts every downstream +// account value. +// +// Replay archives are keyed by config_sha256 (PLT-467). Changing any of the +// three silently produces a different draw sequence for the same (seed, config) +// and invalidates every saved replay. +func substream(seed uint64, streamID string) *mrand.PCG { + return mrand.NewPCG(seed, splitmix64(fnv1a64(streamID))) +} + +const ( + fnvOffset64 = 1469598103934665603 + fnvPrime64 = 1099511628211 +) + +func fnv1a64(s string) uint64 { + h := uint64(fnvOffset64) + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= fnvPrime64 + } + return h +} + +func splitmix64(x uint64) uint64 { + x += 0x9e3779b97f4a7c15 + x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9 + x = (x ^ (x >> 27)) * 0x94d049bb133111eb + return x ^ (x >> 31) +} + +// Source derives sub-streams for a single run from one seed. +type Source struct { + seed uint64 +} + +// NewSource returns a Source rooted at the given seed. +func NewSource(seed uint64) *Source { + return &Source{seed: seed} +} + +// NewRandomSource generates a cryptographically-random seed and returns a Source +// rooted at it alongside the resolved seed, so an unseeded run can still be +// replayed after the fact by re-running with the returned seed. +func NewRandomSource() (*Source, uint64) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + panic("rng: crypto/rand failed: " + err.Error()) + } + seed := binary.LittleEndian.Uint64(b[:]) + return NewSource(seed), seed +} + +// Seed returns the run seed this Source is rooted at. +func (s *Source) Seed() uint64 { return s.seed } + +// Stream returns the sub-stream for a logical consumer named streamID. The same +// (seed, streamID) always yields the same draw sequence for a given call order +// into the stream, independent of any other stream's draws. Concurrent workers +// drawing from one stream still see a reproducible multiset, but their +// interleaving — and thus the per-call ordering — is non-deterministic. +func (s *Source) Stream(streamID string) *Stream { + return &Stream{rand: mrand.New(substream(s.seed, streamID))} +} + +// Stream is a single consumer's reproducible sub-stream. It is safe for +// concurrent use: draws are serialized so the per-stream sequence depends only +// on call order into this stream, not on the goroutine that made the call. +type Stream struct { + mu sync.Mutex + rand *mrand.Rand +} + +// Float64 returns a draw in [0.0, 1.0). +func (s *Stream) Float64() float64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.rand.Float64() +} + +// Uint64N returns a draw in [0, n). +func (s *Stream) Uint64N(n uint64) uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.rand.Uint64N(n) +} + +// Shuffle pseudo-randomizes the order of n elements via swap. +func (s *Stream) Shuffle(n int, swap func(i, j int)) { + s.mu.Lock() + defer s.mu.Unlock() + s.rand.Shuffle(n, swap) +} diff --git a/utils/rng/rng_test.go b/utils/rng/rng_test.go new file mode 100644 index 0000000..693bc8b --- /dev/null +++ b/utils/rng/rng_test.go @@ -0,0 +1,94 @@ +package rng + +import ( + "sync" + "testing" +) + +// drawSeq pulls n uint64 draws from the named stream of a fresh source. +func drawSeq(seed uint64, streamID string, n int) []uint64 { + s := NewSource(seed).Stream(streamID) + out := make([]uint64, n) + for i := range out { + out[i] = s.Uint64N(1 << 32) + } + return out +} + +func TestSameSeedSameStreamReproduces(t *testing.T) { + a := drawSeq(42, "gas:0:base", 64) + b := drawSeq(42, "gas:0:base", 64) + for i := range a { + if a[i] != b[i] { + t.Fatalf("draw %d differs: %d != %d", i, a[i], b[i]) + } + } +} + +func TestDifferentStreamsDiverge(t *testing.T) { + a := drawSeq(42, "gas:0:base", 64) + b := drawSeq(42, "gas:1:base", 64) + same := true + for i := range a { + if a[i] != b[i] { + same = false + break + } + } + if same { + t.Fatal("near-identical stream ids produced identical sequences; diffusion failed") + } +} + +// The replay invariant: a stream's sequence depends only on call order into +// that stream, never on how many other streams exist or the order they were +// created. A goroutine-counter-based scheme would shift the sequence when more +// streams (more workers) are live; a logical-id scheme must not. +func TestStreamIndependentOfSiblings(t *testing.T) { + const seed = 7 + want := drawSeq(seed, "accounts:shared", 100) + + src := NewSource(seed) + for i := 0; i < 32; i++ { + _ = src.Stream("noise") + } + target := src.Stream("accounts:shared") + for i := 0; i < 16; i++ { + _ = src.Stream("more-noise") + } + + for i := range want { + if got := target.Uint64N(1 << 32); got != want[i] { + t.Fatalf("draw %d differs after sibling noise: %d != %d", i, got, want[i]) + } + } +} + +// A single stream is safe for concurrent draws (the per-stream mutex serializes +// them); this guards the -race build for the account-pool and weighted paths. +func TestStreamConcurrentDrawsRaceFree(t *testing.T) { + s := NewSource(3).Stream("x") + var wg sync.WaitGroup + for i := 0; i < 64; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + _ = s.Uint64N(1 << 32) + } + }() + } + wg.Wait() +} + +func TestRandomSourceRecordsSeed(t *testing.T) { + src, seed := NewRandomSource() + if src.Seed() != seed { + t.Fatalf("recorded seed %d != source seed %d", seed, src.Seed()) + } + a := NewSource(seed).Stream("x").Uint64N(1 << 32) + b := NewSource(seed).Stream("x").Uint64N(1 << 32) + if a != b { + t.Fatalf("recorded seed does not replay: %d != %d", a, b) + } +} diff --git a/utils/rng/streams.go b/utils/rng/streams.go new file mode 100644 index 0000000..6c3b547 --- /dev/null +++ b/utils/rng/streams.go @@ -0,0 +1,30 @@ +package rng + +import "fmt" + +// FROZEN STREAM IDS — DO NOT CHANGE (see the FROZEN block in rng.go, input #2). +// +// Each stream id is hashed into a sub-stream seed, so renaming any of these — or +// changing an indexed format string — reseeds that stream and invalidates every +// saved replay for the same config_sha256. They are centralized here so the +// frozen naming surface is reviewable in one place and not edited at call sites. +const ( + // StreamAccountsShared seeds the shared (top-level) account pool. + StreamAccountsShared = "accounts:shared" + // StreamWeightedShuffle seeds the weighted scenario selector's shuffle. + StreamWeightedShuffle = "weighted:shuffle" +) + +// AccountsScenarioStream is the stream id for scenario i's own account pool. +func AccountsScenarioStream(i int) string { + return fmt.Sprintf("accounts:scenario:%d", i) +} + +// GasBaseStream is the stream id for scenario i's base gas picker. +func GasBaseStream(i int) string { return fmt.Sprintf("gas:%d:base", i) } + +// GasTipStream is the stream id for scenario i's tip-cap gas picker. +func GasTipStream(i int) string { return fmt.Sprintf("gas:%d:tip", i) } + +// GasFeeCapStream is the stream id for scenario i's fee-cap gas picker. +func GasFeeCapStream(i int) string { return fmt.Sprintf("gas:%d:feecap", i) }