diff --git a/package-lock.json b/package-lock.json index 2637b244f6..1fa10e2d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "escape-latex": "^1.2.0", "fraction.js": "^5.2.1", "javascript-natural-sort": "^0.7.1", - "seedrandom": "^3.0.5", "tiny-emitter": "^2.1.0", "typed-function": "^4.2.1" }, @@ -117,6 +116,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2302,6 +2302,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -2366,6 +2367,7 @@ "integrity": "sha512-ixiWrCSRi33uqBMRuICcKECW7rtgY43TbsHDpM2XK7lXispd48opW+0IXrBVxv9NMhaz/Ue9kyj6r3NTVyXm8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -2415,6 +2417,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -2828,6 +2831,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3812,6 +3816,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5262,6 +5267,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5347,6 +5353,7 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5462,6 +5469,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5577,6 +5585,7 @@ "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -5690,6 +5699,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -8366,6 +8376,7 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -10324,6 +10335,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11018,6 +11030,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11049,12 +11062,6 @@ "dev": true, "license": "MIT" }, - "node_modules/seedrandom": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", - "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", - "license": "MIT" - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -12079,6 +12086,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12420,6 +12428,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12994,6 +13003,7 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index 07653a8be0..3f7b3f2a36 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "escape-latex": "^1.2.0", "fraction.js": "^5.2.1", "javascript-natural-sort": "^0.7.1", - "seedrandom": "^3.0.5", "tiny-emitter": "^2.1.0", "typed-function": "^4.2.1" }, diff --git a/src/function/probability/util/seededRNG.js b/src/function/probability/util/seededRNG.js index 768d5a4792..683b3283a0 100644 --- a/src/function/probability/util/seededRNG.js +++ b/src/function/probability/util/seededRNG.js @@ -1,4 +1,4 @@ -import seedrandom from 'seedrandom' +import { seedrandom } from './seedrandom.js' const singletonRandom = /* #__PURE__ */ seedrandom(Date.now()) diff --git a/src/function/probability/util/seedrandom.js b/src/function/probability/util/seedrandom.js new file mode 100644 index 0000000000..090f747bfa --- /dev/null +++ b/src/function/probability/util/seedrandom.js @@ -0,0 +1,107 @@ +/** + * Seeded random number generator based on ARC4 (RC4) cipher. + * Based on https://github.com/davidbau/seedrandom + * License: MIT (David Bau) + */ + +const STATE_SIZE = 256 +const BYTE_MASK = STATE_SIZE - 1 +const BASE_DENOMINATOR = 281474976710656 // 256^6 +const SIGNIFICANCE_THRESHOLD = 4503599627370496 // 2^52 +const OVERFLOW_THRESHOLD = SIGNIFICANCE_THRESHOLD * 2 // 2^53 +/** Prime multiplier from original seedrandom for entropy distribution in key scheduling */ +const KEY_MIX_MULTIPLIER = 19 + +class ARC4 { + constructor (key) { + this.i = 0 + this.j = 0 + + const keyLength = key.length || 1 + const s = new Uint8Array(STATE_SIZE) + for (let i = 0; i < STATE_SIZE; i++) s[i] = i + + // Key scheduling algorithm (KSA) + let j = 0 + for (let i = 0; i < STATE_SIZE; i++) { + const t = s[i] + j = (j + (key[i % keyLength] || 0) + t) & BYTE_MASK + s[i] = s[j] + s[j] = t + } + this.s = s + + // RC4-drop[256]: advance state via g() which also sets this.i/j + this.i = 0 + this.j = 0 + this.g(STATE_SIZE) + } + + /** Generate `count` random bytes concatenated as a single number */ + g (count) { + const s = this.s + let si = this.i + let sj = this.j + let r = 0 + while (count--) { + const t = s[si = (si + 1) & BYTE_MASK] + r = r * STATE_SIZE + s[((s[si] = s[sj = (sj + t) & BYTE_MASK]) + (s[sj] = t)) & BYTE_MASK] + } + this.i = si + this.j = sj + return r + } +} + +function flattenSeed (value) { + if (typeof value === 'string') { + return value + } + return String(value) + '\0' +} + +function mixSeedIntoKey (seed, key) { + let scramble = 0 + const len = seed.length + + for (let i = 0; i < len; i++) { + const idx = BYTE_MASK & i + scramble ^= key[idx] * KEY_MIX_MULTIPLIER + key[idx] = BYTE_MASK & (scramble + seed.charCodeAt(i)) + } +} + +/** + * Create a seeded random number generator. + * + * @param {string | number | null} [seed] - The seed value + * @returns {function} A function that returns random numbers in [0, 1) + */ +export function seedrandom (seed) { + const key = [] + + mixSeedIntoKey(flattenSeed(seed ?? Math.random()), key) + + const arc4 = new ARC4(key) + + return function () { + let numerator = arc4.g(6) + let denominator = BASE_DENOMINATOR + let extraBits = 0 + + while (numerator < SIGNIFICANCE_THRESHOLD) { + numerator = (numerator + extraBits) * STATE_SIZE + denominator *= STATE_SIZE + extraBits = arc4.g(1) + } + + // Scale down to avoid rounding up to 1.0 + while (numerator >= OVERFLOW_THRESHOLD) { + numerator /= 2 + denominator /= 2 + extraBits >>>= 1 + } + + return (numerator + extraBits) / denominator + } +} diff --git a/test/unit-tests/function/probability/seedrandom.test.js b/test/unit-tests/function/probability/seedrandom.test.js new file mode 100644 index 0000000000..69fc0948fc --- /dev/null +++ b/test/unit-tests/function/probability/seedrandom.test.js @@ -0,0 +1,123 @@ +import assert from 'assert' +import { seedrandom } from '../../../../src/function/probability/util/seedrandom.js' + +describe('seedrandom', function () { + describe('deterministic behavior', function () { + it('should produce the same sequence for the same seed', function () { + const rng1 = seedrandom('test-seed') + const rng2 = seedrandom('test-seed') + + const sequence1 = [rng1(), rng1(), rng1(), rng1(), rng1()] + const sequence2 = [rng2(), rng2(), rng2(), rng2(), rng2()] + + assert.deepStrictEqual(sequence1, sequence2) + }) + + it('should produce different sequences for different seeds', function () { + const rng1 = seedrandom('seed-a') + const rng2 = seedrandom('seed-b') + + assert.notStrictEqual(rng1(), rng2()) + }) + + it('should work with numeric seeds', function () { + const rng1 = seedrandom(12345) + const rng2 = seedrandom(12345) + + assert.strictEqual(rng1(), rng2()) + }) + + it('should work with null/undefined seeds', function () { + const rng1 = seedrandom(null) + const rng2 = seedrandom(undefined) + + assert.strictEqual(typeof rng1(), 'number') + assert.strictEqual(typeof rng2(), 'number') + }) + }) + + describe('output range', function () { + it('should return values in [0, 1)', function () { + const rng = seedrandom('range-test') + + for (let i = 0; i < 1000; i++) { + const value = rng() + assert.ok(value >= 0, 'value should be >= 0') + assert.ok(value < 1, 'value should be < 1') + } + }) + + it('should produce well-distributed values', function () { + const rng = seedrandom('distribution-test') + const buckets = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + + for (let i = 0; i < 10000; i++) { + const value = rng() + const bucket = Math.floor(value * 10) + buckets[bucket]++ + } + + for (const count of buckets) { + assert.ok(count > 800, 'each bucket should have > 800 values') + assert.ok(count < 1200, 'each bucket should have < 1200 values') + } + }) + }) + + describe('exact sequence stability', function () { + it('should produce the exact reference sequence for seed "hello"', function () { + const rng = seedrandom('hello') + assert.deepStrictEqual([rng(), rng(), rng(), rng(), rng()], [ + 0.5463663768140734, 0.4397379377059223, 0.554769432473455, + 0.7627046759719986, 0.4805307030523447 + ]) + }) + + it('should produce the exact reference sequence for seed "test123"', function () { + const rng = seedrandom('test123') + assert.deepStrictEqual([rng(), rng(), rng(), rng(), rng()], [ + 0.04685716528492509, 0.5600614521575903, 0.6661488235776364, + 0.6245303379613479, 0.2794975513488121 + ]) + }) + + it('should produce the exact reference sequence for seed "42"', function () { + const rng = seedrandom('42') + assert.deepStrictEqual([rng(), rng(), rng(), rng(), rng()], [ + 0.00701751618236155, 0.17185490054868188, 0.967001069269818, + 0.4077816952668805, 0.922687842759339 + ]) + }) + + it('should produce the exact reference sequence for empty string seed', function () { + const rng = seedrandom('') + assert.deepStrictEqual([rng(), rng(), rng(), rng(), rng()], [ + 0.23144008215179881, 0.27404636548159655, 0.7901279251811976, + 0.40384160557189036, 0.1321140086237582 + ]) + }) + }) + + describe('edge cases', function () { + it('should handle empty string seed', function () { + const rng = seedrandom('') + assert.strictEqual(typeof rng(), 'number') + }) + + it('should handle very long seeds', function () { + const longSeed = 'a'.repeat(10000) + const rng = seedrandom(longSeed) + assert.strictEqual(typeof rng(), 'number') + }) + + it('should handle special characters in seed', function () { + const rng = seedrandom('!@#$%^&*()_+-=[]{}|;:,.<>?') + assert.strictEqual(typeof rng(), 'number') + }) + + it('should handle unicode in seed', function () { + const rng = seedrandom('こんにちは世界🎲') + assert.strictEqual(typeof rng(), 'number') + }) + }) +})