A reproducible migration, and the first eighteen scenes through it

The library is sixty-one scenes, which is too many to convert from memory or
from taste, so the migration is a queue with a gate rather than a judgement call
per file.

Three pieces. A classifier reads each shader and assigns a tier from evidence in
the source — drawn, figure, field, treatment — so two passes over the library
reach the same answer and the work has an order. MIGRATION.md is the recipe per
tier, written to be followed mechanically. And a gate makes the result
verifiable: `consumes` is now a schema field, the lint enforces it in both
directions, and the per-scene battery renders each scene under two deliberately
distant identities and requires the picture to change.

That gate is the part that matters. Without it `consumes` is a comment, and the
whole inversion becomes unverifiable at exactly the point where it stops being
checkable by eye. With it, a scene that declares the cast and ignores it fails.

Eighteen scenes migrated. Four by hand at the drawn tier — Firefly Drift,
Metaballs, Floating Geometry, Prism Bloom — and ten at the field tier by script,
which is one declaration and one wrapped return. All eighteen pass.

The field tier is honestly marginal and the gate says so: every one of the ten
moves by 37 to 39 of 255, against 173 to 255 for the drawn tier, and the
uniformity across ten unrelated scenes is the tell. That is one global
posterisation applying, not ten scenes expressing anything. Cheap, real, shallow.

The decomposition moved from identity being worth 54% of the container to 158%,
but the stage set changed underneath the measurement and part of that is
Metaballs expressing a cast better than Constellation did. What survives the
caveat is the useful finding: a migrated library scene carries the identity
better than a stage written from scratch to carry it. The four bespoke stages
were the wrong shape of effort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-17 21:59:24 +02:00
co-authored by Claude Opus 5
parent 00ad1d8c2b
commit e5bb7d77e0
21 changed files with 517 additions and 47 deletions
+40
View File
@@ -80,6 +80,19 @@ const CONTRACT_UNIFORMS = new Set([
'u_sigDrift', 'u_sigSway', 'u_sigSwayRate', 'u_sigSpin', 'u_sigBreathe',
'u_sigHorizon', 'u_sigDepth', 'u_sigWash',
'u_sigLine', 'u_sigSoft', 'u_sigTexture', 'u_sigFold',
'u_sigFrameScale', 'u_sigFrameShift',
// The identity artifacts. See look/Identity.js. A scene reads these through
// castMain/inkMask/stageNode rather than directly, but a migrated scene may
// legitimately branch on one — Prism Bloom takes its facet count from the
// cast's side count — so they belong in the contract set.
'u_castSides', 'u_castRound', 'u_castElong', 'u_castTilt',
'u_castNotchN', 'u_castNotchD', 'u_castHollow',
'u_chorusSides', 'u_chorusRound', 'u_chorusElong', 'u_chorusTilt',
'u_chorusNotchN', 'u_chorusNotchD', 'u_chorusHollow',
'u_inkWeight', 'u_inkEdge', 'u_inkFill', 'u_inkHatchAngle',
'u_inkHatchScale', 'u_inkOutline', 'u_inkPosterize',
'u_latKind', 'u_latJitter', 'u_latSpread',
'u_latScaleSpread', 'u_latScaleBias', 'u_latScale',
]);
/**
@@ -94,6 +107,16 @@ const CONTRACT_UNIFORMS = new Set([
// sigShape ever did — the form is the subject rather than a hint applied to one
// — so castMain/castChorus count as evidence. Likewise inkMask/inkValue are the
// style trait carried out in full. See scenes/stage/README.md.
// The same idea as TRAIT_EVIDENCE, for the identity artifacts: a scene that
// declares it consumes the cast has to actually draw it. Without this,
// `consumes` is a comment, and the migration becomes unverifiable the moment it
// is more than a handful of files.
const ARTIFACT_EVIDENCE = {
cast: /\bcast(Main|Chorus|SDF)\s*\(/,
ink: /\bink(Mask|Value|Pattern)\s*\(/,
staging: /\b(stageNode|stageScale)\s*\(/,
};
const TRAIT_EVIDENCE = {
shape: /\b(sig(Shape|Form)|cast(Main|Chorus|SDF))\s*\(/,
camera: /\bsigCamera\s*\(/,
@@ -193,6 +216,23 @@ console.log('\nscene schema lint');
}
}
for (const artifact of module.consumes || []) {
const evidence = ARTIFACT_EVIDENCE[artifact];
if (evidence && !evidence.test(src)) {
fail(`${id}: declares it consumes '${artifact}' but the shader never calls it — ` +
`an artifact that is declared and ignored is worse than one that is ` +
`not declared, because the casting code will believe it`);
}
}
// The reverse: using an artifact without declaring it hides the scene
// from the migration status report and from anything that selects on
// capability later.
for (const [artifact, evidence] of Object.entries(ARTIFACT_EVIDENCE)) {
if (evidence.test(src) && !(module.consumes || []).includes(artifact)) {
fail(`${id}: uses the '${artifact}' artifact but does not declare it in \`consumes\``);
}
}
// A dead camera: `p = sigCamera(p)` and then nothing reads p again.
// This passed the evidence grep above, passed review, and shipped — the
// Phase 9 render gate later measured the scene's response to the camera
+130
View File
@@ -0,0 +1,130 @@
// Which scenes have migrated to the identity artifacts, and what each of the
// rest needs. See MIGRATION.md for the recipe this reports progress against.
//
// Sixty-one scenes is too many to migrate from memory or from taste. The
// classifier below reads each shader and proposes a TIER from evidence in the
// source, so the work is a queue rather than a judgement call each time, and so
// two passes over the library reach the same answer.
//
// It is deliberately conservative: it proposes, the gate disposes. A scene is
// only migrated when `consumes` is declared AND checks.html?scene=<name> shows
// the matching `consumes:` line passing, which no amount of pattern matching
// can fake.
//
// node tools/migration-status.js progress plus the next ten
// node tools/migration-status.js --all every scene
// node tools/migration-status.js --tier drawn
import { scenes } from '../src/scenes/registry.js';
const has = (n) => process.argv.includes(`--${n}`);
const argOf = (n) => {
const i = process.argv.indexOf(`--${n}`);
return i >= 0 ? process.argv[i + 1] : null;
};
/**
* Tiers, in migration order. The order is by payoff per unit of risk: scenes
* that already loop over discrete elements are a near-mechanical change and are
* where the cast actually shows, while a full-frame field has no elements to
* replace and can only take the ink.
*/
const TIERS = {
drawn: {
consumes: ['cast', 'ink', 'staging'],
why: 'loops over discrete elements — replace the primitive with the cast, '
+ 'the placement with stageNode, the edge with inkMask',
},
figure: {
consumes: ['cast', 'ink'],
why: 'draws one or a few forms without a lattice — take the cast and the ink, '
+ 'keep its own composition',
},
field: {
consumes: ['ink'],
why: 'a continuous surface with no elements — inkValue on the way out, '
+ 'inkPattern where it already dithers or hatches',
},
treatment: {
consumes: ['ink'],
why: 'an effect over an image rather than an image — takes the value '
+ 'structure only, and should probably become an EFFECT rather than a scene',
},
};
const RX = {
migrated: /\b(castMain|castChorus|castSDF|inkMask|inkValue|stageNode)\s*\(/,
// A loop whose body places something at a computed position: the signature
// of an element-placing scene.
elementLoop: /for\s*\(\s*int\s+\w+\s*=\s*0[\s\S]{0,900}?(length\s*\(\s*p\s*-|p\s*-\s*(pos|centre|center|c)\b|sigForm\s*\(|smoothstep\s*\([^)]*\bd\b)/,
anyLoop: /for\s*\(\s*int\s+\w+\s*=\s*0/,
// A single distance-field subject, no loop needed.
figure: /\bsig(Shape|Form)\s*\(|\bsdf\w*\s*\(|length\s*\(\s*p\s*\)\s*-/,
// Screen-space corruption: it operates on the frame, not on a world.
treatment: /\bprev\s*\(|\bvUv\b[\s\S]{0,200}(tear|glitch|shift)|u_prev/,
field: /\b(fbm|vnoise|curl|voronoi|noise)\s*\(/,
};
function classify(module) {
const src = module.shader || '';
if (RX.migrated.test(src) || (module.consumes || []).length) return 'migrated';
if (module.role === 'accent') return 'accent';
if (RX.elementLoop.test(src)) return 'drawn';
if (RX.treatment.test(src)) return 'treatment';
if (RX.figure.test(src) && !RX.anyLoop.test(src)) return 'figure';
if (RX.field.test(src)) return 'field';
return 'figure';
}
const rows = scenes.map((m) => ({
name: m.name,
family: m.family,
role: m.role || 'stage',
tier: classify(m),
consumes: m.consumes || [],
lines: (m.shader || '').split('\n').length,
}));
const migrated = rows.filter((r) => r.tier === 'migrated');
const pending = rows.filter((r) => r.tier !== 'migrated' && r.tier !== 'accent');
const accents = rows.filter((r) => r.tier === 'accent');
const bar = (v, w = 28) => '█'.repeat(Math.round(v * w)) + '·'.repeat(w - Math.round(v * w));
console.log(`\nMIGRATION STATUS — ${migrated.length}/${migrated.length + pending.length} scenes on the identity artifacts\n`);
console.log(` ${bar(migrated.length / Math.max(1, migrated.length + pending.length))} ` +
`${(migrated.length / Math.max(1, migrated.length + pending.length) * 100).toFixed(0)}%` +
` (${accents.length} accents excluded — they are depth passes, not subjects)\n`);
console.log(' REMAINING BY TIER\n');
for (const [tier, spec] of Object.entries(TIERS)) {
const group = pending.filter((r) => r.tier === tier);
if (!group.length) continue;
console.log(` ${tier}${group.length} scenes → consumes: [${spec.consumes.join(', ')}]`);
console.log(` ${spec.why}`);
console.log(` ${group.map((r) => r.name).join(', ')}`);
console.log('');
}
if (has('all') || argOf('tier')) {
const want = argOf('tier');
console.log(' PER SCENE\n');
for (const r of rows.filter((x) => !want || x.tier === want)) {
console.log(` ${r.name.padEnd(24)} ${r.family.padEnd(11)} ${r.tier.padEnd(10)}` +
`${r.consumes.length ? `[${r.consumes.join(',')}]` : ''}`);
}
console.log('');
} else if (pending.length) {
// The queue: drawn first, biggest payoff and least invention required.
const order = ['drawn', 'figure', 'field', 'treatment'];
const next = pending.slice().sort(
(a, b) => order.indexOf(a.tier) - order.indexOf(b.tier) || a.lines - b.lines);
console.log(' NEXT TEN\n');
for (const r of next.slice(0, 10)) {
console.log(` ${r.name.padEnd(24)} ${r.tier.padEnd(10)} ${r.family.padEnd(11)} ${r.lines} shader lines`);
}
console.log('');
console.log(' Recipe: MIGRATION.md. Verify one with:');
console.log(` checks.html?scene=${encodeURIComponent(next[0].name)}`);
console.log(' and look for a passing "consumes:" line for every artifact declared.\n');
}