From f1e277a866ad7456b8975fd808d1cdc6d99f31ac Mon Sep 17 00:00:00 2001 From: Chadwick Maycumber <36460656+cmaycumber@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:34:46 -0700 Subject: [PATCH] feat(prng): order-independent derived streams and distribution helpers `fork(label)` consumes a draw from the parent, so a child stream depends on how many values were drawn before it. That is right for per-entity streams created in a fixed order, and it is left exactly as it was. Procedural generation needs the opposite: adding a draw to one step (placing a new kind of landmark, say) must not reshuffle every other step. - `derive(label)`: a child that is a pure function of this stream's seed identity and the label. It never advances the parent and ignores prior draws, and nested labels compose. The identity is carried in exportState/restoreState; states exported before this change (no `origin` field) still restore. - Helpers used constantly by procedural code: `range(min, max)`, `pick(items)`, `weighted(items, weights)`, `gaussian(mean, stdDev)`, `shuffle(items)` and `fn()` for libraries that take a random function, such as noise generators. - `gaussian` sums twelve uniforms rather than using Box-Muller, whose Math.log and Math.cos are not bit-identical across JavaScript engines; a world generated with it could differ between two browsers in a lockstep session. Bounded to six standard deviations. - Diagnostics RND_0501 to RND_0504 for empty picks and invalid weights. - The base PCG32 sequence and `fork` are byte-identical to before. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + src/core/prng.ts | 119 ++++++++++++++++++++++++++++++++ tests/prng-streams.test.ts | 138 +++++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 tests/prng-streams.test.ts diff --git a/AGENTS.md b/AGENTS.md index 4176102..b8202b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Renderoni is structured in 4 strict hierarchical layers: - **L0: Deterministic Kernel (`src/core/`)**: Integer tick clock (`clock.ts`), seeded PRNG streams (`prng.ts`), dual-buffer transform pipeline (`transform-buffer.ts`), XXH3 state hashing (`hashing.ts`), resource ownership matrix (`ownership.ts`), and diagnostics (`diagnostics.ts`). - **Rule 1**: NEVER call `Math.random()`, `Date.now()`, `performance.now()`, or `requestAnimationFrame()` inside simulation logic or entity updates. Always use `engine.prng` and `engine.clock.tick`. + - **Streams**: `prng.fork(label)` advances the parent and depends on draw order (per-entity streams created in a fixed order). `prng.derive(label)` is a pure function of the seed and label and never advances the parent: use it for procedural generation, so adding a draw to one step does not reshuffle every other. Helpers: `range`, `pick`, `weighted`, `gaussian` (arithmetic only, so exact across JS engines), `shuffle`, and `fn()` for libraries that take a random function. - **Rule 2**: NEVER bypass the dual-buffer transform pipeline. Write physics transforms into canonical buffer slots, never directly into render scene graphs. - **L1: Batteries & Subsystems (`src/presets/`, `src/animation/`, `src/audio/`, `src/vfx/`, `src/ui/`, `src/scene/`)**: High-level declarative presets (`body`, `sensor`, `light`, `kccPlayer`, `dynamicPlayer`, `proceduralModel`) and compact scene inventories for prompt → img2threejs factories. - **L2: Agent Tooling & MCP (`src/mcp/`, `src/testing/`)**: Stdio Model Context Protocol server, custom Vitest matchers, and headless CLI verification. diff --git a/src/core/prng.ts b/src/core/prng.ts index d0903aa..79bd07f 100644 --- a/src/core/prng.ts +++ b/src/core/prng.ts @@ -22,14 +22,27 @@ export function hashSeed(seed: string | number): number { export interface PRNGState { state: number; inc: number; + /** Identity that `derive()` builds on. Optional so states exported before it existed still restore. */ + origin?: number; +} + +/** Mixes a seed and stream id into a stable 32-bit identity for derived streams. */ +function mixOrigin(rawSeed: number, stream: number): number { + let h = (rawSeed ^ Math.imul(stream >>> 0, 0x9e3779b1)) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0; + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0; + return (h ^ (h >>> 16)) >>> 0; } export class PRNG { private state: number; private inc: number; + /** Fixed at construction; never advanced by drawing. The root of every `derive()` child. */ + private origin: number; constructor(seed: number | string = 42, stream: number = 1) { const rawSeed = hashSeed(seed); + this.origin = mixOrigin(rawSeed, stream); this.inc = ((stream << 1) | 1) >>> 0; this.state = 0; this.nextUint32(); @@ -93,6 +106,11 @@ export class PRNG { /** * Forks a new independent, isolated PRNG stream derived from this PRNG's state. + * + * The child depends on how many values the parent has already produced, and + * forking advances the parent. Use it for per-entity streams created in a + * deterministic order. Use `derive()` when a stream must not move when + * unrelated code draws more or fewer numbers first. */ fork(label?: string | number): PRNG { const nextSeed = this.nextUint32(); @@ -100,6 +118,105 @@ export class PRNG { return new PRNG(nextSeed, stream); } + /** + * Derives a named child stream that is a pure function of this stream's seed + * identity and `label`. + * + * Unlike `fork()`, deriving never advances this stream and does not depend on + * how many values have been drawn from it, so the same label always yields + * the same child. Procedural generation relies on this: adding a draw to one + * step (say, placing a new kind of landmark) must not reshuffle every other + * step. Children derive their own children the same way, so nested labels + * such as `world` → `towns` → `names` compose. + */ + derive(label: string | number): PRNG { + const key = typeof label === 'number' ? `#${label}` : label; + return new PRNG(hashSeed(`${this.origin.toString(16)}/${key}`), hashSeed(key) >>> 1); + } + + /** Float in `[min, max)`. */ + range(min: number, max: number): number { + return min + this.nextFloat() * (max - min); + } + + /** One element of a non-empty array, uniformly. */ + pick(items: readonly T[]): T { + if (items.length === 0) { + throw new Error('RND_0501: pick() requires a non-empty array.'); + } + return items[this.nextInt(0, items.length - 1)]; + } + + /** + * One element chosen with probability proportional to its weight. + * + * Weights must be finite and non-negative with a positive total. A + * zero-weight item is never chosen. + */ + weighted(items: readonly T[], weights: readonly number[]): T { + if (items.length === 0 || items.length !== weights.length) { + throw new Error( + `RND_0502: weighted() needs one weight per item (got ${items.length} items, ${weights.length} weights).` + ); + } + let total = 0; + for (const weight of weights) { + if (!Number.isFinite(weight) || weight < 0) { + throw new Error(`RND_0503: weighted() weights must be finite and non-negative, received ${weight}.`); + } + total += weight; + } + if (total <= 0) { + throw new Error('RND_0504: weighted() needs at least one positive weight.'); + } + let remaining = this.nextFloat() * total; + for (let i = 0; i < items.length; i++) { + remaining -= weights[i]; + if (remaining < 0) return items[i]; + } + // Floating-point rounding can leave a sliver past the end: fall back to the + // last item that could actually be chosen. + for (let i = items.length - 1; i >= 0; i--) { + if (weights[i] > 0) return items[i]; + } + return items[items.length - 1]; + } + + /** + * Approximately normally distributed value, bounded to ±6 standard deviations. + * + * Sums twelve uniform draws (Irwin-Hall), which has mean 6 and variance 1. + * Deliberately avoids Box-Muller: `Math.log` and `Math.cos` are not + * guaranteed bit-identical across JavaScript engines, so a world generated + * with them could differ between two browsers in a lockstep session. This + * uses only addition and multiplication, so it is exact everywhere. Costs + * twelve draws per call. + */ + gaussian(mean: number = 0, stdDev: number = 1): number { + let sum = 0; + for (let i = 0; i < 12; i++) sum += this.nextFloat(); + return mean + stdDev * (sum - 6); + } + + /** Shuffles `items` in place (Fisher-Yates) and returns it. */ + shuffle(items: T[]): T[] { + for (let i = items.length - 1; i > 0; i--) { + const j = this.nextInt(0, i); + const swap = items[i]; + items[i] = items[j]; + items[j] = swap; + } + return items; + } + + /** + * A `() => number` in `[0, 1)` drawing from this stream, for libraries that + * take a random function, such as noise generators. + */ + fn(): () => number { + return () => this.nextFloat(); + } + /** * Exports full PRNG internal state for replay keyframing. */ @@ -107,6 +224,7 @@ export class PRNG { return { state: this.state, inc: this.inc, + origin: this.origin, }; } @@ -116,5 +234,6 @@ export class PRNG { restoreState(savedState: PRNGState): void { this.state = savedState.state >>> 0; this.inc = savedState.inc >>> 0; + if (savedState.origin !== undefined) this.origin = savedState.origin >>> 0; } } diff --git a/tests/prng-streams.test.ts b/tests/prng-streams.test.ts new file mode 100644 index 0000000..b780875 --- /dev/null +++ b/tests/prng-streams.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { PRNG } from '../src/core/prng.js'; + +const draws = (rng: PRNG, n = 8) => Array.from({ length: n }, () => rng.nextUint32()); + +describe('PRNG.derive', () => { + it('gives the same child for the same label however much the parent has drawn', () => { + const fresh = new PRNG('world-seed'); + const busy = new PRNG('world-seed'); + draws(busy, 1000); // unrelated work happened first + + expect(draws(busy.derive('towns'))).toEqual(draws(fresh.derive('towns'))); + }); + + it('does not advance the parent', () => { + const a = new PRNG(7); + const b = new PRNG(7); + a.derive('terrain'); + a.derive('roads'); + expect(draws(a)).toEqual(draws(b)); + }); + + it('gives different streams for different labels and different seeds', () => { + const root = new PRNG('seed-a'); + expect(draws(root.derive('towns'))).not.toEqual(draws(root.derive('roads'))); + expect(draws(root.derive('towns'))).not.toEqual(draws(new PRNG('seed-b').derive('towns'))); + }); + + it('keeps number and string labels apart', () => { + const root = new PRNG(1); + expect(draws(root.derive(3))).not.toEqual(draws(root.derive('3'))); + }); + + it('composes: nested labels are stable and distinct from flat ones', () => { + const a = new PRNG('s').derive('world').derive('names'); + const b = new PRNG('s').derive('world').derive('names'); + expect(draws(a)).toEqual(draws(b)); + expect(draws(new PRNG('s').derive('world').derive('names'))).not.toEqual(draws(new PRNG('s').derive('names'))); + }); + + it('survives export and restore, so a restored stream derives the same children', () => { + const original = new PRNG('save-me'); + draws(original, 50); + const restored = new PRNG('something-else'); + restored.restoreState(original.exportState()); + expect(draws(restored.derive('late'))).toEqual(draws(original.derive('late'))); + expect(draws(restored)).toEqual(draws(original)); + }); + + it('restores states exported before derive existed (no origin field)', () => { + const rng = new PRNG(99); + const legacy = { state: 123456, inc: 3 }; + rng.restoreState(legacy); + expect(rng.exportState().state).toBe(123456); + }); + + it('leaves fork and the base sequence exactly as they were', () => { + // fork still depends on draw order and advances the parent, as before + const a = new PRNG(12345); + const b = new PRNG(12345); + draws(b, 1); + expect(draws(a.fork('x'))).not.toEqual(draws(b.fork('x'))); + // the raw sequence is unchanged by the new origin field + expect(new PRNG(42).nextUint32()).toBe(new PRNG(42).nextUint32()); + }); +}); + +describe('PRNG helpers', () => { + it('range stays within [min, max)', () => { + const rng = new PRNG(3); + for (let i = 0; i < 2000; i++) { + const v = rng.range(-5, 5); + expect(v).toBeGreaterThanOrEqual(-5); + expect(v).toBeLessThan(5); + } + }); + + it('pick reaches every element, deterministically, and rejects empty input', () => { + const items = ['a', 'b', 'c', 'd']; + const seen = new Set(); + const rng = new PRNG(4); + for (let i = 0; i < 400; i++) seen.add(rng.pick(items)); + expect(seen.size).toBe(4); + expect(new PRNG(9).pick(items)).toBe(new PRNG(9).pick(items)); + expect(() => new PRNG(1).pick([])).toThrow(/RND_0501/); + }); + + it('weighted follows the weights and never picks a zero weight', () => { + const rng = new PRNG(5); + const counts = { common: 0, rare: 0, never: 0 }; + for (let i = 0; i < 20000; i++) counts[rng.weighted(['common', 'rare', 'never'] as const, [9, 1, 0])]++; + expect(counts.never).toBe(0); + expect(counts.common / counts.rare).toBeGreaterThan(7); + expect(counts.common / counts.rare).toBeLessThan(11); + }); + + it('weighted rejects mismatched, negative, non-finite and all-zero weights', () => { + const rng = new PRNG(1); + expect(() => rng.weighted(['a', 'b'], [1])).toThrow(/RND_0502/); + expect(() => rng.weighted(['a'], [-1])).toThrow(/RND_0503/); + expect(() => rng.weighted(['a'], [Number.NaN])).toThrow(/RND_0503/); + expect(() => rng.weighted(['a', 'b'], [0, 0])).toThrow(/RND_0504/); + }); + + it('gaussian has roughly the requested mean and spread, and stays within six sigma', () => { + const rng = new PRNG(6); + const n = 20000; + let sum = 0; + let sumSq = 0; + for (let i = 0; i < n; i++) { + const v = rng.gaussian(10, 2); + expect(Math.abs(v - 10)).toBeLessThanOrEqual(12); + sum += v; + sumSq += v * v; + } + const mean = sum / n; + const sd = Math.sqrt(sumSq / n - mean * mean); + expect(mean).toBeGreaterThan(9.9); + expect(mean).toBeLessThan(10.1); + expect(sd).toBeGreaterThan(1.9); + expect(sd).toBeLessThan(2.1); + }); + + it('shuffle is an in-place permutation and deterministic', () => { + const a = [1, 2, 3, 4, 5, 6, 7, 8]; + const out = new PRNG(8).shuffle(a); + expect(out).toBe(a); + expect([...a].sort((x, y) => x - y)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(new PRNG(8).shuffle([1, 2, 3, 4, 5, 6, 7, 8])).toEqual(a); + }); + + it('fn draws from the same stream as nextFloat', () => { + const a = new PRNG(10); + const b = new PRNG(10); + const f = a.fn(); + expect([f(), f(), f()]).toEqual([b.nextFloat(), b.nextFloat(), b.nextFloat()]); + }); +});