feat(spec): bit-exact PCG port of Go math/rand/v2

This commit is contained in:
pj committed 2026-06-01 16:09:02 +05:30
1 parent 307213ef45
commit 231f96f5a5
1 file changed
+96
+96
View File
@@ -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;
}
}