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
+128
View File
@@ -0,0 +1,128 @@
// The system-level report: does each episode keep time with its song
// (coupling) while still having dynamics the song does not explain
// (residual)? And do different songs — grown by different songs' laws —
// actually diverge? Outputs a table and out/report.md.
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadSong } from './load.js';
import { Run } from '../src/film.js';
import { evaluate, fingerprint, divergence } from '../src/metrics.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const BANK = resolve(ROOT, '../flow-state/test/songs');
const SEEDS = [999, 12345, 777777];
function args(argv) {
const out = {};
for (let i = 2; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1];
return out;
}
const opt = args(process.argv);
const names = (opt.songs || 'drone,runner,dusk').split(',').map((s) => s.trim()).filter(Boolean);
const seeds = opt.seeds ? opt.seeds.split(',').map(Number) : SEEDS;
mkdirSync(resolve(ROOT, 'out'), { recursive: true });
const rows = [];
for (const name of names) {
for (const seed of seeds) {
const { song } = loadSong(resolve(BANK, `${name}.wav`), { fps: 30, seed });
const run = new Run(song, { fps: 30, width: 480, height: 270, seed });
run.simulate();
const world = run.world;
const m = evaluate(world, song);
const fp = fingerprint(world);
rows.push({
song: name,
seed,
species: song.name,
peak: world.stats().peak,
pop: world.count,
sortCount: song.events.length,
hue: Number(song.palette.hue.toFixed(2)),
...m,
fp,
});
}
}
const md = [];
md.push('# metabolism — song × seed report');
md.push('');
md.push(`Generated ${new Date().toISOString().slice(0, 10)} · 3 test signals (${names.join(', ')}) × ${seeds.length} seeds each.`);
md.push('');
md.push('| song | seed | species | peak | pop | coupling | residual | **fitness** | hue |');
md.push('|---|---|---|---|---|---|---|---|---|');
for (const r of rows) {
md.push(`| ${r.song} | ${r.seed} | ${r.species} | ${r.peak} | ${r.pop} | ${fps2(r.coupling)} | ${fps2(r.residual)} | **${fps2(r.fitness)}** | ${r.hue} |`);
}
md.push('');
md.push('## Grown structure (average over seeds)');
md.push('');
md.push('| song | occupancy | cluster | trailFill | class mix b/f/s |');
md.push('|---|---|---|---|---|');
const bySong = new Map();
for (const r of rows) {
if (!bySong.has(r.song)) bySong.set(r.song, []);
bySong.get(r.song).push(r);
}
for (const name of names) {
const list = bySong.get(name) || [];
const avg = {
occupancy: (list.reduce((s, r) => s + r.fp.occupancy, 0) / list.length).toFixed(3),
cluster: (list.reduce((s, r) => s + r.fp.cluster, 0) / list.length).toFixed(2),
trailFill: (list.reduce((s, r) => s + r.fp.trailFill, 0) / list.length).toFixed(3),
mix: [0, 1, 2].map((i) =>
(list.reduce((s, r) => s + r.fp.classMix[i], 0) / list.length).toFixed(2)
).join('/'),
};
md.push(`| ${name} | ${avg.occupancy} | ${avg.cluster} | ${avg.trailFill} | ${avg.mix} |`);
}
md.push('');
md.push('## Divergence (L1 distance between fingerprints)');
md.push('');
md.push('Across songs (seed 12345 each):');
md.push('');
md.push('| pair | divergence |');
md.push('|---|---|');
const songs = [...new Set(rows.map((r) => r.song))];
const char = (song, seed) => rows.find((r) => r.song === song && r.seed === seed).fp;
for (let i = 0; i < songs.length; i++) {
for (let j = i + 1; j < songs.length; j++) {
const d = divergence(char(songs[i], 12345), char(songs[j], 12345));
md.push(`| ${songs[i]} ↔ ${songs[j]} | ${fps2(d)} |`);
}
}
md.push('');
md.push('Across seeds (drone):');
md.push('');
md.push('| pair | divergence |');
md.push('|---|---|');
for (let i = 0; i < seeds.length; i++) {
for (let j = i + 1; j < seeds.length; j++) {
const d = divergence(char('drone', seeds[i]), char('drone', seeds[j]));
md.push(`| seed ${seeds[i]} ↔ ${seeds[j]} | ${fps2(d)} |`);
}
}
md.push('');
md.push('_Note: coupling is correlation with the song envelope, residual is 1 − R² after');
md.push('regressing out song features; fitness is a blend of both. See src/metrics.js._');
const text = md.join('\n') + '\n';
const reportFile = resolve(ROOT, 'out', 'report.md');
writeFileSync(reportFile, text);
writeFileSync(resolve(ROOT, 'out', 'report.json'), JSON.stringify(rows, null, 2));
console.log(text);
console.log('wrote ' + reportFile);
function fps2(v) {
return v.toFixed(3).replace('-', '−');
}