Files
suaveplan/openspec/archive/2026-04-19-enterprise-random-family/design.md
2026-05-27 23:29:53 +01:00

9.9 KiB

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

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:

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:

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

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:

{
  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

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:

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:

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