// 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 } 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';

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((m) => m.role !== 'accent');
        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(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((m) => m.role !== 'accent'));
    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((m) => m.role !== 'accent');
    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));

    // 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,
        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 }));
    }

    // Accents 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) => layer.module.role !== 'accent')
                .map((layer) => layer.module.name))),
    )].sort());

    const usable = scenes.filter((m) => m.role !== 'accent');
    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) => s.layers[0].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') && m.role !== 'accent');

    // 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,
    };
}
