engine/helpers/random.js

/**
 * @file
 * @brief Engine randomness — the single source of every non-deterministic draw in the prompt
 * pipeline (the stages, the keyword randomizers, the suggestion builder, the salt, the DPL renderer).
 *
 * Every function reads from the **ambient** random source. By default that is the global
 * `Math.random` (so unseeded generation behaves exactly as before, and a test that swaps
 * `Math.random` still works). During a *seeded* generation the engine installs an {@link Rng} as the
 * ambient source via {@link withAmbientRng}, so the entire pipeline draws from one seeded stream and
 * the run is reproducible. This is why the engine no longer needs to monkey-patch `Math.random`.
 *
 * Do NOT use lodash's `_.random` / `_.sample` / `_.shuffle` in engine code: lodash captures a
 * reference to `Math.random` at import time, so those calls keep using the original source and
 * ignore the ambient one — which is exactly why they can never be seeded. Use these replacements.
 * @see src/core/rng.js
 */

// The ambient generator is stored on a PROCESS-GLOBAL slot (keyed by a well-known Symbol), NOT a
// module-level `let`. This is defensive: a bundler can end up with more than one copy of this module
// in the graph (the SPA code-splits the prompt corpus into its own chunk, and a duplicated
// `random.js` there would keep its own `ambient`). With module-level state, one copy could be seeded
// while another kept drawing from `Math.random`, silently un-seeding part of a "pinned" run. A single
// global slot is shared by every copy, so the whole pipeline always draws from the one seeded stream.
// (The reroll-reproducibility bug that surfaced this was actually leftover per-generation state in the
// list stage, fixed there; this hardening removes the adjacent duplicate-instance failure mode.)
// See notes/reference/rng-design.md.
const AMBIENT_KEY = Symbol.for("rap.ambientRng");

/** @returns {import("../core/rng.js").Rng|null} The current ambient generator (null → `Math.random`). */
function readAmbient() {
  return globalThis[AMBIENT_KEY] ?? null;
}

/**
 * Install `rng` as the ambient source for the duration of `fn`, restoring the previous source
 * afterward (even if `fn` throws). Nestable. This is how the engine scopes a seeded stream to a
 * single generation.
 * @param {import("../core/rng.js").Rng|null} rng The generator to make ambient (null → `Math.random`).
 * @param {() => T} fn The work to run.
 * @returns {T} Whatever `fn` returns.
 * @template T
 */
export function withAmbientRng(rng, fn) {
  const prev = readAmbient();
  globalThis[AMBIENT_KEY] = rng;
  try {
    return fn();
  } finally {
    globalThis[AMBIENT_KEY] = prev;
  }
}

/** @param {import("../core/rng.js").Rng|null} rng Set the ambient generator (null → `Math.random`). */
export function setAmbientRng(rng) {
  globalThis[AMBIENT_KEY] = rng;
}

/** @returns {import("../core/rng.js").Rng|null} The current ambient generator (null → `Math.random`). */
export function getAmbientRng() {
  return readAmbient();
}

/** A uniform float in [0, 1) from the ambient source — the probability-roll primitive. */
export function randomFloat() {
  const ambient = readAmbient();
  return ambient ? ambient.float() : Math.random();
}

/** A uniform integer in [min, max] inclusive; order-tolerant. */
export function randomInt(min, max) {
  if (min > max) [min, max] = [max, min];
  return min + Math.floor(randomFloat() * (max - min + 1));
}

/** A uniformly random element of `arr` (undefined for an empty array). */
export function sample(arr) {
  return arr[Math.floor(randomFloat() * arr.length)];
}

/** A new array with `arr`'s elements in uniformly random order (Fisher–Yates). */
export function shuffle(arr) {
  const out = [...arr];
  for (let i = out.length - 1; i > 0; i--) {
    const j = Math.floor(randomFloat() * (i + 1));
    [out[i], out[j]] = [out[j], out[i]];
  }
  return out;
}