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.
This commit is contained in:
Dejvino
2026-10-30 07:26:28 +01:00
parent 53a973c34e
commit 23d3696adc
8 changed files with 505 additions and 159 deletions
+15 -4
View File
@@ -97,11 +97,11 @@ test('genesis pre-simulates generations so frame 0 is already an ecosystem', ()
});
test('the region pool is exact: parallel == serial, bit for bit', () => {
const grow = (workers) => {
const grow = (workers, { frames = 150, seed = 12345 } = {}) => {
const song = load('drone');
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0, workers });
for (let f = 0; f < 150; f++) { run.world.step(); run.camera.update(f); }
const history = Array.from(run.world.history.alive.slice(0, 150));
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 };
@@ -113,6 +113,17 @@ test('the region pool is exact: parallel == serial, bit for bit', () => {
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', () => {