From 231f96f5a585edbe018f131c36b24d2f5d35f225 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 1 Jun 2026 16:09:02 +0530 Subject: [PATCH] feat(spec): bit-exact PCG port of Go math/rand/v2 --- pkg/spec/src/pcg.ts | 96 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 pkg/spec/src/pcg.ts diff --git a/pkg/spec/src/pcg.ts b/pkg/spec/src/pcg.ts new file mode 100644 index 0000000..d2427ec --- /dev/null +++ b/pkg/spec/src/pcg.ts @@ -0,0 +1,96 @@ +// Bit-exact port of Go's math/rand/v2 PCG source wrapped in rand.Rand. +// +// This MUST match `rand.New(rand.NewPCG(hi, lo))` draw-for-draw, because every +// random decision the action picker makes is shared between the goja verifier +// and the V8 web runtime. Divergence here breaks reproducibility across engines. +// +// The golden fixture in test/fixtures/pcg-golden.json is generated by +// internal/_oracle/main.go and pins the exact sequences asserted in pcg.test.ts. + +const MASK64 = (1n << 64n) - 1n; + +const MUL_HI = 2549297995355413924n; +const MUL_LO = 4865540595714422341n; +const INC_HI = 6364136223846793005n; +const INC_LO = 1442695040888963407n; +const CHEAP_MUL = 0xda942042e4dd58b5n; + +const POW53 = 1n << 53n; + +// mul64 returns the 128-bit product of two uint64 values split into high and +// low 64-bit halves, matching Go's math/bits.Mul64. +function mul64(a: bigint, b: bigint): { hi: bigint; lo: bigint } { + const product = a * b; + return { hi: (product >> 64n) & MASK64, lo: product & MASK64 }; +} + +export class Pcg { + private hi: bigint; + private lo: bigint; + + constructor(hi: bigint, lo: bigint) { + this.hi = hi & MASK64; + this.lo = lo & MASK64; + } + + // next advances the 128-bit LCG state and returns the new (hi, lo) pair. + private next(): { hi: bigint; lo: bigint } { + // 128-bit multiply of state by the 128-bit multiplier, keeping 128 bits. + let { hi, lo } = mul64(this.lo, MUL_LO); + hi = (hi + this.hi * MUL_LO + this.lo * MUL_HI) & MASK64; + + // 128-bit add of the increment with carry propagation. + const loSum = lo + INC_LO; + const carry = loSum >> 64n; + lo = loSum & MASK64; + hi = (hi + INC_HI + carry) & MASK64; + + this.lo = lo; + this.hi = hi; + return { hi, lo }; + } + + // uint64 mirrors PCG.Uint64: the DXSM output permutation over next(). + uint64(): bigint { + let { hi, lo } = this.next(); + hi ^= hi >> 32n; + hi = (hi * CHEAP_MUL) & MASK64; + hi ^= hi >> 48n; + hi = (hi * (lo | 1n)) & MASK64; + return hi & MASK64; + } + + // float64 mirrors rand.Rand.Float64: float64(Uint64()<<11>>11) / (1<<53), + // which keeps the low 53 bits and divides by 2^53. + float64(): number { + const bits = this.uint64() & (POW53 - 1n); + return Number(bits) / Number(POW53); + } + + // intN mirrors rand.Rand.IntN via uint64n (Lemire with a rejection threshold). + // The host arch is 64-bit, so the 32-bit fast path never applies. + intN(n: number | bigint): number { + const bound = typeof n === "bigint" ? n : BigInt(n); + if (bound <= 0n) { + throw new Error("invalid argument to intN"); + } + return Number(this.uint64n(bound)); + } + + private uint64n(n: bigint): bigint { + if ((n & (n - 1n)) === 0n) { + // n is a power of two: mask the low bits. + return this.uint64() & (n - 1n); + } + + let { hi, lo } = mul64(this.uint64(), n); + if (lo < n) { + // Go computes thresh := -n % n in uint64 math, where -n wraps to 2^64 - n. + const threshold = ((1n << 64n) - n) % n; + while (lo < threshold) { + ({ hi, lo } = mul64(this.uint64(), n)); + } + } + return hi; + } +}