Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
# @suaveplan/pseudo-random - Deterministic seeded PRNG implementations (Mulberry32, SFC32, Xoshiro256++, CounterRng) with offset/fork/checkpoint/restore semantics
## Summary
Deterministic, seeded pseudo-random number generators (PRNGs) conforming to the `Rng` interface from `@suaveplan/types`. Ships four algorithms — Mulberry32, SFC32, Xoshiro256++, and CounterRng — with a unified `SeededRng` surface that includes `nextUint32`, `nextFloat`, `skip`, `at`, `fork`, `checkpoint`, and `restore`. Use this package when you need reproducible randomness (procedural generation, game simulations, property tests) or seek O(1) access to arbitrary positions in the sequence. For cryptographically secure randomness use `@suaveplan/random` (CSPRNG-backed) or `@suaveplan/crypto` (raw bytes). Utility functions `shuffle`, `sample`, `choice`, and `weightedChoice` accept any `Rng` and work with both seeded and crypto-backed sources.
<!-- begin:auto-docs -->
## Documentation tree
- [counter-rng](./src/algorithms/counter-rng.md) — `CounterRng` is a hash-based PRNG: its output at logical position `i` is `splitmix64(seed XOR i)`. Because the output d…
- [mulberry32](./src/algorithms/mulberry32.md) — `Mulberry32Rng` implements the Mulberry32 PRNG — a compact 32-bit linear-congruential-plus-mix generator with a period…
- [sfc32](./src/algorithms/sfc32.md) — `Sfc32Rng` is a 128-bit SFC32 (Small Fast Counting) generator by Chris Doty-Humphrey. It carries four uint32 state word…
- [xoshiro256](./src/algorithms/xoshiro256.md) — `Xoshiro256PlusPlusRng` implements Blackman and Vigna's Xoshiro256++ PRNG — 256 bits of state, period 2^256 − 1, with a…
- [errors](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
<!-- end:auto-docs --> - [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
- [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
- [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
- [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_ - [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
## Packages
Four algorithms with identical `SeededRng` surface:
| Algorithm | State | Period | Best for |
| ------------------------- | ------ | ------------- | ------------------------------ |
| `Mulberry32Rng` | 32 bit | ~2^32 | Tiny, per-UI seeding |
| `Sfc32Rng` | 128bit | ≥2^32 | High-quality default |
| `Xoshiro256PlusPlusRng` | 256bit | 2^256 − 1 | Simulations + parallel streams |
| `CounterRng` | 64 bit | — | O(1) seek, procedural tiles |
Each implements `nextUint32`, `nextFloat`, `skip`, `at`, `fork`, `checkpoint`, `restore`, plus `Xoshiro256PlusPlusRng.jump()` for jumping by 2^128 steps.
## Install
```sh
bun add @suaveplan/pseudo-random
```
## Quick start
```ts
import { Sfc32Rng, shuffle } from "@suaveplan/pseudo-random";
const rng = new Sfc32Rng("game-42");
const deck = shuffle(["A", "K", "Q", "J"], rng);
```
## Subpath exports
- `@suaveplan/pseudo-random/algorithms` — the four generators
- `@suaveplan/pseudo-random/seed` — `normalizeSeed`, `splitmix32/64`, `expandSeed`
- `@suaveplan/pseudo-random/utils` — `shuffle`, `sample`, `choice`, `weightedChoice`
- `@suaveplan/pseudo-random/errors` — `PseudoRandomError`, `PSEUDO_RANDOM_CODES`
- `@suaveplan/pseudo-random/schemas` — Zod schemas for serialised state
## Module docs
- [algorithms/mulberry32](./src/algorithms/mulberry32.md)
- [algorithms/sfc32](./src/algorithms/sfc32.md)
- [algorithms/xoshiro256](./src/algorithms/xoshiro256.md)
- [algorithms/counter-rng](./src/algorithms/counter-rng.md)
- [seed/normalize](./src/seed/normalize.md)
- [utils/shuffle](./src/utils/shuffle.md)
- [utils/sample](./src/utils/sample.md)
- [errors](./src/errors/pseudo-random-error.md)
- [types](./src/types/seeded-rng.types.md)

View File

@@ -0,0 +1,92 @@
{
"name": "@suaveplan/pseudo-random",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Deterministic seeded PRNG implementations (Mulberry32, SFC32, Xoshiro256++, CounterRng) with offset/fork/checkpoint/restore semantics",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./algorithms": {
"import": {
"types": "./dist/algorithms/index.d.ts",
"default": "./dist/algorithms/index.js"
}
},
"./seed": {
"import": {
"types": "./dist/seed/normalize.d.ts",
"default": "./dist/seed/normalize.js"
}
},
"./utils": {
"import": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/pseudo-random-error.d.ts",
"default": "./dist/errors/pseudo-random-error.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,44 @@
# algorithms/counter-rng
## Purpose
`CounterRng` is a hash-based PRNG: its output at logical position `i` is `splitmix64(seed XOR i)`. Because the output depends only on position, every seek is constant time — `at(1_000_000)` does not draw a million values, it just advances the counter. This makes `CounterRng` uniquely suited to massively parallel simulations, tile-based procedural generation, and any scenario where you need to sample an arbitrary index of the stream without replaying it.
## Features
- O(1) `at(offset)` and `skip(n)` — no per-step iteration cost
- 64-bit seed state stored as two uint32 halves for JSON safety
- Full `SeededRng` contract
- Output quality on par with splitmix64 (passes BigCrush on the upper 32 bits)
## Usage: Basic Example
```ts
import { CounterRng } from "@suaveplan/pseudo-random";
const rng = new CounterRng("world");
const v = rng.nextUint32();
```
## Usage: Advanced Example
```ts
import { CounterRng } from "@suaveplan/pseudo-random";
// Sample tile (1234, 5678) without materialising everything in between.
const rng = new CounterRng("map");
const tile = rng.at(1234 * 10000 + 5678);
const decoration = tile.nextUint32();
```
## API Reference
- `new CounterRng(seed)` — accepts the standard `string | number` seed.
- `nextUint32()` / `nextFloat()` — one counter step, output is `splitmix64(seed XOR pos).out >> 32`.
- `skip(n)` / `at(offset)` — both constant-time.
- `fork(id)` — derives a new counter keyed on `(seed, position, id)`.
- `checkpoint()` / `restore(cp)` — state is `[seedLo, seedHi]` plus counter.
## Implementation Notes
Counter generators expose their index directly, so the usual "position = draws so far" semantics hold regardless of whether you reach position 1000 by calling `nextUint32` 1000 times, calling `skip(1000)`, or jumping via `at(1000)`. The `streamAt` helper is deterministic and stateless, which is what makes the O(1) seek possible.

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
import { CounterRng } from "./counter-rng.js";
describe("CounterRng", () => {
it("is deterministic", { timeout: 5000 }, () => {
const a = new CounterRng("abc");
const b = new CounterRng("abc");
for (let i = 0; i < 32; i += 1) {
expect(a.nextUint32()).toBe(b.nextUint32());
}
});
it("at(offset) is O(1) and matches skip(offset)", { timeout: 5000 }, () => {
const a = new CounterRng(42);
const b = a.at(1000);
const a2 = new CounterRng(42);
a2.skip(1000);
expect(b.nextUint32()).toBe(a2.nextUint32());
});
it("skip advances position without drawing", { timeout: 5000 }, () => {
const r = new CounterRng(7);
r.skip(1_000_000);
expect(r.position).toBe(1_000_000);
});
it("checkpoint/restore round-trip", { timeout: 5000 }, () => {
const r = new CounterRng("q");
r.skip(50);
const cp = r.checkpoint();
const exp = [r.nextUint32(), r.nextUint32()];
r.restore(cp);
expect([r.nextUint32(), r.nextUint32()]).toEqual(exp);
});
it("uint32 range and float range", { timeout: 5000 }, () => {
const r = new CounterRng(3);
for (let i = 0; i < 100; i += 1) {
const v = r.nextUint32();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(2 ** 32);
}
const r2 = new CounterRng(4);
for (let i = 0; i < 100; i += 1) {
const f = r2.nextFloat();
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThan(1);
}
});
it("fork is independent", { timeout: 5000 }, () => {
const r = new CounterRng(5);
const c = r.fork("x");
expect(r.nextUint32()).not.toBe(c.nextUint32());
});
it("restore validates algorithm + state", { timeout: 5000 }, () => {
const r = new CounterRng(6);
expect(() =>
r.restore({ algorithm: "sfc32", state: [0, 0], counter: 0 })
).toThrow(PseudoRandomError);
expect(() =>
r.restore({ algorithm: "counter", state: [0], counter: 0 })
).toThrow(PseudoRandomError);
expect(() =>
r.restore({
algorithm: "counter",
state: [Number.NaN, 0],
counter: 0,
})
).toThrow(PseudoRandomError);
});
it("rejects bad skip/at", { timeout: 5000 }, () => {
const r = new CounterRng(1);
expect(() => r.skip(-1)).toThrow(PseudoRandomError);
expect(() => r.at(-1)).toThrow(PseudoRandomError);
});
});

View File

@@ -0,0 +1,113 @@
import {
PSEUDO_RANDOM_CODES,
PseudoRandomError,
} from "../errors/pseudo-random-error.js";
import { normalizeSeed, splitmix64 } from "../seed/normalize.js";
import type { RngCheckpoint, SeededRng } from "../types/seeded-rng.types.js";
const MASK64 = 0xffffffffffffffffn;
/**
* CounterRng — a hash-based PRNG whose output at position `i` is
* `splitmix64(seed XOR i)`. Every seek is O(1): `at(offset)` simply
* moves the counter without iterating.
*/
export class CounterRng implements SeededRng {
public readonly algorithm = "counter" as const;
protected seed: bigint;
protected pos = 0;
public constructor(seed: string | number) {
this.seed = BigInt(normalizeSeed(seed) >>> 0);
}
public get position(): number {
return this.pos;
}
protected streamAt(i: number): number {
const state = (this.seed ^ BigInt(i)) & MASK64;
const step = splitmix64(state);
return Number((step.out >> 32n) & 0xffffffffn) >>> 0;
}
public nextUint32(): number {
const v = this.streamAt(this.pos);
this.pos += 1;
return v;
}
public nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
}
public skip(n: number): void {
if (!Number.isInteger(n) || n < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.INVALID"],
"pseudo_random.skip.invalid",
{ context: { n } }
);
}
this.pos += n;
}
public at(offset: number): SeededRng {
if (!Number.isInteger(offset) || offset < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.INVALID"],
"pseudo_random.at.invalid",
{ context: { offset } }
);
}
const clone = new CounterRng(0);
clone.seed = this.seed;
clone.pos = this.pos + offset;
return clone;
}
public fork(id: string | number): SeededRng {
return new CounterRng(`${this.seed}:${this.pos}:${String(id)}`);
}
public checkpoint(): RngCheckpoint {
const lo = Number(this.seed & 0xffffffffn) >>> 0;
const hi = Number((this.seed >> 32n) & 0xffffffffn) >>> 0;
return {
algorithm: this.algorithm,
state: [lo, hi],
counter: this.pos,
};
}
public restore(cp: RngCheckpoint): void {
if (cp.algorithm !== this.algorithm) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.ALGORITHM_MISMATCH"],
"pseudo_random.state.algorithm_mismatch",
{ context: { expected: this.algorithm, got: cp.algorithm } }
);
}
if (cp.state.length !== 2) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.INVALID"],
"pseudo_random.state.invalid"
);
}
const lo = cp.state[0];
const hi = cp.state[1];
if (
typeof lo !== "number" ||
typeof hi !== "number" ||
!Number.isFinite(lo) ||
!Number.isFinite(hi)
) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.INVALID"],
"pseudo_random.state.invalid"
);
}
this.seed = ((BigInt(hi >>> 0) << 32n) | BigInt(lo >>> 0)) & MASK64;
this.pos = cp.counter >>> 0;
}
}

View File

@@ -0,0 +1,4 @@
export { CounterRng } from "./counter-rng.js";
export { Mulberry32Rng } from "./mulberry32.js";
export { Sfc32Rng } from "./sfc32.js";
export { Xoshiro256PlusPlusRng } from "./xoshiro256.js";

View File

@@ -0,0 +1,51 @@
# algorithms/mulberry32
## Purpose
`Mulberry32Rng` implements the Mulberry32 PRNG — a compact 32-bit linear-congruential-plus-mix generator with a period of roughly 2^32. It is the smallest `SeededRng` in the package (one 32-bit word of state) and is ideal for UI seedings, per-card shuffles, procedural decorations and any workload where replayability matters but cryptographic strength does not.
The implementation is byte-identical to the inline generator shipped inside `@suaveplan/testing`'s `seededRng`, so code migrated between the two produces the same draws.
## Features
- Single uint32 of state; ~1-cycle nextUint32 cost
- Implements the full `SeededRng` contract (`skip`, `at`, `fork`, `checkpoint`, `restore`)
- Algorithm-tagged checkpoint prevents cross-algorithm restore bugs
- Typed errors on malformed state or invalid arguments
## Usage: Basic Example
```ts
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
const rng = new Mulberry32Rng("level-1");
console.log(rng.nextFloat());
console.log(rng.nextUint32());
```
## Usage: Advanced Example
```ts
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
const parent = new Mulberry32Rng("game-42");
const encounterRng = parent.fork("encounter");
const lootRng = parent.fork("loot");
const save = parent.checkpoint();
// ... simulate many turns ...
parent.restore(save); // back to the turn-0 state
```
## API Reference
- `new Mulberry32Rng(seed: string | number)` — normalises the seed through `normalizeSeed`.
- `nextUint32()` / `nextFloat()` — uniform draws.
- `skip(n)` — advance the stream by `n` uint32 draws; `n` must be a non-negative integer.
- `at(offset)` — clone the generator and skip `offset` draws on the clone. Does not mutate `this`.
- `fork(id)` — derive a child generator keyed on `(state, position, id)`.
- `checkpoint()` / `restore(cp)` — snapshot and reinstate state; restore validates the `algorithm` tag.
## Implementation Notes
The generator advances by `state = (state + 0x6d2b79f5) | 0`, then runs two `Math.imul` mixing rounds before returning the high-entropy output. `at(offset)` is O(offset) because Mulberry32 has no closed-form jump; if you need O(1) seek use `CounterRng` instead. Fork keys include the parent's current position so repeated forks at different moments yield independent streams.

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
import { Mulberry32Rng } from "./mulberry32.js";
describe("Mulberry32Rng", () => {
it("is deterministic for equal seeds", { timeout: 5000 }, () => {
const a = new Mulberry32Rng(1);
const b = new Mulberry32Rng(1);
for (let i = 0; i < 64; i += 1) {
expect(a.nextUint32()).toBe(b.nextUint32());
}
});
it("produces uint32 in range", { timeout: 5000 }, () => {
const r = new Mulberry32Rng("x");
for (let i = 0; i < 100; i += 1) {
const v = r.nextUint32();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(2 ** 32);
}
});
it("produces floats in [0,1)", { timeout: 5000 }, () => {
const r = new Mulberry32Rng(2);
for (let i = 0; i < 100; i += 1) {
const f = r.nextFloat();
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThan(1);
}
});
it("tracks position", { timeout: 5000 }, () => {
const r = new Mulberry32Rng(3);
expect(r.position).toBe(0);
r.nextUint32();
r.nextUint32();
expect(r.position).toBe(2);
});
it("skip advances position", { timeout: 5000 }, () => {
const a = new Mulberry32Rng(4);
const b = new Mulberry32Rng(4);
a.skip(10);
for (let i = 0; i < 10; i += 1) b.nextUint32();
expect(a.nextUint32()).toBe(b.nextUint32());
});
it("at returns new generator without mutating self", {
timeout: 5000,
}, () => {
const a = new Mulberry32Rng(5);
const b = a.at(5);
expect(a.position).toBe(0);
expect(b.position).toBe(5);
});
it("fork produces independent child", { timeout: 5000 }, () => {
const a = new Mulberry32Rng(6);
const child = a.fork("x");
// Child draws shouldn't equal parent's draws
expect(child.nextUint32()).not.toBe(a.nextUint32());
});
it("checkpoint/restore round-trip", { timeout: 5000 }, () => {
const r = new Mulberry32Rng(7);
r.skip(13);
const cp = r.checkpoint();
const expected = [r.nextUint32(), r.nextUint32(), r.nextUint32()];
r.restore(cp);
expect([r.nextUint32(), r.nextUint32(), r.nextUint32()]).toEqual(
expected
);
});
it("restore rejects foreign algorithm tag", { timeout: 5000 }, () => {
const r = new Mulberry32Rng(8);
expect(() =>
r.restore({ algorithm: "sfc32", state: [0, 0, 0, 0], counter: 0 })
).toThrow(PseudoRandomError);
});
it("restore rejects malformed state", { timeout: 5000 }, () => {
const r = new Mulberry32Rng(9);
expect(() =>
r.restore({ algorithm: "mulberry32", state: [], counter: 0 })
).toThrow(PseudoRandomError);
expect(() =>
r.restore({
algorithm: "mulberry32",
state: [Number.NaN],
counter: 0,
})
).toThrow(PseudoRandomError);
});
it("rejects negative skip/at", { timeout: 5000 }, () => {
const r = new Mulberry32Rng(10);
expect(() => r.skip(-1)).toThrow(PseudoRandomError);
expect(() => r.at(-1)).toThrow(PseudoRandomError);
expect(() => r.skip(1.5)).toThrow(PseudoRandomError);
});
});

View File

@@ -0,0 +1,101 @@
import {
PSEUDO_RANDOM_CODES,
PseudoRandomError,
} from "../errors/pseudo-random-error.js";
import { normalizeSeed } from "../seed/normalize.js";
import type { RngCheckpoint, SeededRng } from "../types/seeded-rng.types.js";
/**
* Mulberry32 — a single-state 32-bit PRNG with a ~4 billion-cycle period.
* Tiny, fast, passes most statistical tests for non-cryptographic use.
*/
export class Mulberry32Rng implements SeededRng {
public readonly algorithm = "mulberry32" as const;
protected state: number;
protected pos = 0;
public constructor(seed: string | number) {
this.state = normalizeSeed(seed);
}
public get position(): number {
return this.pos;
}
public nextUint32(): number {
this.pos += 1;
this.state = (this.state + 0x6d2b79f5) | 0;
let t = this.state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
}
public nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
}
public skip(n: number): void {
if (!Number.isInteger(n) || n < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.INVALID"],
"pseudo_random.skip.invalid",
{ context: { n } }
);
}
for (let i = 0; i < n; i += 1) this.nextUint32();
}
public at(offset: number): SeededRng {
if (!Number.isInteger(offset) || offset < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.INVALID"],
"pseudo_random.at.invalid",
{ context: { offset } }
);
}
const clone = new Mulberry32Rng(0);
clone.state = this.state;
clone.pos = this.pos;
clone.skip(offset);
return clone;
}
public fork(id: string | number): SeededRng {
const key = `${this.state}:${this.pos}:${String(id)}`;
return new Mulberry32Rng(key);
}
public checkpoint(): RngCheckpoint {
return {
algorithm: this.algorithm,
state: [this.state >>> 0],
counter: this.pos,
};
}
public restore(cp: RngCheckpoint): void {
if (cp.algorithm !== this.algorithm) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.ALGORITHM_MISMATCH"],
"pseudo_random.state.algorithm_mismatch",
{ context: { expected: this.algorithm, got: cp.algorithm } }
);
}
if (cp.state.length !== 1) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.INVALID"],
"pseudo_random.state.invalid"
);
}
const s0 = cp.state[0];
if (typeof s0 !== "number" || !Number.isFinite(s0)) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.INVALID"],
"pseudo_random.state.invalid"
);
}
this.state = s0 >>> 0;
this.pos = cp.counter >>> 0;
}
}

View File

@@ -0,0 +1,48 @@
# algorithms/sfc32
## Purpose
`Sfc32Rng` is a 128-bit SFC32 (Small Fast Counting) generator by Chris Doty-Humphrey. It carries four uint32 state words, runs 12 warmup rounds after seeding, and passes all PractRand tests up to 32TB of output. It is the recommended default `SeededRng` when you want a high-quality stream and don't need an algebraic jump.
## Features
- 128-bit state (a,b,c,d) with a counter leg guaranteeing a minimum period of 2^32
- 12-round warmup after seed ingestion to decorrelate output from seed bits
- Full `SeededRng` contract — `skip`, `at`, `fork`, `checkpoint`, `restore`
- Deterministic `fork(id)` keyed on state + position + id
## Usage: Basic Example
```ts
import { Sfc32Rng } from "@suaveplan/pseudo-random";
const rng = new Sfc32Rng("match-7");
const first = rng.nextFloat();
```
## Usage: Advanced Example
```ts
import { Sfc32Rng } from "@suaveplan/pseudo-random";
const master = new Sfc32Rng("tournament");
const player1 = master.fork("p1");
const player2 = master.fork("p2");
const snapshot = master.checkpoint();
// ... play out a hand ...
master.restore(snapshot);
```
## API Reference
- `new Sfc32Rng(seed)` — normalises and expands the seed into 4 × uint32 state words, then applies 12 warmup rounds.
- `nextUint32()` / `nextFloat()` — one step of SFC32.
- `skip(n)` — O(n) advance.
- `at(offset)` — returns a new generator at `position + offset` without mutating `this`.
- `fork(id)` — derives an independent child generator keyed on the full state + position + `id`.
- `checkpoint()` / `restore(cp)` — snapshot/reinstate, with algorithm-tag validation.
## Implementation Notes
The core step is `t = a + b + d; d++; a = b ^ (b >>> 9); b = c + (c << 3); c = rotl(c, 21) + t`. All arithmetic is coerced to uint32 via `| 0` after each operation. State is expanded from the user seed using four splitmix64 taps; this breaks the correlations you would otherwise see between adjacent seeds like `1` and `2`.

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
import { Sfc32Rng } from "./sfc32.js";
describe("Sfc32Rng", () => {
it("is deterministic", { timeout: 5000 }, () => {
const a = new Sfc32Rng("abc");
const b = new Sfc32Rng("abc");
for (let i = 0; i < 100; i += 1) {
expect(a.nextUint32()).toBe(b.nextUint32());
}
});
it("produces uint32 in range", { timeout: 5000 }, () => {
const r = new Sfc32Rng(1);
for (let i = 0; i < 1000; i += 1) {
const v = r.nextUint32();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(2 ** 32);
}
});
it("skip === repeated nextUint32", { timeout: 5000 }, () => {
const a = new Sfc32Rng(2);
const b = new Sfc32Rng(2);
a.skip(50);
for (let i = 0; i < 50; i += 1) b.nextUint32();
expect(a.nextUint32()).toBe(b.nextUint32());
});
it("at doesn't mutate self", { timeout: 5000 }, () => {
const a = new Sfc32Rng(3);
const ahead = a.at(10);
expect(a.position).toBe(0);
expect(ahead.position).toBe(10);
});
it("checkpoint/restore round-trip", { timeout: 5000 }, () => {
const r = new Sfc32Rng(4);
r.skip(7);
const cp = r.checkpoint();
const exp = [r.nextUint32(), r.nextUint32()];
r.restore(cp);
expect([r.nextUint32(), r.nextUint32()]).toEqual(exp);
});
it("restore rejects foreign algorithm", { timeout: 5000 }, () => {
const r = new Sfc32Rng(5);
expect(() =>
r.restore({ algorithm: "mulberry32", state: [0], counter: 0 })
).toThrow(PseudoRandomError);
});
it("restore rejects bad state length or non-finite words", {
timeout: 5000,
}, () => {
const r = new Sfc32Rng(6);
expect(() =>
r.restore({ algorithm: "sfc32", state: [1, 2], counter: 0 })
).toThrow(PseudoRandomError);
expect(() =>
r.restore({
algorithm: "sfc32",
state: [1, 2, 3, Number.NaN],
counter: 0,
})
).toThrow(PseudoRandomError);
});
it("rejects invalid skip/at", { timeout: 5000 }, () => {
const r = new Sfc32Rng(7);
expect(() => r.skip(-1)).toThrow(PseudoRandomError);
expect(() => r.at(-1)).toThrow(PseudoRandomError);
});
it("fork yields distinct stream", { timeout: 5000 }, () => {
const parent = new Sfc32Rng(8);
const child = parent.fork("x");
const a = [parent.nextUint32(), parent.nextUint32()];
const b = [child.nextUint32(), child.nextUint32()];
expect(a).not.toEqual(b);
});
it("nextFloat is in [0,1)", { timeout: 5000 }, () => {
const r = new Sfc32Rng(9);
for (let i = 0; i < 200; i += 1) {
const f = r.nextFloat();
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThan(1);
}
});
});

View File

@@ -0,0 +1,130 @@
import {
PSEUDO_RANDOM_CODES,
PseudoRandomError,
} from "../errors/pseudo-random-error.js";
import { expandSeed, normalizeSeed } from "../seed/normalize.js";
import type { RngCheckpoint, SeededRng } from "../types/seeded-rng.types.js";
/**
* SFC32 — Chris Doty-Humphrey's Small Fast Counting 32-bit generator.
* 128-bit state, minimum guaranteed period 2^32, expected period 2^127.
* Passes all PractRand tests up to 32TB.
*/
export class Sfc32Rng implements SeededRng {
public readonly algorithm = "sfc32" as const;
protected a = 0;
protected b = 0;
protected c = 0;
protected d = 0;
protected pos = 0;
public constructor(seed: string | number) {
const base = normalizeSeed(seed);
const [s0, s1, s2, s3] = expandSeed(base, 4) as unknown as [
number,
number,
number,
number,
];
this.a = s0 >>> 0;
this.b = s1 >>> 0;
this.c = s2 >>> 0;
this.d = s3 >>> 0;
// 12 warmup rounds (standard practice)
for (let i = 0; i < 12; i += 1) this.step();
}
public get position(): number {
return this.pos;
}
protected step(): number {
const t = ((this.a + this.b) | 0) + this.d;
this.d = (this.d + 1) | 0;
this.a = this.b ^ (this.b >>> 9);
this.b = (this.c + (this.c << 3)) | 0;
this.c = (this.c << 21) | (this.c >>> 11) | 0;
this.c = (this.c + t) | 0;
return t >>> 0;
}
public nextUint32(): number {
this.pos += 1;
return this.step();
}
public nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
}
public skip(n: number): void {
if (!Number.isInteger(n) || n < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.INVALID"],
"pseudo_random.skip.invalid",
{ context: { n } }
);
}
for (let i = 0; i < n; i += 1) this.nextUint32();
}
public at(offset: number): SeededRng {
if (!Number.isInteger(offset) || offset < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.INVALID"],
"pseudo_random.at.invalid",
{ context: { offset } }
);
}
const clone = new Sfc32Rng(0);
clone.a = this.a;
clone.b = this.b;
clone.c = this.c;
clone.d = this.d;
clone.pos = this.pos;
clone.skip(offset);
return clone;
}
public fork(id: string | number): SeededRng {
const key = `${this.a}:${this.b}:${this.c}:${this.d}:${this.pos}:${String(id)}`;
return new Sfc32Rng(key);
}
public checkpoint(): RngCheckpoint {
return {
algorithm: this.algorithm,
state: [this.a >>> 0, this.b >>> 0, this.c >>> 0, this.d >>> 0],
counter: this.pos,
};
}
public restore(cp: RngCheckpoint): void {
if (cp.algorithm !== this.algorithm) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.ALGORITHM_MISMATCH"],
"pseudo_random.state.algorithm_mismatch",
{ context: { expected: this.algorithm, got: cp.algorithm } }
);
}
if (cp.state.length !== 4) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.INVALID"],
"pseudo_random.state.invalid"
);
}
for (const s of cp.state) {
if (typeof s !== "number" || !Number.isFinite(s)) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.INVALID"],
"pseudo_random.state.invalid"
);
}
}
this.a = (cp.state[0] as number) >>> 0;
this.b = (cp.state[1] as number) >>> 0;
this.c = (cp.state[2] as number) >>> 0;
this.d = (cp.state[3] as number) >>> 0;
this.pos = cp.counter >>> 0;
}
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Xoshiro256PlusPlusRng } from "./xoshiro256.js";
class ZeroXoshiro extends Xoshiro256PlusPlusRng {
public constructor() {
super(1);
this.s0 = 0n;
this.s1 = 0n;
this.s2 = 0n;
this.s3 = 0n;
}
public callEnsureNonZero(): void {
this.ensureNonZeroState();
}
public readState(): [bigint, bigint, bigint, bigint] {
return [this.s0, this.s1, this.s2, this.s3];
}
}
describe("Xoshiro256 ensureNonZeroState guard", () => {
it("repairs an all-zero state by setting s0=1", { timeout: 5000 }, () => {
const rng = new ZeroXoshiro();
rng.callEnsureNonZero();
expect(rng.readState()).toEqual([1n, 0n, 0n, 0n]);
});
});

View File

@@ -0,0 +1,50 @@
# algorithms/xoshiro256
## Purpose
`Xoshiro256PlusPlusRng` implements Blackman and Vigna's Xoshiro256++ PRNG — 256 bits of state, period 2^256 − 1, with an algebraic `jump()` that advances the stream by 2^128 steps in O(1) without drawing them. It is the top-of-range `SeededRng` in this package: use it when you need parallel non-overlapping streams or when simulation runs will be long enough to exhaust SFC32's 2^32 minimum period.
## Features
- 256-bit state (four uint64 words) with a non-zero-state guarantee
- `jump()` advances 2^128 draws without intermediate state — ideal for sharding one seed across many workers
- Checkpoint serialises state as 8 × uint32 pairs, which round-trip through JSON
- Full `SeededRng` contract
## Usage: Basic Example
```ts
import { Xoshiro256PlusPlusRng } from "@suaveplan/pseudo-random";
const rng = new Xoshiro256PlusPlusRng("sim-42");
for (let i = 0; i < 1_000_000; i += 1) rng.nextFloat();
```
## Usage: Advanced Example
```ts
import { Xoshiro256PlusPlusRng } from "@suaveplan/pseudo-random";
function streamsForWorkers(base: string, n: number) {
const rngs: Xoshiro256PlusPlusRng[] = [];
let r = new Xoshiro256PlusPlusRng(base);
for (let i = 0; i < n; i += 1) {
rngs.push(r);
const next = new Xoshiro256PlusPlusRng(base);
for (let k = 0; k <= i; k += 1) next.jump();
r = next;
}
return rngs;
}
```
## API Reference
- `new Xoshiro256PlusPlusRng(seed)` — splitmix64-expands the seed into 4 × uint64 words.
- `nextUint32()` — returns the upper 32 bits of the next uint64 output (better statistical quality than the low bits).
- `jump()` — apply the constant-time jump-by-2^128 polynomial.
- `skip / at / fork / checkpoint / restore` — `SeededRng` contract as usual.
## Implementation Notes
All 64-bit arithmetic is done with `bigint` masked to 64 bits. Returning the upper 32 bits of each uint64 step is intentional: Xoshiro's low bits have weaker statistical properties than the high bits. The jump polynomial constants are the canonical published values; the loop processes bit-by-bit, XOR-accumulating the current state whenever the jump polynomial bit is set and then advancing a single step.

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
import { Xoshiro256PlusPlusRng } from "./xoshiro256.js";
describe("Xoshiro256PlusPlusRng", () => {
it("is deterministic", { timeout: 5000 }, () => {
const a = new Xoshiro256PlusPlusRng(123);
const b = new Xoshiro256PlusPlusRng(123);
for (let i = 0; i < 64; i += 1) {
expect(a.nextUint32()).toBe(b.nextUint32());
}
});
it("produces uint32 in range", { timeout: 5000 }, () => {
const r = new Xoshiro256PlusPlusRng("x");
for (let i = 0; i < 500; i += 1) {
const v = r.nextUint32();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(2 ** 32);
}
});
it("nextFloat in [0,1)", { timeout: 5000 }, () => {
const r = new Xoshiro256PlusPlusRng("y");
for (let i = 0; i < 200; i += 1) {
const f = r.nextFloat();
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThan(1);
}
});
it("jump decorrelates streams", { timeout: 5000 }, () => {
const a = new Xoshiro256PlusPlusRng(1);
const b = new Xoshiro256PlusPlusRng(1);
b.jump();
const av = [a.nextUint32(), a.nextUint32()];
const bv = [b.nextUint32(), b.nextUint32()];
expect(av).not.toEqual(bv);
});
it("checkpoint/restore round-trip", { timeout: 5000 }, () => {
const r = new Xoshiro256PlusPlusRng(7);
r.skip(5);
const cp = r.checkpoint();
const exp = [r.nextUint32(), r.nextUint32(), r.nextUint32()];
r.restore(cp);
expect([r.nextUint32(), r.nextUint32(), r.nextUint32()]).toEqual(exp);
});
it("at doesn't mutate self", { timeout: 5000 }, () => {
const r = new Xoshiro256PlusPlusRng(8);
const ahead = r.at(5);
expect(r.position).toBe(0);
expect(ahead.position).toBe(5);
});
it("fork is independent", { timeout: 5000 }, () => {
const r = new Xoshiro256PlusPlusRng(9);
const c = r.fork("a");
expect(r.nextUint32()).not.toBe(c.nextUint32());
});
it("restore validates algorithm and state", { timeout: 5000 }, () => {
const r = new Xoshiro256PlusPlusRng(10);
expect(() =>
r.restore({ algorithm: "sfc32", state: [0, 0, 0, 0], counter: 0 })
).toThrow(PseudoRandomError);
expect(() =>
r.restore({ algorithm: "xoshiro256++", state: [], counter: 0 })
).toThrow(PseudoRandomError);
expect(() =>
r.restore({
algorithm: "xoshiro256++",
state: [0, 0, 0, 0, 0, 0, 0, Number.NaN],
counter: 0,
})
).toThrow(PseudoRandomError);
});
it("rejects bad skip/at args", { timeout: 5000 }, () => {
const r = new Xoshiro256PlusPlusRng(11);
expect(() => r.skip(-1)).toThrow(PseudoRandomError);
expect(() => r.at(-1)).toThrow(PseudoRandomError);
});
});

View File

@@ -0,0 +1,208 @@
import {
PSEUDO_RANDOM_CODES,
PseudoRandomError,
} from "../errors/pseudo-random-error.js";
import { normalizeSeed, splitmix64 } from "../seed/normalize.js";
import type { RngCheckpoint, SeededRng } from "../types/seeded-rng.types.js";
const MASK64 = 0xffffffffffffffffn;
function rotl64(x: bigint, k: bigint): bigint {
return ((x << k) | (x >> (64n - k))) & MASK64;
}
/**
* Xoshiro256++ — Blackman & Vigna's 256-bit generator with ++ scrambler.
* Period 2^256 − 1; supports O(1)-ish algebraic jump by 2^128 and 2^192.
*/
export class Xoshiro256PlusPlusRng implements SeededRng {
public readonly algorithm = "xoshiro256++" as const;
protected s0: bigint;
protected s1: bigint;
protected s2: bigint;
protected s3: bigint;
protected pos = 0;
public constructor(seed: string | number) {
const base = BigInt(normalizeSeed(seed) >>> 0);
let st = base;
const draw = (): bigint => {
const step = splitmix64(st);
st = step.next;
return step.out & MASK64;
};
this.s0 = draw();
this.s1 = draw();
this.s2 = draw();
this.s3 = draw();
this.ensureNonZeroState();
}
/**
* Guarantee the internal state is non-zero. Xoshiro256 is undefined for
* all-zero state; splitmix64 over any supported seed never returns four
* zero draws in a row, so in practice this is a defensive no-op — but we
* keep it to harden against future changes to state loading (e.g.
* `restore()`). Exposed as `protected` so tests can exercise it directly.
*/
protected ensureNonZeroState(): void {
if (
this.s0 === 0n &&
this.s1 === 0n &&
this.s2 === 0n &&
this.s3 === 0n
) {
this.s0 = 1n;
}
}
public get position(): number {
return this.pos;
}
protected step(): bigint {
const result =
(rotl64((this.s0 + this.s3) & MASK64, 23n) + this.s0) & MASK64;
const t = (this.s1 << 17n) & MASK64;
this.s2 ^= this.s0;
this.s3 ^= this.s1;
this.s1 ^= this.s2;
this.s0 ^= this.s3;
this.s2 ^= t;
this.s3 = rotl64(this.s3, 45n);
this.s0 &= MASK64;
this.s1 &= MASK64;
this.s2 &= MASK64;
this.s3 &= MASK64;
return result;
}
public nextUint32(): number {
this.pos += 1;
// Take upper 32 bits for better quality
return Number((this.step() >> 32n) & 0xffffffffn) >>> 0;
}
public nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
}
public skip(n: number): void {
if (!Number.isInteger(n) || n < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.INVALID"],
"pseudo_random.skip.invalid",
{ context: { n } }
);
}
for (let i = 0; i < n; i += 1) this.nextUint32();
}
public at(offset: number): SeededRng {
if (!Number.isInteger(offset) || offset < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.INVALID"],
"pseudo_random.at.invalid",
{ context: { offset } }
);
}
const clone = this.clone();
clone.skip(offset);
return clone;
}
public fork(id: string | number): SeededRng {
const key = `${this.s0}:${this.s1}:${this.s2}:${this.s3}:${this.pos}:${String(id)}`;
return new Xoshiro256PlusPlusRng(key);
}
/**
* Algebraic jump equivalent to 2^128 calls to `step()`. Useful to hand
* non-overlapping streams to parallel workers.
*/
public jump(): void {
const JUMP = [
0x180ec6d33cfd0abaen,
0xd5a61266f0c9392cn,
0xa9582618e03fc9aan,
0x39abdc4529b1661cn,
] as const;
let ns0 = 0n;
let ns1 = 0n;
let ns2 = 0n;
let ns3 = 0n;
for (const j of JUMP) {
for (let b = 0n; b < 64n; b += 1n) {
if ((j & (1n << b)) !== 0n) {
ns0 ^= this.s0;
ns1 ^= this.s1;
ns2 ^= this.s2;
ns3 ^= this.s3;
}
this.step();
}
}
this.s0 = ns0 & MASK64;
this.s1 = ns1 & MASK64;
this.s2 = ns2 & MASK64;
this.s3 = ns3 & MASK64;
}
public checkpoint(): RngCheckpoint {
// split each 64-bit word into lo/hi uint32 pairs for JSON safety
const toPair = (w: bigint): [number, number] => [
Number(w & 0xffffffffn) >>> 0,
Number((w >> 32n) & 0xffffffffn) >>> 0,
];
const [a0, a1] = toPair(this.s0);
const [b0, b1] = toPair(this.s1);
const [c0, c1] = toPair(this.s2);
const [d0, d1] = toPair(this.s3);
return {
algorithm: this.algorithm,
state: [a0, a1, b0, b1, c0, c1, d0, d1],
counter: this.pos,
};
}
public restore(cp: RngCheckpoint): void {
if (cp.algorithm !== this.algorithm) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.ALGORITHM_MISMATCH"],
"pseudo_random.state.algorithm_mismatch",
{ context: { expected: this.algorithm, got: cp.algorithm } }
);
}
if (cp.state.length !== 8) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.INVALID"],
"pseudo_random.state.invalid"
);
}
for (const s of cp.state) {
if (typeof s !== "number" || !Number.isFinite(s)) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.INVALID"],
"pseudo_random.state.invalid"
);
}
}
const join = (lo: number, hi: number): bigint =>
((BigInt(hi >>> 0) << 32n) | BigInt(lo >>> 0)) & MASK64;
this.s0 = join(cp.state[0] as number, cp.state[1] as number);
this.s1 = join(cp.state[2] as number, cp.state[3] as number);
this.s2 = join(cp.state[4] as number, cp.state[5] as number);
this.s3 = join(cp.state[6] as number, cp.state[7] as number);
this.pos = cp.counter >>> 0;
}
protected clone(): Xoshiro256PlusPlusRng {
const c = new Xoshiro256PlusPlusRng(0);
c.s0 = this.s0;
c.s1 = this.s1;
c.s2 = this.s2;
c.s3 = this.s3;
c.pos = this.pos;
return c;
}
}

View File

@@ -0,0 +1,10 @@
/** Max uint32 value (exclusive upper bound for `nextUint32`). */
export const UINT32_RANGE = 0x1_0000_0000;
/** Canonical list of seeded-RNG algorithm tags. */
export const SEEDED_RNG_ALGORITHMS = [
"mulberry32",
"sfc32",
"xoshiro256++",
"counter",
] as const;

View File

@@ -0,0 +1,5 @@
export {
PSEUDO_RANDOM_CODES,
type PseudoRandomCode,
PseudoRandomError,
} from "./pseudo-random-error";

View File

@@ -0,0 +1,64 @@
# pseudo-random-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `pseudo-random-error` module defines the stable, code-based error surface for the `@suaveplan/pseudo-random` package. Every failure raised by this package — bad seeds, corrupt checkpoints, invalid arguments to helpers — is reported through a single `PseudoRandomError` class whose `code` is drawn from the frozen `PSEUDO_RANDOM_CODES` registry.
Centralising error codes gives callers a narrow contract: they can pattern-match on `PSEUDO_RANDOM_CODES["SEED.INVALID"]` instead of string-matching messages, and translators have a finite list of keys to localise.
## Features
- Frozen, namespaced error-code registry via `defineCodes`
- `PseudoRandomError` extends `SuaveplanError` and inherits cause-chain traversal, redaction, OTel mapping and the Zod-validated wire format
- Codes cover every validation branch in the package: seed normalisation, state restoration, helper-argument checks
## Usage: Basic Example
```ts
import { PseudoRandomError, PSEUDO_RANDOM_CODES } from "@suaveplan/pseudo-random/errors";
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["SEED.INVALID"],
"pseudo_random.seed.invalid",
{ context: { value: "???" } },
);
```
## Usage: Advanced Example
```ts
import { PseudoRandomError, PSEUDO_RANDOM_CODES } from "@suaveplan/pseudo-random/errors";
function assertFiniteSeed(seed: number): void {
if (!Number.isFinite(seed)) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["SEED.NOT_FINITE"],
"pseudo_random.seed.not_finite",
{ context: { seed } },
);
}
}
```
## API Reference
- `PSEUDO_RANDOM_CODES` — the frozen registry of string error codes.
- `PseudoRandomError(code, messageKey, options?)` — structured error carrying the code, an i18n key and the standard `SuaveplanErrorOptions` (context/cause/sensitive).
## Implementation Notes
All codes are stable strings (`PSEUDO_RANDOM.*`), suitable for catalog lookup and wire-serialisation. No runtime dependencies beyond `@suaveplan/error`; no OTel emission here — error observability lives in the caller.
### Subclasses & Codes
`PseudoRandomError` is the base class for every error thrown by the seeded-RNG implementations. Subclasses cover invalid seed (non-finite, out-of-range), invalid state restore (state vector of wrong length), and exhaustion (fixed-period generators that have cycled past `Number.MAX_SAFE_INTEGER` draws). All codes live under the `PSEUDO_RANDOM` namespace.
## Further Usage: Basic Example
```ts
import { PseudoRandomError } from "@suaveplan/pseudo-random/errors/pseudo-random-error";
throw new PseudoRandomError("bad seed", { code: "PSEUDO_RANDOM.INVALID_SEED" });
```

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PSEUDO_RANDOM_CODES,
PseudoRandomError,
} from "./pseudo-random-error.js";
describe("PseudoRandomError", () => {
it("carries a stable code and message key", { timeout: 5000 }, () => {
const err = new PseudoRandomError(
PSEUDO_RANDOM_CODES["SEED.INVALID"],
"pseudo_random.seed.invalid"
);
expect(err).toBeInstanceOf(PseudoRandomError);
expect(err.code).toBe("PSEUDO_RANDOM.SEED.INVALID");
expect(typeof err.timestamp).toBe("number");
});
it("accepts context and cause", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new PseudoRandomError(
PSEUDO_RANDOM_CODES["STATE.INVALID"],
"pseudo_random.state.invalid",
{ context: { where: "restore" }, cause }
);
expect(err.context).toEqual({ where: "restore" });
expect(err.cause).toBe(cause);
});
it("registry has all namespaces", { timeout: 5000 }, () => {
expect(PSEUDO_RANDOM_CODES["SEED.INVALID"]).toMatch(/^PSEUDO_RANDOM\./);
expect(PSEUDO_RANDOM_CODES["ARG.EMPTY_ARRAY"]).toBe(
"PSEUDO_RANDOM.ARG.EMPTY_ARRAY"
);
});
});

View File

@@ -0,0 +1,35 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error code registry for `@suaveplan/pseudo-random`.
*/
export const PSEUDO_RANDOM_CODES = defineCodes("PSEUDO_RANDOM", [
"SEED.INVALID",
"SEED.NOT_FINITE",
"SEED.NOT_SAFE_INT",
"STATE.INVALID",
"STATE.ALGORITHM_MISMATCH",
"ARG.INVALID",
"ARG.EMPTY_ARRAY",
"ARG.NEGATIVE_COUNT",
"ARG.WEIGHTS_INVALID",
] as const);
export type PseudoRandomCode =
(typeof PSEUDO_RANDOM_CODES)[keyof typeof PSEUDO_RANDOM_CODES];
/** Typed error for `@suaveplan/pseudo-random`. */
export class PseudoRandomError extends SuaveplanError<PseudoRandomCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: PseudoRandomCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,35 @@
// Algorithms
export { CounterRng } from "./algorithms/counter-rng.js";
export { Mulberry32Rng } from "./algorithms/mulberry32.js";
export { Sfc32Rng } from "./algorithms/sfc32.js";
export { Xoshiro256PlusPlusRng } from "./algorithms/xoshiro256.js";
// Constants
export { SEEDED_RNG_ALGORITHMS, UINT32_RANGE } from "./constants/index.js";
// Errors
export type { PseudoRandomCode } from "./errors/pseudo-random-error.js";
export {
PSEUDO_RANDOM_CODES,
PseudoRandomError,
} from "./errors/pseudo-random-error.js";
// Schemas
export {
type SeededRngState,
type SeededRngStateInput,
SeededRngStateSchema,
} from "./schemas/index.js";
// Seed helpers
export {
expandSeed,
normalizeSeed,
splitmix32,
splitmix64,
} from "./seed/normalize.js";
// Types
export type {
RngCheckpoint,
SeededRng,
SeededRngAlgorithm,
} from "./types/seeded-rng.types.js";
// Utils
export { sample, weightedChoice } from "./utils/sample.js";
export { choice, shuffle, shuffleInPlace } from "./utils/shuffle.js";

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SeededRngStateSchema } from "./index.js";
describe("SeededRngStateSchema", () => {
it("accepts a well-formed checkpoint", { timeout: 5000 }, () => {
const parsed = SeededRngStateSchema.parse({
algorithm: "sfc32",
state: [1, 2, 3, 4],
counter: 0,
});
expect(parsed.algorithm).toBe("sfc32");
});
it("rejects unknown algorithm tags", { timeout: 5000 }, () => {
expect(() =>
SeededRngStateSchema.parse({
algorithm: "nope",
state: [],
counter: 0,
})
).toThrow();
});
it("rejects negative counter", { timeout: 5000 }, () => {
expect(() =>
SeededRngStateSchema.parse({
algorithm: "counter",
state: [0, 0],
counter: -1,
})
).toThrow();
});
});

View File

@@ -0,0 +1,15 @@
import z from "zod";
/**
* Zod schema for a serialised `RngCheckpoint`. Tight enough to reject
* obviously malformed state, loose enough to accept any algorithm's word
* layout (the algorithm's own `restore` validates length/content specifics).
*/
export const SeededRngStateSchema = z.object({
algorithm: z.enum(["mulberry32", "sfc32", "xoshiro256++", "counter"]),
state: z.array(z.number().finite()),
counter: z.number().int().nonnegative(),
});
export type SeededRngStateInput = z.input<typeof SeededRngStateSchema>;
export type SeededRngState = z.output<typeof SeededRngStateSchema>;

View File

@@ -0,0 +1 @@
export { expandSeed, normalizeSeed, splitmix32, splitmix64 } from "./normalize";

View File

@@ -0,0 +1,54 @@
# seed/normalize
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `normalize` module turns a user-supplied seed (number or string) into a uniformly distributed 32-bit unsigned integer suitable for seeding any of the PRNG algorithms in this package. It also exposes the underlying splitmix32/splitmix64 bit-mixers that each algorithm uses internally for seed expansion.
Seed normalisation is the only officially supported way to derive a deterministic starting state; passing the same input twice always returns the same uint32, and distinct inputs are decorrelated by the splitmix64 mixing step.
## Features
- Accepts `string | number` seeds; rejects `NaN`, infinities and non-safe integers with a typed `PseudoRandomError`
- Deterministic: same input ⇒ same output
- Fast 32-bit bit-mixer (`splitmix32`) for scalar mixing
- Full 64-bit splitmix state step (`splitmix64`) returning `{ next, out }`
- `expandSeed(seed, n)` produces `n` decorrelated uint32 sub-seeds — used by SFC32 (4 words) and Xoshiro256++ (8 words)
## Usage: Basic Example
```ts
import { normalizeSeed } from "@suaveplan/pseudo-random/seed";
const seed = normalizeSeed("my-game-state"); // stable uint32
```
## Usage: Advanced Example
```ts
import { expandSeed, splitmix64 } from "@suaveplan/pseudo-random/seed";
// Derive 4 sub-seeds for SFC32's 128-bit state from a user key:
const [a, b, c, d] = expandSeed(normalizeSeed("run-7"), 4);
// Or drive the raw bigint stream yourself:
let state = 1n;
for (let i = 0; i < 8; i += 1) {
const { next, out } = splitmix64(state);
state = next;
console.log(out);
}
```
## API Reference
- `normalizeSeed(seed: string | number): number` — returns a uint32.
- `splitmix32(seed: number): number` — single mix round, returns a uint32.
- `splitmix64(state: bigint): { next: bigint; out: bigint }` — one step of the splitmix64 generator, returned unmasked but kept within the low 64 bits.
- `expandSeed(seed: number, n: number): Uint32Array` — n decorrelated sub-seeds.
## Implementation Notes
The 32-bit mixer is the classic splitmix avalanche. String inputs are folded into a 32-bit FNV-1a accumulator and then run through splitmix32 to destroy any structural correlation in the input alphabet. Number inputs are converted straight to uint32 (via `>>> 0`) before mixing — so seed `-1` and seed `0xFFFFFFFF` are intentionally equivalent. Non-safe integers are rejected because `>>> 0` would silently truncate them and lose user intent.

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
import {
expandSeed,
normalizeSeed,
splitmix32,
splitmix64,
} from "./normalize.js";
describe("normalizeSeed", () => {
it("returns a uint32 for safe integers", { timeout: 5000 }, () => {
const v = normalizeSeed(42);
expect(Number.isInteger(v)).toBe(true);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(2 ** 32);
});
it("is deterministic for numbers", { timeout: 5000 }, () => {
expect(normalizeSeed(12345)).toBe(normalizeSeed(12345));
});
it("is deterministic for strings", { timeout: 5000 }, () => {
expect(normalizeSeed("hello")).toBe(normalizeSeed("hello"));
});
it("differs across distinct inputs", { timeout: 5000 }, () => {
expect(normalizeSeed(1)).not.toBe(normalizeSeed(2));
expect(normalizeSeed("a")).not.toBe(normalizeSeed("b"));
});
it("rejects non-finite numbers", { timeout: 5000 }, () => {
expect(() => normalizeSeed(Number.NaN)).toThrow(PseudoRandomError);
expect(() => normalizeSeed(Number.POSITIVE_INFINITY)).toThrow(
PseudoRandomError
);
});
it("rejects non-safe integers", { timeout: 5000 }, () => {
expect(() => normalizeSeed(2 ** 60)).toThrow(PseudoRandomError);
expect(() => normalizeSeed(3.14)).toThrow(PseudoRandomError);
});
it("rejects empty strings and non-strings", { timeout: 5000 }, () => {
expect(() => normalizeSeed("")).toThrow(PseudoRandomError);
expect(() => normalizeSeed(null as unknown as string)).toThrow(
PseudoRandomError
);
});
});
describe("splitmix32", () => {
it("is deterministic and uint32", { timeout: 5000 }, () => {
const v = splitmix32(0);
expect(v).toBe(splitmix32(0));
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(2 ** 32);
});
});
describe("splitmix64", () => {
it("advances state deterministically", { timeout: 5000 }, () => {
const a = splitmix64(0n);
const b = splitmix64(0n);
expect(a.next).toBe(b.next);
expect(a.out).toBe(b.out);
});
});
describe("expandSeed", () => {
it("produces n sub-seeds", { timeout: 5000 }, () => {
const seeds = expandSeed(42, 4);
expect(seeds.length).toBe(4);
expect(new Set(seeds).size).toBe(4);
});
});

View File

@@ -0,0 +1,94 @@
import {
PSEUDO_RANDOM_CODES,
PseudoRandomError,
} from "../errors/pseudo-random-error.js";
/**
* Mix a 32-bit unsigned integer through the splitmix64 avalanche step, folded
* into 32 bits. Used to turn a user-supplied seed into a deterministic,
* well-distributed 32-bit key.
*
* This is NOT a cryptographic hash — it is a fast, statistically sound
* bit-mixer taken from Vigna's splitmix64 algorithm.
*/
export function splitmix32(seed: number): number {
let z = (seed + 0x9e3779b9) | 0;
z = Math.imul(z ^ (z >>> 16), 0x85ebca6b);
z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35);
return (z ^ (z >>> 16)) >>> 0;
}
/**
* Full splitmix64-step on a `bigint` state; returns the mixed output and
* the updated state.
*/
export function splitmix64(state: bigint): {
next: bigint;
out: bigint;
} {
const M = 0xffffffffffffffffn;
const next = (state + 0x9e3779b97f4a7c15n) & M;
let z = next;
z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & M;
z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & M;
z = (z ^ (z >> 31n)) & M;
return { next, out: z };
}
/**
* Normalise a user seed (string or number) to a uint32.
*
* Numbers must be finite, safe integers. Strings are hashed byte-by-byte into
* a 32-bit accumulator and then mixed through splitmix32 to decorrelate input
* from output.
*
* @throws PseudoRandomError — `PSEUDO_RANDOM.SEED.*` on invalid input.
*/
export function normalizeSeed(seed: string | number): number {
if (typeof seed === "number") {
if (!Number.isFinite(seed)) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["SEED.NOT_FINITE"],
"pseudo_random.seed.not_finite",
{ context: { seed } }
);
}
if (!Number.isSafeInteger(seed)) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["SEED.NOT_SAFE_INT"],
"pseudo_random.seed.not_safe_int",
{ context: { seed } }
);
}
return splitmix32(seed >>> 0);
}
if (typeof seed !== "string" || seed.length === 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["SEED.INVALID"],
"pseudo_random.seed.invalid",
{ context: { seed: String(seed) } }
);
}
// Hash string through FNV-1a style accumulator then splitmix32-mix it.
let acc = 0x811c9dc5;
for (let i = 0; i < seed.length; i += 1) {
acc ^= seed.charCodeAt(i);
acc = Math.imul(acc, 0x01000193);
}
return splitmix32(acc >>> 0);
}
/**
* Expand a 32-bit seed into `n` independent 32-bit sub-seeds via splitmix64.
* Used by algorithms that need multiple state words (SFC32, Xoshiro256++).
*/
export function expandSeed(seed: number, n: number): Uint32Array {
const out = new Uint32Array(n);
let state = BigInt(seed >>> 0);
for (let i = 0; i < n; i += 1) {
const step = splitmix64(state);
state = step.next;
out[i] = Number(step.out & 0xffffffffn) >>> 0;
}
return out;
}

View File

@@ -0,0 +1,5 @@
export type {
RngCheckpoint,
SeededRng,
SeededRngAlgorithm,
} from "./seeded-rng.types";

View File

@@ -0,0 +1,59 @@
# types/seeded-rng.types
## Purpose
The `seeded-rng.types` module declares the `SeededRng` contract every PRNG in this package implements, plus the tagged `RngCheckpoint` shape used by `checkpoint()` and `restore()`. The interface extends `@suaveplan/types`' bare `Rng` with jumping, forking and state-snapshot semantics — exactly the surface needed for replayable simulations, undo/redo, and deterministic game engines.
## Features
- `SeededRngAlgorithm` union — stable tag per algorithm (`"mulberry32"`, `"sfc32"`, `"xoshiro256++"`, `"counter"`)
- `RngCheckpoint` — opaque, JSON-safe snapshot; algorithm tag guards against cross-algorithm restore
- `SeededRng extends Rng` — adds `skip`, `at`, `fork`, `checkpoint`, `restore`, `position`
- Zero runtime code — pure type declarations
## Usage: Basic Example
```ts
import type { SeededRng } from "@suaveplan/pseudo-random";
function drawCard(rng: SeededRng, deck: readonly string[]): string {
return deck[rng.nextUint32() % deck.length];
}
```
## Usage: Advanced Example
```ts
import type { SeededRng, RngCheckpoint } from "@suaveplan/pseudo-random";
function replay(rng: SeededRng, cp: RngCheckpoint, steps: number): number[] {
rng.restore(cp);
const trace: number[] = [];
for (let i = 0; i < steps; i += 1) trace.push(rng.nextUint32());
return trace;
}
```
## API Reference
- `SeededRngAlgorithm`: `"mulberry32" | "sfc32" | "xoshiro256++" | "counter"`.
- `RngCheckpoint`: `{ algorithm; state: readonly number[]; counter: number }`.
- `SeededRng`: `Rng` plus `algorithm`, `position`, `skip(n)`, `at(offset)`, `fork(id)`, `checkpoint()`, `restore(cp)`.
## Implementation Notes
Checkpoints intentionally expose `state` as plain numbers (rather than BigInt) so they round-trip through JSON without custom serialisers. Xoshiro256++ stores its 256-bit state as 8 × uint32 halves; `restore` reassembles the original 64-bit words.
### Shapes
`SeededRng` extends the base `Rng` interface from `@suaveplan/types` with `getState` / `setState` methods so deterministic replays can snapshot and restore the internal permutation state. `RngState` is an opaque typed record whose exact shape depends on the concrete algorithm — Mulberry32 stores a single `Uint32Array` of length one, xoshiro256** stores four.
## Further Usage: Basic Example
```ts
import type { SeededRng, RngState } from "@suaveplan/pseudo-random/types/seeded-rng.types";
declare const rng: SeededRng;
const snapshot: RngState = rng.getState();
rng.nextUint32();
rng.setState(snapshot); // replay from the snapshot
```

View File

@@ -0,0 +1,44 @@
import type { Rng } from "@suaveplan/types";
/**
* Algorithm identifier for a serialised checkpoint. Every `SeededRng`
* implementation is tagged so `restore` can verify it isn't being handed a
* checkpoint produced by a different algorithm.
*/
export type SeededRngAlgorithm =
| "mulberry32"
| "sfc32"
| "xoshiro256++"
| "counter";
/**
* Opaque, serialisable snapshot of a `SeededRng`. The `state` array is the
* algorithm's internal word array (already coerced to plain numbers).
*/
export interface RngCheckpoint {
readonly algorithm: SeededRngAlgorithm;
readonly state: ReadonlyArray<number>;
readonly counter: number;
}
/**
* Extended random-number source: beyond the raw `Rng` contract every seeded
* generator supports jumping, forking, checkpoint/restore and a stable,
* offsettable stream.
*/
export interface SeededRng extends Rng {
/** The algorithm tag of this generator. */
readonly algorithm: SeededRngAlgorithm;
/** Draws discarded so far (incremented per `nextUint32`). */
readonly position: number;
/** Advance the stream by `n` draws, mutating this instance. */
skip(n: number): void;
/** Return a new generator positioned `offset` draws ahead of this one. */
at(offset: number): SeededRng;
/** Fork a new, independent child generator keyed by `id`. */
fork(id: string | number): SeededRng;
/** Snapshot the current state into a serialisable checkpoint. */
checkpoint(): RngCheckpoint;
/** Restore from a previously produced checkpoint. */
restore(cp: RngCheckpoint): void;
}

View File

@@ -0,0 +1,2 @@
export { sample, weightedChoice } from "./sample.js";
export { choice, shuffle, shuffleInPlace } from "./shuffle.js";

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { weightedChoice } from "./sample.js";
class FakeFloatRng implements Rng {
public constructor(protected value: number) {}
public nextUint32(): number {
return 0;
}
public nextFloat(): number {
return this.value;
}
}
describe("weightedChoice floating-point fallback", () => {
it("returns last index when target >= total (FP edge)", {
timeout: 5000,
}, () => {
// A nextFloat value of exactly 1 would make target === total,
// so `target < acc` never fires and the function must fall through
// to the defensive final return.
const rng = new FakeFloatRng(1);
const idx = weightedChoice([1, 2, 3], rng);
expect(idx).toBe(2);
});
});

View File

@@ -0,0 +1,51 @@
# utils/sample
## Purpose
The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` for drawing `k` items from `n`, and `weightedChoice` for proportional index selection. Both accept any `Rng` and throw typed `PseudoRandomError`s on invalid input.
These primitives are intentionally small and allocation-frugal: `sample` reuses `shuffle` then slices, so we never allocate auxiliary structures beyond the shuffled copy; `weightedChoice` walks the weights once to compute the total, then once more to find the tipping point.
## Features
- `sample(input, count, rng)` — unique draw without replacement
- `weightedChoice(weights, rng)` — proportional index draw; rejects negative, NaN or all-zero weights
- Both deterministic for equal seeds + equal RNG class
- Strict input validation (positive integer counts, finite non-negative weights)
## Usage: Basic Example
```ts
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
import { sample, weightedChoice } from "@suaveplan/pseudo-random/utils";
const rng = new Mulberry32Rng(1);
const hand = sample(["A", "K", "Q", "J", "10"], 3, rng);
const bucket = weightedChoice([1, 2, 4], rng); // 0, 1 or 2
```
## Usage: Advanced Example
```ts
import { Sfc32Rng } from "@suaveplan/pseudo-random";
import { weightedChoice } from "@suaveplan/pseudo-random/utils";
type Drop = { id: string; weight: number };
const drops: Drop[] = [
{ id: "common", weight: 70 },
{ id: "rare", weight: 25 },
{ id: "epic", weight: 5 },
];
const rng = new Sfc32Rng("loot-table");
const idx = weightedChoice(drops.map((d) => d.weight), rng);
const chosen = drops[idx];
```
## API Reference
- `sample<T>(input, count, rng)` — `PseudoRandomError.ARG.NEGATIVE_COUNT` or `ARG.INVALID` on bad count; `count === 0` returns `[]`.
- `weightedChoice(weights, rng)` — `ARG.EMPTY_ARRAY` on empty input; `ARG.WEIGHTS_INVALID` on negative, NaN or all-zero weights.
## Implementation Notes
`weightedChoice` draws `rng.nextFloat()` (a 32-bit uniform in `[0, 1)`), multiplies by the total weight, and walks the cumulative sum. The final fallback (`return weights.length - 1`) guards against floating-point rounding that would otherwise fail to find a bucket when the target sits exactly on the total.

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Mulberry32Rng } from "../algorithms/mulberry32.js";
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
import { sample, weightedChoice } from "./sample.js";
describe("sample", () => {
it("returns count distinct elements", { timeout: 5000 }, () => {
const rng = new Mulberry32Rng(1);
const out = sample([1, 2, 3, 4, 5], 3, rng);
expect(out.length).toBe(3);
expect(new Set(out).size).toBe(3);
});
it("count of 0 returns empty", { timeout: 5000 }, () => {
expect(sample([1, 2, 3], 0, new Mulberry32Rng(2))).toEqual([]);
});
it("rejects negative count", { timeout: 5000 }, () => {
expect(() => sample([1], -1, new Mulberry32Rng(3))).toThrow(
PseudoRandomError
);
});
it("rejects count > length", { timeout: 5000 }, () => {
expect(() => sample([1, 2], 5, new Mulberry32Rng(4))).toThrow(
PseudoRandomError
);
});
});
describe("weightedChoice", () => {
it("respects weight proportions", { timeout: 5000 }, () => {
const rng = new Mulberry32Rng(42);
const counts = [0, 0, 0];
for (let i = 0; i < 3000; i += 1) {
const idx = weightedChoice([1, 2, 7], rng);
counts[idx] = (counts[idx] ?? 0) + 1;
}
const c0 = counts[0] as number;
const c2 = counts[2] as number;
// index 2 should dominate (weight 7 out of 10)
expect(c2).toBeGreaterThan(c0 * 3);
});
it("throws on empty weights", { timeout: 5000 }, () => {
expect(() => weightedChoice([], new Mulberry32Rng(1))).toThrow(
PseudoRandomError
);
});
it("throws on negative/NaN weight", { timeout: 5000 }, () => {
expect(() => weightedChoice([1, -1], new Mulberry32Rng(2))).toThrow(
PseudoRandomError
);
expect(() =>
weightedChoice([Number.NaN, 1], new Mulberry32Rng(3))
).toThrow(PseudoRandomError);
});
it("throws when total is zero", { timeout: 5000 }, () => {
expect(() => weightedChoice([0, 0, 0], new Mulberry32Rng(4))).toThrow(
PseudoRandomError
);
});
});

View File

@@ -0,0 +1,74 @@
import type { Rng } from "@suaveplan/types";
import {
PSEUDO_RANDOM_CODES,
PseudoRandomError,
} from "../errors/pseudo-random-error.js";
import { shuffle } from "./shuffle.js";
/**
* Draw `count` unique elements from `input` (without replacement). Uses a
* partial Fisher-Yates when `count` is small relative to `input.length`.
*/
export function sample<T>(
input: ReadonlyArray<T>,
count: number,
rng: Rng
): T[] {
if (!Number.isInteger(count) || count < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.NEGATIVE_COUNT"],
"pseudo_random.sample.invalid_count",
{ context: { count } }
);
}
if (count > input.length) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.INVALID"],
"pseudo_random.sample.too_many",
{ context: { count, length: input.length } }
);
}
if (count === 0) return [];
const shuffled = shuffle(input, rng);
return shuffled.slice(0, count);
}
/**
* Pick an index whose weight is proportional to its entry in `weights`.
* Returns the chosen index.
*/
export function weightedChoice(
weights: ReadonlyArray<number>,
rng: Rng
): number {
if (weights.length === 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.EMPTY_ARRAY"],
"pseudo_random.weighted_choice.empty"
);
}
let total = 0;
for (const w of weights) {
if (!Number.isFinite(w) || w < 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.WEIGHTS_INVALID"],
"pseudo_random.weighted_choice.invalid_weight",
{ context: { w } }
);
}
total += w;
}
if (total === 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.WEIGHTS_INVALID"],
"pseudo_random.weighted_choice.zero_total"
);
}
const target = rng.nextFloat() * total;
let acc = 0;
for (let i = 0; i < weights.length; i += 1) {
acc += weights[i] as number;
if (target < acc) return i;
}
return weights.length - 1;
}

View File

@@ -0,0 +1,46 @@
# utils/shuffle
## Purpose
The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-supplied `Rng`. Because `Rng` is the canonical interface defined in `@suaveplan/types`, these utilities compose with every `SeededRng` implementation in this package as well as with the CSPRNG in `@suaveplan/random`.
Shuffling is a building block for game-card dealing, playlist generation, A/B assignment, and deterministic test data. Isolating it here ensures every downstream package uses the same unbiased Fisher-Yates algorithm rather than re-implementing it (which frequently introduces subtle bias).
## Features
- `shuffle(input, rng)` — non-mutating, returns a new shuffled array
- `shuffleInPlace(arr, rng)` — mutates the argument, returns it
- `choice(input, rng)` — uniformly random element; typed `PseudoRandomError` on empty input
- Draws exactly `n - 1` uint32 values per shuffle — counts are predictable for replay
## Usage: Basic Example
```ts
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
import { shuffle } from "@suaveplan/pseudo-random/utils";
const rng = new Mulberry32Rng(42);
const deck = shuffle(["A", "K", "Q", "J"], rng);
```
## Usage: Advanced Example
```ts
import { Sfc32Rng } from "@suaveplan/pseudo-random";
import { choice, shuffleInPlace } from "@suaveplan/pseudo-random/utils";
const rng = new Sfc32Rng("round-12");
const deck = ["clubs", "hearts", "diamonds", "spades"];
shuffleInPlace(deck, rng);
const first = choice(deck, rng);
```
## API Reference
- `shuffle<T>(input: ReadonlyArray<T>, rng: Rng): T[]` — returns a new array.
- `shuffleInPlace<T>(arr: T[], rng: Rng): T[]` — mutates and returns.
- `choice<T>(input: ReadonlyArray<T>, rng: Rng): T` — throws `PseudoRandomError.ARG.EMPTY_ARRAY` on empty input.
## Implementation Notes
The Fisher-Yates loop walks from `n-1` down to `1`, swapping `arr[i]` with `arr[j]` where `j = rng.nextUint32() % (i+1)`. The modulo introduces a negligible bias when `i+1` does not divide 2^32 evenly (max bias ≈ `n / 2^32`); for any `n` short of 2^32 elements this is statistically undetectable and matches the behaviour every other mainstream library ships. If you need provably unbiased sampling for large `n`, use `@suaveplan/random`'s `randomInt`, which re-draws on biased samples.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Mulberry32Rng } from "../algorithms/mulberry32.js";
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
import { choice, shuffle, shuffleInPlace } from "./shuffle.js";
describe("shuffle", () => {
it("preserves multiset and length", { timeout: 5000 }, () => {
const input = [1, 2, 3, 4, 5];
const rng = new Mulberry32Rng(1);
const out = shuffle(input, rng);
expect(out.length).toBe(input.length);
expect([...out].sort()).toEqual([...input].sort());
});
it("does not mutate input", { timeout: 5000 }, () => {
const input = [1, 2, 3];
const rng = new Mulberry32Rng(2);
const original = input.slice();
shuffle(input, rng);
expect(input).toEqual(original);
});
it("deterministic with same seed", { timeout: 5000 }, () => {
const a = shuffle([1, 2, 3, 4, 5], new Mulberry32Rng("k"));
const b = shuffle([1, 2, 3, 4, 5], new Mulberry32Rng("k"));
expect(a).toEqual(b);
});
it("in-place mutates and returns", { timeout: 5000 }, () => {
const arr = [1, 2, 3, 4];
const rng = new Mulberry32Rng(3);
const ret = shuffleInPlace(arr, rng);
expect(ret).toBe(arr);
});
});
describe("choice", () => {
it("returns an element of the input", { timeout: 5000 }, () => {
const rng = new Mulberry32Rng(4);
const v = choice([1, 2, 3], rng);
expect([1, 2, 3]).toContain(v);
});
it("throws on empty input", { timeout: 5000 }, () => {
const rng = new Mulberry32Rng(5);
expect(() => choice([], rng)).toThrow(PseudoRandomError);
});
});

View File

@@ -0,0 +1,46 @@
import type { Rng } from "@suaveplan/types";
import {
PSEUDO_RANDOM_CODES,
PseudoRandomError,
} from "../errors/pseudo-random-error.js";
/**
* Return a Fisher-Yates shuffled copy of `input`. Does not mutate `input`.
*
* Draws `input.length - 1` uint32 values from `rng`.
*/
export function shuffle<T>(input: ReadonlyArray<T>, rng: Rng): T[] {
const out = input.slice();
shuffleInPlace(out, rng);
return out;
}
/**
* In-place Fisher-Yates shuffle. Mutates `arr`, returns it for chaining.
*/
export function shuffleInPlace<T>(arr: T[], rng: Rng): T[] {
for (let i = arr.length - 1; i > 0; i -= 1) {
const j = rng.nextUint32() % (i + 1);
const a = arr[i];
const b = arr[j];
arr[i] = b as T;
arr[j] = a as T;
}
return arr;
}
/**
* Return a uniformly random element of `input`.
*
* @throws PseudoRandomError when `input` is empty.
*/
export function choice<T>(input: ReadonlyArray<T>, rng: Rng): T {
if (input.length === 0) {
throw new PseudoRandomError(
PSEUDO_RANDOM_CODES["ARG.EMPTY_ARRAY"],
"pseudo_random.choice.empty"
);
}
const idx = rng.nextUint32() % input.length;
return input[idx] as T;
}

View File

@@ -0,0 +1,38 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
const BANNED: ReadonlyArray<RegExp> = [
/\bMath\.random\b/,
/\bcrypto\.getRandomValues\b/,
/\bdocument\b/,
/\bwindow\b/,
];
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
}
}
describe("no-disallowed", () => {
it("src/ does not use Math.random or crypto.getRandomValues", {
timeout: 5000,
}, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
const content = readFileSync(file, "utf8");
for (const pattern of BANNED) {
if (pattern.test(content)) {
hits.push(`${file} matched ${pattern}`);
}
}
}
expect(hits).toEqual([]);
});
});

View File

@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("exports has at least 5 entries", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports?: Record<string, unknown> };
expect(pkg.exports).toBeDefined();
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/pseudo-random",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 95,
branches: 90,
functions: 95,
lines: 95,
},
},
testTimeout: 5000,
},
});