Metabolism: song as substrate, ecology grown from the track

A new top-level section building the metabolism concept: the track
supplies the food field, event impulses and per-section laws; the seed
picks the species (a rule set, not a parameter vector). Same song + seed
=> bit-identical frames; different songs => different worlds.

- analysis: single FFT pass -> per-frame feature table; score (onsets
  folded into world geometry via voice ring + pitch-class sector), wells
  (food placed by the harmony), graph (band correlations -> interaction
  matrix), laws (per-section regimes)
- sim: typed-array world, 3 castes (builder/orbit, forager/commute,
  spark/orbit-wide) with causality rings, persistence trails,
  consequence field; fully deterministic
- lens: software framebuffer renderer (PNG in node, ImageData in
  browser) + event-reactive camera (cuts on activity change, alternating
  shot sizes, drift-and-push)
- metrics: coupling x residual fitness (not per-frame descriptors) plus
  a grown-structure fingerprint; report + strip tools; 15 tests green
- DESIGN.md captures the concept, invariants and build order

3 reference test signals (drone/runner/dusk) grow measurably distinct
worlds while seeds of one song stay in the same biome.
This commit is contained in:
Dejvino
2026-10-24 08:23:33 +02:00
parent 6a2af2e45f
commit 13b0939221
26 changed files with 4469 additions and 0 deletions
+632
View File
@@ -0,0 +1,632 @@
// The ecology. Agents on a resource field, with the song's species and the
// song's laws. No player, no realtime budget, no rendering: a step function.
//
// Three invariants are load-bearing and every design choice here serves them:
//
// PERSISTENCE agents live for seconds, not frames, and the trail grid keeps
// what they did for tens of seconds. You can follow something.
// CAUSALITY food arrives where and when the track put it. A bloom has an
// audible cause; a migration has a visible one.
// CONSEQUENCE the field is depleted, the trail accumulates. The state at t
// is made of the state at t-1, not re-rolled each frame.
import { Rng } from '../rng.js';
import { buildWells } from './wells.js';
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
const TAU = Math.PI * 2;
const H = 1.6; // field samples are one and a half cells apart
export const FIELD = 128;
export class World {
/**
* @param {ReturnType<import('../song/song.js').buildSong>} song
*/
constructor(song, { fps = 30, field = FIELD, seed = song.seed } = {}) {
this.song = song;
this.fps = fps;
this.dt = 1 / fps;
this.field = field;
this.genome = song.genome;
this.palette = song.palette;
this.rng = new Rng(seed >>> 0).fork('world');
this.resource = new Float32Array(field * field);
this.wells = buildWells(song.summary, field);
// The dish is delivered already furnished: colonies exist at t=0 and
// the first bars are life forming, not a blank plate waiting for food.
for (const well of this.wells) {
const add = well.rate * 7;
const idx = well.idx;
const w = well.w;
for (let i = 0; i < idx.length; i++) {
this.resource[idx[i]] = Math.min(1.6, add * w[i]);
}
}
this.trail = [
new Float32Array(field * field),
new Float32Array(field * field),
new Float32Array(field * field),
];
this.activity = new Float32Array(32 * 32);
this.cap = this.genome.maxAgents;
this.x = new Float32Array(this.cap);
this.y = new Float32Array(this.cap);
this.vx = new Float32Array(this.cap);
this.vy = new Float32Array(this.cap);
this.energy = new Float32Array(this.cap);
this.age = new Float32Array(this.cap);
this.cls = new Uint8Array(this.cap);
this.wellOf = new Uint8Array(this.cap);
this.pulses = [];
this.count = 0;
this.newborn = new Float32Array(this.cap * 7);
this.newbornCount = 0;
// Neighbour grid.
this.gridN = 22;
this.cellCount = new Int32Array(this.gridN * this.gridN + 1);
this.cellStart = new Int32Array(this.gridN * this.gridN + 1);
this.cellItems = new Int32Array(this.cap);
this.history = {
alive: new Uint16Array(song.analysis.frames),
movement: new Float32Array(song.analysis.frames),
food: new Float32Array(song.analysis.frames),
births: new Uint16Array(song.analysis.frames),
deaths: new Uint16Array(song.analysis.frames),
};
this.frame = -1;
this.birthsThisFrame = 0;
this.deathsThisFrame = 0;
this.seedInitial();
}
/** The law for a frame. Section boundaries are the only place laws change. */
lawForFrame(frame) {
const laws = this.song.laws;
const t = frame / this.fps;
for (let i = laws.length - 1; i >= 0; i--) {
if (t >= laws[i].start) return laws[i];
}
return laws[0];
}
seedInitial() {
const n = this.genome.seedAgents;
const wells = this.wells;
for (let i = 0; i < n && this.count + this.newbornCount < this.cap; i++) {
// Most life starts at a well; a few are already in transit, so the
// first frame is a world with roads, not a row of dots.
let x = 0.5;
let y = 0.5;
if (i % 5 !== 4 && wells.length) {
const w = wells[i % wells.length];
x = w.x + this.rng.gauss() * w.r;
y = w.y + this.rng.gauss() * w.r;
} else {
const r = Math.sqrt(this.rng.next()) * 0.44;
const a = this.rng.next() * TAU;
x = 0.5 + Math.cos(a) * r;
y = 0.5 + Math.sin(a) * r;
}
this.queueAgent(
Math.max(0.02, Math.min(0.98, x)),
Math.max(0.02, Math.min(0.98, y)),
this.pickClass(),
0.52 + 0.24 * this.rng.next()
);
}
this.flushNewborns();
this.birthsThisFrame = 0;
this.deathsThisFrame = 0;
}
nearestWell(x, y) {
let best = 0;
let bd = Infinity;
for (let i = 0; i < this.wells.length; i++) {
const w = this.wells[i];
const d = (w.x - x) ** 2 + (w.y - y) ** 2;
if (d < bd) {
bd = d;
best = i;
}
}
return best;
}
pickClass() {
const w = this.genome.classes.map((c) => c.seed);
let total = 0;
for (const v of w) total += v;
let r = this.rng.next() * total;
for (let i = 0; i < w.length; i++) {
r -= w[i];
if (r <= 0) return i;
}
return 0;
}
queueAgent(x, y, cls, energy) {
if (this.count + this.newbornCount >= this.cap) return false;
const p = this.newbornCount * 7;
this.newborn[p] = x;
this.newborn[p + 1] = y;
this.newborn[p + 2] = 0;
this.newborn[p + 3] = 0;
this.newborn[p + 4] = energy;
this.newborn[p + 5] = 0;
this.newborn[p + 6] = cls;
this.newbornCount++;
return true;
}
flushNewborns() {
for (let k = 0; k < this.newbornCount; k++) {
if (this.count >= this.cap) break;
const p = k * 7;
const i = this.count++;
this.x[i] = this.newborn[p];
this.y[i] = this.newborn[p + 1];
this.vx[i] = this.newborn[p + 2];
this.vy[i] = this.newborn[p + 3];
this.energy[i] = this.newborn[p + 4];
this.age[i] = this.newborn[p + 5];
this.cls[i] = this.newborn[p + 6];
this.wellOf[i] = this.nearestWell(this.x[i], this.y[i]);
}
this.newbornCount = 0;
}
// ---- field -------------------------------------------------------------
sample(fx, fy) {
const F = this.field;
const gx = Math.max(0, Math.min(F - 1.001, fx * (F - 1)));
const gy = Math.max(0, Math.min(F - 1.001, fy * (F - 1)));
const x0 = gx | 0;
const y0 = gy | 0;
const tx = gx - x0;
const ty = gy - y0;
const i = y0 * F + x0;
const a = this.resource[i];
const b = this.resource[i + 1];
const c = this.resource[i + F];
const d = this.resource[i + F + 1];
return (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty;
}
/** Consume from the four cells under (fx, fy). Returns what was eaten. */
eat(fx, fy, amount) {
const F = this.field;
const gx = Math.max(0, Math.min(F - 1.001, fx * (F - 1)));
const gy = Math.max(0, Math.min(F - 1.001, fy * (F - 1)));
const x0 = gx | 0;
const y0 = gy | 0;
const tx = gx - x0;
const ty = gy - y0;
const res = this.resource;
const i0 = y0 * F + x0;
const i1 = i0 + 1;
const i2 = i0 + F;
const i3 = i2 + 1;
const w0 = (1 - tx) * (1 - ty);
const w1 = tx * (1 - ty);
const w2 = (1 - tx) * ty;
const w3 = tx * ty;
let eaten = 0;
let take = Math.min(res[i0], amount * w0);
res[i0] -= take; eaten += take;
take = Math.min(res[i1], amount * w1);
res[i1] -= take; eaten += take;
take = Math.min(res[i2], amount * w2);
res[i2] -= take; eaten += take;
take = Math.min(res[i3], amount * w3);
res[i3] -= take; eaten += take;
return eaten;
}
depositTrail(cls, x, y, amount) {
const F = this.field;
const ix = Math.max(0, Math.min(F - 1, (x * F) | 0));
const iy = Math.max(0, Math.min(F - 1, (y * F) | 0));
this.trail[cls][iy * F + ix] += amount;
const ax = Math.max(0, Math.min(31, (x * 32) | 0));
const ay = Math.max(0, Math.min(31, (y * 32) | 0));
this.activity[ay * 32 + ax] += amount * 3;
}
splat(x, y, spread, amount) {
const F = this.field;
const sigma = Math.max(0.8, spread * F);
const r = Math.ceil(sigma * 2.4);
const cx = Math.round(x * (F - 1));
const cy = Math.round(y * (F - 1));
const denom = 1 / (2 * sigma * sigma);
let total = 0;
const idx = [];
const w = [];
for (let dy = -r; dy <= r; dy++) {
const py = cy + dy;
if (py < 0 || py >= F) continue;
for (let dx = -r; dx <= r; dx++) {
const px = cx + dx;
if (px < 0 || px >= F) continue;
const k = Math.exp(-(dx * dx + dy * dy) * denom);
if (k < 0.01) continue;
idx.push(py * F + px);
w.push(k);
total += k;
}
}
if (total <= 0) return;
const k = amount / total;
for (let i = 0; i < idx.length; i++) this.resource[idx[i]] += w[i] * k;
}
// ---- neighbour grid ----------------------------------------------------
buildGrid() {
const n = this.gridN;
const counts = this.cellCount;
counts.fill(0);
for (let i = 0; i < this.count; i++) {
const cx = Math.min(n - 1, Math.max(0, (this.x[i] * n) | 0));
const cy = Math.min(n - 1, Math.max(0, (this.y[i] * n) | 0));
counts[cy * n + cx + 1]++;
}
for (let i = 1; i <= n * n; i++) counts[i] += counts[i - 1];
this.cellStart.set(counts);
for (let i = 0; i < this.count; i++) {
const cx = Math.min(n - 1, Math.max(0, (this.x[i] * n) | 0));
const cy = Math.min(n - 1, Math.max(0, (this.y[i] * n) | 0));
const c = cy * n + cx;
this.cellItems[this.cellStart[c]++] = i;
}
}
// ---- step --------------------------------------------------------------
step() {
this.frame++;
const f = this.frame;
const law = this.lawForFrame(f);
const cutoff = f - Math.ceil(this.fps * 1.6);
while (this.pulses.length && this.pulses[0].frame < cutoff) this.pulses.shift();
this.birthsThisFrame = 0;
this.deathsThisFrame = 0;
this.inject(f, law);
this.decay(law);
this.buildGrid();
this.update(law);
this.compact();
this.flushNewborns();
this.record(law);
return law;
}
inject(frame, law) {
const events = this.song.index[frame];
if (!events) return;
for (const e of events) {
this.splat(e.x, e.y, e.spread, e.amount * law.spawn);
const births = Math.round(e.spawn * law.spawn);
for (let k = 0; k < births; k++) {
if (this.queueAgent(
e.x + this.rng.range(-0.02, 0.02),
e.y + this.rng.range(-0.02, 0.02),
e.voice,
0.45 + 0.25 * e.strength
)) this.birthsThisFrame++;
}
const ax = Math.max(0, Math.min(31, (e.x * 32) | 0));
const ay = Math.max(0, Math.min(31, (e.y * 32) | 0));
this.activity[ay * 32 + ax] += 0.5 + 2 * e.strength;
// A visible consequence: the hit lands as a ring on the ground.
if (e.strength > 0.42) {
this.pulses.push({ x: e.x, y: e.y, frame, strength: e.strength, voice: e.voice });
}
}
}
decay(law) {
const dt = this.dt;
const fieldK = Math.pow(0.5, dt / (this.genome.fieldHalfLife * (0.8 + 0.4 * law.spawn)));
const trailK = Math.pow(0.5, dt / (this.genome.trailHalfLife * (0.75 + 0.35 * law.deposit)));
const res = this.resource;
for (let i = 0; i < res.length; i++) res[i] *= fieldK;
for (let c = 0; c < 3; c++) {
const t = this.trail[c];
for (let i = 0; i < t.length; i++) t[i] *= trailK;
}
const act = this.activity;
const actK = Math.pow(0.5, dt / 1.4);
for (let i = 0; i < act.length; i++) act[i] *= actK;
// The wells recharge. Slowly, and to a ceiling: a well is a place with
// a carrying capacity, not a faucet. The section's energy sets the rate,
// so a breakdown is still a leaner world.
const scale = (0.45 + 1.25 * law.energy) * dt;
for (const well of this.wells) {
const add = well.rate * scale;
const idx = well.idx;
const w = well.w;
for (let i = 0; i < idx.length; i++) {
const cell = idx[i];
res[cell] = Math.min(1.6, res[cell] + add * w[i]);
}
}
}
update(law) {
const dt = this.dt;
const n = this.gridN;
const sense = 0.13;
const sense2 = sense * sense;
const maxNeighbours = 24;
const g = this.genome;
const wells = this.wells;
for (let i = 0; i < this.count; i++) {
const cls = this.cls[i];
const c = g.classes[cls];
const x = this.x[i];
const y = this.y[i];
let vx = this.vx[i];
let vy = this.vy[i];
let fx = 0;
let fy = 0;
let homeDx = 0;
let homeDy = 0;
// Food gradient. A hungry agent commits harder to what it smells.
const e = this.energy[i];
const hunger = 0.55 + 0.85 * (1 - clamp01(e));
const h = H / this.field;
const gx = (this.sample(x + h, y) - this.sample(x - h, y)) / (2 * h);
const gy = (this.sample(x, y + h) - this.sample(x, y - h)) / (2 * h);
const grad = Math.hypot(gx, gy);
if (grad > 1e-6) {
fx += (gx / grad) * c.seek * hunger;
fy += (gy / grad) * c.seek * hunger;
}
// Place. Each caste has a different relation to the wells, and the
// song sets how strongly it holds: builders orbit and paint,
// foragers commute and draw roads, sparks orbit wide and scatter.
if (wells.length) {
const home = wells[this.wellOf[i]];
const hx = home.x - x;
const hy = home.y - y;
const hd = Math.hypot(hx, hy) + 1e-6;
homeDx = hx / hd;
homeDy = hy / hd;
if (c.role === 'commute') {
const next = (this.wellOf[i] + 1) % wells.length;
const w = wells[next];
const wx = w.x - x;
const wy = w.y - y;
const wd = Math.hypot(wx, wy) + 1e-6;
if (wd < 0.05) {
this.wellOf[i] = next;
} else {
const pull = 1.0 + 1.7 * g.separation;
fx += (wx / wd) * pull;
fy += (wy / wd) * pull;
}
} else {
const err = (hd - c.orbit) / c.orbit;
const k = 1.9 * g.cohesion;
fx += (hx / hd) * Math.max(-1.4, Math.min(1.4, err)) * k;
fy += (hy / hd) * Math.max(-1.4, Math.min(1.4, err)) * k;
const dir = cls === 2 ? -1 : 1;
fx += (-hy / hd) * dir * c.spin;
fy += (hx / hd) * dir * c.spin;
}
}
// Society.
let alignX = 0;
let alignY = 0;
let cohX = 0;
let cohY = 0;
let sepX = 0;
let sepY = 0;
let neighbours = 0;
const cx = Math.min(n - 1, Math.max(0, (x * n) | 0));
const cy = Math.min(n - 1, Math.max(0, (y * n) | 0));
for (let oy = -1; oy <= 1 && neighbours < maxNeighbours; oy++) {
const gy2 = cy + oy;
if (gy2 < 0 || gy2 >= n) continue;
for (let ox = -1; ox <= 1 && neighbours < maxNeighbours; ox++) {
const gx2 = cx + ox;
if (gx2 < 0 || gx2 >= n) continue;
const cell = gy2 * n + gx2;
const a = this.cellStart[cell];
const b = this.cellStart[cell + 1];
for (let p = a; p < b; p++) {
const j = this.cellItems[p];
if (j === i) continue;
const dx = this.x[j] - x;
const dy = this.y[j] - y;
const d2 = dx * dx + dy * dy;
if (d2 > sense2 || d2 < 1e-12) continue;
const d = Math.sqrt(d2);
alignX += this.vx[j];
alignY += this.vy[j];
cohX += dx;
cohY += dy;
const push = (1 - d / sense) / d;
sepX -= dx * push;
sepY -= dy * push;
const k = g.interaction[cls][this.cls[j]];
fx += (dx / d) * k;
fy += (dy / d) * k;
if (++neighbours >= maxNeighbours) break;
}
}
}
if (neighbours > 0) {
const inv = 1 / neighbours;
fx += cohX * inv * g.cohesion * 5.0;
fy += cohY * inv * g.cohesion * 5.0;
fx += (alignX * inv - vx) * g.alignment * 3.2;
fy += (alignY * inv - vy) * g.alignment * 3.2;
fx += sepX * g.separation * 1.05;
fy += sepY * g.separation * 1.05;
}
// Crowding keeps bodies apart: a colony must never collapse to a
// single blob, or the species reads as paint instead of life. The
// push is outward, away from the well where the crowd is densest,
// and scales with how far the crowd has overshot.
if (neighbours > 9) {
const c = Math.min(2.6, (neighbours - 9) * 0.30);
fx -= homeDx * c;
fy -= homeDy * c;
}
// Jitter: what a noisy track does to a well-meaning rule.
fx += this.rng.signed() * g.jitter * 0.26;
fy += this.rng.signed() * g.jitter * 0.26;
// Soft walls: the dish has an edge, but it does not trap.
const m = 0.035;
if (x < m) fx += (m - x) * 14;
if (x > 1 - m) fx -= (x - (1 - m)) * 14;
if (y < m) fy += (m - y) * 14;
if (y > 1 - m) fy -= (y - (1 - m)) * 14;
const speed = c.speed * law.speed;
const mag = Math.hypot(fx, fy);
if (mag > 1e-9) {
const turn = Math.min(1, c.turn * law.turn * dt);
vx += ((fx / mag) * speed - vx) * turn;
vy += ((fy / mag) * speed - vy) * turn;
} else {
vx *= 0.94;
vy *= 0.94;
}
let nx = x + vx * dt;
let ny = y + vy * dt;
if (nx < 0.005) { nx = 0.005; vx = Math.abs(vx) * 0.6; }
if (nx > 0.995) { nx = 0.995; vx = -Math.abs(vx) * 0.6; }
if (ny < 0.005) { ny = 0.005; vy = Math.abs(vy) * 0.6; }
if (ny > 0.995) { ny = 0.995; vy = -Math.abs(vy) * 0.6; }
// Metabolism.
const eaten = this.eat(nx, ny, 0.62 * dt);
let energy = e + eaten * 2.1;
const work = 0.35 + 0.65 * Math.min(1, Math.hypot(vx, vy) / Math.max(1e-6, speed));
energy -= c.drain * dt * work;
energy = Math.min(1.7, energy);
this.x[i] = nx;
this.y[i] = ny;
this.vx[i] = vx;
this.vy[i] = vy;
this.energy[i] = energy;
this.age[i] += dt;
// History is written into the ground, not into a list of events.
// Ink, not fog. A body lays a line roughly its own width; if the
// deposit rate is high enough to fill the dish, the history is lost.
// The floor keeps a fast swarm from reading as invisible: every
// body leaves SOMETHING, however thin.
const deposit = (0.30 + 0.70 * c.deposit) * law.deposit * dt * 0.032;
this.depositTrail(cls, nx, ny, deposit);
if (energy >= c.split * law.split && neighbours < 16) {
this.energy[i] = energy * 0.52;
if (this.queueAgent(
nx + this.rng.range(-0.012, 0.012),
ny + this.rng.range(-0.012, 0.012),
cls,
energy * 0.42
)) this.birthsThisFrame++;
} else if (energy <= 0) {
this.energy[i] = 0;
this.deathsThisFrame++;
}
}
}
compact() {
let w = 0;
for (let i = 0; i < this.count; i++) {
if (this.energy[i] <= 0) continue;
if (w !== i) {
this.x[w] = this.x[i];
this.y[w] = this.y[i];
this.vx[w] = this.vx[i];
this.vy[w] = this.vy[i];
this.energy[w] = this.energy[i];
this.age[w] = this.age[i];
this.cls[w] = this.cls[i];
}
w++;
}
this.count = w;
}
record(law) {
const h = this.history;
const f = this.frame;
if (f < 0 || f >= h.alive.length) return;
h.alive[f] = this.count;
let move = 0;
for (let i = 0; i < this.count; i++) move += Math.hypot(this.vx[i], this.vy[i]);
h.movement[f] = move;
let food = 0;
const res = this.resource;
for (let i = 0; i < res.length; i += 3) food += res[i];
h.food[f] = (food * 3) / res.length;
h.births[f] = this.birthsThisFrame;
h.deaths[f] = this.deathsThisFrame;
}
/** The camera's view of where something just happened. */
activityCell(x, y) {
const ax = Math.max(0, Math.min(31, (x * 32) | 0));
const ay = Math.max(0, Math.min(31, (y * 32) | 0));
return this.activity[ay * 32 + ax];
}
stats() {
const h = this.history;
let peak = 0;
for (let i = 0; i < h.alive.length; i++) peak = Math.max(peak, h.alive[i]);
return {
alive: this.count,
peak,
fieldMass: this.sampleFieldMass(),
trailMass: this.sampleTrailMass(),
name: this.genome.name,
};
}
sampleFieldMass() {
let s = 0;
for (let i = 0; i < this.resource.length; i++) s += this.resource[i];
return s / this.resource.length;
}
sampleTrailMass() {
let s = 0;
for (let c = 0; c < 3; c++) {
const t = this.trail[c];
for (let i = 0; i < t.length; i++) s += t[i];
}
return s / (this.trail[0].length * 3);
}
}