Skip to content

Commit

Permalink
feat(random): add an RNG based on Alea (#82)
Browse files Browse the repository at this point in the history
* feat(random): add good, seedable RNG

We often use some seedable RNGs based on sinus. There are several
problems with these:
- The values are generally highly correlated.
- Implementation differs and therefore fails pinning tests.
- **Vast majority** of the returned values is **very** close to 1.
- Values close to 0 are as good as imposibble.

This provides a solid alternatives that will hopefully be much better
and consistent in different environments (allowing for pinning tests).

* test(deep-merge): fix failing test

Creating a snapshot somehow made this fail. I suspect some kind of
global scope pollution. Sadly unless we want to switch to AVA or
something this seems to be the most elaborate and best maintained
snapshotting library.

Truth is though that the test should have always looked like this.
  • Loading branch information
Thomaash authored Jan 12, 2020
1 parent 1090476 commit f2408b4
Show file tree
Hide file tree
Showing 8 changed files with 5,014 additions and 433 deletions.
3,707 changes: 3,707 additions & 0 deletions __snapshots__/test/random/alea.test.ts.js

Large diffs are not rendered by default.

1,539 changes: 1,107 additions & 432 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
"pre-push": "run-s type-check test lint"
}
},
"config": {
"snap-shot-it": {
"sortSnapshots": true,
"useRelativePath": true
}
},
"engines": {
"node": ">=8"
},
Expand Down Expand Up @@ -121,6 +127,7 @@
"rollup-plugin-terser": "^5.1.3",
"semantic-release": "^16.0.0",
"sinon": "^8.0.1",
"snap-shot-it": "^7.9.1",
"stryker-cli": "^1.0.0",
"typedoc": "^0.16.0",
"typescript": "^3.7.4",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// New API (tree shakeable).
export * from "./random";
export * from "./util";

// Old API (treeshakeable only if completely unused).
Expand Down
132 changes: 132 additions & 0 deletions src/random/alea.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Seedable, fast and reasonably good (not crypto but more than okay for our
* needs) random number generator.
*
* @remarks
* Adapted from {@link https://web.archive.org/web/20110429100736/http://baagoe.com:80/en/RandomMusings/javascript}.
* Original algorithm created by Johannes Baagøe \<baagoe\@baagoe.com\> in 2010.
*/

/**
* Random number generator.
*/
export interface RNG {
/** Returns \<0, 1). Faster than [[fract53]]. */
(): number;
/** Returns \<0, 1). Provides more precise data. */
fract53(): number;
/** Returns \<0, 2^32). */
uint32(): number;

/** The algorithm gehind this instance. */
algorithm: string;
/** The seed used to seed this instance. */
seed: Mashable[];
/** The version of this instance. */
version: string;
}

/**
* Create a seeded pseudo random generator based on Alea by Johannes Baagøe.
*
* @param seed - All supplied arguments will be used as a seed. In case nothing
* is supplied the current time will be used to seed the generator.
*
* @returns A ready to use seeded generator.
*/
export function Alea(...seed: Mashable[]): RNG {
return AleaImplementation(seed.length ? seed : [Date.now()]);
}

/**
* An implementation of [[Alea]] without user input validation.
*
* @param seed - The data that will be used to seed the generator.
*
* @returns A ready to use seeded generator.
*/
function AleaImplementation(seed: Mashable[]): RNG {
let [s0, s1, s2] = mashSeed(seed);
let c = 1;

const random: RNG = (): number => {
const t = 2091639 * s0 + c * 2.3283064365386963e-10; // 2^-32
s0 = s1;
s1 = s2;
return (s2 = t - (c = t | 0));
};

random.uint32 = (): number => random() * 0x100000000; // 2^32

random.fract53 = (): number =>
random() + ((random() * 0x200000) | 0) * 1.1102230246251565e-16; // 2^-53

random.algorithm = "Alea";
random.seed = seed;
random.version = "0.9";

return random;
}

/**
* Turn arbitrary data into values [[AleaImplementation]] can use to generate
* random numbers.
*
* @param seed - Arbitrary data that will be used as the seed.
*
* @returns Three numbers to use as initial values for [[AleaImplementation]].
*/
function mashSeed(...seed: Mashable[]): [number, number, number] {
const mash = Mash();

let s0 = mash(" ");
let s1 = mash(" ");
let s2 = mash(" ");

for (let i = 0; i < seed.length; i++) {
s0 -= mash(seed[i]);
if (s0 < 0) {
s0 += 1;
}
s1 -= mash(seed[i]);
if (s1 < 0) {
s1 += 1;
}
s2 -= mash(seed[i]);
if (s2 < 0) {
s2 += 1;
}
}

return [s0, s1, s2];
}

/**
* Values of these types can be used as a seed.
*/
export type Mashable = number | string | boolean | object | bigint;

/**
* Create a new mash function.
*
* @returns A nonpure function that takes arbitrary [[Mashable]] data and turns
* them into numbers.
*/
function Mash(): (data: Mashable) => number {
let n = 0xefc8249d;

return function(data): number {
const string = data.toString();
for (let i = 0; i < string.length; i++) {
n += string.charCodeAt(i);
let h = 0.02519603282416938 * n;
n = h >>> 0;
h -= n;
h *= n;
n = h >>> 0;
h -= n;
n += h * 0x100000000; // 2^32
}
return (n >>> 0) * 2.3283064365386963e-10; // 2^-32
};
}
1 change: 1 addition & 0 deletions src/random/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./alea";
2 changes: 1 addition & 1 deletion test/deep-extend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe("deepExtend", function(): void {
p3: "S"
},
objectFromNull: {},
objectFromObject: {},
objectFromObject: Object.create(Object),
objectFromMap: new Map()
}
});
Expand Down
58 changes: 58 additions & 0 deletions test/random/alea.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import snapshot from "snap-shot-it";

import { Alea, RNG } from "../../src/random";

/* global BigInt */
// Note: BigInt is supported by all maintained Node versions and even a bunch
// of dead so it's safe to use it in tests. However it is not a standard yet
// and therefore ESLint complains about it hence the declaration above. We
// should remove this comment block once it becomes a standard and ESLint adds
// it into it's list of builtin globals.

describe("Alea", function(): void {
this.timeout(60000);

const count = 100;

for (const { name, get } of [
{ name: "rng()", get: (rng: RNG): number => rng() },
{ name: "rng.fract53()", get: (rng: RNG): number => rng.fract53() },
{ name: "rng.uint32()", get: (rng: RNG): number => rng.uint32() }
]) {
describe(name, function(): void {
for (const seed of [
[
"I'm an alligator",
" I'm a mama-papa coming for you",
" I'm the space invader",
" I'll be a rock 'n' rollin' bitch for you."
],
+new Date("2020-01-01"),
false,
true,
Math.PI,
BigInt("4235467986087964853726437"),
-0.00432,
-423,
0.1244942,
77,
0,
"We can be zeros, just for one day"
]) {
it(`Seed: ${seed.toString()}`, function(): void {
const alea = Alea(seed);

const values = Array(count);
for (let i = 0; i < count; ++i) {
values[i] = get(alea);
}

// Note: All the code above could be replaced by two args for data
// driven snapshots however the overhead of that is insane. The tests
// would go from milliseconds to seconds.
snapshot(values);
});
}
});
}
});

0 comments on commit f2408b4

Please sign in to comment.