From 00ad1d8c2b29a31bc6559b2fa39c079f963c14dd Mon Sep 17 00:00:00 2001 From: Dejvino Date: Mon, 17 Aug 2026 21:42:04 +0200 Subject: [PATCH] Measure where visual difference comes from, and correct the epic's framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cheap measurements settled a question three expensive ones had not, and both of them contradicted the diagnosis offered for it. The stated diagnosis was that the identity's expressive range had become the bottleneck. It has not. A census of the identities themselves — no GPU, seconds to run — puts mean distance between twelve songs at 0.43 with 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 decomposition then asked whether those designs reach the picture, by holding the container fixed and varying only the identity, then the reverse: identity only 0.0299 container only 0.0557 both 0.1101 neither 0.0000 Identity is worth 54% of what the container is worth, against an instrument noise floor of exactly zero, and the two compose to more than their sum. The inversion works at the frame level. What it does not do is replace the container. That corrects EPIC-3 §5, which proposed a song picking two to five stages on the theory that shared content would substitute for container variety. Container variety is the larger of the two effects and identity adds to it. Four stages with a rich identity throws away the 0.056 the library was already providing — which is exactly the shape of every measurement in this epic: stages have the lowest floor of any arm and no advantage in spread. The direction is therefore 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 rather than their own. The migration was filed as a cost; it is the payoff. Co-Authored-By: Claude Opus 5 --- flow-state/EPIC-3.md | 35 +++++++ flow-state/src/checks/main.js | 17 ++++ flow-state/src/checks/variety/print.js | 52 +++++++++- flow-state/src/checks/variety/report.js | 75 +++++++++++++++ flow-state/tools/identity-census.js | 120 ++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 flow-state/tools/identity-census.js 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');