Files
Dejvino 23d3696adc metabolism: region pool exactness + browser boot — deterministic velocity pass, pool warm-gate for the web worker boot, breaker
Two root causes fixed end to end:

1. Exactness race: phaseFields' velocity pass wrote its own coarse blocks
   and then read halo blocks no neighbour had committed yet — pool runs
   diverged at random frames (2/4 runs). The velocity pass now runs as a
   block-owner section at the END of phaseStrains; phaseFields only reads
   committed velocities. 8/8 pool runs bit-exact through f1239; tests 9/9
   (including 1600-frame x 2-worker runs across the section boundary).

2. Browser boot stall: beginPhase's hot spin (Atomics.wait throws on a
   window) froze pending web-worker script loads — Chromium routes worker
   startup through the busy main thread, so the show stalled 30s and
   threw 'region pool stalled'. The web shell now acks ('pool') after
   makeRegion (mail[63] is a real boot counter), the world runs cold until
   every worker acks (step/stepTo skip frames; post-warm catch-up is
   unbounded), and loadSongData awaits player.warm() before the show.
   Node pools are untouched (native threads answer barriers directly).

Also: Player.build/rewind dispose the old pool (no more leaked idle
worlds); region-core.js is the shared worker body; region-worker.web.js
the browser shell (vite bundles it via the literal new Worker(new URL)
spelling — dist emits a self-contained worker asset instead of a broken
base64 data URL).

Circuit breaker in the demo: >=2 consecutive sub-25fps windows trip;
>=30 frames behind the audio clock for >=750ms trips (dormant while
stepTo catches up unconditionally — the fps path is live). 12/12 mirror
scenarios pass.

Bench: node serial 74.2ms/f, 2w 38.9 (1.91x), 4w 24.6 (3.02x). Browser:
steady 30fps including a 64s soak to 20k organisms at 640x360.
2026-10-30 07:26:28 +01:00

138 lines
6.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The world must be deterministic, bounded, and fundamentally unstable under
// seed change — while two DIFFERENT songs must grow clearly different worlds.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve, dirname } from 'node:path';
import { analyse } from '../src/audio/analyse.js';
import { decodeWav, mono } from '../src/audio/wav.js';
import { buildSong } from '../src/song/song.js';
import { World } from '../src/sim/world.js';
import { Run } from '../src/film.js';
import { fingerprint, divergence } from '../src/metrics.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
function load(name) {
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
return buildSong(analyse(mono(wav.channels), wav.sampleRate), 12345);
}
/** Run `seconds` of the world for song `name` with a seed. Returns world. */
function grow(name, seed, seconds = 20) {
const song = load(name);
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0 });
for (let f = 0; f < Math.floor(seconds * 30); f++) {
run.world.step();
run.camera.update(f);
}
return { world: run.world, song };
}
test('same seed reproduces identical history', () => {
const { world: a } = grow('drone', 12345);
const { world: b } = grow('drone', 12345);
assert.deepEqual(Array.from(a.history.alive), Array.from(b.history.alive));
assert.deepEqual(Array.from(a.history.movement), Array.from(b.history.movement));
assert.deepEqual(Array.from(a.trail[0]), Array.from(b.trail[0]));
assert.equal(a.frame, b.frame);
});
test('different seeds give different ecologies', () => {
const { world: a } = grow('drone', 12345);
const { world: b } = grow('drone', 99999);
const moved = a.history.movement.some((v, i) => Math.abs(v - b.history.movement[i]) > 1e-3);
assert.ok(moved, 'ecologies should diverge under a different seed');
});
test('population stays within bounds and alive', () => {
const { world: w } = grow('runner', 12345, 30);
for (const v of w.history.alive) {
assert.ok(v <= w.cap + 40, `population exceeded cap: ${v}`);
}
assert.ok(w.history.alive.some((v) => v > 0), 'the dish should stay inhabited');
});
test('field and trail never go negative', () => {
const { world: w } = grow('drone', 7, 25);
for (const v of w.resource) assert.ok(v >= -1e-9, `negative resource ${v}`);
for (let c = 0; c < 3; c++) {
for (const v of w.trail[c]) assert.ok(v >= -1e-9, `negative trail ${v}`);
}
});
test('trail mass reflects a busy vs sparse world', () => {
const sparse = grow('drone', 12345, 25);
const dense = grow('runner', 12345, 25);
assert.ok(dense.world.count > sparse.world.count * 1.5);
});
test('three songs grow fingerprints further apart than seeds of one song', () => {
const a = fingerprint(grow('drone', 12345).world);
const b = fingerprint(grow('runner', 12345).world);
const c = fingerprint(grow('dusk', 12345).world);
const cross = Math.max(divergence(a, b), divergence(a, c), divergence(b, c));
const a2 = fingerprint(grow('drone', 99).world);
const within = divergence(a, a2);
assert.ok(cross > within * 2, `cross ${cross} !> 2×within ${within}`);
});
test('genesis pre-simulates generations so frame 0 is already an ecosystem', () => {
const song = load('drone');
const withGenesis = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
const without = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
assert.ok(withGenesis.world.genesisFrames > 0, 'song should demand a genesis epoch');
// The world at frame 0 is more evolved (generations already lived)…
assert.ok(withGenesis.world.count > without.world.count * 1.1,
`genesis world should be more populated (${withGenesis.world.count} vs ${without.world.count})`);
// …and the ground already carries history, where a fresh plate has none.
assert.ok(withGenesis.world.sampleTrailMass() > 0.00003,
`genesis world should already hold ink (${withGenesis.world.sampleTrailMass()})`);
assert.equal(withGenesis.world.history.alive[0], 0, 'genesis must not record into the film');
assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0');
});
test('the region pool is exact: parallel == serial, bit for bit', () => {
const grow = (workers, { frames = 150, seed = 12345 } = {}) => {
const song = load('drone');
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0, workers });
for (let f = 0; f < frames; f++) { run.world.step(); run.camera.update(f); }
const history = Array.from(run.world.history.alive.slice(0, frames));
const trail = Array.from(run.world.trail[0].slice(0, 4000));
run.world.dispose?.();
return { history, trail };
};
const serial = grow(0);
const two = grow(2);
const four = grow(4);
const eq = (a, b) => a.history.every((v, i) => v === b.history[i]) &&
a.trail.every((v, i) => v === b.trail[i]);
assert.ok(eq(serial, two), '2 regions must match the serial world exactly');
assert.ok(eq(serial, four), '4 regions must match the serial world exactly');
// LONG: across a section boundary (drone's drop lands at frame 1545) and
// with a seed distinct from song.seed (the workers must be told the same
// seed — they seed their own jitter-noise fields). A 150-frame run inside
// one section with the default seed passes even with both bugs present.
const longSerial = grow(0, { frames: 1600, seed: 999 });
const longTwo = grow(2, { frames: 1600, seed: 999 });
const longEq = (a, b) => a.history.every((v, i) => v === b.history[i]) &&
a.trail.every((v, i) => v === b.trail[i]);
assert.ok(longEq(longSerial, longTwo),
'2 regions must match the serial world exactly across the section boundary');
});
test('genesis is deterministic and song-scaled', () => {
const a = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
const b = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
assert.equal(a.world.genesisFrames, b.world.genesisFrames);
assert.deepEqual(Array.from(a.world.pop[0]), Array.from(b.world.pop[0]));
const runner = new Run(load('runner'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
assert.notEqual(a.world.genesisFrames, runner.world.genesisFrames,
'the genesis amount should depend on the song');
});