// 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('-', '−'); }