// Phase 11 gate — Epic 2. See EPIC-2.md.
//
// Phase 10 asked whether two tracks look different from each other. This phase
// asks the question that only shows up when you actually sit and watch one:
// does a single track hold five minutes?
//
// The failures it exists to catch were all measured on a working build, not
// imagined — a cut metronome, a palette that never moves, scenes that are
// violently animated and read as static, and a kind-to-family table that made
// every video make the same genre decisions.

import { check, expect } from './framework.js';
import { generateLook } from '../look/LookGenerator.js';
import { FeatureTrack } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS } from '../look/shots.js';
import { DIRECTORS, RESTFUL_FAMILIES, pickDirector } from '../look/directors.js';
import { scenes, scenesInFamily } from '../scenes/registry.js';
import { paletteShiftAt, MAX_HUE_ROTATION } from '../look/paletteArc.js';
import { shiftPalette, paletteContrast } from '../look/palette.js';
import { ArcDriver } from '../look/ArcDriver.js';
import { Engine } from '../engine/Engine.js';
import { featureProviderFor } from '../audio/FeatureTrack.js';
import { sampleValues, clampValue } from '../params/schema.js';
import { Rng } from '../engine/rng.js';
import { frameDistance, frameLuminance } from '../engine/hash.js';
import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.js';

/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
let cached = null;
function tempoBattery() {
    if (!cached) {
        cached = [
            { name: '90bpm', track: track(90) },
            { name: '124bpm', track: track(124) },
            { name: '150bpm', track: track(150) },
        ];
    }
    return cached;
}
function track(bpm) {
    return FeatureTrack.fromAudioBuffer(
        synthesizeSectioned({ bpm, duration: 300, changeAt: 150 }), { fps: 60 });
}

/** Every shot in a look, with its length in seconds, grouped by section. */
function shotLengths(look) {
    return look.sections.map((s) => (s.shots || []).map((sh) => (sh.endFrame - sh.startFrame) / 60));
}

check(11, 'a section does not cut on a metronome', () => {
    // The complaint, as a number. Before this, a five-minute track at 90 BPM
    // was sixteen shots of 18.7 seconds — coefficient of variation about 0.01.
    // A section has to show real spread between its shot lengths, or the eye
    // starts predicting the cuts and the video reads as a slideshow.
    const problems = [];
    let measured = 0;

    for (const { name, track: t } of tempoBattery()) {
        for (let s = 0; s < 6; s++) {
            const look = generateLook(t, { seed: 2100 + s * 7919 });
            shotLengths(look).forEach((lengths, si) => {
                if (lengths.length < 3) return;      // too few shots to have a rhythm
                measured++;
                const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
                const sd = Math.sqrt(
                    lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
                const cv = sd / Math.max(1e-6, mean);
                if (cv < 0.12) {
                    problems.push(`${name} s${s}/§${si}: cv ${cv.toFixed(3)} over ` +
                        `${lengths.length} shots (${lengths.map((l) => l.toFixed(1)).join(', ')})`);
                }
            });
        }
    }

    return expect(measured > 0 && problems.length === 0,
        problems.length ? problems.slice(0, 3).join(' · ')
            : `${measured} sections all cut with varying shot lengths`);
});

check(11, 'the cutting rhythm repeats rather than wandering', () => {
    // The counter-check, and the reason the gate above is not sufficient on its
    // own: RANDOM shot lengths would pass it and would look worse than a
    // metronome. The ear is following an eight-bar structure; the eye has to be
    // following one too. So a section's shot lengths must come from a small set
    // of values that recur, not from a continuum.
    const problems = [];
    let measured = 0;

    for (const { name, track: t } of tempoBattery()) {
        for (let s = 0; s < 6; s++) {
            const look = generateLook(t, { seed: 3300 + s * 6841 });
            shotLengths(look).forEach((lengths, si) => {
                if (lengths.length < 5) return;
                measured++;
                // Quantise to a quarter second: two shots from the same pattern
                // entry differ only by downbeat snapping.
                const buckets = new Set(lengths.map((l) => Math.round(l * 4)));
                if (buckets.size > Math.ceil(lengths.length * 0.7)) {
                    problems.push(`${name} s${s}/§${si}: ${buckets.size} distinct lengths ` +
                        `over ${lengths.length} shots — wandering, not phrased`);
                }
            });
        }
    }

    return expect(measured > 0 && problems.length === 0,
        problems.length ? problems.slice(0, 3).join(' · ')
            : `${measured} sections reuse a small set of shot lengths`);
});

check(11, 'shot length floor and ceiling still hold', () => {
    // The rhythm work moves cuts around; these two limits are what keep it from
    // producing a subliminal flash or a two-minute hold.
    const problems = [];
    let count = 0;

    for (const { name, track: t } of tempoBattery()) {
        for (let s = 0; s < 8; s++) {
            const look = generateLook(t, { seed: 4400 + s * 15485863 });
            shotLengths(look).forEach((lengths, si) => {
                lengths.forEach((l, i) => {
                    count++;
                    // Half a frame of slack: shot bounds are rounded to frames.
                    if (l < MIN_SHOT_SECONDS - 0.02) problems.push(`${name} s${s}/§${si}#${i}: ${l.toFixed(2)}s under floor`);
                    if (l > MAX_SHOT_SECONDS + 0.02) problems.push(`${name} s${s}/§${si}#${i}: ${l.toFixed(2)}s over ceiling`);
                });
            });
        }
    }

    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 4).join(' · ')
            : `${count} shots all within ${MIN_SHOT_SECONDS}–${MAX_SHOT_SECONDS}s`);
});

check(11, 'cuts land on the beat grid', () => {
    // A cut that lands across a phrase instead of on it reads as a mistake, and
    // it is the thing most easily lost when shot timing stops being uniform.
    const problems = [];
    let total = 0;
    let onGrid = 0;

    for (const { name, track: t } of tempoBattery()) {
        const beatSeconds = t.tempo.period / 60;
        for (let s = 0; s < 6; s++) {
            const look = generateLook(t, { seed: 5500 + s * 2654435761 });
            look.sections.forEach((section) => {
                (section.shots || []).forEach((shot, i) => {
                    if (i === 0) return;             // section starts are not cuts
                    total++;
                    const at = shot.startFrame / 60;
                    // Distance to the nearest downbeat, in beats.
                    let best = Infinity;
                    for (const d of (t.tempo.downbeats || [])) {
                        best = Math.min(best, Math.abs(d - at));
                    }
                    if (best <= beatSeconds * 0.75) onGrid++;
                });
            });
        }
        if (!t.tempo.downbeats || !t.tempo.downbeats.length) problems.push(`${name}: no downbeat grid`);
    }

    const ratio = total ? onGrid / total : 0;
    return expect(problems.length === 0 && total > 0 && ratio > 0.8,
        `${onGrid}/${total} cuts within three quarters of a beat of a downbeat ` +
        `(${(ratio * 100).toFixed(0)}%, floor 80%)`);
});

// --- the director ----------------------------------------------------------
// EPIC-2.md §3.2. The kind-to-family mapping used to be a module constant, so
// every video made the same genre decisions before a single seeded draw
// happened. It is now a per-track choice — see look/directors.js.

check(11, 'every scene in the library is reachable', () => {
    // The number that made this worth doing: across twelve tracks, twenty-nine
    // of forty-two scenes were cast in none of them. A scene no director can
    // reach is dead weight, and the fault is in the mapping rather than in the
    // scene, so this is asked of the mapping directly.
    const reachable = new Set();
    for (const d of DIRECTORS) {
        for (const families of Object.values(d.families)) {
            for (const f of families) {
                for (const m of scenesInFamily(f)) reachable.add(m.name);
            }
        }
    }
    // Accents are cast by role rather than by family and are always eligible.
    const missing = scenes.filter((m) => m.role !== 'accent' && !reachable.has(m.name));

    return expect(missing.length === 0,
        missing.length ? `unreachable: ${missing.map((m) => m.name).join(', ')}`
            : `all ${reachable.size} non-accent scenes reachable across ${DIRECTORS.length} directors`);
});

check(11, 'no director starves a section kind', () => {
    // A director is a point of view, not a corner. Every kind it defines has to
    // leave enough scenes to build a roster from and still have room for the
    // signature filter to remove some.
    const problems = [];
    for (const d of DIRECTORS) {
        for (const [kind, families] of Object.entries(d.families)) {
            const pool = new Set();
            for (const f of families) {
                for (const m of scenesInFamily(f)) if (m.role !== 'accent') pool.add(m.name);
            }
            if (pool.size < 12) problems.push(`${d.name}/${kind}: only ${pool.size}`);
        }
        const kinds = Object.keys(d.families);
        for (const k of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) {
            if (!kinds.includes(k)) problems.push(`${d.name}: no mapping for '${k}'`);
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
            : `${DIRECTORS.length} directors, every kind backed by 12+ scenes`);
});

check(11, 'no director puts a loud family in a quiet section', () => {
    // Phase 7 has enforced this since the minimal family existed, and the first
    // draft of directors.js broke it — `corrupt` opened on glitch, `geometer`
    // on geometric. An intro that opens strobing and a breakdown that answers a
    // lull with a dense pattern are the two specific mistakes the family
    // coupling exists to prevent, and a viewer meets them in the first fifteen
    // seconds. Asserted against the mappings directly so a new director cannot
    // reintroduce it without tripping this rather than a downstream render gate.
    const problems = [];
    for (const d of DIRECTORS) {
        for (const kind of ['intro', 'breakdown', 'outro']) {
            for (const f of d.families[kind] || []) {
                if (!RESTFUL_FAMILIES.includes(f)) problems.push(`${d.name}/${kind}: ${f}`);
            }
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
            : `${DIRECTORS.length} directors keep intro/breakdown/outro on ${RESTFUL_FAMILIES.join('/')}`);
});

check(11, 'every director is castable', () => {
    // Weighting tilts the odds by timbre; none of it may drive a weight to zero,
    // or a director exists in the file and never in a video.
    const probes = [
        { meanFlatness: 0.02, meanCentroid: 0.15 },   // tonal and dark
        { meanFlatness: 0.5,  meanCentroid: 0.9 },    // noisy and bright
        { meanFlatness: 0.2,  meanCentroid: 0.5 },
    ];
    const rng = { pickWeighted: (arr, w) => w };      // capture the weights
    const problems = [];
    for (const p of probes) {
        const weights = pickDirector(p, rng);
        weights.forEach((w, i) => {
            if (!(w > 0)) problems.push(`${DIRECTORS[i].name} unreachable at flatness ${p.meanFlatness}`);
        });
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
            : `all ${DIRECTORS.length} directors carry weight on every timbre probed`);
});

check(11, 'two tracks do not agree on what a section kind looks like', () => {
    // The population question. Across a battery, the same section kind must be
    // answered by more than one family, or the director layer is decorative.
    const seen = new Map();       // kind -> Set of family names actually cast
    const directors = new Set();

    for (const { track: t } of tempoBattery()) {
        for (let s = 0; s < 20; s++) {
            const look = generateLook(t, { seed: 6600 + s * 7919 });
            directors.add(look.director);
            look.sections.forEach((section) => {
                const set = seen.get(section.kind) || new Set();
                (section.variants || [section.layers]).forEach((v) => set.add(v[0].module.family));
                seen.set(section.kind, set);
            });
        }
    }

    const thin = [...seen.entries()].filter(([, set]) => set.size < 3)
        .map(([kind, set]) => `${kind}: ${[...set].join('/')}`);

    // Three of five in a population this size is a real spread; demanding all
    // five would be demanding a particular draw rather than a working mechanism,
    // and reachability is asserted directly by the check above.
    return expect(directors.size >= 3 && thin.length === 0,
        `${directors.size}/${DIRECTORS.length} directors cast · ` +
        (thin.length ? `thin kinds — ${thin.join(' · ')}`
            : [...seen.entries()].map(([k, v]) => `${k}:${v.size}fam`).join(' ')));
});

check(11, 'a track shows more of the library than it used to', () => {
    // Twelve tracks used to reach thirteen distinct scenes between them. This
    // asks the same question of the same size population.
    const cast = new Set();
    tempoBattery().forEach(({ track: t }, i) => {
        for (let s = 0; s < 4; s++) {
            const look = generateLook(t, { seed: (i * 2654435761 + s * 40503) >>> 0 });
            look.sections.forEach((section) =>
                (section.variants || [section.layers]).forEach((v) => cast.add(v[0].module.name)));
        }
    });
    const pool = scenes.filter((m) => m.role !== 'accent').length;
    return expect(cast.size >= pool * 0.4,
        `${cast.size}/${pool} scenes cast across 12 tracks (floor ${Math.ceil(pool * 0.4)})`);
});

// --- colour movement -------------------------------------------------------
// EPIC-2.md §3.3. The palette was generated once and pushed to every layer of
// every section for the whole runtime — five minutes, one scheme, no movement,
// with colour being the strongest perceptual variable available.

check(11, 'the palette moves across a track', () => {
    // The complaint as a number. Sample the arc from first frame to last and
    // require the colours to actually end up somewhere else.
    const moved = [];
    const held = [];

    for (const { track: t } of tempoBattery()) {
        for (let s = 0; s < 8; s++) {
            const look = generateLook(t, { seed: 7700 + s * 6841 });
            const arc = look.paletteArc;
            let worst = 0;
            for (const kind of ['intro', 'drop', 'breakdown', 'outro']) {
                for (const p of [0, 0.5, 1]) {
                    const shift = paletteShiftAt(arc, {
                        progress: p, sectionKind: kind,
                        features: { sectionEnergy: p, buildSlope: 0 },
                    });
                    const shifted = shiftPalette(look.palette, shift);
                    for (let i = 0; i < look.palette.length; i++) {
                        const a = look.palette[i], b = shifted[i];
                        worst = Math.max(worst, Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]));
                    }
                }
            }
            (arc.mode === 'static' ? held : moved).push(worst);
        }
    }

    const weak = moved.filter((d) => d < 0.03).length;
    const ratio = moved.length / (moved.length + held.length);
    return expect(weak === 0 && ratio > 0.6,
        `${moved.length} moving / ${held.length} held · weakest move ` +
        `${(moved.length ? Math.min(...moved) : 0).toFixed(3)} channel distance (floor 0.03)`);
});

check(11, 'colour movement never breaks the contrast floor', () => {
    // The Phase 3 palette gate proves the palette is usable when it is
    // generated. That says nothing about where the arc takes it — a saturation
    // lift or a lightness shift can flatten a perfectly good set. So the floor
    // is asserted at every sampled point of the movement rather than only at
    // the two ends, which is where this would otherwise be silently violated.
    const problems = [];
    let sampled = 0;

    for (const { track: t } of tempoBattery()) {
        for (let s = 0; s < 6; s++) {
            const look = generateLook(t, { seed: 8800 + s * 15485863 });
            const base = paletteContrast(look.palette).luminanceSpread;
            for (const kind of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) {
                for (let p = 0; p <= 1.001; p += 0.25) {
                    for (const energy of [0, 0.5, 1]) {
                        sampled++;
                        const shift = paletteShiftAt(look.paletteArc, {
                            progress: p, sectionKind: kind,
                            features: { sectionEnergy: energy, buildSlope: energy },
                        });
                        const spread = paletteContrast(
                            shiftPalette(look.palette, shift)).luminanceSpread;
                        // Held against the palette's OWN spread, not an absolute:
                        // the arc must not degrade what generation achieved.
                        if (spread < Math.min(0.18, base * 0.75)) {
                            problems.push(`seed ${s} ${kind}@${p.toFixed(2)}/e${energy}: ` +
                                `spread ${spread.toFixed(3)} from base ${base.toFixed(3)}`);
                        }
                    }
                }
            }
        }
    }

    return expect(problems.length === 0,
        problems.length ? `${problems.length} of ${sampled} points muddy — ${problems[0]}`
            : `${sampled} points across the movement all clear the contrast floor`);
});

check(11, 'colour movement stays inside its identity', () => {
    // The counter-check. A palette that moves far enough is a different
    // palette, and then the track has no colour identity at all — which would
    // pass the movement gate above with room to spare.
    const problems = [];
    for (const { track: t } of tempoBattery()) {
        for (let s = 0; s < 8; s++) {
            const look = generateLook(t, { seed: 9900 + s * 7919 });
            for (const kind of ['intro', 'drop', 'breakdown']) {
                for (const p of [0, 0.5, 1]) {
                    const shift = paletteShiftAt(look.paletteArc, {
                        progress: p, sectionKind: kind,
                        features: { sectionEnergy: p, buildSlope: p },
                    });
                    if (Math.abs(shift.hue) > MAX_HUE_ROTATION + 1e-6) {
                        problems.push(`seed ${s}: hue ${shift.hue.toFixed(2)} past the ceiling`);
                    }
                }
            }
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems[0]
            : `hue travel stays within ±${(MAX_HUE_ROTATION * 57.3).toFixed(0)}° of the track's palette`);
});

check(11, 'a moved palette is the same on a seek as on playback', () => {
    // The movement is memoised on a rounded shift. Rounding is what keeps a
    // seeked frame bit-identical to a played one rather than merely close, and
    // getting that wrong would be a determinism fault that only shows up in an
    // export.
    const t = tempoBattery()[1].track;
    const look = generateLook(t, { seed: 4321 });
    const a = new ArcDriver(look, t);
    const b = new ArcDriver(look, t);

    const probes = [0, 900, 4500, 9000, 12000];
    let worst = 0;
    try {
        // One driver plays through; the other jumps straight to each probe.
        for (let f = 0; f <= 12000; f += 30) a.update(f, t.at(f));
        for (const f of probes) {
            const seq = a._paletteAt(f, t.at(f));
            const jump = b._paletteAt(f, t.at(f));
            for (let i = 0; i < seq.length; i++) {
                for (let c = 0; c < 3; c++) worst = Math.max(worst, Math.abs(seq[i][c] - jump[i][c]));
            }
        }
    } finally {
        a.dispose(); b.dispose();
    }

    return expect(worst === 0,
        `worst channel difference between seeked and played colours: ${worst}`);
});

// --- the slow axis ---------------------------------------------------------
// EPIC-2.md §3.4. Eleven scenes changed as much in half a second as in two
// minutes: everything moving in them was cyclic, so the eye adapted in about
// two seconds. Drift is a 20-70s LFO and an LFO returns; the slow axis is
// monotonic across the whole track. See ArcDriver._slowAxisFor.

check(11, 'every scene gets a slow axis with real travel', () => {
    // Structural half of the gate, so a scene added later cannot quietly end up
    // with nothing to evolve.
    const t = tempoBattery()[1].track;
    const look = generateLook(t, { seed: 20250 });
    const arc = new ArcDriver(look, t);
    const problems = [];

    try {
        for (const module of scenes) {
            const axis = arc._slowAxisFor(module);
            const eligible = Object.entries(module.params || {}).filter(([, d]) =>
                d.type !== 'palette' && d.type !== 'bool' && !d.fixed && !d.rate && !d.noDrift && d.range);
            if (!eligible.length) continue;      // nothing it could legally move

            if (!axis.length) { problems.push(`${module.name}: no axis`); continue; }
            for (const item of axis) {
                const [lo, hi] = item.def.range;
                const fraction = Math.abs(item.travel) / (hi - lo);
                if (fraction < 0.2) problems.push(`${module.name}.${item.name}: travels only ${(fraction * 100).toFixed(0)}%`);
                if (item.def.rate) problems.push(`${module.name}.${item.name}: rate param on the axis`);
            }
        }
    } finally {
        arc.dispose();
    }

    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 3).join(' · ')
            : `${scenes.length} scenes all carry a slow axis travelling 20%+ of range`);
});

check(11, 'the slow axis is a journey rather than a cycle', () => {
    // The counter-check. An axis that returned to where it started would satisfy
    // "params move" and would leave the churn exactly as it was — which is what
    // the existing drift LFO already did.
    const t = tempoBattery()[1].track;
    const look = generateLook(t, { seed: 31337 });
    const arc = new ArcDriver(look, t);
    const problems = [];

    try {
        const cue = arc.cues[0];
        const at = (time) => arc._paramsAt(cue, 0, time, t.at(Math.round(time * 60)));
        const spec = arc._specFor(cue.sectionIndex, cue.variant, 0);
        const axis = arc._slowAxisFor(spec.module);

        const start = at(t.duration * 0.05);
        const mid = at(t.duration * 0.5);
        const end = at(t.duration * 0.95);

        for (const item of axis) {
            const [lo, hi] = item.def.range;
            const span = hi - lo;
            const a = start[item.name], m = mid[item.name], z = end[item.name];
            // Monotonic in the sense that matters: the end is further from the
            // start than the middle is, in the direction of travel.
            const total = Math.abs(z - a) / span;
            if (total < 0.12) {
                problems.push(`${spec.module.name}.${item.name}: start ${a.toFixed(3)} ` +
                    `mid ${m.toFixed(3)} end ${z.toFixed(3)} — only ${(total * 100).toFixed(0)}% travelled`);
            }
        }
        if (!axis.length) problems.push('no axis on the opening scene');
    } finally {
        arc.dispose();
    }

    return expect(problems.length === 0,
        problems.length ? problems.join(' · ') : 'the opening scene ends the track somewhere else');
});

/**
 * Structure, with the churn averaged out.
 *
 * Single-frame distance cannot answer "did this develop?" for exactly the
 * scenes that fail it: a churning scene's consecutive frames are already ~0.6
 * apart, so every pair of its frames scores the same whether the structure
 * moved or not. Averaging ten seconds of frames cancels the churn and leaves
 * the structure — and is much closer to what a viewer perceives over seconds
 * than any single frame is.
 *
 * Ten seconds was measured, not guessed. At a one-second window Moiré Grid's
 * frozen-parameter control still read 0.024; at ten it reads 0.0095, while the
 * axis-driven change stays at 0.037. The window has to be wide enough that the
 * control collapses and the signal does not.
 */
function averagedFrame(engine, look, module, params, frame0, n = 90, step = 7) {
    engine.setLayerSpecs([{ module, params, seed: 9, opacity: 1, blend: 'normal',
        palette: look.palette, personality: look.personality }]);
    engine.prime(frame0);
    engine.compositor.reset();
    let acc = null;
    for (let k = 0; k < n; k++) {
        const px = engine.readPixels(engine.renderFrame(frame0 + k * step));
        if (!acc) acc = new Float64Array(px.length);
        for (let i = 0; i < px.length; i++) acc[i] += px[i];
    }
    for (let i = 0; i < acc.length; i++) acc[i] /= n;
    return acc;
}

function meanAbs(a, b) {
    let sum = 0;
    for (let i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]);
    return sum / a.length / 255;
}

/**
 * How much the declared axis moves a scene, against the metric's own noise.
 *
 * The experiment holds TIME constant and varies only the axis, which the first
 * version of this check did not — and getting that wrong made it measure
 * something else entirely.
 *
 * That version compared the scene at thirty seconds against the scene at two
 * and a half minutes, and divided by what the scene did between those points
 * with its parameters frozen. But those are two different places in the song,
 * so the denominator was full of audio reactivity: the check was really asking
 * "does the slow axis move this scene MORE than the music does", which is a
 * different and much harder question, and one no scene should have to pass.
 * Circuit Bloom failed it at 0.92x while its mean luminance was moving 5.6x
 * across the axis — a large structural change, scored as nothing.
 *
 * So: one frame window, two parameter sets, and a control that is the same
 * parameters over the next window — the residual churn the ten-second average
 * failed to cancel, which is exactly the noise floor this measurement has to
 * beat. With the axis disabled the two parameter sets are identical and the
 * ratio is 0.00 by construction, which is what makes the companion check below
 * a real discriminator rather than a formality.
 */
function axisRatio(engine, arc, look, track, module, { withAxis }) {
    const paramsAt = (seconds) => {
        const out = sampleValues(module, new Rng(77), look.sections[0].bias,
            look.personality.temperament);
        if (!withAxis) return out;
        const p = Math.min(1, seconds / track.duration);
        const journey = p * p * (3 - 2 * p);
        for (const item of arc._slowAxisFor(module)) {
            if (typeof out[item.name] !== 'number') continue;
            out[item.name] = clampValue(item.def, out[item.name] + item.travel * (journey - 0.5));
        }
        return out;
    };

    const FRAME = 1800;
    const WINDOW = 90 * 7;
    const early = averagedFrame(engine, look, module, paramsAt(0), FRAME);
    const late = averagedFrame(engine, look, module, paramsAt(track.duration), FRAME);
    const control = averagedFrame(engine, look, module, paramsAt(0), FRAME + WINDOW);

    const moved = meanAbs(early, late);
    const noise = meanAbs(early, control);
    return { moved, noise, ratio: moved / Math.max(1e-6, noise) };
}

check(11, 'a declared slow axis actually changes the scene', () => {
    // EPIC-2.md §3.4, and the honest scope of it. A scene that declares an axis
    // is claiming that walking that param is what it looks like changing, so
    // the claim is measured: the structural change with the axis has to beat
    // what the scene does on its own by a real margin.
    const t = tempoBattery()[1].track;
    const look = generateLook(t, { seed: 5150 });
    const engine = new Engine({ width: 128, height: 72 });
    engine.timeline.setDuration(t.duration);
    engine.setFeatureProvider(featureProviderFor(t));
    const arc = new ArcDriver(look, t);

    const problems = [];
    const detail = [];
    try {
        const declared = scenes.filter((m) => Object.values(m.params || {}).some((d) => d.slowAxis));
        for (const module of declared) {
            const r = axisRatio(engine, arc, look, t, module, { withAxis: true });
            detail.push(`${module.name} ${r.ratio.toFixed(2)}x`);
            if (r.ratio < 1.25) {
                problems.push(`${module.name}: axis moved ${r.moved.toFixed(4)} against a ` +
                    `${r.noise.toFixed(4)} noise floor — only ${r.ratio.toFixed(2)}x`);
            }
        }
        if (!declared.length) problems.push('no scene declares a slow axis');
    } finally {
        arc.dispose();
        engine.dispose();
    }

    return expect(problems.length === 0,
        problems.length ? problems.join(' · ') : `${detail.join(', ')}`);
}, { slow: true });

// --- framing ---------------------------------------------------------------
// EPIC-2.md §3.5. Every scene was a locked-off, full-frame wide, so a cut changed
// the subject and never the framing. A shot now carries a scale and a recentre,
// applied in scene coordinates inside sigCamera — see look/framing.js. These
// gates hold the layer honest: it must actually change, stay within headroom, and
// be the same on a seek as on playback.

check(11, 'a shot cut actually changes the framing', () => {
    // The complaint as a number: before this, every shot in every video was the
    // same full-frame wide, and a cut moved the image and not the camera. The
    // gate is on the population — most tracks should be framed at all, and of
    // those, the cuts must really change the shot size rather than cosmetically
    // vary a number no eye can see.
    const tracks = tempoBattery();
    const modes = [];
    let nonLocked = 0;
    let withSizeChange = 0;
    let transitions = 0;
    let changed = 0;

    // Two seed families so the draw is not at the mercy of one: the whole gate
    // ran once against a single family whose first few seeds happened to land
    // heavy on `locked`, and a sample that small has no business holding a
    // population claim. Locked is ~15% of tracks by design; 24 seeds a family
    // put it far from that.
    for (const { track: t } of tracks) {
        for (const family of [11100, 12200]) {
            for (let s = 0; s < 12; s++) {
                const look = generateLook(t, { seed: family + s * 7919 });
                const style = look.framing;
                modes.push(style.mode);
                if (style.mode === 'locked') continue;
                nonLocked++;

                const arc = new ArcDriver(look, t);
                try {
                    for (let i = 1; i < arc.cues.length; i++) {
                        const a = arc.cues[i - 1].framing;
                        const b = arc.cues[i].framing;
                        if (!a || !b) continue;
                        transitions++;
                        if (a.size !== b.size) changed++;
                    }
                    const sizes = new Set(arc.cues.map((c) => c.framing && c.framing.size));
                    if (sizes.size > 1) withSizeChange++;
                } finally {
                    arc.dispose();
                }
            }
        }
    }

    const lockedShare = modes.filter((m) => m === 'locked').length / modes.length;
    const changeRate = transitions ? changed / transitions : 0;
    return expect(
        nonLocked > 0 && withSizeChange >= nonLocked * 0.6
            && lockedShare < 0.5 && changeRate >= 0.15,
        `${modes.length} tracks · locked ${(lockedShare * 100).toFixed(0)}% · ` +
        `${withSizeChange}/${nonLocked} framed tracks show 2+ sizes · ` +
        `${changed}/${transitions} cuts change size (${(changeRate * 100).toFixed(0)}%)`);
});

check(11, 'framing stays inside the library\'s headroom', () => {
    // A size is only worth using if the scene still has detail at that size.
    // Past ~2.2 the library runs out and a close-up is a blurry wide; below
    // ~0.55 the subject is a speck. Those bounds are the measured edges of what
    // is watchable, so every framed shot has to respect them.
    const problems = [];
    let framed = 0;

    for (const { track: t } of tempoBattery()) {
        for (let s = 0; s < 6; s++) {
            const look = generateLook(t, { seed: 12200 + s * 6841 });
            if (look.framing.mode === 'locked') continue;
            const arc = new ArcDriver(look, t);
            try {
                for (const cue of arc.cues) {
                    const f = cue.framing;
                    if (!f) continue;
                    framed++;
                    if (f.scale < 0.55 || f.scale > 2.2) {
                        problems.push(`§${cue.sectionIndex}#${cue.shotIndex}: scale ${f.scale.toFixed(2)}`);
                    }
                    if (Math.abs(f.shift[0]) > 0.3 || Math.abs(f.shift[1]) > 0.3) {
                        problems.push(`§${cue.sectionIndex}#${cue.shotIndex}: shift ${f.shift.map((v) => v.toFixed(2))}`);
                    }
                }
            } finally {
                arc.dispose();
            }
        }
    }

    return expect(problems.length === 0 && framed > 0,
        problems.length ? problems.slice(0, 3).join(' · ')
            : `${framed} framed shots all within scale headroom and off-centre`);
});

check(11, 'framing is identical on a seek and on playback', () => {
    // Framing is planned once per cue, but a reroll re-plans it. The gate that
    // matters is the same one every other layer honours: the same frame has to
    // carry the same framing however you reach it. Two drivers over the same
    // look must agree on every shot.
    const t = tempoBattery()[1].track;
    const look = generateLook(t, { seed: 31337 });
    const a = new ArcDriver(look, t);
    const b = new ArcDriver(look, t);

    const problems = [];
    try {
        for (let i = 0; i < a.cues.length; i++) {
            const fa = a.cues[i].framing;
            const fb = b.cues[i].framing;
            if (fa && fb && (fa.scale !== fb.scale
                || fa.shift[0] !== fb.shift[0] || fa.shift[1] !== fb.shift[1])) {
                problems.push(`cue ${i}: ${JSON.stringify(fa)} vs ${JSON.stringify(fb)}`);
            }
        }
    } finally {
        a.dispose(); b.dispose();
    }

    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 3).join(' · ')
            : `${a.cues.length} cues carry the same framing in both drivers`);
});

check(11, 'the axis measurement would notice if the axis stopped working', () => {
    // EPIC-2.md §4 names this failure mode by name: a gate that measures the
    // wrong thing. This one has already happened once here — the first version
    // of the check above used single-frame distance, which is saturated on
    // churning scenes, and passed while the axis was provably doing nothing.
    //
    // So the metric is verified by breaking what it is supposed to catch. Run
    // the identical measurement with the axis disabled; it must come back at
    // about 1.0 and fail the threshold the real check applies.
    const t = tempoBattery()[1].track;
    const look = generateLook(t, { seed: 5150 });
    const engine = new Engine({ width: 128, height: 72 });
    engine.timeline.setDuration(t.duration);
    engine.setFeatureProvider(featureProviderFor(t));
    const arc = new ArcDriver(look, t);

    const problems = [];
    const detail = [];
    try {
        const declared = scenes.filter((m) => Object.values(m.params || {}).some((d) => d.slowAxis));
        for (const module of declared) {
            const off = axisRatio(engine, arc, look, t, module, { withAxis: false });
            detail.push(`${module.name} ${off.ratio.toFixed(2)}x`);
            if (off.ratio >= 1.25) {
                problems.push(`${module.name}: reads ${off.ratio.toFixed(2)}x with the axis ` +
                    `DISABLED — the measurement is not tracking the axis`);
            }
        }
    } finally {
        arc.dispose();
        engine.dispose();
    }

    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
            : `axis disabled reads ${detail.join(', ')} — the measurement tracks the axis`);
}, { slow: true });

check(11, 'framing visibly changes every scene in the library', () => {
    // The gate the framing work shipped without, and the reason this revisit
    // found anything. The other framing checks assert that the PLAN differs —
    // cue sizes, distribution, headroom — and a plan that never reaches the
    // image passes all of them. It did: framing was applied inside sigCamera,
    // which is gated on the `camera` personality trait, so any scene that
    // declined the track's drift silently declined the shot size too. Scan
    // Tear, Pylon Grid, Pitch Shatter and the 3D layer rendered identically at
    // every size and nothing noticed.
    //
    // Measured relative to how much image there is, for the reason Phase 10
    // documents: most of this library is mostly dark.
    const PALETTE = [[1, 0.3, 0.2], [0.2, 0.8, 1], [1, 0.9, 0.3],
                     [0.6, 0.3, 1], [0.2, 1, 0.6], [1, 1, 1]];
    const PERSON = {
        shape: { sides: 0, roundness: 0.25, elongation: 1, tilt: 0 },
        camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 },
        space: { horizon: 0.5, depth: 0.5, washAngle: 0, wash: 0.2 },
        style: { lineWeight: 0.5, softness: 0.5, texture: 0, symmetry: 1 },
    };

    const engine = new Engine({ width: 160, height: 90 });
    engine.timeline.setDuration(60);
    const problems = [];
    let weakest = Infinity;
    let weakestName = '';

    try {
        const shot = (module, framing) => {
            engine.setLayerSpecs([{
                module, params: sampleValues(module, new Rng(31), {}, null),
                seed: 9, opacity: 1, blend: 'normal', palette: PALETTE, personality: PERSON,
            }]);
            // Framing is pushed onto live layers, the way the arc driver does it.
            const layers = engine.compositor.layers || [];
            layers.forEach((l) => l.setFraming && l.setFraming(framing));
            engine.prime(600);
            engine.compositor.reset();
            layers.forEach((l) => l.setFraming && l.setFraming(framing));
            return Uint8Array.from(engine.readPixels(engine.renderFrame(600)));
        };

        for (const module of scenes) {
            const normal = shot(module, { size: 'normal', scale: 1, shift: [0, 0] });
            const close = shot(module, { size: 'close', scale: SHOT_SIZES.close.scale, shift: [0.05, 0.02] });
            const wide = shot(module, { size: 'wide', scale: SHOT_SIZES.wide.scale, shift: [0.05, 0.02] });

            const brightness = Math.max(1e-3,
                (frameLuminance(normal) + frameLuminance(close)) * 0.5);
            const best = Math.max(frameDistance(normal, close), frameDistance(normal, wide))
                / brightness;

            if (best < weakest) { weakest = best; weakestName = module.name; }
            if (best < 0.05) {
                problems.push(`${module.name}${module.kind === 'layer3d' ? ' [3D]' : ''}: ` +
                    `${best.toFixed(3)} — renders the same at every shot size`);
            }
        }
    } finally {
        engine.dispose();
    }

    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
            : `${scenes.length} scenes all respond to framing · weakest ` +
              `${weakestName} at ${weakest.toFixed(2)} of its own brightness`);
}, { slow: true });
