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,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)})`);
|
||||
});
|
||||
Reference in New Issue
Block a user