Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
119 changes: 119 additions & 0 deletions src/core/prng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -93,20 +106,125 @@ 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();
const stream = label !== undefined ? hashSeed(label) : this.nextUint32();
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<T>(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<T>(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<T>(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.
*/
exportState(): PRNGState {
return {
state: this.state,
inc: this.inc,
origin: this.origin,
};
}

Expand All @@ -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;
}
}
138 changes: 138 additions & 0 deletions tests/prng-streams.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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()]);
});
});