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');