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