From 149839b60c2794067e23fdf6da45d662a382811e Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Thu, 5 Sep 2024 13:17:10 +0800 Subject: [PATCH] feat(random/unstable): basic randomization functions (#5626) Co-authored-by: Yoshiya Hinosawa Co-authored-by: Asher Gomez --- _tools/check_circular_package_dependencies.ts | 2 + _tools/check_docs.ts | 1 + browser-compat.tsconfig.json | 1 + deno.json | 1 + import_map.json | 1 + random/_pcg32.ts | 100 ++++++++++++ random/_pcg32_test.ts | 122 ++++++++++++++ random/_types.ts | 27 ++++ random/between.ts | 51 ++++++ random/between_test.ts | 115 +++++++++++++ random/deno.json | 12 ++ random/integer_between.ts | 36 +++++ random/integer_between_test.ts | 94 +++++++++++ random/mod.ts | 26 +++ random/sample.ts | 97 +++++++++++ random/sample_test.ts | 152 ++++++++++++++++++ random/seeded.ts | 41 +++++ random/seeded_test.ts | 63 ++++++++ random/shuffle.ts | 47 ++++++ random/shuffle_test.ts | 69 ++++++++ 20 files changed, 1058 insertions(+) create mode 100644 random/_pcg32.ts create mode 100644 random/_pcg32_test.ts create mode 100644 random/_types.ts create mode 100644 random/between.ts create mode 100644 random/between_test.ts create mode 100644 random/deno.json create mode 100644 random/integer_between.ts create mode 100644 random/integer_between_test.ts create mode 100644 random/mod.ts create mode 100644 random/sample.ts create mode 100644 random/sample_test.ts create mode 100644 random/seeded.ts create mode 100644 random/seeded_test.ts create mode 100644 random/shuffle.ts create mode 100644 random/shuffle_test.ts diff --git a/_tools/check_circular_package_dependencies.ts b/_tools/check_circular_package_dependencies.ts index 02e779a82..57c3cae22 100644 --- a/_tools/check_circular_package_dependencies.ts +++ b/_tools/check_circular_package_dependencies.ts @@ -64,6 +64,7 @@ type Mod = | "msgpack" | "net" | "path" + | "random" | "regexp" | "semver" | "streams" @@ -107,6 +108,7 @@ const ENTRYPOINTS: Record = { msgpack: ["mod.ts"], net: ["mod.ts"], path: ["mod.ts"], + random: ["mod.ts"], regexp: ["mod.ts"], semver: ["mod.ts"], streams: ["mod.ts"], diff --git a/_tools/check_docs.ts b/_tools/check_docs.ts index 4b03fd8ce..7db9afba1 100644 --- a/_tools/check_docs.ts +++ b/_tools/check_docs.ts @@ -62,6 +62,7 @@ const ENTRY_POINTS = [ "../path/mod.ts", "../path/posix/mod.ts", "../path/windows/mod.ts", + "../random/mod.ts", "../regexp/mod.ts", "../semver/mod.ts", "../streams/mod.ts", diff --git a/browser-compat.tsconfig.json b/browser-compat.tsconfig.json index 6fcb71097..8f0faa7cc 100644 --- a/browser-compat.tsconfig.json +++ b/browser-compat.tsconfig.json @@ -36,6 +36,7 @@ "./msgpack", "./net", "./path", + "./random", "./regexp", "./semver", "./streams", diff --git a/deno.json b/deno.json index 99c17076b..6941a3c73 100644 --- a/deno.json +++ b/deno.json @@ -77,6 +77,7 @@ "./msgpack", "./net", "./path", + "./random", "./regexp", "./semver", "./streams", diff --git a/import_map.json b/import_map.json index 2a7cbc2da..c56fbf3e7 100644 --- a/import_map.json +++ b/import_map.json @@ -36,6 +36,7 @@ "@std/net": "jsr:@std/net@^1.0.2", "@std/path": "jsr:@std/path@^1.0.4", "@std/regexp": "jsr:@std/regexp@^1.0.0", + "@std/random": "jsr:@std/random@^0.1.0", "@std/semver": "jsr:@std/semver@^1.0.3", "@std/streams": "jsr:@std/streams@^1.0.4", "@std/tar": "jsr:@std/tar@^0.1.0", diff --git a/random/_pcg32.ts b/random/_pcg32.ts new file mode 100644 index 000000000..d2ff57b6b --- /dev/null +++ b/random/_pcg32.ts @@ -0,0 +1,100 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// Based on Rust `rand` crate (https://github.com/rust-random/rand). Apache-2.0 + MIT license. + +/** Multiplier for the PCG32 algorithm. */ +const MUL: bigint = 6364136223846793005n; +/** Initial increment for the PCG32 algorithm. Only used during seeding. */ +const INC: bigint = 11634580027462260723n; + +// Constants are for 64-bit state, 32-bit output +const ROTATE = 59n; // 64 - 5 +const XSHIFT = 18n; // (5 + 32) / 2 +const SPARE = 27n; // 64 - 32 - 5 + +/** + * Internal state for the PCG32 algorithm. + * `state` prop is mutated by each step, whereas `inc` prop remains constant. + */ +type PcgMutableState = { + state: bigint; + inc: bigint; +}; + +/** + * Modified from https://github.com/rust-random/rand/blob/f7bbccaedf6c63b02855b90b003c9b1a4d1fd1cb/rand_pcg/src/pcg64.rs#L129-L135 + */ +export function fromSeed(seed: Uint8Array) { + const d = new DataView(seed.buffer); + return fromStateIncr(d.getBigUint64(0, true), d.getBigUint64(8, true) | 1n); +} + +/** + * Mutates `pcg` by advancing `pcg.state`. + */ +function step(pgc: PcgMutableState) { + pgc.state = BigInt.asUintN(64, pgc.state * MUL + (pgc.inc | 1n)); +} + +/** + * Modified from https://github.com/rust-random/rand/blob/f7bbccaedf6c63b02855b90b003c9b1a4d1fd1cb/rand_pcg/src/pcg64.rs#L99-L105 + */ +function fromStateIncr(state: bigint, inc: bigint): PcgMutableState { + const pcg: PcgMutableState = { state, inc }; + // Move away from initial value + pcg.state = BigInt.asUintN(64, state + inc); + step(pcg); + return pcg; +} + +/** + * Internal PCG32 implementation, used by both the public seeded random + * function and the seed generation algorithm. + * + * Modified from https://github.com/rust-random/rand/blob/f7bbccaedf6c63b02855b90b003c9b1a4d1fd1cb/rand_pcg/src/pcg64.rs#L140-L153 + * + * `pcg.state` is internally advanced by the function. + * + * @param pcg The state and increment values to use for the PCG32 algorithm. + * @returns The next pseudo-random 32-bit integer. + */ +export function nextU32(pcg: PcgMutableState): number { + const state = pcg.state; + step(pcg); + // Output function XSH RR: xorshift high (bits), followed by a random rotate + const rot = state >> ROTATE; + const xsh = BigInt.asUintN(32, (state >> XSHIFT ^ state) >> SPARE); + return Number(rotateRightU32(xsh, rot)); +} + +// `n`, `rot`, and return val are all u32 +function rotateRightU32(n: bigint, rot: bigint): bigint { + const left = BigInt.asUintN(32, n << (-rot & 31n)); + const right = n >> rot; + return left | right; +} + +/** + * Convert a scalar bigint seed to a Uint8Array of the specified length. + * Modified from https://github.com/rust-random/rand/blob/f7bbccaedf6c63b02855b90b003c9b1a4d1fd1cb/rand_core/src/lib.rs#L359-L388 + */ +export function seedFromU64(state: bigint, numBytes: number): Uint8Array { + const seed = new Uint8Array(numBytes); + + const pgc: PcgMutableState = { state: BigInt.asUintN(64, state), inc: INC }; + // We advance the state first (to get away from the input value, + // in case it has low Hamming Weight). + step(pgc); + + for (let i = 0; i < Math.floor(numBytes / 4); ++i) { + new DataView(seed.buffer).setUint32(i * 4, nextU32(pgc), true); + } + + const rem = numBytes % 4; + if (rem) { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, nextU32(pgc), true); + seed.set(bytes.subarray(0, rem), numBytes - rem); + } + + return seed; +} diff --git a/random/_pcg32_test.ts b/random/_pcg32_test.ts new file mode 100644 index 000000000..fb3a0d222 --- /dev/null +++ b/random/_pcg32_test.ts @@ -0,0 +1,122 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { assertEquals } from "../assert/equals.ts"; +import { fromSeed, nextU32, seedFromU64 } from "./_pcg32.ts"; + +Deno.test("seedFromU64() generates seeds from bigints", async (t) => { + await t.step("first 10 16-bit seeds are same as rand crate", async (t) => { + /** + * Expected results obtained by copying the Rust code from + * https://github.com/rust-random/rand/blob/f7bbccaedf6c63b02855b90b003c9b1a4d1fd1cb/rand_core/src/lib.rs#L359-L388 + * but directly returning `seed` instead of `Self::from_seed(seed)` + */ + // deno-fmt-ignore + const expectedResults = [ + [236, 242, 115, 249, 129, 181, 205, 69, 135, 240, 70, 115, 6, 173, 108, 173], + [234, 216, 29, 114, 93, 38, 16, 78, 137, 156, 59, 248, 66, 206, 120, 46], + [77, 209, 16, 204, 177, 124, 55, 30, 237, 239, 68, 142, 238, 125, 215, 7], + [108, 90, 247, 27, 160, 186, 6, 71, 76, 124, 221, 142, 87, 133, 92, 175], + [197, 166, 196, 87, 44, 68, 69, 62, 55, 32, 34, 218, 130, 107, 171, 170], + [60, 64, 172, 11, 74, 188, 224, 128, 161, 112, 220, 75, 85, 212, 145, 251], + [177, 93, 150, 16, 48, 3, 23, 51, 155, 104, 76, 121, 82, 134, 239, 107], + [200, 12, 64, 59, 208, 32, 108, 9, 55, 166, 59, 111, 242, 79, 37, 30], + [222, 11, 88, 159, 202, 89, 63, 215, 36, 57, 0, 156, 63, 131, 114, 90], + [21, 119, 90, 241, 241, 191, 180, 229, 150, 199, 126, 251, 25, 141, 7, 4], + ]; + + for (const [i, expected] of expectedResults.entries()) { + await t.step(`With seed ${i}n`, () => { + const actual = Array.from(seedFromU64(BigInt(i), 16)); + assertEquals(actual, expected); + }); + } + }); + + await t.step( + "generates arbitrary-length seed data from a single bigint", + async (t) => { + // deno-fmt-ignore + const expectedBytes = [234, 216, 29, 114, 93, 38, 16, 78, 137, 156, 59, 248, 66, 206, 120, 46, 186]; + + for (const i of expectedBytes.keys()) { + const slice = expectedBytes.slice(0, i + 1); + + await t.step(`With length ${i + 1}`, () => { + const actual = Array.from(seedFromU64(1n, i + 1)); + assertEquals(actual, slice); + }); + } + }, + ); + + const U64_CEIL = 2n ** 64n; + + await t.step("wraps bigint input to u64", async (t) => { + await t.step("exact multiple of U64_CEIL", () => { + const expected = Array.from(seedFromU64(BigInt(0n), 16)); + const actual = Array.from(seedFromU64(U64_CEIL * 99n, 16)); + assertEquals(actual, expected); + }); + + await t.step("multiple of U64_CEIL + 1", () => { + const expected = Array.from(seedFromU64(1n, 16)); + const actual = Array.from(seedFromU64(1n + U64_CEIL * 3n, 16)); + assertEquals(actual, expected); + }); + + await t.step("multiple of U64_CEIL - 1", () => { + const expected = Array.from(seedFromU64(-1n, 16)); + const actual = Array.from(seedFromU64(U64_CEIL - 1n, 16)); + assertEquals(actual, expected); + }); + + await t.step("negative multiple of U64_CEIL", () => { + const expected = Array.from(seedFromU64(0n, 16)); + const actual = Array.from(seedFromU64(U64_CEIL * -3n, 16)); + assertEquals(actual, expected); + }); + + await t.step("negative multiple of U64_CEIL", () => { + const expected = Array.from(seedFromU64(0n, 16)); + const actual = Array.from(seedFromU64(U64_CEIL * -3n, 16)); + assertEquals(actual, expected); + }); + }); +}); + +Deno.test("nextU32() generates random 32-bit integers", async (t) => { + /** + * Expected results obtained from the Rust `rand` crate as follows: + * ```rs + * use rand_pcg::rand_core::{RngCore, SeedableRng}; + * use rand_pcg::Lcg64Xsh32; + * + * let mut rng = Lcg64Xsh32::seed_from_u64(0); + * for _ in 0..10 { + * println!("{}", rng.next_u32()); + * } + * ``` + */ + const expectedResults = [ + 298703107, + 4236525527, + 336081875, + 1056616254, + 1060453275, + 1616833669, + 501767310, + 2864049166, + 56572352, + 2362354238, + ]; + + const pgc = fromSeed(seedFromU64(0n, 16)); + const next = () => nextU32(pgc); + + for (const [i, expected] of expectedResults.entries()) { + await t.step(`#${i + 1} generated uint32`, () => { + const actual = next(); + assertEquals(actual, expected); + }); + } +}); diff --git a/random/_types.ts b/random/_types.ts new file mode 100644 index 000000000..9b280efbc --- /dev/null +++ b/random/_types.ts @@ -0,0 +1,27 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. + +/** + * A pseudo-random number generator implementing the same contract as + * `Math.random`, i.e. taking zero arguments and returning a random number in + * the range `[0, 1)`. The behavior of a function that accepts a `Prng` an + * option may be customized by passing a `Prng` with different behavior from + * `Math.random`, for example it may be seeded. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export type Prng = typeof Math.random; + +/** + * Options for random number generation. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export type RandomOptions = { + /** + * A pseudo-random number generator returning a random number in the range + * `[0, 1)`, used for randomization. + * @default {Math.random} + */ + prng?: Prng; +}; diff --git a/random/between.ts b/random/between.ts new file mode 100644 index 000000000..8afab3667 --- /dev/null +++ b/random/between.ts @@ -0,0 +1,51 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. +import type { Prng, RandomOptions } from "./_types.ts"; +export type { Prng, RandomOptions }; + +/** + * Generates a random number between the provided minimum and maximum values. + * + * The number is in the range `[min, max)`, i.e. `min` is included but `max` is excluded. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param min The minimum value (inclusive) + * @param max The maximum value (exclusive) + * @param options The options for the random number generator + * @returns A random number between the provided minimum and maximum values + * + * @example Usage + * ```ts no-assert + * import { randomBetween } from "@std/random"; + * + * randomBetween(1, 10); // 6.688009464410508 + * randomBetween(1, 10); // 3.6267118101712006 + * randomBetween(1, 10); // 7.853320239013774 + * ``` + */ +export function randomBetween( + min: number, + max: number, + options?: RandomOptions, +): number { + if (!Number.isFinite(min)) { + throw new RangeError( + `Cannot generate a random number: min cannot be ${min}`, + ); + } + if (!Number.isFinite(max)) { + throw new RangeError( + `Cannot generate a random number: max cannot be ${max}`, + ); + } + if (max < min) { + throw new RangeError( + `Cannot generate a random number as max must be greater than or equal to min: max=${max}, min=${min}`, + ); + } + + const x = (options?.prng ?? Math.random)(); + const y = min * (1 - x) + max * x; + return y >= min && y < max ? y : min; +} diff --git a/random/between_test.ts b/random/between_test.ts new file mode 100644 index 000000000..3d6d0c2cb --- /dev/null +++ b/random/between_test.ts @@ -0,0 +1,115 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { randomBetween } from "./between.ts"; +import { randomSeeded } from "./seeded.ts"; +import { + assert, + assertAlmostEquals, + assertEquals, + assertGreaterOrEqual, + assertLessOrEqual, + assertNotEquals, + assertThrows, +} from "@std/assert"; + +Deno.test("randomBetween() generates a random number between the provided minimum and maximum values", () => { + const prng = randomSeeded(0n); + const results = Array.from( + { length: 1e4 }, + () => randomBetween(1, 10, { prng }), + ); + + const min = Math.min(...results); + const max = Math.max(...results); + + assertGreaterOrEqual(min, 1); + assertLessOrEqual(max, 10); + assertAlmostEquals(min, 1, 0.01); + assertAlmostEquals(max, 10, 0.01); + + const avg = results.reduce((sum, n) => sum + n, 0) / results.length; + assertAlmostEquals(avg, 5.5, 0.1); +}); + +Deno.test("randomBetween() throws if min or max are NaN", () => { + assertThrows( + () => randomBetween(NaN, 1), + RangeError, + "Cannot generate a random number: min cannot be NaN", + ); + assertThrows( + () => randomBetween(1, NaN), + RangeError, + "Cannot generate a random number: max cannot be NaN", + ); +}); + +Deno.test("randomBetween() throws if min or max are +/-Infinity", () => { + assertThrows( + () => randomBetween(-Infinity, 1), + RangeError, + "Cannot generate a random number: min cannot be -Infinity", + ); + assertThrows( + () => randomBetween(1, Infinity), + RangeError, + "Cannot generate a random number: max cannot be Infinity", + ); +}); + +Deno.test("randomBetween() throws if max is less than min", () => { + assertThrows( + () => randomBetween(10, 1), + RangeError, + "Cannot generate a random number as max must be greater than or equal to min: max=1, min=10", + ); +}); + +Deno.test("randomBetween() allows negative min and max", () => { + const prng = randomSeeded(0n); + const results = Array.from( + { length: 3 }, + () => randomBetween(-10, -1, { prng }), + ); + + assertEquals(results, [ + -9.374074870022014, + -1.1224633122328669, + -9.295748566510156, + ]); +}); + +Deno.test("randomBetween() allows non-integer min and max", () => { + const prng = randomSeeded(0n); + const results = Array.from( + { length: 3 }, + () => randomBetween(1.5, 2.5, { prng }), + ); + + assertEquals(results, [ + 1.5695472366642207, + 2.4863929653074592, + 1.5782501592766494, + ]); +}); + +Deno.test("randomBetween() allows min and max to be the same, in which case it returns constant values", () => { + const results = Array.from({ length: 3 }, () => randomBetween(9.99, 9.99)); + assertEquals(results, [9.99, 9.99, 9.99]); +}); + +Deno.test("randomBetween() never returns max, even if the prng returns its max value", () => { + const prng = () => 0.9999999999999999; + const result = randomBetween(1, 2, { prng }); + assertNotEquals(result, 2); +}); + +Deno.test("randomBetween() doesn't overflow even for min = -Number.MAX_VALUE; max = Number.MAX_VALUE", () => { + for (const val of [0, 0.5, 0.9999999999999999]) { + const result = randomBetween( + -Number.MAX_VALUE, + Number.MAX_VALUE, + { prng: () => val! }, + ); + assert(Number.isFinite(result)); + } +}); diff --git a/random/deno.json b/random/deno.json new file mode 100644 index 000000000..711f03a40 --- /dev/null +++ b/random/deno.json @@ -0,0 +1,12 @@ +{ + "name": "@std/random", + "version": "0.1.0", + "exports": { + ".": "./mod.ts", + "./between": "./between.ts", + "./integer-between": "./integer_between.ts", + "./sample": "./sample.ts", + "./seeded": "./seeded.ts", + "./shuffle": "./shuffle.ts" + } +} diff --git a/random/integer_between.ts b/random/integer_between.ts new file mode 100644 index 000000000..3aefa166b --- /dev/null +++ b/random/integer_between.ts @@ -0,0 +1,36 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. +import type { Prng, RandomOptions } from "./_types.ts"; +import { randomBetween } from "./between.ts"; +export type { Prng, RandomOptions }; + +/** + * Generates a random integer between the provided minimum and maximum values. + * + * The number is in the range `[min, max]`, i.e. both `min` and `max` are included. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param min The minimum value (inclusive) + * @param max The maximum value (inclusive) + * @param options The options for the random number generator + * @returns A random integer between the provided minimum and maximum values + * + * @example Usage + * ```ts no-assert + * import { randomIntegerBetween } from "@std/random"; + * + * randomIntegerBetween(1, 10); // 7 + * randomIntegerBetween(1, 10); // 9 + * randomIntegerBetween(1, 10); // 2 + * ``` + */ +export function randomIntegerBetween( + min: number, + max: number, + options?: RandomOptions, +): number { + return Math.floor( + randomBetween(Math.ceil(min), Math.floor(max) + 1, options), + ); +} diff --git a/random/integer_between_test.ts b/random/integer_between_test.ts new file mode 100644 index 000000000..0e7306655 --- /dev/null +++ b/random/integer_between_test.ts @@ -0,0 +1,94 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { randomIntegerBetween } from "./integer_between.ts"; +import { randomSeeded } from "./seeded.ts"; +import { + assert, + assertAlmostEquals, + assertEquals, + assertThrows, +} from "@std/assert"; + +Deno.test("randomIntegerBetween() generates a random integer between the provided minimum and maximum values", () => { + const prng = randomSeeded(0n); + + const results = Array.from( + { length: 1e4 }, + () => randomIntegerBetween(1, 10, { prng }), + ); + + assertEquals(Math.min(...results), 1); + assertEquals(Math.max(...results), 10); + + for (let i = 1; i <= 10; ++i) { + assert(results.includes(i)); + } + + const avg = results.reduce((sum, n) => sum + n, 0) / results.length; + assertAlmostEquals(avg, 5.5, 0.1); +}); + +Deno.test("randomIntegerBetween() throws if min or max are NaN", () => { + assertThrows( + () => randomIntegerBetween(NaN, 1), + RangeError, + "Cannot generate a random number: min cannot be NaN", + ); + assertThrows( + () => randomIntegerBetween(1, NaN), + RangeError, + "Cannot generate a random number: max cannot be NaN", + ); +}); + +Deno.test("randomIntegerBetween() throws if min or max are +/-Infinity", () => { + assertThrows( + () => randomIntegerBetween(-Infinity, 1), + RangeError, + "Cannot generate a random number: min cannot be -Infinity", + ); + assertThrows( + () => randomIntegerBetween(1, Infinity), + RangeError, + "Cannot generate a random number: max cannot be Infinity", + ); +}); + +Deno.test("randomIntegerBetween() throws if max is less than min", () => { + assertThrows( + () => randomIntegerBetween(10, 1), + RangeError, + "Cannot generate a random number as max must be greater than or equal to min: max=2, min=10", + ); +}); + +Deno.test("randomIntegerBetween() allows negative min and max", () => { + const prng = randomSeeded(1n); + const results = Array.from( + { length: 3 }, + () => randomIntegerBetween(-10, -1, { prng }), + ); + + assertEquals(results, [-8, -6, -3]); +}); + +Deno.test( + "randomIntegerBetween() returns evenly-distributed values in [ceil(min), floor(max)] for non-integer min and max", + () => { + const prng = randomSeeded(1n); + const getRand = () => randomIntegerBetween(-0.001, 1.999, { prng }); + const length = 1000; + const results = Array.from({ length }, () => getRand()); + + const { 0: zeroes = [], 1: ones = [] } = Object.values( + Object.groupBy(results, (result) => Math.floor(result)), + ); + + assertAlmostEquals(zeroes.length, length / 2, length / 10); + assertAlmostEquals(ones.length, length / 2, length / 10); + }, +); + +Deno.test("randomIntegerBetween() allows min and max to be the same, in which case it returns constant values", () => { + const results = Array.from({ length: 3 }, () => randomIntegerBetween(99, 99)); + assertEquals(results, [99, 99, 99]); +}); diff --git a/random/mod.ts b/random/mod.ts new file mode 100644 index 000000000..aa09dd1fc --- /dev/null +++ b/random/mod.ts @@ -0,0 +1,26 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. + +/** + * Utilities for generating random numbers. + * + * ```ts + * import { randomIntegerBetween } from "@std/random"; + * import { randomSeeded } from "@std/random"; + * import { assertEquals } from "@std/assert"; + * + * const prng = randomSeeded(1n); + * + * assertEquals(randomIntegerBetween(1, 10, { prng }), 3); + * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @module + */ + +export * from "./between.ts"; +export * from "./integer_between.ts"; +export * from "./sample.ts"; +export * from "./seeded.ts"; +export * from "./shuffle.ts"; diff --git a/random/sample.ts b/random/sample.ts new file mode 100644 index 000000000..0f4e519f9 --- /dev/null +++ b/random/sample.ts @@ -0,0 +1,97 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. +import type { Prng, RandomOptions } from "./_types.ts"; +import { randomIntegerBetween } from "./integer_between.ts"; +export type { Prng, RandomOptions }; + +/** + * Options for {@linkcode sample}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export type SampleOptions = RandomOptions & { + /** + * An array of weights corresponding to each item in the input array. + * If supplied, this is used to determine the probability of each item being + * selected. + */ + weights?: ArrayLike; +}; + +/** + * Returns a random element from the given array. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam T The type of the elements in the array. + * @typeParam O The type of the accumulator. + * + * @param array The array to sample from. + * @param options Options modifying the sampling behavior. + * + * @returns A random element from the given array, or `undefined` if the array + * is empty. + * + * @example Basic usage + * ```ts + * import { sample } from "@std/random/sample"; + * import { assertArrayIncludes } from "@std/assert"; + * + * const numbers = [1, 2, 3, 4]; + * const sampled = sample(numbers); + * + * assertArrayIncludes(numbers, [sampled]); + * ``` + * + * @example Using `weights` option + * ```ts no-assert + * import { sample } from "@std/random/sample"; + * + * const values = ["a", "b", "c"]; + * const weights = [5, 3, 2]; + * const result = sample(values, { weights }); + * // gives "a" 50% of the time, "b" 30% of the time, and "c" 20% of the time + * ``` + */ +export function sample( + array: ArrayLike, + options?: SampleOptions, +): T | undefined { + const { weights } = { ...options }; + + if (weights) { + if (weights.length !== array.length) { + throw new RangeError( + "Cannot sample an item: The length of the weights array must match the length of the input array", + ); + } + + if (!array.length) return undefined; + + const total = Object.values(weights).reduce((sum, n) => sum + n, 0); + + if (total <= 0) { + throw new RangeError( + "Cannot sample an item: Total weight must be greater than 0", + ); + } + + const rand = (options?.prng ?? Math.random)() * total; + let current = 0; + + for (let i = 0; i < array.length; ++i) { + current += weights[i]!; + if (rand < current) { + return array[i]!; + } + } + + // this line should never be hit, but in case of rounding errors etc. + return array[0]!; + } + + const length = array.length; + return length + ? array[randomIntegerBetween(0, length - 1, options)] + : undefined; +} diff --git a/random/sample_test.ts b/random/sample_test.ts new file mode 100644 index 000000000..f28428143 --- /dev/null +++ b/random/sample_test.ts @@ -0,0 +1,152 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { sample } from "./sample.ts"; +import { + assertAlmostEquals, + assertArrayIncludes, + assertEquals, + assertThrows, +} from "@std/assert"; +import { randomSeeded } from "./seeded.ts"; + +Deno.test({ + name: "sample() handles no mutation", + fn() { + const array = ["a", "abc", "ba"]; + sample(array); + + assertEquals(array, ["a", "abc", "ba"]); + }, +}); + +Deno.test({ + name: "sample() returns undefined if the array is empty", + fn() { + const actual = sample([]); + assertEquals(actual, undefined); + }, +}); + +Deno.test({ + name: "sample() handles array of numbers", + fn() { + const input = [1, 2, 3]; + const actual = sample(input); + + assertArrayIncludes(input, [actual]); + }, +}); + +Deno.test({ + name: "sample() handles array of objects", + fn() { + const input = [ + { + name: "Anna", + age: 18, + }, + { + name: "Kim", + age: 24, + }, + ]; + const actual = sample(input); + + assertArrayIncludes(input, [actual]); + }, +}); + +Deno.test("sample() picks a random item from the provided items", () => { + const items = ["a", "b", "c"]; + const prng = randomSeeded(0n); + + const picks = Array.from( + { length: 10 }, + () => sample(items, { prng }), + ); + + assertEquals(picks, ["a", "c", "a", "a", "a", "b", "a", "c", "a", "b"]); +}); + +Deno.test("sample() with weights returns undefined if the array is empty", () => { + const items: unknown[] = []; + const weights: number[] = []; + assertEquals(sample(items, { weights }), undefined); +}); + +Deno.test("sample() with weights throws if the total weight is 0", () => { + const items = ["a", "b", "c"]; + const weights = [0, 0, 0]; + + assertThrows( + () => sample(items, { weights }), + RangeError, + "Total weight must be greater than 0", + ); +}); + +Deno.test("sample() with weights throws if the wrong number of weights is provided", () => { + const items = ["a", "b", "c"] as const; + const weights = [1, 2, 3, 4] as const; + + assertThrows( + () => sample(items, { weights }), + RangeError, + "The length of the weights array must match the length of the input array", + ); +}); + +Deno.test("sample() works with typed arrays", () => { + const items = new Uint8Array([0, 1, 2]); + const weights = new Uint8Array([10, 5, 250]); + const prng = randomSeeded(1n); + + assertEquals(sample(items, { weights, prng }), 2); +}); + +Deno.test("sample() with weights never picks an item with weight of 0", () => { + const items = ["a", "b"]; + const weights = [1, 0]; + + assertEquals(sample(items, { weights }), "a"); +}); + +Deno.test("sample() with weights picks a random item from the provided items with a weighted probability", () => { + const weightedItems = [["a", 1], ["b", 2], ["c", 3]] as const; + const weights = weightedItems.map(([, weight]) => weight); + const values = weightedItems.map(([value]) => value); + const totalWeight = weights.reduce((sum, n) => sum + n, 0); + const prng = randomSeeded(0n); + + const picks = Array.from( + { length: 1000 }, + () => sample(values, { prng, weights }), + ); + + const groups = Object.values( + Object.groupBy(picks, (item) => item!), + ) as string[][]; + + assertEquals(groups.length, 3); + + for (const group of groups) { + const [, weight] = weightedItems.find(([item]) => item === group[0]!)!; + assertAlmostEquals( + group.length, + picks.length / totalWeight * weight, + picks.length / 10, + ); + } +}); + +Deno.test("sample() with weights works with a Map", () => { + const weightedItems = new Map([["a", 1], ["b", 2], ["c", 999]]); + const prng = randomSeeded(0n); + + const result = sample([...weightedItems.keys()], { + weights: [...weightedItems.values()], + prng, + }); + + assertEquals(result, "c"); +}); diff --git a/random/seeded.ts b/random/seeded.ts new file mode 100644 index 000000000..37a031ba6 --- /dev/null +++ b/random/seeded.ts @@ -0,0 +1,41 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. +import { fromSeed, nextU32, seedFromU64 } from "./_pcg32.ts"; +import type { Prng } from "./_types.ts"; + +/** + * Creates a pseudo-random number generator that generates random numbers in + * the range `[0, 1)`, based on the given seed. The algorithm used for + * generation is {@link https://www.pcg-random.org/download.html | PCG32}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param seed The seed used to initialize the random number generator's state. + * @returns A pseudo-random number generator function, which will generate + * different random numbers on each call. + * + * @example Usage + * ```ts + * import { randomSeeded } from "@std/random"; + * import { assertEquals } from "@std/assert"; + * + * const prng = randomSeeded(1n); + * + * assertEquals(prng(), 0.20176767697557807); + * assertEquals(prng(), 0.4911644416861236); + * assertEquals(prng(), 0.7924694607499987); + * ``` + */ +export function randomSeeded(seed: bigint): Prng { + const pcg = fromSeed(seedFromU64(seed, 16)); + return () => uint32ToFloat64(nextU32(pcg)); +} + +/** + * Convert a 32-bit unsigned integer to a float64 in the range `[0, 1)`. + * This operation is lossless, i.e. it's always possible to get the original + * value back by multiplying by 2 ** 32. + */ +function uint32ToFloat64(u32: number): number { + return u32 / 2 ** 32; +} diff --git a/random/seeded_test.ts b/random/seeded_test.ts new file mode 100644 index 000000000..4325a80ae --- /dev/null +++ b/random/seeded_test.ts @@ -0,0 +1,63 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { randomSeeded } from "./seeded.ts"; +import { assertAlmostEquals, assertEquals } from "@std/assert"; + +Deno.test("randomSeeded() generates random numbers", () => { + const prng = randomSeeded(1n); + + assertEquals(prng(), 0.20176767697557807); + assertEquals(prng(), 0.4911644416861236); + assertEquals(prng(), 0.7924694607499987); +}); + +Deno.test("randomSeeded() gives relatively uniform distribution of random numbers", async (t) => { + const prng = randomSeeded(1n); + const results = Array.from({ length: 1e4 }, prng); + + await t.step("all results are in [0, 1)", () => { + assertEquals(results.every((result) => result >= 0 && result < 1), true); + }); + + await t.step("all (or almost all) results are unique", () => { + assertAlmostEquals(new Set(results).size, results.length, 10); + }); + + await t.step("the mean average of the results is close to 0.5", () => { + const avg = results.reduce((sum, n) => sum + n, 0) / results.length; + assertAlmostEquals(avg, 0.5, 0.05); + }); + + await t.step( + "approximately one tenth of the results lie in each decile", + () => { + const deciles = Object.values( + Object.groupBy(results, (result) => Math.floor(result * 10)), + ) as number[][]; + + assertEquals(deciles.length, 10); + + for (const decile of deciles) { + assertAlmostEquals( + decile.length, + results.length / 10, + results.length / 50, + ); + } + }, + ); + + await t.step( + "the mean average of each thousand results is close to 0.5", + () => { + const slices = Object.values( + Object.groupBy(results, (_, idx) => Math.floor(idx / 1000)), + ) as number[][]; + + for (const results of slices) { + const average = results.reduce((sum, result) => sum + result, 0) / + results.length; + assertAlmostEquals(average, 0.5, 0.05); + } + }, + ); +}); diff --git a/random/shuffle.ts b/random/shuffle.ts new file mode 100644 index 000000000..396a60dce --- /dev/null +++ b/random/shuffle.ts @@ -0,0 +1,47 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. +import type { Prng, RandomOptions } from "./_types.ts"; +import { randomIntegerBetween } from "./integer_between.ts"; +export type { Prng, RandomOptions }; + +/** + * Shuffles the provided array, returning a copy and without modifying the original array. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam T The type of the items in the array + * @param items The items to shuffle + * @param options The options for the random number generator + * @returns A shuffled copy of the provided items + * + * @example Usage + * ```ts no-assert + * import { shuffle } from "@std/random"; + * + * const items = [1, 2, 3, 4, 5]; + * + * shuffle(items); // [2, 5, 1, 4, 3] + * shuffle(items); // [3, 4, 5, 1, 2] + * shuffle(items); // [5, 2, 4, 3, 1] + * + * items; // [1, 2, 3, 4, 5] (original array is unchanged) + * ``` + */ +export function shuffle( + items: readonly T[], + options?: RandomOptions, +): T[] { + const result = [...items]; + + // https://en.wikipedia.org/wiki/Fisher–Yates_shuffle#The_modern_algorithm + // -- To shuffle an array a of n elements (indices 0..n-1): + // for i from n−1 down to 1 do + for (let i = result.length - 1; i >= 1; --i) { + // j ← random integer such that 0 ≤ j ≤ i + const j = randomIntegerBetween(0, i, options); + // exchange a[j] and a[i] + [result[i], result[j]] = [result[j]!, result[i]!]; + } + + return result; +} diff --git a/random/shuffle_test.ts b/random/shuffle_test.ts new file mode 100644 index 000000000..60900a375 --- /dev/null +++ b/random/shuffle_test.ts @@ -0,0 +1,69 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { randomSeeded } from "./seeded.ts"; +import { shuffle } from "./shuffle.ts"; +import { + assertAlmostEquals, + assertEquals, + assertNotStrictEquals, +} from "@std/assert"; + +Deno.test("shuffle() handles empty arrays", () => { + const array: number[] = []; + const shuffled = shuffle(array); + + assertEquals(shuffled, array); + assertNotStrictEquals(shuffled, array); +}); + +Deno.test("shuffle() handles arrays with only one item", () => { + const array = [1]; + const shuffled = shuffle(array); + + assertEquals(shuffled, array); + assertNotStrictEquals(shuffled, array); +}); + +Deno.test("shuffle() shuffles the provided array", () => { + const prng = randomSeeded(0n); + + const items = [1, 2, 3, 4, 5]; + + assertEquals(shuffle(items, { prng }), [2, 3, 5, 4, 1]); + assertEquals(shuffle(items, { prng }), [3, 4, 1, 5, 2]); + assertEquals(shuffle(items, { prng }), [2, 4, 5, 3, 1]); +}); + +Deno.test("shuffle() returns a copy and without modifying the original array", () => { + const items = [1, 2, 3, 4, 5]; + const originalItems = [...items]; + + for (let i = 0; i < 10; ++i) { + shuffle(items); + assertEquals(items, originalItems); + } +}); + +Deno.test("shuffle() gives relatively uniform distribution of results", () => { + const prng = randomSeeded(0n); + + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + const results = Array.from( + { length: 1e3 }, + () => shuffle(items, { prng }), + ); + + for (const idx of items.keys()) { + const groupsByidx = Object.values( + Object.groupBy(results.map((result) => result[idx]), (item) => item!), + ) as number[][]; + + for (const group of groupsByidx) { + assertAlmostEquals( + group.length, + results.length / items.length, + results.length / 10, + ); + } + } +});