engine/core/rng.js

/**
 * @file
 * @brief The engine's seeded pseudo-random number generator — a real, self-contained PRNG that
 * makes prompt generation reproducible from a seed.
 *
 * Randomness in this project used to bottom out in the global `Math.random`, and "seeding" meant
 * swapping `Math.random` for a fixed sequence (fine for a test, but a leaky global side effect, and
 * anything that captured `Math.random` at import — e.g. lodash — escaped it). This module replaces
 * that with an explicit generator:
 *
 *   - `cyrb128(str)` hashes an arbitrary seed string into 128 bits of state.
 *   - `sfc32(a,b,c,d)` is a small, fast, well-distributed 128-bit-state generator.
 *   - `Rng` wraps them with the draw operations the pipeline needs (`float`/`int`/`chance`/`pick`/
 *     `sample`/`shuffle`), plus `fork(label)` to spin off an independent, deterministic sub-stream
 *     (used to give each prompt in a batch its own reproducible sequence).
 *
 * The engine installs one `Rng` as the *ambient* source for a generation (see
 * `src/helpers/random.js`), so every draw in the pipeline — the DPL renderer, the keyword
 * randomizers, the salt — comes from the same seeded stream and the whole run is reproducible. Given
 * the same seed and the same catalog, `Rng` produces the identical sequence in Node and the browser.
 * See notes/reference/rng-design.md.
 */

/**
 * cyrb128 — hash a string into four 32-bit unsigned integers of seed state. Public-domain algorithm
 * (bryc). Distinct strings give well-separated state, so `"1"` and `"2"` are unrelated streams.
 * @param {string} str The seed string.
 * @returns {[number, number, number, number]} Four 32-bit unsigned seed words.
 */
export function cyrb128(str) {
  let h1 = 1779033703;
  let h2 = 3144134277;
  let h3 = 1013904242;
  let h4 = 2773480762;
  for (let i = 0; i < str.length; i++) {
    const k = str.codePointAt(i);
    h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
    h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
    h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
    h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
  }
  h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
  h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
  h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
  h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
  return [(h1 ^ h2 ^ h3 ^ h4) >>> 0, (h2 ^ h1) >>> 0, (h3 ^ h1) >>> 0, (h4 ^ h1) >>> 0];
}

/**
 * sfc32 — Small Fast Counter, a 128-bit-state PRNG returning floats in [0, 1). Public-domain
 * (PractRand-tested). Not cryptographic — it's for reproducible content generation.
 * @param {number} a First 32-bit state word.
 * @param {number} b Second.
 * @param {number} c Third.
 * @param {number} d Fourth.
 * @returns {() => number} A function returning the next float in [0, 1).
 */
export function sfc32(a, b, c, d) {
  return function next() {
    a >>>= 0;
    b >>>= 0;
    c >>>= 0;
    d >>>= 0;
    let t = Math.trunc(a + b);
    a = b ^ (b >>> 9);
    b = Math.trunc(c + (c << 3));
    c = (c << 21) | (c >>> 11);
    d = Math.trunc(d + 1);
    t = Math.trunc(t + d);
    c = Math.trunc(c + t);
    return (t >>> 0) / 4294967296;
  };
}

/** A fresh, unpredictable seed string (crypto if available, else time + Math.random). */
export function randomSeed() {
  try {
    if (typeof globalThis !== "undefined" && globalThis.crypto?.getRandomValues) {
      const a = new Uint32Array(2);
      globalThis.crypto.getRandomValues(a);
      return `${a[0].toString(36)}${a[1].toString(36)}`;
    }
  } catch {
    /* fall through to the non-crypto seed */
  }
  return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
}

/**
 * A seeded random source with the draw operations the pipeline uses. Construct via {@link createRng}
 * (which fills in a random seed when none is given).
 */
export class Rng {
  /**
   * @param {string|number} seed The seed (a number is stringified). Recorded as {@link Rng#seed}.
   */
  constructor(seed) {
    this.seed = String(seed);
    const [a, b, c, d] = cyrb128(this.seed);
    this._next = sfc32(a, b, c, d);
    this._forks = 0;
  }

  /** @returns {number} The next float in [0, 1). */
  float() {
    return this._next();
  }

  /**
   * @param {number} min Inclusive lower bound.
   * @param {number} max Inclusive upper bound (order-tolerant).
   * @returns {number} A uniform integer in [min, max].
   */
  int(min, max) {
    if (min > max) [min, max] = [max, min];
    return min + Math.floor(this.float() * (max - min + 1));
  }

  /**
   * @param {number} p Probability in [0, 1].
   * @returns {boolean} True with probability `p`.
   */
  chance(p) {
    return this.float() < p;
  }

  /**
   * @param {Array<T>} arr A non-empty array.
   * @returns {T} A uniformly random element (undefined for an empty array).
   * @template T
   */
  pick(arr) {
    return arr[Math.floor(this.float() * arr.length)];
  }

  /** Alias of {@link Rng#pick} — matches the `sample` helper name. */
  sample(arr) {
    return this.pick(arr);
  }

  /**
   * @param {Array<T>} arr The source array (not mutated).
   * @returns {Array<T>} A new array in uniformly random order (Fisher–Yates).
   * @template T
   */
  shuffle(arr) {
    const out = [...arr];
    for (let i = out.length - 1; i > 0; i--) {
      const j = Math.floor(this.float() * (i + 1));
      [out[i], out[j]] = [out[j], out[i]];
    }
    return out;
  }

  /**
   * Derive an independent, deterministic sub-stream. Two forks of the same parent with the same
   * label are identical; different labels (or the auto-incrementing counter) diverge — used to give
   * each prompt in a batch its own reproducible sequence without them sharing draws.
   * @param {string|number} [label] A stable label; defaults to an incrementing counter.
   * @returns {Rng} The child generator.
   */
  fork(label) {
    const tag = label ?? this._forks++;
    return new Rng(`${this.seed}/${tag}`);
  }
}

/**
 * Create an `Rng`. With no seed (or an empty one) a fresh random seed is generated and recorded on
 * the returned instance's `.seed`, so a caller can capture it to reproduce the run later.
 * @param {string|number} [seed] The seed; omitted → a random one.
 * @returns {Rng} The generator.
 */
export function createRng(seed) {
  const s = seed === undefined || seed === null || seed === "" ? randomSeed() : seed;
  return new Rng(s);
}