Metabolism M2.7: one species, many minds — strains (song-compiled LittleVMs), bodies with fixed allegiance, living wells, food-limited economy
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// M2.7 — one species, many minds: strains & allegiances, living wells.
|
||||
// The tests here pin the new story layer: the allegiance invariant, the
|
||||
// bodies' politics (war converts, splinters fission, friends merge), the
|
||||
// strain mix evolving by local selection, and the wells' lifecycle.
|
||||
|
||||
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 { wellPos } from '../src/sim/wells.js';
|
||||
import { Run } from '../src/film.js';
|
||||
import { OP, S_FOE, G_FOE, A_MOVE_X, A_MOVE_Y, A_HIT } from '../src/song/strains.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 run(name, opts = {}) {
|
||||
const song = load(name, opts.seed ?? 12345);
|
||||
const r = new Run(song, {
|
||||
fps: 30, width: 240, height: 135, field: 64, genesis: 0,
|
||||
...(opts.run ?? {}),
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---- living wells -----------------------------------------------------------
|
||||
|
||||
test('well timeline compiles deterministically', () => {
|
||||
const a = load('drone').wells;
|
||||
const b = load('drone').wells;
|
||||
assert.equal(a.wells.length, b.wells.length);
|
||||
for (let i = 0; i < a.wells.length; i++) {
|
||||
assert.equal(a.wells[i].born, b.wells[i].born);
|
||||
assert.equal(a.wells[i].died, b.wells[i].died);
|
||||
assert.deepEqual(Array.from(a.wells[i].px), Array.from(b.wells[i].px));
|
||||
}
|
||||
});
|
||||
|
||||
test('the world wakes on living wells and never exceeds the cap', () => {
|
||||
const song = load('drone');
|
||||
const w = run('drone').world;
|
||||
assert.ok(w.livingWells(0).length >= 3, 'at least three wells alive at frame 0');
|
||||
for (let f = 0; f < 600; f++) {
|
||||
const alive = w.livingWells(f).length;
|
||||
assert.ok(alive <= song.wells.maxActive, `too many wells at f${f}: ${alive}`);
|
||||
if (f < 600) w.step();
|
||||
}
|
||||
});
|
||||
|
||||
test('wells drift: at least one well moves a visible distance', () => {
|
||||
const w = run('drone').world;
|
||||
let maxDrift = 0;
|
||||
for (const well of w.wells) {
|
||||
const life = well.died - well.born;
|
||||
if (life < 300) continue;
|
||||
const [x0, y0] = wellPos(well, well.born);
|
||||
const [x1, y1] = wellPos(well, well.born + Math.floor(life / 2));
|
||||
maxDrift = Math.max(maxDrift, Math.hypot(x1 - x0, y1 - y0));
|
||||
}
|
||||
assert.ok(maxDrift > 0.02, `wells should drift (max ${maxDrift.toFixed(4)})`);
|
||||
});
|
||||
|
||||
test('a well birth is a land-rush: food lands where the well sprouts', () => {
|
||||
const w = run('runner').world;
|
||||
const soon = w.wells.find((well) => well.born > 10 && well.born < 450);
|
||||
assert.ok(soon, 'runner should grow a well within 15 s');
|
||||
// A control world where the same well is born one frame later: the only
|
||||
// difference at the birth frame is the land-rush itself.
|
||||
const cw = run('runner').world;
|
||||
const twin = cw.wells.find((well) => well.id === soon.id);
|
||||
twin.born += 1;
|
||||
|
||||
const [x, y] = wellPos(soon, soon.born);
|
||||
const near = (world) => {
|
||||
let s = 0;
|
||||
const F = world.field;
|
||||
const cx = Math.round(x * (F - 1));
|
||||
const cy = Math.round(y * (F - 1));
|
||||
for (let dy = -3; dy <= 3; dy++) {
|
||||
for (let dx = -3; dx <= 3; dx++) {
|
||||
const px = Math.min(F - 1, Math.max(0, cx + dx));
|
||||
const py = Math.min(F - 1, Math.max(0, cy + dy));
|
||||
s += world.resource[py * F + px];
|
||||
}
|
||||
}
|
||||
return s;
|
||||
};
|
||||
for (let f = w.frame + 1; f < soon.born; f++) { w.step(); cw.step(); }
|
||||
const pre = near(w);
|
||||
w.step();
|
||||
cw.step();
|
||||
const post = near(w);
|
||||
const controlPost = near(cw);
|
||||
assert.ok(post > pre, `a well birth must add ground (${pre.toFixed(3)} → ${post.toFixed(3)})`);
|
||||
assert.ok(post > controlPost, `the birth feeds more than the same frame without one (${post.toFixed(3)} vs ${controlPost.toFixed(3)})`);
|
||||
});
|
||||
|
||||
// ---- allegiance: the invariant ----------------------------------------------
|
||||
|
||||
test('allegiance tracks the population exactly (Σ bodies = density)', () => {
|
||||
const w = run('dusk').world;
|
||||
for (let f = 0; f < 240; f++) w.step();
|
||||
let worst = 0;
|
||||
let negatives = 0;
|
||||
for (let i = 0; i < w.n; i++) {
|
||||
let s = 0;
|
||||
for (let b = 0; b < w.B; b++) {
|
||||
if (w.alleg[b][i] < -1e-9) negatives++;
|
||||
s += w.alleg[b][i];
|
||||
}
|
||||
if (w.density[i] > 1e-4) worst = Math.max(worst, Math.abs(s - w.density[i]));
|
||||
}
|
||||
assert.equal(negatives, 0, 'no negative allegiance');
|
||||
assert.ok(worst < 1e-2, `composition must track population (worst ${worst.toFixed(4)})`);
|
||||
});
|
||||
|
||||
test('the song opens several bodies and the count stays sane', () => {
|
||||
const song = load('dusk');
|
||||
const w = run('dusk').world;
|
||||
assert.ok(w.aliveBodies.length >= 2, 'the song opens more than one body');
|
||||
const horizon = Math.min(600, song.analysis.frames - 1);
|
||||
for (let f = 0; f < horizon; f++) {
|
||||
w.step();
|
||||
assert.ok(w.aliveBodies.length >= 1 && w.aliveBodies.length <= w.B);
|
||||
assert.ok(w.history.bodies[w.frame] >= 1, 'history records the body count');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- allegiance: war converts -------------------------------------------------
|
||||
|
||||
test('hostile bodies contest a front: war kills, but never converts', () => {
|
||||
const make = (base) => {
|
||||
const r = run('runner');
|
||||
const w = r.world;
|
||||
// War (or truce) footing: every strain marches toward the foe and
|
||||
// strikes on contact.
|
||||
r.song.program.diplomacy.base = base;
|
||||
for (const s of w.strains) {
|
||||
s.code = [
|
||||
[OP.SENSE, 5, S_FOE, 1],
|
||||
[OP.IFGT, 5, 0.02, 7],
|
||||
[OP.GRAD, 4, G_FOE, 0, 1],
|
||||
[OP.GRAD, 5, G_FOE, 1, 1],
|
||||
[OP.ACT, A_MOVE_X, 4, 1],
|
||||
[OP.ACT, A_MOVE_Y, 5, 1],
|
||||
[OP.HALT],
|
||||
[OP.GRAD, 4, G_FOE, 0, 1],
|
||||
[OP.GRAD, 5, G_FOE, 1, 1],
|
||||
[OP.ACT, A_MOVE_X, 4, 1],
|
||||
[OP.ACT, A_MOVE_Y, 5, 1],
|
||||
[OP.ACT, A_HIT, 5, 2],
|
||||
[OP.HALT],
|
||||
];
|
||||
}
|
||||
// Two bodies planted face to face across the meridian.
|
||||
const F = w.field;
|
||||
const seedSide = (xa, xb, body) => {
|
||||
for (let y = 20; y < 44; y++) {
|
||||
for (let x = xa; x < xb; x++) {
|
||||
const i = y * F + x;
|
||||
for (let s = 0; s < w.m; s++) w.pop[s][i] = 4;
|
||||
w.alleg[body][i] = 4 * w.m;
|
||||
}
|
||||
}
|
||||
};
|
||||
seedSide(12, 30, 0);
|
||||
seedSide(34, 52, 1);
|
||||
w.bodies[0].cx = 14 / F; w.bodies[0].cy = 0.5; w.bodies[0].alive = true;
|
||||
w.bodies[1].cx = 50 / F; w.bodies[1].cy = 0.5; w.bodies[1].alive = true;
|
||||
if (!w.aliveBodies.includes(0)) w.aliveBodies.push(0);
|
||||
if (!w.aliveBodies.includes(1)) w.aliveBodies.push(1);
|
||||
w.recount();
|
||||
return w;
|
||||
};
|
||||
|
||||
const war = make(-1);
|
||||
const peace = make(1);
|
||||
for (let f = 0; f < 240; f++) { war.step(); peace.step(); }
|
||||
|
||||
// War kills: the contested world ends up with fewer organisms than the
|
||||
// same world at truce.
|
||||
assert.ok(war.count < peace.count * 0.98,
|
||||
`war should cost lives (war ${war.count.toFixed(0)} vs peace ${peace.count.toFixed(0)})`);
|
||||
|
||||
// But allegiance never changes hands: each body's mass stays (to within
|
||||
// organic growth) where it was planted — nothing joined the other side.
|
||||
const F = war.field;
|
||||
const heartland = (w, b, xa, xb) => {
|
||||
let inside = 0;
|
||||
let outside = 0;
|
||||
for (let y = 0; y < F; y++) {
|
||||
for (let x = 0; x < F; x++) {
|
||||
const i = y * F + x;
|
||||
if (x >= xa && x < xb) inside += w.alleg[b][i];
|
||||
else outside += w.alleg[b][i];
|
||||
}
|
||||
}
|
||||
return { inside, outside };
|
||||
};
|
||||
const h0 = heartland(war, 0, 0, 32); // body 0 planted left
|
||||
const h1 = heartland(war, 1, 32, F); // body 1 planted right
|
||||
assert.ok(h0.outside < h0.inside * 0.2, `body 0 must not take the east (${h0.outside.toFixed(1)} vs ${h0.inside.toFixed(1)})`);
|
||||
assert.ok(h1.outside < h1.inside * 0.2, `body 1 must not take the west (${h1.outside.toFixed(1)} vs ${h1.inside.toFixed(1)})`);
|
||||
});
|
||||
|
||||
// ---- allegiance: war & colonisation ---------------------------------------------
|
||||
|
||||
test('colonisation: a settlement on virgin ground can plant a new flag', () => {
|
||||
const w = run('dusk').world;
|
||||
const F = w.field;
|
||||
// A colony with allegiance to body 0 far from any other body's core.
|
||||
for (let y = 18; y < 26; y++) {
|
||||
for (let x = 18; x < 26; x++) {
|
||||
const i = y * F + x;
|
||||
for (let s = 0; s < w.m; s++) w.pop[s][i] = 6;
|
||||
w.alleg[0][i] = 6 * w.m;
|
||||
}
|
||||
}
|
||||
w.bodies[0].alive = true;
|
||||
w.bodies[0].cx = 0.34; w.bodies[0].cy = 0.34;
|
||||
if (!w.aliveBodies.includes(0)) w.aliveBodies.push(0);
|
||||
w.recount();
|
||||
|
||||
const FC = w.FC;
|
||||
const block = 10 * FC + 10;
|
||||
const n0 = w.aliveBodies.length;
|
||||
const founded = w.foundBody(block, w.frame + 1);
|
||||
assert.ok(founded >= 0, 'a settlement can found a body');
|
||||
assert.equal(w.aliveBodies.length, n0 + 1);
|
||||
// The new body holds the seceded ground.
|
||||
let newMass = 0;
|
||||
for (let i = 0; i < w.n; i++) newMass += w.alleg[founded][i];
|
||||
assert.ok(newMass > 10, `the new body took ground (${newMass.toFixed(1)})`);
|
||||
});
|
||||
|
||||
// ---- the strain mix evolves ----------------------------------------------------
|
||||
|
||||
test('the strain mix evolves by local selection', () => {
|
||||
const w = run('runner', { run: { field: 64 } }).world;
|
||||
const share = () => w.pop.map((p) => {
|
||||
let s = 0;
|
||||
for (let i = 0; i < p.length; i++) s += p[i];
|
||||
return s;
|
||||
});
|
||||
const early = share();
|
||||
for (let f = 0; f < 600; f++) w.step();
|
||||
const late = share();
|
||||
const total0 = early.reduce((a, v) => a + v, 0);
|
||||
const total1 = late.reduce((a, v) => a + v, 0);
|
||||
let drift = 0;
|
||||
for (let s = 0; s < w.m; s++) {
|
||||
drift += Math.abs(early[s] / total0 - late[s] / total1);
|
||||
}
|
||||
assert.ok(drift > 0.02, `strain proportions must drift (L1 ${drift.toFixed(3)})`);
|
||||
});
|
||||
@@ -33,12 +33,12 @@ test('different songs get structurally different programs', () => {
|
||||
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)));
|
||||
// Strains differ (speed/deposit/cap/growth from the track's stats).
|
||||
const dr = JSON.stringify(drone.strains);
|
||||
assert.notDeepEqual(JSON.parse(dr), JSON.parse(JSON.stringify(runner.strains)));
|
||||
|
||||
// Interaction rules differ (band-correlation graph).
|
||||
assert.notEqual(JSON.stringify(drone.rules), JSON.stringify(dusk.rules));
|
||||
// Diplomacy differs (the tracks' own consonance).
|
||||
assert.notEqual(JSON.stringify(drone.diplomacy), JSON.stringify(dusk.diplomacy));
|
||||
|
||||
// At least one operator family differs materially in the same section kind.
|
||||
const dropOf = (p) => p.sections.find((s) => s.kind === 'drop')?.operators;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// The strains (M2.7): the song decides how many kinds of minds it needs and
|
||||
// compiles a LittleVM program for each. These tests pin down determinism,
|
||||
// per-song structural variety, the count being musical, the branching itself,
|
||||
// and that the world truly executes the programs.
|
||||
|
||||
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 {
|
||||
compileStrains, runProgram, OP, S_HUNGER, S_FOOD, S_SELF, S_FOE, S_PEACE,
|
||||
A_MOVE_X, A_MOVE_Y, A_EXCRETE, A_SPAWN, A_HIT,
|
||||
} from '../src/song/strains.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('strains compile deterministically and differ per song', () => {
|
||||
const a = load('drone').strains;
|
||||
const b = load('drone').strains;
|
||||
assert.equal(a.length, b.length);
|
||||
for (let s = 0; s < a.length; s++) assert.equal(snapshot(a[s].code), snapshot(b[s].code));
|
||||
|
||||
const runner = load('runner').strains;
|
||||
const anyDiffer = a.some((ta, i) => snapshot(ta.code) !== snapshot(runner[i].code));
|
||||
assert.ok(anyDiffer, 'runner should compile structurally different strain programs');
|
||||
});
|
||||
|
||||
test('the strain count is musical, not fixed', () => {
|
||||
const songs = ['drone', 'runner', 'dusk'].map((n) => load(n));
|
||||
const counts = songs.map((s) => s.strains.length);
|
||||
assert.ok(counts.every((m) => m >= 2 && m <= 5), `counts in 2..5 (${counts})`);
|
||||
// A sparse song and a dense one should not always agree on the cast size
|
||||
// (the seed may still add a voice, so 2 of 3 differing is enough).
|
||||
assert.ok(new Set(counts).size >= 2 || counts[0] !== counts[1],
|
||||
`songs should differ in cast size (${counts})`);
|
||||
});
|
||||
|
||||
test('strains have bounded programs with real branching', () => {
|
||||
const strains = load('dusk').strains;
|
||||
assert.ok(strains.every((s) => s.code.length > 3 && s.code.length <= 32), 'programs bounded and non-trivial');
|
||||
const ops = new Set(strains.flatMap((s) => s.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 works', () => {
|
||||
// A tiny program: if hunger high → move along food gradient; else excrete.
|
||||
const code = [
|
||||
[OP.SENSE, 3, S_HUNGER, 1],
|
||||
[OP.IFLT, 3, 0.5, 4], // fed → jump to 4
|
||||
[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],
|
||||
[OP.ACT, A_EXCRETE, 2, 1], // fed: excrete (R2=0 → ~0)
|
||||
[OP.HALT],
|
||||
];
|
||||
const mem = new Float32Array(6);
|
||||
const senses = new Float32Array(12);
|
||||
const grads = new Float32Array(12);
|
||||
const act = new Float32Array(6);
|
||||
|
||||
// Hungry (HUNGER high) → moves, no excrete.
|
||||
senses[S_HUNGER] = 0.9;
|
||||
grads[0] = 1; grads[1] = -1;
|
||||
runProgram(mem, 0, senses, grads, act, code);
|
||||
assert.ok(act[A_MOVE_X] > 0 && act[A_MOVE_Y] < 0, 'hungry strain should move toward food');
|
||||
|
||||
// Fed (HUNGER low) → no movement.
|
||||
senses[S_HUNGER] = 0.1;
|
||||
mem.fill(0);
|
||||
grads[0] = 1; grads[1] = 1;
|
||||
runProgram(mem, 0, senses, grads, act, code);
|
||||
assert.equal(act[A_MOVE_X], 0, 'fed strain should not chase food');
|
||||
});
|
||||
|
||||
test('the sentinel branch: hostile peace strikes, peaceful peace shadows', () => {
|
||||
// Foe present, program branches on PEACE.
|
||||
const code = [
|
||||
[OP.SENSE, 3, S_PEACE, 1], // 0
|
||||
[OP.SENSE, 5, S_FOE, 1], // 1
|
||||
[OP.IFGT, 5, 0.05, 4], // 2: foe in range → 4
|
||||
[OP.HALT], // 3: no foe → nothing
|
||||
[OP.IFLT, 3, 0.45, 8], // 4: war → 8
|
||||
[OP.HALT], // 5: (truce tail placeholder)
|
||||
[OP.GRAD, 4, 3, 0, -0.8], // 6: truce: back away (unused path)
|
||||
[OP.HALT], // 7
|
||||
[OP.GRAD, 4, 3, 0, 1], // 8: war: head toward the foe
|
||||
[OP.ACT, A_MOVE_X, 4, 1], // 9
|
||||
[OP.ACT, A_HIT, 5, 1], // 10
|
||||
[OP.HALT], // 11
|
||||
];
|
||||
const mem = new Float32Array(6);
|
||||
const senses = new Float32Array(12);
|
||||
const grads = new Float32Array(12);
|
||||
const act = new Float32Array(6);
|
||||
|
||||
senses[S_FOE] = 0.8;
|
||||
senses[S_PEACE] = 0.1; // war
|
||||
grads[3 * 2] = 1; // foe gradient x
|
||||
runProgram(mem, 0, senses, grads, act, code);
|
||||
assert.ok(act[A_HIT] > 0, 'at war the sentinel strikes');
|
||||
|
||||
senses[S_PEACE] = 0.9; // truce
|
||||
act.fill(0);
|
||||
runProgram(mem, 0, senses, grads, act, code);
|
||||
assert.equal(act[A_HIT], 0, 'at truce the sentinel does not strike');
|
||||
});
|
||||
|
||||
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(6);
|
||||
const senses = new Float32Array(12);
|
||||
const grads = new Float32Array(12);
|
||||
const act = new Float32Array(6);
|
||||
senses[S_SELF] = 0.8;
|
||||
runProgram(mem, 0, senses, grads, act, code);
|
||||
runProgram(mem, 0, senses, grads, act, code);
|
||||
assert.ok(mem[1] > 0, 'register kept value across runs');
|
||||
assert.ok(Math.abs(mem[1] - 0.6) < 1e-6, `memory integrates deterministically (${mem[1]})`);
|
||||
});
|
||||
|
||||
test('the world executes the strain programs (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 strain 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 strain of inert.strains) strain.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)})`);
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
// 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)})`);
|
||||
});
|
||||
@@ -90,7 +90,7 @@ test('genesis pre-simulates generations so frame 0 is already an ecosystem', ()
|
||||
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,
|
||||
assert.ok(withGenesis.world.sampleTrailMass() > 0.00005,
|
||||
`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');
|
||||
|
||||
Reference in New Issue
Block a user