arthur/frontend/test/parity/oracle.mjs
Olive Vaughn 942e2f38ab Port step 4: measure the anchor and the mouth, condition on its own
`stabilize` is three things wearing one name, and it is now three functions in two
stages: `flow/measure/anchor` fits the rigid transform, `flow/condition` smooths
its parameters, `flow/measure/mouth` measures the lip rings through the result.
Parity is on the COMPOSITION and not on the pieces -- a split that agreed
function by function and not end to end would be a split rather than a port.

The oracle now drives `stabilize` at three configurations and the port agrees to
1e-9 on ref, rigid, transforms, outer, inner and aperture, plus `smoothContours`
at three radii. Two of the three configurations are at aspect 0.5625, a 1080x1920
phone clip, because at aspect 1 `pick` is the identity: a port that dropped the
anisotropy correction outright would pass every other assertion in the suite.
148 tests, up from 134.

Three decisions worth the reading time.

`makeXform` is not ported, and its absence takes the face oval with it. It
centres on the oval's bounding box and zooms until the face is 80% of the raster
height, so every vertex it touched carried a cropping decision made once, at
analysis time, from one frame's landmarks. Geometry belongs in the node's own
local space with the framing as a transform on a node, so this is a deletion. The
oval's only other consumer was the placeholder plate outline, which is painting.

The residual is taken against the RAW fit, and the prototype took it against the
smoothed one. That is the only deliberate numeric divergence here, and parity is
kept by asserting `anchor/residuals` on exactly what the prototype handed it. The
number's job is to say whether a section is stabilisable at all; folding the
smoothing error into it makes a slider look like a property of the footage, and
docs/architecture.md lists the residual under stage 3, which requires it to be
knob-free. `condition/anchor` therefore replaces `:transforms` and leaves
`:residual` alone.

The stage order is not the strict chain the table in docs/architecture.md looks
like, and that document now says so. The fit is knob-free, conditioning smooths
it, and the rings are measured *through* the conditioned transform -- so
`anchor avg` does re-run the ring mapping, which is a few hundred frames of twenty
points. The guarantee was only ever about the part that reads a source pixel, and
that part never sees a transform.

Two things fall out and are asserted rather than assumed. Smoothing and
subsampling commute, because both are per-slot, which is what lets `vertices`
stay a stage-5 knob downstream of a stage-4 one -- and it is also why the port can
smooth the full twenty slots where the prototype smooths eight and still match.
And `condition/contours` is `geom/moving-average` per vertex per axis rather than
its own clamped window, so "radius 2" cannot come to mean two different things at
the two knobs.

One dead end recorded so nobody walks it twice: the synth's head is perfectly
rigid -- its jitter is a whole-head translation, which a similarity absorbs
exactly -- so every frame's rigid configuration is congruent with frame zero's and
the Procrustes mean IS frame zero to 1e-15, jitter or none. "The reference is the
mean and not frame zero" cannot be asserted on this track and is asserted in
geom-test, where the two can differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:00:11 -04:00

146 lines
7.2 KiB
JavaScript

// Runs the JS prototype — the numeric oracle — and writes its answers to JSON
// for arthur.parity-test to diff against the CLJS port.
//
// DELETABLE. This file and arthur.parity-test go together, in one commit, once
// the CLJS player renders the synthetic take correctly (port-plan step 5). A
// parity test pins behaviour while the code moves; keeping it afterwards would
// bake the prototype's mistakes into the rewrite and make them permanent.
//
// Math.random is stubbed to a constant so both sides get the IDENTICAL track:
// js/synth.js reads Math.random at call time, not at import time, so assigning
// it here — before synthDense is called below — is enough, and js/ stays
// untouched. 0.5 makes every (Math.random() - 0.5) jitter term exactly zero,
// which is also what `:rand-fn (constantly 0.5)` does on the CLJS side.
Math.random = () => 0.5;
import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { RIGID, LIPS_OUTER, EYE_R_RING, BROW_A_RING, FACE_OVAL,
subsampleSlots } from '../../../js/landmarks.js';
import { fitSimilarity, applySim, fitResidual, procrustesMean,
movingAverage, smoothTransforms, offsetRing } from '../../../js/mathutil.js';
import { stabilize, smoothContours } from '../../../js/pipeline.js';
import { synthDense } from '../../../js/synth.js';
import { IndexedRaster, hexToRgb } from '../../../js/raster.js';
const FRAMES = 72;
const track = synthDense(FRAMES);
const rigid = track.map((f) => RIGID.map((i) => f[i]));
// The two the port plan names explicitly, plus everything else in mathutil.js:
// a function nobody diffed is a function nobody ported.
const ref = procrustesMean(rigid);
const tfs = rigid.map((r) => fitSimilarity(r, ref));
const strip = (p) => ({ x: p.x, y: p.y, z: p.z ?? 0 });
const xy = (p) => ({ x: p.x, y: p.y });
const ring = (r) => r.map(xy);
const stripTf = (t) => ({ s: t.s, theta: t.theta, tx: t.tx, ty: t.ty });
// A known transform recovered exactly, which is the same case the CLJS unit test
// asserts — here so a disagreement can be localised to the fit rather than to
// the track.
const knownSrc = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 2, y: 3 }];
const knownTruth = { s: 1.7, theta: 0.6, tx: 4, ty: -2 };
const knownDst = knownSrc.map((p) => applySim(knownTruth, p));
const out = {
frames: FRAMES,
track: track.map((f) => f.map(strip)),
rigidRef: ref.map(strip),
transforms: tfs.map(stripTf),
residuals: rigid.map((r, i) => fitResidual(tfs[i], r, ref)),
smoothed: [0, 1, 2, 5].map((radius) => ({
radius, tfs: smoothTransforms(tfs, radius).map(stripTf),
})),
known: { src: knownSrc, truth: knownTruth, dst: knownDst,
fit: stripTf(fitSimilarity(knownSrc, knownDst)) },
// tx over the shot is the sway; it is the one-dimensional series the smoothing
// knob actually acts on, so it is what movingAverage gets diffed on.
movingAverage: [0, 1, 2, 3, 7].map((radius) => ({
radius, vals: movingAverage(tfs.map((t) => t.tx), radius),
})),
// stabilize(), which the CLJS side reaches as three stages: the anchor fit,
// the conditioning of its parameters, and the mouth measured through the
// result. Diffing the composition is the point — a split that agreed on each
// piece and not on the whole would be a split, not a port.
//
// aspect 1 is in here to isolate the rest, and 0.5625 (a 1080x1920 phone clip)
// because it is the only value that exercises the anisotropy correction at all:
// at aspect 1 `pick` is the identity and a port that dropped it entirely would
// pass. radius 0 and 2 because the split moved the smoothing OUT of the middle
// of this function, so agreeing only at radius 0 would prove nothing about it.
stabilize: [{ aspect: 1, radius: 0 },
{ aspect: 0.5625, radius: 0 },
{ aspect: 0.5625, radius: 2 }].map(({ aspect, radius }) => {
const st = stabilize(track, radius, aspect);
return {
aspect, radius,
ref: st.ref.map(xy),
rigid: st.rigid.map(ring),
transforms: st.transforms.map(stripTf),
residual: st.residual,
outer: st.outer.map(ring),
inner: st.inner.map(ring),
aperture: st.aperture,
};
}),
// The contour knob, on the ring it is actually dragged for. The rings are the
// full 20 slots and not a subsample, which is where the CLJS side differs in
// arrangement and must not differ in numbers: the prototype subsamples before
// smoothing, the port smooths before subsampling, and the two commute because
// both operations are per-slot.
smoothContours: (() => {
const st = stabilize(track, 0, 0.5625);
return [0, 1, 3].map((radius) => ({
radius, outer: smoothContours(st.outer, radius).map(ring),
}));
})(),
offsetRing: [0, 0.5, 2, -1].map((d) => ({
d,
ring: offsetRing(LIPS_OUTER.map((i) => track[0][i]), d).map(strip),
// The degenerate case: a shut lid is a flat sliver and must still open into
// a band, and a ring collapsed onto its own centroid must not emit NaN.
shutLid: offsetRing([{ x: -10, y: 0 }, { x: 0, y: -0.02 },
{ x: 10, y: 0 }, { x: 0, y: 0.02 }], d).map(strip),
collapsed: offsetRing([{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }], d).map(strip),
})),
subsampleSlots: Object.fromEntries(
[[20, 4], [20, 6], [20, 8], [20, 10], [20, 16], [16, 4], [16, 6], [16, 12],
[10, 4], [10, 6], [10, 10], [36, 8]]
.map(([len, n]) => [`${len}/${n}`, subsampleSlots(len, n)])),
tables: { RIGID, LIPS_OUTER, EYE_R_RING, BROW_A_RING, FACE_OVAL },
// The raster is integer output, so parity here is EXACT equality, not 1e-9.
// One scanline drawn one pixel wide of the JS would read as a seam between two
// parts rather than as an error, which is why the whole buffer is diffed and
// not a pixel count.
//
// toImageData is not exercised: it needs an ImageData, the CLJS side returns
// plain bytes on purpose so domain/ stays DOM-free, and the palette expansion
// is asserted directly in arthur.domain.raster-test instead.
raster: (() => {
const r = new IndexedRaster(64, 48);
r.clear(0);
// A real mouth ring at raster scale, so the scanline fill is diffed on a
// shape with fractional coordinates and non-convex spans rather than on an
// axis-aligned box that would agree even if the rounding were wrong.
r.fillPoly(LIPS_OUTER.map((i) => ({ x: track[0][i].x * 320 - 100,
y: track[0][i].y * 200 - 40 })), 2);
r.fillPoly([{ x: 8.5, y: 8.5 }, { x: 56.25, y: 8.5 },
{ x: 56.25, y: 40.75 }, { x: 8.5, y: 40.75 }], 1);
r.fillDisc(30.4, 24.6, 9.2, 3, 1); // stencilled by the box
r.fillDisc(5.5, 44.5, 4, 4); // unstencilled, clipped by the edge
r.fillRect(30.49, 24.51, 3, 5, 3); // stencilled by the disc
r.fillRect(1, 1, 0, 6); // size 0 draws nothing
return { w: r.w, h: r.h, buf: Array.from(r.buf) };
})(),
paletteRgb: ['#12141c', '#b07a5a', '#7a4f3a', '#24161a', '#d9cfc2',
'#c9c3b4', '#4a5468', '#171a22', '#3a2a22'].map(hexToRgb),
};
const here = dirname(fileURLToPath(import.meta.url));
const path = join(here, 'oracle.json');
writeFileSync(path, JSON.stringify(out));
console.log(`oracle: ${FRAMES} frames -> ${path}`);