Metabolism: rule program, song-driven life, tribe VMs; M2.7 design (strains & allegiances, living wells)
This commit is contained in:
@@ -20,8 +20,8 @@ const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
|
||||
function grow(name, seed, seconds = 45) {
|
||||
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
|
||||
const song = buildSong(analyse(mono(wav.channels), wav.sampleRate), seed);
|
||||
const run = new Run(song, { fps: 30, seed, width: 240, height: 135 });
|
||||
run.simulate();
|
||||
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 48, genesis: 0 });
|
||||
run.simulate(45);
|
||||
return { world: run.world, song };
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ test('fingerprint distances: songs farther than seeds', () => {
|
||||
});
|
||||
|
||||
function growSong(song, seed) {
|
||||
const run = new Run(song, { fps: 30, seed, width: 240, height: 135 });
|
||||
run.simulate();
|
||||
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 48, genesis: 0 });
|
||||
run.simulate(45);
|
||||
return { world: run.world, song };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// The prepare step must be a cache, never a parallel pipeline: a song rebuilt
|
||||
// from a prepared artifact has to produce a world bit-identical to one analysed
|
||||
// from the WAV, for every frame.
|
||||
|
||||
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 { loadSong } from '../tools/load.js';
|
||||
import { serializeSong, deserializeSong } from '../src/song/serialize.js';
|
||||
import { Run } from '../src/film.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
|
||||
|
||||
test('prepared song is bit-identical to the live WAV path', () => {
|
||||
for (const name of ['drone', 'runner', 'dusk']) {
|
||||
const { song, analysis } = loadSong(resolve(BANK, `${name}.wav`), { fps: 30, seed: 12345 });
|
||||
const artifact = serializeSong(song);
|
||||
const rebuilt = deserializeSong(artifact);
|
||||
|
||||
assert.equal(rebuilt.seed, song.seed);
|
||||
assert.equal(rebuilt.name, song.name);
|
||||
assert.deepEqual(rebuilt.summary, song.summary);
|
||||
assert.equal(rebuilt.events.length, song.events.length);
|
||||
assert.equal(rebuilt.laws.length, song.laws.length);
|
||||
|
||||
const frames = [0, 31, 217, 1200, 3599];
|
||||
const a = new Run(song, { fps: 30, width: 480, height: 270, seed: 12345, field: 64, genesis: 0 }).renderAt(frames);
|
||||
const b = new Run(rebuilt, { fps: 30, width: 480, height: 270, seed: 12345, field: 64, genesis: 0 }).renderAt(frames);
|
||||
|
||||
for (const f of frames) {
|
||||
const pa = a.get(f).data;
|
||||
const pb = b.get(f).data;
|
||||
assert.equal(pa.length, pb.length, `${name} frame ${f} size`);
|
||||
for (let i = 0; i < pa.length; i++) {
|
||||
assert.equal(pa[i], pb[i], `${name} frame ${f} pixel ${i}: live=${pa[i]} prepared=${pb[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// The rule program (M2) must be: deterministic to compile, structurally
|
||||
// different across songs, and genuinely EXECUTED by the world (editing an
|
||||
// operator changes behaviour, not just numbers).
|
||||
|
||||
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 { regimeFor } from '../src/song/rules.js';
|
||||
import { Run } from '../src/film.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
|
||||
|
||||
function load(name, seed = 12345) {
|
||||
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
|
||||
const analysis = analyse(mono(wav.channels), wav.sampleRate);
|
||||
return buildSong(analysis, seed);
|
||||
}
|
||||
|
||||
test('the program compiles deterministically', () => {
|
||||
const a = load('drone', 12345);
|
||||
const b = load('drone', 12345);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(a.program)), JSON.parse(JSON.stringify(b.program)));
|
||||
});
|
||||
|
||||
test('different songs get structurally different programs', () => {
|
||||
const drone = load('drone').program;
|
||||
const runner = load('runner').program;
|
||||
const dusk = load('dusk').program;
|
||||
|
||||
// Castes differ (speed/seek/cap/growth from the track's stats).
|
||||
const dr = JSON.stringify(drone.castes);
|
||||
assert.notDeepEqual(JSON.parse(dr), JSON.parse(JSON.stringify(runner.castes)));
|
||||
|
||||
// Interaction rules differ (band-correlation graph).
|
||||
assert.notEqual(JSON.stringify(drone.rules), JSON.stringify(dusk.rules));
|
||||
|
||||
// At least one operator family differs materially in the same section kind.
|
||||
const dropOf = (p) => p.sections.find((s) => s.kind === 'drop')?.operators;
|
||||
const drDrop = dropOf(drone);
|
||||
const ruDrop = dropOf(runner);
|
||||
const hasDrop = drDrop && ruDrop;
|
||||
const differs = Object.keys(drDrop ?? {}).some((k) => Math.abs(drDrop[k] - ruDrop[k]) > 0.05);
|
||||
assert.ok(!hasDrop || differs, 'drop regimes should differ across songs');
|
||||
});
|
||||
|
||||
test('regimes are structurally distinct by kind', () => {
|
||||
const p = load('dusk').program; // rich: build → breakdown → drop → outro
|
||||
const byKind = (k) => p.sections.find((s) => s.kind === k)?.operators ?? p.sections[0].operators;
|
||||
// breakdown: swarming off, inscription on.
|
||||
assert.ok(byKind('breakdown').disperse < 0.3, 'breakdown should suppress swarming');
|
||||
assert.ok(byKind('breakdown').deposit > 1.4, 'breakdown should turn on inscription');
|
||||
assert.ok(byKind('breakdown').turbulence < 0.4, 'breakdown should calm');
|
||||
// drop: scatter on, home off — and clearly opposite to the breakdown.
|
||||
assert.ok(byKind('drop').turbulence > byKind('breakdown').turbulence * 5 + 0.5, 'drop should be turbulent');
|
||||
assert.ok(byKind('drop').home < byKind('breakdown').home * 0.5, 'drop should abandon home');
|
||||
assert.ok(byKind('drop').spawn > byKind('breakdown').spawn * 4, 'drop should open the floodgates');
|
||||
});
|
||||
|
||||
test('the world actually executes the program (a growth=0 edit halts breeding)', () => {
|
||||
const song = load('runner', 12345);
|
||||
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
|
||||
for (let f = 0; f < 900; f++) run.world.step();
|
||||
|
||||
// Same song, but every regime loses its growth AND spawn operators:
|
||||
// the world must stop breeding (events may still seed a little, but no
|
||||
// logistic growth → population collapses far below the live run).
|
||||
const sterile = load('runner', 12345);
|
||||
for (const s of sterile.program.sections) {
|
||||
s.operators.growth = 0;
|
||||
s.operators.spawn = 0;
|
||||
}
|
||||
const run2 = new Run(sterile, { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
|
||||
for (let f = 0; f < 900; f++) run2.world.step();
|
||||
|
||||
assert.ok(run.world.count > 500, `live world should be inhabited (${run.world.count})`);
|
||||
assert.ok(run2.world.count < run.world.count * 0.1,
|
||||
`sterile world should collapse (${run2.world.count} vs ${run.world.count})`);
|
||||
});
|
||||
|
||||
test('regimeFor picks the right operators at a boundary', () => {
|
||||
const p = load('drone').program;
|
||||
const mid = regimeFor(p, Math.round(p.sections[0].start * 30) + 30, 30);
|
||||
assert.equal(mid.kind, p.sections[0].kind);
|
||||
const later = regimeFor(p, Math.round(p.sections[1].start * 30) + 30, 30);
|
||||
assert.equal(later.kind, p.sections[1].kind);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// The tribe programs (M2.6): the song compiles small deterministic behaviour
|
||||
// programs with sensing, memory, actions and branching, and the world executes
|
||||
// them. These tests pin down determinism, per-song structural variety, the
|
||||
// branching itself, and that the world truly executes the program.
|
||||
|
||||
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 {
|
||||
compileTribes, runTribe, OP, S_HUNGER, S_FOOD, S_SELF, A_MOVE_X, A_MOVE_Y, A_EXCRETE, A_SPAWN,
|
||||
} from '../src/song/tribe.js';
|
||||
import { Run } from '../src/film.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
|
||||
|
||||
function load(name, seed = 12345) {
|
||||
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
|
||||
return buildSong(analyse(mono(wav.channels), wav.sampleRate), seed);
|
||||
}
|
||||
|
||||
function snapshot(code) {
|
||||
return JSON.stringify(code.map((ins) => ins.join(':')));
|
||||
}
|
||||
|
||||
test('tribes compile deterministically and differ per song', () => {
|
||||
const a = load('drone').tribes;
|
||||
const b = load('drone').tribes;
|
||||
assert.equal(a.length, b.length);
|
||||
for (let t = 0; t < a.length; t++) assert.equal(snapshot(a[t].code), snapshot(b[t].code));
|
||||
|
||||
const runner = load('runner').tribes;
|
||||
const anyDiffer = a.some((ta, i) => snapshot(ta.code) !== snapshot(runner[i].code));
|
||||
assert.ok(anyDiffer, 'runner should compile structurally different tribe programs');
|
||||
});
|
||||
|
||||
test('tribes have bounded programs with real branching', () => {
|
||||
const tribes = load('drone').tribes;
|
||||
assert.ok(tribes.every((t) => t.code.length > 3 && t.code.length <= 24), 'programs bounded and non-trivial');
|
||||
const ops = new Set(tribes.flatMap((t) => t.code.map((ins) => ins[0])));
|
||||
assert.ok(ops.has(OP.IFGT) || ops.has(OP.IFLT), 'programs branch');
|
||||
assert.ok(ops.has(OP.SENSE) && ops.has(OP.ACT), 'programs sense and act');
|
||||
});
|
||||
|
||||
test('the VM branches on senses: starving seeks food, fed excreats', () => {
|
||||
// A tiny program: if hunger high → move along food gradient; else excrete.
|
||||
const code = [
|
||||
[OP.SENSE, 3, S_HUNGER, 1],
|
||||
[OP.IFGT, 3, 0.5, 4], // hungry → jump to 4
|
||||
[OP.ACT, A_EXCRETE, 2, 1], // (fed) R2=0 → excrete ~0
|
||||
[OP.HALT],
|
||||
[OP.GRAD, 0, S_FOOD, 0], // hungry: heading x = +1
|
||||
[OP.GRAD, 1, S_FOOD, 1], // heading y = +1
|
||||
[OP.ACT, A_MOVE_X, 0, 1],
|
||||
[OP.ACT, A_MOVE_Y, 1, 1],
|
||||
[OP.HALT],
|
||||
];
|
||||
const mem = new Float32Array(4);
|
||||
const senses = new Float32Array(10);
|
||||
const grads = new Float32Array(20);
|
||||
const act = new Float32Array(6);
|
||||
|
||||
// Hungry (HUNGER high) → moves, no excrete.
|
||||
senses[S_HUNGER] = 0.9;
|
||||
grads[0] = 1; grads[1] = -1;
|
||||
runTribe(mem, 0, senses, grads, act, code);
|
||||
assert.ok(act[A_MOVE_X] > 0 && act[A_MOVE_Y] < 0, 'hungry tribe should move toward food');
|
||||
|
||||
// Fed (HUNGER low) → no movement, excrete from R2 (memory persists? R2=0 → excrete 0).
|
||||
senses[S_HUNGER] = 0.1;
|
||||
mem.fill(0);
|
||||
grads[0] = 1; grads[1] = 1;
|
||||
runTribe(mem, 0, senses, grads, act, code);
|
||||
assert.equal(act[A_MOVE_X], 0, 'fed tribe should not chase food');
|
||||
assert.equal(act[A_EXCRETE], 0);
|
||||
});
|
||||
|
||||
test('memories (registers) persist across runs', () => {
|
||||
const code = [
|
||||
[OP.SENSE, 0, S_SELF, 1], // R0 = local density
|
||||
[OP.LERP, 1, 0, 0.5], // R1 decays toward R0 (memory)
|
||||
[OP.HALT],
|
||||
];
|
||||
const mem = new Float32Array(4);
|
||||
const senses = new Float32Array(10);
|
||||
const grads = new Float32Array(20);
|
||||
const act = new Float32Array(6);
|
||||
senses[S_SELF] = 0.8;
|
||||
runTribe(mem, 0, senses, grads, act, code);
|
||||
runTribe(mem, 0, senses, grads, act, code);
|
||||
assert.ok(mem[1] > 0, 'register kept value across runs');
|
||||
// R1 after two 50% steps toward 0.8 is exactly 0.6 (float tolerance).
|
||||
assert.ok(Math.abs(mem[1] - 0.6) < 1e-6, `memory integrates deterministically (${mem[1]})`);
|
||||
});
|
||||
|
||||
test('the world executes the tribe program (a no-op program stops its actions)', () => {
|
||||
const song = load('runner');
|
||||
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
|
||||
for (let f = 0; f < 900; f++) run.world.step();
|
||||
|
||||
// Same song, same world — but every tribe program halts immediately: no
|
||||
// movement or actions from the program (forces become ambient-only). The
|
||||
// grown world must be materially different.
|
||||
const inert = load('runner');
|
||||
for (const tribe of inert.tribes) tribe.code = [[OP.HALT]];
|
||||
const run2 = new Run(inert, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
|
||||
for (let f = 0; f < 900; f++) run2.world.step();
|
||||
|
||||
let d = 0;
|
||||
const a = run.world.history.movement;
|
||||
const b = run2.world.history.movement;
|
||||
for (let i = 0; i < a.length; i++) d += Math.abs(a[i] - b[i]);
|
||||
assert.ok(d > 1e-3, `inert programs must change the film (movement Δ=${d.toFixed(3)})`);
|
||||
});
|
||||
@@ -24,7 +24,7 @@ function load(name) {
|
||||
/** 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 });
|
||||
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);
|
||||
@@ -78,4 +78,31 @@ test('three songs grow fingerprints further apart than seeds of one song', () =>
|
||||
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.0001,
|
||||
`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('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');
|
||||
});
|
||||
Reference in New Issue
Block a user