Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
130
packages/foundation/random/pseudo-random/src/algorithms/sfc32.ts
Normal file
130
packages/foundation/random/pseudo-random/src/algorithms/sfc32.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user