From 52d5008cdfe8ee6642fcc8e3684bcc0ec0187d50 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 12 Jun 2026 13:54:40 +0000 Subject: [PATCH] feat: seeded RNG with per-label deterministic rolls --- src/sim/rng.js | 28 ++++++++++++++++++++++++++++ tests/rng.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/sim/rng.js create mode 100644 tests/rng.test.js diff --git a/src/sim/rng.js b/src/sim/rng.js new file mode 100644 index 0000000..eebeace --- /dev/null +++ b/src/sim/rng.js @@ -0,0 +1,28 @@ +// FNV-1a 32-bit string hash. +export function hash32(str) { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +// mulberry32 PRNG: returns function yielding uniforms in [0,1). +export function makeRng(seed) { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// One deterministic uniform in [0,1) for a (seed, label) pair. +// This is the whole hidden-variable mechanism: outcomes are fixed +// the moment their label exists, revealed whenever first asked for. +export function rollFor(seed, label) { + return makeRng(hash32(seed + ':' + label))(); +} diff --git a/tests/rng.test.js b/tests/rng.test.js new file mode 100644 index 0000000..bb8215f --- /dev/null +++ b/tests/rng.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { hash32, makeRng, rollFor } from '../src/sim/rng.js'; + +describe('rng', () => { + it('hash32 is deterministic and spreads', () => { + expect(hash32('abc')).toBe(hash32('abc')); + expect(hash32('abc')).not.toBe(hash32('abd')); + }); + + it('makeRng produces a deterministic sequence in [0,1)', () => { + const a = makeRng(42), b = makeRng(42); + for (let i = 0; i < 100; i++) { + const v = a(); + expect(v).toBe(b()); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('rollFor depends on seed and label, independently', () => { + expect(rollFor('s1', 'x')).toBe(rollFor('s1', 'x')); + expect(rollFor('s1', 'x')).not.toBe(rollFor('s1', 'y')); + expect(rollFor('s1', 'x')).not.toBe(rollFor('s2', 'x')); + }); + + it('rollFor is roughly uniform', () => { + let sum = 0; + for (let i = 0; i < 2000; i++) sum += rollFor('u', `l${i}`); + expect(sum / 2000).toBeGreaterThan(0.45); + expect(sum / 2000).toBeLessThan(0.55); + }); +});