music-video-gen/flow-state/src/checks/variety/report.js
Dejvino 89e05459c0 Every shot stands on something, and the frame has two ends
A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.

Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.

The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.

The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.

Chasing the numbers turned up four real faults:

  * A scene that reads prev() cannot be a ground. It returns the whole
    composited frame including the layers above it, so a datamosh under a shot
    is eating it: the render stopped reproducing from a seek and two WebGL
    contexts diverged by 91/255 against a tolerance of 4.
  * Screen was the wrong operator for a shot over a bed. It lightens, so a
    median quarter of every frame clipped to paper and whole sections rendered
    100% white. Replaced by a lumakey — the shot's brightness is its alpha.
  * Feedback was an accumulator: a still image settled at 2.3x its own
    brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
    plus a highlight shoulder so the top rolls off instead of clipping.
  * useTrack never prewarmed, so a fresh Show's first frame differed from every
    later render of it — the export-breaking hazard Compositor.prime documents.

Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.

Rendered across twelve videos, middle of every section:

    painted        51% mean, darkest 0.3%  ->  87% mean, darkest 43%
    clipped white  24% median, worst 100%  ->   1% mean, worst 30%
    separation     0.10                    ->  0.44

Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:02:01 +02:00

703 lines
29 KiB
JavaScript

// The variety measurement: does changing the seed actually change the video?
//
// A raw distance between two seeds means nothing on its own — 0.14 is not
// interpretable. It is only a number once it sits between two references that
// the same instrument produced:
//
// FLOOR how far one video travels from ITSELF over its own length (drift).
// Two seeds that differ by less than this are, in the only sense that
// matters, the same video shown twice.
// CEILING how far two videos are when the same pipeline is run with the
// design deliberately thrown away — every layer recast at random.
// This is the most variety the library can express, so it is what the
// generator is measured against, not some abstract 1.0.
//
// separation = (between-seed - floor) / (ceiling - floor)
//
// 0 means the seed does nothing a viewer could name. 1 means two seeds are as
// unalike as two randomly assembled videos. The honest target is somewhere well below
// 1 — a generator with a house style SHOULD land under the ceiling — but it has
// to clear the floor by a wide margin, and the per-block breakdown is what says
// where the missing variety went.
//
// Alongside the pixel measurement there is a SPEC measurement, which needs no
// GPU: how much the generator's own decisions differ across seeds. Pixels
// measure the symptom, the spec measures the cause. If spec diversity is high
// and pixel variety is low, the generator is deciding freely and the renderer
// or the post chain is flattening it. If spec diversity is also low, the
// casting is the bottleneck and no amount of shader work will fix it.
import { Show } from '../../Show.js';
import { generateLook } from '../../look/LookGenerator.js';
import { scenes } from '../../scenes/registry.js';
import { defaultValues, sampleValues, canBackground } from '../../params/schema.js';
import { Rng } from '../../engine/rng.js';
import { videoSignature, signatureDistance, STRUCTURAL } from './signature.js';
import { songBank } from '../../audio/songbank.js';
import { hashString } from '../../engine/rng.js';
import { subjectOf, isGround } from '../../look/stack.js';
import { canGround } from '../../scenes/surface.js';
const RENDER = { width: 160, height: 90 };
/** Signature for one seed, rendered through the whole normal pipeline. */
export function signatureForSeed(track, seed, options = {}) {
const { pool = null, poolSize, ...rest } = options;
const show = new Show({ ...RENDER });
try {
show.useTrack(track, generateLook(track, {
seed: seed >>> 0, pool, ...(poolSize ? { poolSize } : {}),
}));
return videoSignature(show, rest);
} finally {
show.dispose();
}
}
/**
* The ceiling reference: a video with the design thrown away.
*
* Same pipeline, same shot planning, same post — but every layer is recast to a
* scene picked uniformly at random and its parameters resampled without regard
* for the section. Two of these agree about nothing, so the distance between
* them is the most this library and this renderer can express. That is the
* honest thing to measure the generator against: not 1.0, which no pipeline
* reaches, and not two default-parameter scenes either — that was the first
* attempt, and it produced videos so internally uneventful that real seeds
* scored ABOVE the supposed ceiling on three blocks out of five.
*/
export function signatureForChaos(track, seed, options = {}) {
const show = new Show({ ...RENDER });
try {
const look = generateLook(track, { seed: seed >>> 0 });
const rng = new Rng((seed * 2246822519) >>> 0);
const pool = scenes.filter(canBackground);
// The ground keeps its JOB when its identity is thrown away. Recasting
// it from the whole library would give the reference videos thin,
// half-black frames no real video can have any more, and a ceiling
// measured on those is a ceiling for a pipeline that does not exist —
// it fell below the floor the first time this ran.
const groundPool = scenes.filter(canGround);
const temperament = look.personality && look.personality.temperament;
for (const section of look.sections) {
for (const variant of section.variants) {
for (const layer of variant) {
layer.module = rng.pick(isGround(layer) ? groundPool : pool);
layer.params = sampleValues(layer.module, rng, section.bias, temperament);
layer.seed = rng.int(0, 0x7fffffff);
}
}
section.layers = section.variants[0];
}
show.useTrack(track, look);
return videoSignature(show, options);
} finally {
show.dispose();
}
}
/**
* Signature for a video forced onto ONE library scene.
*
* `sampled` swaps default parameters for a seeded draw, which is what the
* ceiling wants: defaults are the middle of every range and make a scene look
* tamer than the generator would ever cast it.
*/
export function signatureForScene(track, module, seed, options = {}) {
const { sampled = false, ...rest } = options;
const show = new Show({ ...RENDER });
try {
const look = generateLook(track, { seed: seed >>> 0 });
const prng = new Rng((seed * 40503) >>> 0);
for (const section of look.sections) {
const layers = [{
module,
params: sampled
? sampleValues(module, prng, section.bias,
look.personality && look.personality.temperament)
: defaultValues(module),
seed: seed >>> 0,
blend: 'normal',
opacity: 1,
}];
section.variants = [layers];
section.layers = layers;
for (const shot of section.shots || []) shot.variant = 0;
}
show.useTrack(track, look);
return videoSignature(show, rest);
} finally {
show.dispose();
}
}
/**
* The ceiling: videos with the same STRUCTURE as real ones, cast from pools that
* share no scenes at all.
*
* Getting this reference right took three attempts, and both failures were
* instructive enough to record.
*
* 1. Recast every layer at random. Averaging a dozen random scenes converges
* on the same generic busy image every time, so two "chaos" videos came out
* closer to each other than two real ones.
* 2. One scene per video, no rotation. That collapsed the other way: a video
* that never changes scene has almost no internal variation, so the ceiling
* landed BELOW the floor, which is a within-video quantity.
*
* The reference has to match what it is bounding. These keep the real pipeline —
* rosters, shots, per-section sampling, so a reference video rotates between
* three or four scenes exactly as a real one does — while the pools they draw
* from are disjoint slices of the library. Same complexity, nothing in common.
*/
export function ceilingSignatures(track, { count = 4, probes = 4, seed = 0xbadc0de } = {}) {
const rng = new Rng(seed >>> 0);
const pool = rng.shuffle(scenes.filter(canBackground));
const slice = Math.max(4, Math.floor(pool.length / count));
const out = [];
for (let i = 0; i < count; i++) {
const mine = pool.slice(i * slice, (i + 1) * slice);
if (mine.length < 4) break;
const show = new Show({ ...RENDER });
try {
// The real generator, given a restricted cast. An earlier version
// reached in and reassigned every layer at random instead, and that
// averaged a dozen scenes per video — the more sections a track had,
// the more its references converged on the same generic image, so
// the ceiling fell BELOW the floor on any track with five sections.
show.useTrack(track, generateLook(track, {
seed: (seed + i * 40503) >>> 0,
pool: mine,
}));
out.push(videoSignature(show, { probes }));
} finally {
show.dispose();
}
}
return out;
}
/**
* Every visualization in the library, measured structurally, against every
* other one.
*
* This is the map the seed test is drawn on. A library of sixty names is not a
* library of sixty looks: two scenes built from different maths can land on the
* same image — the same feature scale, the same composition, the same kind of
* movement — and once they do, no amount of casting variety can produce a
* different-looking video by choosing between them.
*
* Note what this catches that the existing per-scene "distinct" gate cannot.
* That one compares raw pixels, so two scenes that are structurally the same
* image in different colours pass it comfortably. Here colour is not counted at
* all, so structural twins have nowhere to hide.
*
* Default parameters throughout: the question is what a scene inherently looks
* like, and sampled parameters would make the answer depend on which roll it
* got.
*/
export function librarySweep(track, { probes = 3, onProgress = null } = {}) {
const pool = scenes.filter(canBackground);
const sigs = [];
for (let i = 0; i < pool.length; i++) {
sigs.push(signatureForScene(track, pool[i], 4242, { probes }));
if (onProgress) onProgress(i + 1, pool.length, pool[i].name);
}
const n = pool.length;
const matrix = Array.from({ length: n }, () => new Float64Array(n));
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const d = signatureDistance(sigs[i], sigs[j]).total;
matrix[i][j] = d;
matrix[j][i] = d;
}
}
const all = [];
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) all.push(matrix[i][j]);
all.sort((a, b) => a - b);
const nearest = pool.map((module, i) => {
let best = Infinity, at = -1;
for (let j = 0; j < n; j++) {
if (i !== j && matrix[i][j] < best) { best = matrix[i][j]; at = j; }
}
return { name: module.name, family: module.family, nearest: pool[at].name, distance: best };
});
// COMPLETE-link clustering at the twin threshold: a scene joins a group only
// if it is close to every member, not merely to one of them.
//
// Single link was the first attempt and it lied. Structural distance is
// chainable — A near B, B near C, C near D — so it reported fourteen scenes
// as one look when what actually existed was a chain of overlapping pairs.
// A group here means every pair inside it is a twin, which is a claim worth
// acting on.
const twinAt = all[Math.floor(all.length * 0.02)]; // the closest 2% of pairs
const order = [];
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) order.push([matrix[i][j], i, j]);
order.sort((a, b) => a[0] - b[0]);
const groupOf = new Array(n).fill(-1);
const clusters = [];
for (const [d, i, j] of order) {
if (d > twinAt) break;
const gi = groupOf[i], gj = groupOf[j];
const fits = (member, group) => group.every((k) => matrix[member][k] <= twinAt);
if (gi < 0 && gj < 0) {
groupOf[i] = groupOf[j] = clusters.length;
clusters.push([i, j]);
} else if (gi >= 0 && gj < 0 && fits(j, clusters[gi])) {
clusters[gi].push(j); groupOf[j] = gi;
} else if (gj >= 0 && gi < 0 && fits(i, clusters[gj])) {
clusters[gj].push(i); groupOf[i] = gj;
}
}
const groups = clusters.map((c) => c.map((i) => pool[i].name));
return {
scenes: pool.map((m) => m.name),
matrix,
nearest,
median: all[Math.floor(all.length / 2)],
mean: mean(all),
p05: all[Math.floor(all.length * 0.05)],
twinAt,
twins: groups.filter((g) => g.length > 1).sort((a, b) => b.length - a.length),
twinPairs: order.filter(([d]) => d <= twinAt)
.map(([d, i, j]) => ({ a: pool[i].name, b: pool[j].name, distance: d })),
closestPairs: nearest.slice().sort((a, b) => a.distance - b.distance).slice(0, 8),
};
}
function pairwise(items, fn) {
const out = [];
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) out.push(fn(items[i], items[j], i, j));
}
return out;
}
const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0);
/**
* The full measurement.
*
* @param {FeatureTrack} track one analysed track — held FIXED, so the only
* variable is the seed. Measuring across different audio would confound
* "the generator ignores its seed" with "these songs are alike".
* @param {object} options
* @returns {object} report
*/
export function measureVariety(track, {
seeds = 8, refScenes = 5, probes = 5, seed0 = 0x5eed,
} = {}) {
const seedList = [];
for (let i = 0; i < seeds; i++) seedList.push((seed0 + i * 2654435761) >>> 0);
const sigs = seedList.map((s) => signatureForSeed(track, s, { probes }));
// --- floor: a video against itself, later ---------------------------
const floor = mean(sigs.map((s) => s.drift));
// --- between-seed ---------------------------------------------------
const between = pairwise(sigs, (a, b) => signatureDistance(a, b));
const observed = mean(between.map((d) => d.total));
// --- ceiling: single-scene videos, each on a different scene ---------
const refSigs = ceilingSignatures(track, { count: refScenes, probes });
const ceilingPairs = pairwise(refSigs, (a, b) => signatureDistance(a, b));
const ceiling = mean(ceilingPairs.map((d) => d.total));
// The same statistic on the single-scene references — a video with no story
// in it at all. It is the zero this measurement is read against, rather than
// a theoretical 0: probes are not evenly spaced and a slow scene drifts on
// its own, so an arcless video does not score exactly nothing.
const directionFloor = mean(refSigs.map((s) => s.direction ?? 0));
// If the reference is not above the floor it is not a ceiling, and the
// ratio built on it is meaningless rather than large. Say so instead of
// printing four digits of nonsense.
const valid = ceiling > floor * 1.05;
const separation = valid ? (observed - floor) / (ceiling - floor) : NaN;
// Per block, the same three numbers — this is the diagnosis.
const byBlock = {};
for (const block of [...STRUCTURAL, 'colour']) {
const b = mean(between.map((d) => d.byBlock[block] ?? 0));
const c = mean(ceilingPairs.map((d) => d.byBlock[block] ?? 0));
byBlock[block] = {
between: b,
ceiling: c,
ratio: c > 1e-6 ? b / c : 0,
};
}
// Nearest-neighbour collapse: for each seed, how close is its closest
// sibling? A healthy generator has no seed that another seed shadows. A mean
// can look acceptable while two of eight seeds are visually the same video.
const nearest = sigs.map((_, i) => {
let best = 1;
for (let j = 0; j < sigs.length; j++) {
if (i === j) continue;
best = Math.min(best, signatureDistance(sigs[i], sigs[j]).total);
}
return best;
});
return {
seeds: seedList,
floor,
// Of that floor, how much is a video GOING somewhere rather than merely
// changing. The floor alone cannot tell the two apart, and a video with
// a story raises it on purpose. See variety/signature.js directionOf.
direction: mean(sigs.map((s) => s.direction ?? 0)),
directionFloor,
ceiling,
observed,
separation,
ceilingValid: valid,
identity: ceiling > 1e-6 ? observed / ceiling : 0,
byBlock,
nearest,
worstPair: worstPairOf(seedList, between),
motion: mean(sigs.map((s) => s.motion)),
};
}
function worstPairOf(seedList, between) {
let idx = 0, best = Infinity, k = 0;
for (let i = 0; i < seedList.length; i++) {
for (let j = i + 1; j < seedList.length; j++) {
if (between[k].total < best) { best = between[k].total; idx = k; }
k++;
}
}
k = 0;
for (let i = 0; i < seedList.length; i++) {
for (let j = i + 1; j < seedList.length; j++) {
if (k === idx) return { a: i, b: j, distance: best, byBlock: between[k].byBlock };
k++;
}
}
return null;
}
// --- spec-level diversity -------------------------------------------------
// No GPU, milliseconds to run. This is the one to run first when the score
// drops, because it says whether the generator ever MEANT to make two different
// videos.
function entropy(values) {
const counts = new Map();
for (const v of values) counts.set(v, (counts.get(v) || 0) + 1);
let h = 0;
for (const c of counts.values()) {
const p = c / values.length;
h -= p * Math.log2(p);
}
const max = Math.log2(Math.max(2, counts.size));
return { unique: counts.size, entropy: h, normalized: max > 0 ? h / Math.log2(values.length) : 0 };
}
export function measureSpecDiversity(track, { seeds = 32, seed0 = 0x5eed } = {}) {
const looks = [];
for (let i = 0; i < seeds; i++) {
looks.push(generateLook(track, { seed: (seed0 + i * 2654435761) >>> 0 }));
}
// Overlay-only scenes excluded on both sides of the ratio. They were
// counted in the numerator and not the denominator, which reported 102%
// coverage once the casting pool started reaching them.
const sceneSets = looks.map((l) => [...new Set(
l.sections.flatMap((s) => s.variants.flatMap(
(v) => v.filter((layer) => canBackground(layer.module))
.map((layer) => layer.module.name))),
)].sort());
const usable = scenes.filter(canBackground);
const covered = new Set(sceneSets.flat());
const uncast = usable.filter((m) => !covered.has(m.name)).map((m) => m.name);
// Jaccard distance between the scene SETS of two seeds: the most direct
// possible statement of "did the generator cast a different show".
const jaccard = pairwise(sceneSets, (a, b) => {
const A = new Set(a), B = new Set(b);
let inter = 0;
for (const x of A) if (B.has(x)) inter++;
const union = A.size + B.size - inter;
return union ? 1 - inter / union : 0;
});
return {
seeds,
libraryCoverage: covered.size / usable.length,
uncast,
sceneSetDistance: mean(jaccard),
identicalCasts: pairwise(sceneSets, (a, b) => (a.join('|') === b.join('|') ? 1 : 0))
.reduce((x, y) => x + y, 0),
director: entropy(looks.map((l) => l.director)),
paletteScheme: entropy(looks.map((l) => l.paletteScheme)),
signature: entropy(looks.map((l) => l.personality.signature.join('+'))),
grain: entropy(looks.map((l) => l.grain.mode)),
framing: entropy(looks.map((l) => l.framing.mode)),
paletteArc: entropy(looks.map((l) => l.paletteArc.mode)),
anchorScenes: entropy(looks.map((l) => l.sections.map((s) => subjectOf(s.layers).module.name).join('>'))),
};
}
// --- the SONG variety test -------------------------------------------------
//
// The seed test holds the song fixed and varies the seed, which answers "does
// the generator's randomness do anything". This varies the SONG, which is the
// question that actually matters: two different tracks should not obviously
// come out of the same software.
//
// It needs one thing the seed test does not. Separation alone can be reached by
// a generator that ignores the audio entirely and hashes the file — that would
// score perfectly and be completely wrong, because the video would have nothing
// to do with the music. So coupling is measured alongside it: songs that sound
// alike should look alike, and songs that sound different should look different.
// A high separation with zero coupling is not variety, it is noise.
/** Distance between two tracks as MUSIC, on the statistics the generator reads. */
function musicalDistance(a, b) {
const axes = [
[(t) => t.summary.bpm, 120],
[(t) => t.summary.meanCentroid, 0.7],
[(t) => t.summary.meanFlatness, 0.8],
[(t) => t.summary.dynamicRange, 0.7],
[(t) => t.sections.length, 6],
];
let d = 0;
for (const [of_, span] of axes) d += Math.min(1, Math.abs(of_(a) - of_(b)) / span);
return d / axes.length;
}
/** Spearman rank correlation — monotone association, robust to the scales. */
function spearman(xs, ys) {
const rank = (values) => {
const order = values.map((v, i) => [v, i]).sort((p, q) => p[0] - q[0]);
const r = new Array(values.length);
order.forEach(([, i], k) => { r[i] = k; });
return r;
};
const rx = rank(xs), ry = rank(ys);
const n = xs.length;
let sum = 0;
for (let i = 0; i < n; i++) sum += (rx[i] - ry[i]) ** 2;
return 1 - (6 * sum) / (n * (n * n - 1) || 1);
}
/**
* @param {object} options
* @returns {object} report
*/
export function measureSongVariety({
songs = 6, probes = 5, refScenes = 4, pool = null, poolSize = null, seedSalt = 0,
} = {}) {
const bank = songBank({ count: songs });
// The seed is derived from the audio in the real pipeline, so each song must
// get its own — deriving it from the name is the same relationship without
// needing the samples.
const sigs = bank.map((entry) =>
signatureForSeed(entry.track, (hashString(entry.name) + seedSalt) >>> 0,
{ probes, pool, poolSize }));
const floor = mean(sigs.map((s) => s.drift));
const between = [];
const musical = [];
const visual = [];
for (let i = 0; i < bank.length; i++) {
for (let j = i + 1; j < bank.length; j++) {
const d = signatureDistance(sigs[i], sigs[j]);
between.push({ ...d, a: bank[i].name, b: bank[j].name });
musical.push(musicalDistance(bank[i].track, bank[j].track));
visual.push(d.total);
}
}
const observed = mean(visual);
// Ceiling on the same songs, so it is not a different measurement.
const refSigs = ceilingSignatures(bank[0].track, { count: refScenes, probes });
const ceilingPairs = [];
for (let i = 0; i < refSigs.length; i++) {
for (let j = i + 1; j < refSigs.length; j++) {
ceilingPairs.push(signatureDistance(refSigs[i], refSigs[j]));
}
}
const ceiling = mean(ceilingPairs.map((d) => d.total));
const byBlock = {};
for (const block of [...STRUCTURAL, 'colour']) {
const b = mean(between.map((d) => d.byBlock[block] ?? 0));
const c = mean(ceilingPairs.map((d) => d.byBlock[block] ?? 0));
byBlock[block] = { between: b, ceiling: c, ratio: c > 1e-6 ? b / c : 0 };
}
const nearest = sigs.map((_, i) => {
let best = 1, at = i;
for (let j = 0; j < sigs.length; j++) {
if (i === j) continue;
const d = signatureDistance(sigs[i], sigs[j]).total;
if (d < best) { best = d; at = j; }
}
return { song: bank[i].name, nearest: bank[at].name, distance: best };
});
return {
bank: bank.map((b) => ({ name: b.name, covers: b.covers, kinds: b.track.sections.map((s) => s.kind) })),
floor,
ceiling,
observed,
separation: ceiling > floor * 1.05 ? (observed - floor) / (ceiling - floor) : NaN,
ceilingValid: ceiling > floor * 1.05,
identity: ceiling > 1e-6 ? observed / ceiling : 0,
coupling: spearman(musical, visual),
byBlock,
nearest,
pairs: between.map((d, k) => ({ ...d, musical: musical[k] }))
.sort((x, y) => x.total - y.total),
};
}
/**
* The house fingerprint: which descriptor dimensions never move.
*
* This is the direct measurement of "you can tell it came from the same
* software". A dimension whose variance across real outputs is a small fraction
* of its variance across randomly assembled ones is a constant the generator
* imposes on every video it makes — and constants are exactly what a viewer
* learns to recognise. Reported per block, in the order they most give the game
* away.
*/
export function measureFingerprint({ songs = 6, probes = 4, refScenes = 5 } = {}) {
const bank = songBank({ count: songs });
const ours = bank.map((entry) =>
signatureForSeed(entry.track, hashString(entry.name), { probes }));
const refs = ceilingSignatures(bank[0].track, { count: refScenes, probes, seed: 0x1337 });
// One vector per VIDEO, not per probe. Pooling probes mixes in how much each
// video varies over its own length, which is large for everything and washed
// the answer out to a flat 100% — the measurement said nothing was frozen
// while the separation score said almost everything was.
const flatten = (sigs, block) => sigs.map((s) => {
const rows = s.probes.map((p) => p[block]);
const out = new Array(rows[0].length).fill(0);
for (const r of rows) for (let i = 0; i < r.length; i++) out[i] += r[i] / rows.length;
return out;
});
const variance = (rows) => {
if (!rows.length) return [];
const n = rows[0].length;
const out = new Array(n).fill(0);
for (let d = 0; d < n; d++) {
const col = rows.map((r) => r[d]);
const m = col.reduce((a, b) => a + b, 0) / col.length;
out[d] = col.reduce((a, b) => a + (b - m) ** 2, 0) / col.length;
}
return out;
};
const tells = [];
for (const block of [...STRUCTURAL, 'colour']) {
const vo = variance(flatten(ours, block));
const vr = variance(flatten(refs, block));
const ratios = vo.map((v, d) => (vr[d] > 1e-12 ? v / vr[d] : 1));
const blockRatio = mean(ratios);
tells.push({
block,
ratio: blockRatio,
frozen: ratios.filter((r) => r < 0.15).length,
dims: ratios.length,
});
}
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;
// Every scene that actually draws the cast, not a hardcoded list — so this
// number tracks the migration instead of being pinned to the four scenes
// that happened to be written first.
const stages = stageNames
? stageNames.map((n) => scenes.find((m) => m.name === n)).filter(Boolean)
: scenes.filter((m) => (m.consumes || []).includes('cast') && canBackground(m));
// 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, stageCount: stages.length,
};
}