diff --git a/flow-state/EPIC-3.md b/flow-state/EPIC-3.md index 1fea53f..2361b43 100644 --- a/flow-state/EPIC-3.md +++ b/flow-state/EPIC-3.md @@ -339,6 +339,41 @@ as if they differed in one. And this metric's noise floor at seven songs is large enough to invent findings — anything under about 0.01 of spread needs repeats before it is believed. +## 9b. Where the difference comes from — and the correction to §5 + +Two cheap measurements settled what three expensive ones could not. + +**The identity is not the bottleneck.** `tools/identity-census.js` over twelve +songs: mean identity distance 0.43, no near-identical pairs, and full coverage +of every decision space — six of six fills, five of five lattices, six of six +protagonist forms, three of three element scales. The songs are handed genuinely +different designs. + +**The identity does reach the frame.** `checks.html?decompose=1` holds the stage +fixed and varies only the identity, then holds the identity fixed and varies +only the stage: + + identity only 0.0299 one stage, each song's cast, ink and lattice + container only 0.0557 one identity, four different stages + both 0.1101 what the generator actually does + neither 0.0000 the same thing twice — the noise floor + +Identity is worth 54% of what the container is worth, on a noise floor of +exactly zero, and the two compose to more than their sum. + +That is the mechanism working, and it corrects the framing in §5. The proposal +there was that a song picks two to five stages — that the identity would +*replace* container variety. It does not. Container variety is still the larger +of the two effects, and identity adds to it rather than substituting for it. A +generator with four stages and a rich identity throws away the 0.056 it could +have had from the library. + +So the direction is not "a small set of stages". It is **the whole library +consuming the cast**: keep the sixty-one containers, and make them draw the +song's content instead of their own. The migration in §8 stops being a nice-to- +have and becomes the entire point — and the payoff is additive with everything +the library already provides. + ## 10. The smallest experiment worth running first One artifact, three stages, one measurement. Do not build the whole identity layer on a diff --git a/flow-state/src/checks/main.js b/flow-state/src/checks/main.js index fb81a1c..6811457 100644 --- a/flow-state/src/checks/main.js +++ b/flow-state/src/checks/main.js @@ -2,6 +2,7 @@ import { runAll, summarize, allChecks } from './framework.js'; import { runSceneGate } from './scene-gate.js'; import { varietyReportLines, songVarietyReportLines, experimentReportLines, poolSweepLines, + decomposeReportLines, } from './variety/print.js'; // Registering a phase's checks is a side effect of importing it. @@ -137,6 +138,22 @@ async function main() { return; } + // checks.html?decompose=1 — identity against container, measured apart. + if (params.get('decompose')) { + summaryEl.textContent = 'decomposing identity vs container…'; + const started = Date.now(); + const { lines, ok, headline } = await decomposeReportLines({ + songs: Number(params.get('count')) || 6, + }); + out.innerHTML = `
${lines.join('\n')}
`; + summaryEl.textContent = `${headline} · ${((Date.now() - started) / 1000).toFixed(1)}s`; + summaryEl.className = ok ? 'ok' : 'bad'; + window.__CHECKS__ = { decompose: true, ok, lines }; + window.__CHECKS_DONE__ = true; + console.log('[decompose]\n' + lines.join('\n')); + return; + } + const phaseArg = params.get('phase'); const phases = phaseArg ? phaseArg.split(',').map(Number) : null; const skipSlow = params.get('slow') !== '1'; diff --git a/flow-state/src/checks/variety/print.js b/flow-state/src/checks/variety/print.js index a6df653..668d88e 100644 --- a/flow-state/src/checks/variety/print.js +++ b/flow-state/src/checks/variety/print.js @@ -8,7 +8,7 @@ import { song } from '../../audio/songbank.js'; import { measureVariety, measureSpecDiversity, librarySweep, - measureSongVariety, measureFingerprint, + measureSongVariety, measureFingerprint, measureDecomposition, } from './report.js'; import { generateLook, describeLook } from '../../look/LookGenerator.js'; @@ -365,3 +365,53 @@ export async function poolSweepLines({ songs = 6, probes = 4, sizes = null, repe return { lines, ok: true, headline: `widest spread at pool ${best.size}` }; } + + +/** + * Identity versus container, measured separately. + * + * The one experiment that says whether Epic 3 can work at all: hold the stage + * fixed and vary only the song's identity, then hold the identity fixed and + * vary only the stage. + */ +export async function decomposeReportLines({ songs = 6, probes = 3 } = {}) { + await new Promise((r) => setTimeout(r, 0)); + const d = measureDecomposition({ songs, probes }); + + const lines = []; + lines.push('IDENTITY vs CONTAINER — where visual difference actually comes from'); + lines.push(''); + lines.push(` ${d.songs} songs · one fixed track · stage held or varied as labelled`); + lines.push(''); + lines.push(` identity only ${bar(Math.min(1, d.identityOnly * 5))} ${d.identityOnly.toFixed(4)}`); + lines.push(` one stage (${d.stage}), each song's cast, ink and lattice`); + lines.push(''); + lines.push(` container only ${bar(Math.min(1, d.containerOnly * 5))} ${d.containerOnly.toFixed(4)}`); + lines.push(' one identity, four different stages'); + lines.push(''); + lines.push(` both ${bar(Math.min(1, d.both * 5))} ${d.both.toFixed(4)}`); + lines.push(' what the generator actually does'); + lines.push(''); + lines.push(` neither ${bar(Math.min(1, d.sameBoth * 5))} ${d.sameBoth.toFixed(4)}`); + lines.push(' the same thing rendered twice — the instrument\'s own noise floor'); + lines.push(''); + + const ratio = d.containerOnly > 1e-6 ? d.identityOnly / d.containerOnly : 0; + lines.push(` identity is worth ${(ratio * 100).toFixed(0)}% of what the container is worth.`); + lines.push(''); + if (d.identityOnly < d.sameBoth * 3) { + lines.push(' IDENTITY IS NOT REACHING THE FRAME. Swapping every design decision a song'); + lines.push(' makes moves the picture barely more than rendering the same thing twice.'); + lines.push(' More registers cannot help until this number moves.'); + } else if (ratio < 0.5) { + lines.push(' Identity reaches the frame but the container still dominates. The stages'); + lines.push(' express what they are given only weakly — worth fixing the stages before'); + lines.push(' adding registers for them to ignore.'); + } else { + lines.push(' Identity carries as much as the container does. The inversion is working'); + lines.push(' at the frame level, and the place to look next is whether the harness'); + lines.push(' aggregates it away.'); + } + lines.push(''); + return { lines, ok: ratio >= 0.5, headline: `identity ${d.identityOnly.toFixed(4)} vs container ${d.containerOnly.toFixed(4)} (${(ratio * 100).toFixed(0)}%)` }; +} diff --git a/flow-state/src/checks/variety/report.js b/flow-state/src/checks/variety/report.js index f3fb91b..d7f84c9 100644 --- a/flow-state/src/checks/variety/report.js +++ b/flow-state/src/checks/variety/report.js @@ -600,3 +600,78 @@ export function measureFingerprint({ songs = 6, probes = 4, refScenes = 5 } = {} } return { tells: tells.sort((a, b) => a.ratio - b.ratio) }; } + + +// --- where does visual difference actually come from? ---------------------- +// +// The identity census settled one question and opened a better one. Across +// twelve songs the identities are genuinely far apart — 0.43 mean distance, +// no near-identical pairs, every fill, lattice, form and scale used — while the +// videos separate by about half of what the reference reaches. So the +// bottleneck is not the identity's range. Either the stages fail to turn +// identity differences into different frames, or the instrument cannot see the +// difference when they do. +// +// Those are opposite problems with opposite fixes, and one experiment separates +// them: hold the container fixed and vary only the identity, then hold the +// identity fixed and vary only the container. + +/** + * @returns {{identityOnly:number, containerOnly:number, both:number, sameBoth:number}} + */ +export function measureDecomposition({ songs = 6, probes = 3, stageNames = null } = {}) { + const bank = songBank({ count: songs }); + const track = bank[0].track; + const names = stageNames || ['Procession', 'Constellation', 'Soloist', 'Swarm']; + const stages = names.map((n) => scenes.find((m) => m.name === n)).filter(Boolean); + + // Each song's identity, lifted off its own look so it can be transplanted. + const looks = bank.map((e) => generateLook(e.track, { seed: hashString(e.name) })); + + /** One stage, on one fixed track, wearing a given song's identity. */ + const render = (stage, look, seed) => { + const show = new Show({ ...RENDER }); + try { + const base = generateLook(track, { seed: seed >>> 0, pool: [stage] }); + // Transplant the identity AND the signature form it reads from — + // the protagonist's geometry lives in personality.shape. + base.personality = { + ...base.personality, + identity: look.personality.identity, + shape: look.personality.shape, + }; + show.useTrack(track, base); + return videoSignature(show, { probes }); + } finally { + show.dispose(); + } + }; + + const dist = (list) => { + const out = []; + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) out.push(signatureDistance(list[i], list[j]).total); + } + return mean(out); + }; + + // A: one container, many identities. This is the whole point of Epic 3 — + // if it is near zero, the inversion cannot work no matter how many + // registers get added. + const oneStage = stages[1] || stages[0]; + const identityOnly = dist(looks.map((l) => render(oneStage, l, 4242))); + + // B: one identity, many containers. The old lever, measured on its own. + const containerOnly = dist(stages.map((st) => render(st, looks[0], 4242))); + + // C: both vary, which is what the generator actually does. + const both = dist(looks.map((l, i) => render(stages[i % stages.length], l, 4242 + i))); + + // D: nothing varies — the noise floor of the instrument itself. + const sameBoth = dist([ + render(oneStage, looks[0], 4242), + render(oneStage, looks[0], 4242), + ]); + + return { identityOnly, containerOnly, both, sameBoth, stage: oneStage.name, songs: bank.length }; +} diff --git a/flow-state/tools/identity-census.js b/flow-state/tools/identity-census.js new file mode 100644 index 0000000..7a720cb --- /dev/null +++ b/flow-state/tools/identity-census.js @@ -0,0 +1,120 @@ +// How much does the IDENTITY itself vary between songs? +// +// The measured state after Epic 3's first two slices: stages have the lowest +// floor of any arm, so sharing content does make a video look like itself — but +// they do not beat ordinary scenes on the distance BETWEEN songs. The diagnosis +// offered for that was "the identity's expressive range is the bottleneck", +// which is a claim, and claims of that shape have been wrong twice already in +// this epic. +// +// It is also cheap to check, because it needs no GPU. If two songs' identities +// are already far apart and their videos are not, the stages are failing to +// express what they are given and more registers will not help. If the +// identities themselves are clustered, the registers are the problem and the +// diagnosis stands. +// +// node tools/identity-census.js +// node tools/identity-census.js 17 use the whole bank + +import { songBank } from '../src/audio/songbank.js'; +import { generateIdentity, FILLS, LATTICES } from '../src/look/Identity.js'; +import { Rng, hashString } from '../src/engine/rng.js'; + +const COUNT = Number(process.argv[2]) || 12; +const bank = songBank({ count: COUNT }); + +const identities = bank.map((e) => ({ + name: e.name, + id: generateIdentity(e.track.summary, new Rng(hashString(e.name)), e.track.sections.length), +})); + +/** + * Identity distance, 0..1, split by register. + * + * Categorical decisions (sides, fill, lattice) count as a flat 1 when they + * differ, because a hexagon is not "nearer" a pentagon than a circle in any way + * a viewer experiences. Continuous ones are scaled by the range they can take. + */ +function registerDistances(a, b) { + const cat = (x, y) => (x === y ? 0 : 1); + const num = (x, y, span) => Math.min(1, Math.abs(x - y) / span); + + const cast = [ + cat(a.cast.protagonist.sides, b.cast.protagonist.sides), + cat(a.cast.chorus.sides, b.cast.chorus.sides), + cat(a.cast.protagonist.notchCount > 0, b.cast.protagonist.notchCount > 0), + cat(a.cast.protagonist.hollow > 0, b.cast.protagonist.hollow > 0), + num(a.cast.protagonist.round, b.cast.protagonist.round, 0.5), + num(a.cast.protagonist.elong, b.cast.protagonist.elong, 0.7), + ]; + const ink = [ + cat(a.ink.fill, b.ink.fill), + cat(a.ink.outline > 0, b.ink.outline > 0), + cat(a.ink.posterize > 0, b.ink.posterize > 0), + num(a.ink.weight, b.ink.weight, 1), + num(a.ink.edge, b.ink.edge, 1), + ]; + const staging = [ + cat(a.lattice.kind, b.lattice.kind), + num(a.lattice.jitter, b.lattice.jitter, 0.8), + num(a.lattice.spread, b.lattice.spread, 0.7), + num(a.lattice.scaleSpread, b.lattice.scaleSpread, 1), + num(Math.log2(a.lattice.elementScale), Math.log2(b.lattice.elementScale), 2.8), + ]; + const mean = (v) => v.reduce((x, y) => x + y, 0) / v.length; + return { cast: mean(cast), ink: mean(ink), staging: mean(staging) }; +} + +const pairs = []; +for (let i = 0; i < identities.length; i++) { + for (let j = i + 1; j < identities.length; j++) { + pairs.push({ + a: identities[i].name, b: identities[j].name, + ...registerDistances(identities[i].id, identities[j].id), + }); + } +} +const mean = (v) => v.reduce((x, y) => x + y, 0) / v.length; +const bar = (v, w = 24) => '█'.repeat(Math.round(v * w)) + '·'.repeat(w - Math.round(v * w)); + +console.log(`\n${identities.length} songs · ${pairs.length} pairs\n`); +console.log('IDENTITY DISTANCE BETWEEN SONGS — per register, 0 = the same decisions\n'); +for (const reg of ['cast', 'ink', 'staging']) { + const v = pairs.map((p) => p[reg]); + const identical = v.filter((x) => x < 0.05).length; + console.log(` ${reg.padEnd(8)} ${bar(mean(v))} ${mean(v).toFixed(2)}` + + ` min ${Math.min(...v).toFixed(2)} max ${Math.max(...v).toFixed(2)}` + + ` ${identical} pairs near-identical`); +} +console.log(` ${'overall'.padEnd(8)} ${bar(mean(pairs.map((p) => (p.cast + p.ink + p.staging) / 3)))}` + + ` ${mean(pairs.map((p) => (p.cast + p.ink + p.staging) / 3)).toFixed(2)}`); + +// --- which decisions are actually being used ------------------------------ +// A register can look varied on average while one option takes nine songs in +// ten. Coverage of the option space is the other half of the question. +console.log('\nOPTION COVERAGE — how much of each decision space the bank reaches\n'); +const tally = (label, values, space) => { + const counts = new Map(); + for (const v of values) counts.set(v, (counts.get(v) || 0) + 1); + const used = [...counts.entries()].sort((x, y) => y[1] - x[1]); + console.log(` ${label.padEnd(16)} ${counts.size}/${space.length} used ` + + used.map(([k, n]) => `${k}×${n}`).join(' ')); +}; +tally('ink fill', identities.map((e) => e.id.ink.fill), FILLS); +tally('lattice', identities.map((e) => e.id.lattice.kind), LATTICES); +tally('protagonist', identities.map((e) => String(e.id.cast.protagonist.sides)), ['0', '3', '4', '5', '6', '8']); +tally('element scale', identities.map((e) => (e.id.lattice.elementScale < 0.2 ? 'tiny' + : e.id.lattice.elementScale > 0.6 ? 'huge' : 'mid')), ['tiny', 'mid', 'huge']); + +console.log('\nCLOSEST IDENTITY PAIRS\n'); +for (const p of pairs.slice().sort((x, y) => (x.cast + x.ink + x.staging) - (y.cast + y.ink + y.staging)).slice(0, 5)) { + console.log(` ${((p.cast + p.ink + p.staging) / 3).toFixed(2)} ${p.a.padEnd(9)} ≈ ${p.b.padEnd(9)}` + + ` cast ${p.cast.toFixed(2)} ink ${p.ink.toFixed(2)} staging ${p.staging.toFixed(2)}`); +} + +console.log('\nREAD THIS AGAINST THE VISUAL NUMBERS\n'); +console.log(' The stages arm separates songs by about 0.10 in visual distance against a'); +console.log(' reference of roughly 0.19. If identity distance here is already high, the'); +console.log(' stages are not expressing what they are handed and more registers will not'); +console.log(' help. If it is low, the registers are the bottleneck and widening them is'); +console.log(' the right next move.\n');