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,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;
}
}