Measure where visual difference comes from, and correct the epic's framing

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 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-17 21:42:04 +02:00
parent 6e4106048b
commit 00ad1d8c2b
5 changed files with 298 additions and 1 deletions

View File

@ -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

View File

@ -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 = `<pre class="scene-gate">${lines.join('\n')}</pre>`;
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';

View File

@ -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)}%)` };
}

View File

@ -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 };
}

View File

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