Files
Dejvino 13b0939221 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.
2026-10-24 08:23:33 +02:00

128 lines
4.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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('-', '−');
}