Files
2026-05-27 23:29:53 +01:00

236 lines
9.9 KiB
Markdown

# Design: enterprise-random-family
## Context
`@suaveplan/types` defines `Rng = { nextUint32(): number; nextFloat(): number }` but ships zero implementations. The genesis `@suaveplan/testing` package contains an inline Mulberry32 `seededRng` for test fixtures — this inline copy stays (testing packages cannot take workspace deps), but the canonical implementations live here. The games codebase runs five different hand-rolled PRNG algorithms across bingo, baccarat, rummy, minesweeper, and fruit-machine — this change provides the single canonical set they all migrate to.
Three packages with zero inter-dependency between them at the leaf level: `pseudo-random` has no workspace deps, `random` depends on `@suaveplan/types` and `@suaveplan/error`, and `noise` depends only on `@suaveplan/error`.
## Goals / Non-Goals
**Goals:**
- Ship `SeededRng` interface and four algorithm implementations (Mulberry32, SFC32, Xoshiro256++, CounterRng) with full offset/fork/checkpoint support
- Ship the full high-level random utilities surface: strings, IDs, collections, objects, game primitives, and all statistical distributions with `DistributionConfig` serializable dispatch
- Ship spatial noise functions (Perlin, Simplex, Value, Worley, fBm, Blue noise)
- All three packages at 100% coverage (foundational layer)
- Full enterprise checklist for each
**Non-Goals:**
- Cryptographic PRNGs (ChaCha20-DRBG, HMAC-DRBG) — belong in `@suaveplan/crypto-sodium`/`crypto-browser`
- Gaussian Process regression, Monte Carlo integration, statistical hypothesis testing — future `@suaveplan/stats`
- Geo random geometry (`randomPoint`, `randomPolygon`) — deferred to `@suaveplan/geo/random`
- Changing `@suaveplan/testing`'s inline Mulberry32 — testing rule holds; it stays inline
## Decisions
### Package: `@suaveplan/pseudo-random`
```text
src/
types/
seeded-rng.types.ts / seeded-rng.md # SeededRng interface + RngCheckpoint
algorithms/
mulberry32.ts / mulberry32.test.ts / mulberry32.md
sfc32.ts / sfc32.test.ts / sfc32.md
xoshiro256.ts / xoshiro256.test.ts / xoshiro256.md
counter-rng.ts / counter-rng.test.ts / counter-rng.md
seed/
normalize.ts / normalize.test.ts / normalize.md # normalizeSeed(string|number): number
utils/
shuffle.ts / shuffle.test.ts / shuffle.md # shuffle, shuffleInPlace
sample.ts / sample.test.ts / sample.md # sample, weightedSample
distributions.ts / distributions.test.ts / distributions.md # uniform int/float helpers
errors/
codes.ts / pseudo-random-error.ts / index.ts
constants/index.ts
index.ts
```
**`SeededRng` interface:**
```ts
interface SeededRng extends Rng {
readonly seed: number
readonly position: number
skip(n: number): this
reset(): this
at(offset: number): SeededRng
fork(id: number | string): SeededRng
checkpoint(): RngCheckpoint
restore(checkpoint: RngCheckpoint): this
}
```
**Algorithm selection guide** (exported as docs, not enforced):
- `Mulberry32Rng` — 32-bit state, ~2^32 period. Best for trivial use cases, minimal memory footprint.
- `Sfc32Rng` — 128-bit state, period >2^127. Best default for games and simulations.
- `Xoshiro256Rng` — 256-bit state, period 2^256-1, supports fixed-size jump (2^128 and 2^192 steps). Best when independent parallel streams are needed at scale.
- `CounterRng` — splitmix64-based hash counter. O(1) seek to any position. Best for direct-address use cases like bingo ticket generation.
**Seed normalization** — `normalizeSeed(seed: string | number): number`:
- String seeds: splitmix64 mixing over char codes (replaces djb2/FNV-1a used in bingo)
- Numeric seeds: splitmix64 single-round mixing to avalanche input bias
- Rejects NaN, Infinity, non-safe-integers
**`fork(id)`** — derives a child seed via `splitmix64(parentSeed ^ hash(id))`. Child streams are statistically independent from the parent and from each other. Critical for game use cases:
```ts
const master = new Sfc32Rng(gameSeed)
const weather = master.fork('weather')
const enemies = master.fork('enemies')
const loot = master.fork('loot')
```
**`at(offset)`** — for linear-burn algorithms (Mulberry32, SFC32): constructs a new instance at `seed` and burns `offset` values at construction time (O(offset), cost paid once). For CounterRng: O(1), directly sets the counter.
**Offset validation** — no hard cap (the bingo 1,000,000 limit was app-specific). Throws `PRNG.OFFSET.OUT_OF_RANGE` only for negative values.
### Package: `@suaveplan/random`
```text
src/
source/
crypto-rng.ts / crypto-rng.test.ts / crypto-rng.md # cryptoRng(): Rng
default-rng.ts / default-rng.test.ts / default-rng.md # getDefaultRng / setDefaultRng
bytes/
random-bytes.ts / random-bytes.test.ts / random-bytes.md
strings/
random-string.ts / random-string.test.ts / random-string.md # presets + custom charset
ids/
uuid.ts / uuid.test.ts / uuid.md # randomUUID (v4+v7), randomULID, randomNanoid
numbers/
random-int.ts / random-int.test.ts / random-int.md
random-float.ts / random-float.test.ts / random-float.md
collections/
shuffle.ts / shuffle.test.ts / shuffle.md
sample.ts / sample.test.ts / sample.md
choice.ts / choice.test.ts / choice.md
weighted.ts / weighted.test.ts / weighted.md # weightedChoice, weightedSample
objects/
random-key.ts / random-key.test.ts / random-key.md
random-value.ts / ...
random-entry.ts / ...
random-subset.ts / ...
game/
dice.ts / dice.test.ts / dice.md # coin, dice, roll
distributions/
continuous/
normal.ts / normal.test.ts / normal.md
log-normal.ts / ...
exponential.ts / ...
gamma.ts / ...
beta.ts / ...
pareto.ts / ...
weibull.ts / ...
cauchy.ts / ...
logistic.ts / ...
discrete/
bernoulli.ts / bernoulli.test.ts / bernoulli.md
geometric.ts / ...
binomial.ts / ...
poisson.ts / ...
sampler.ts / sampler.test.ts / sampler.md # createSampler, sample, DistributionConfig
errors/
codes.ts / random-error.ts / index.ts
constants/index.ts
index.ts
```
**`cryptoRng()` factory** — returns an object implementing `@suaveplan/types`'s `Rng` interface backed by `crypto.getRandomValues`:
```ts
{
nextUint32: () => { const buf = new Uint32Array(1); crypto.getRandomValues(buf); return buf[0] },
nextFloat: function() { return this.nextUint32() / 0x1_0000_0000 }
}
```
This is the **only** callsite for `crypto.getRandomValues` in `@suaveplan/random`. Guarded by `tests/no-disallowed.test.ts`.
**Default source** — module-level mutable (non-exported) singleton. `getDefaultRng()` returns it; `setDefaultRng(rng)` replaces it (test hook). Default is `cryptoRng()`.
**String presets** (`randomString(length, { type, rng? })`):
| type | charset |
|---|---|
| `hex` | `0-9a-f` |
| `base64` | A-Z, a-z, 0-9, +, / |
| `base64url` | A-Z, a-z, 0-9, -, _ |
| `url-safe` | A-Z, a-z, 0-9, -, _, . |
| `numeric` | `0-9` |
| `alphanumeric` | A-Z, a-z, 0-9 |
| `distinguishable` | `CDEHKMPRTUWXY012458` (no I/O/0/1 confusion) |
| `ascii-printable` | all printable ASCII 0x20-0x7E |
Custom charset via `{ characters: string }` option.
**UUID v7** — time-ordered, 48-bit millisecond timestamp in high bits, 74 random bits. Monotonically increasing within the same millisecond via sequence counter.
**`DistributionConfig` tagged union** — every distribution has a named-parameter config object. The `type` discriminant selects the algorithm; all other fields are the algorithm's parameters. `createSampler(config, rng?)` returns `() => number`. `sample(config, rng?)` is a one-shot convenience. Config objects are plain JSON-serializable data — `rng` is always external.
**Implementation notes for distributions:**
- Normal: Box-Muller transform
- LogNormal: `exp(normal(mu, sigma))`
- Gamma: Marsaglia-Tsang method (k ≥ 1); Ahrens-Dieter for k < 1
- Beta: ratio of two Gamma samples
- Poisson: Knuth algorithm for λ ≤ 30; rejection method for larger λ
- All discrete distributions use `nextUint32()` not `nextFloat()` for the base draw
### Package: `@suaveplan/noise`
```text
src/
perlin/
perlin.ts / perlin.test.ts / perlin.md # perlin2, perlin3 with seed
simplex/
simplex.ts / simplex.test.ts / simplex.md # simplex2, simplex3, simplex4
value/
value.ts / value.test.ts / value.md # value2, value3
worley/
worley.ts / worley.test.ts / worley.md # worley2, worley3, WorleyMode (F1|F2|F1-F2)
fbm/
fbm.ts / fbm.test.ts / fbm.md # fbm compositor, FbmOptions
blue/
blue.ts / blue.test.ts / blue.md # blueNoiseSample, Poisson disk sampling
errors/
codes.ts / noise-error.ts / index.ts
constants/index.ts
index.ts
```
All noise functions accept a `seed: number` parameter and return values normalised to `[-1, 1]` (except Worley which returns `[0, 1]` distances). No dependency on `@suaveplan/pseudo-random` — noise uses its own internal seeded hash permutation tables, which is the standard noise implementation pattern.
**fBm compositor:**
```ts
fbm(noiseFn, x, y, {
octaves: 6,
lacunarity: 2.0,
gain: 0.5,
seed: 0
}): number
```
Composes any 2D noise function with octave summation.
**Blue noise** — Bridson's Poisson disk sampling algorithm:
```ts
blueNoiseSample(width, height, minDistance, { seed?, rng? }): [number, number][]
```
## Risks / Trade-offs
| Risk | Mitigation |
|---|---|
| `at(offset)` is O(offset) for Mulberry32/SFC32 | Document clearly; `CounterRng` is the O(1) alternative; no silent perf traps |
| `setDefaultRng` is global mutable state | Documented as test-only; tests restore previous value with `afterEach`; not exported from subpath exports |
| `fork` child streams not proven independent | Use `normalizeSeed` mixing + test with fast-check that `fork('a')` and `fork('b')` produce uncorrelated sequences across 10k samples |
| Simplex noise patent (expired 2022) | US patent 6,867,776 expired April 2022 — safe to use |
| Blue noise is O(n) output size | Cap at 100k points; throw `NOISE.BLUE.SAMPLE_LIMIT_EXCEEDED` above |