Port steps 0-1: scaffold, the oracle, and the pure bottom
Scaffolds frontend/ (shadow-cljs, reagent 1.2.0, re-frame 1.4.3) and ports everything below the data model, with the JS kept as a numeric oracle. domain/landmarks index tables, verbatim domain/ring subsample, offset, simplicity domain/geom similarity fit, procrustes, moving average domain/raster indexed scanline fill, stencil, disc, rect domain/palette the ramp, and the no-sampled-RGB rule 58 tests, 166 assertions. Parity with js/ on the identical 72-frame synthetic track: fit-similarity, procrustes-mean, fit-residual, moving-average, smooth-transforms, offset-ring and subsample-slots to 1e-9; the raster pixel-for-pixel over the whole buffer. Three deviations from the JS, each for a reason: - synth.cljs jitters from a SEEDED generator, not Math.random. Parity is only checkable if both sides can be handed the same track, and a failing assertion has to be reproducible. `:rand-fn` takes the generator over, so oracle.mjs stubs js/Math.random and js/ itself stays untouched. - raster/->rgba replaces toImageData. ImageData is a DOM type and domain/ may not touch the DOM; returning plain bytes also lets the no-intermediate-colours assertion run in node. ui/canvas wraps it later. - offset-ring lives in domain/ring, not domain/geom, per architecture.md: it is an operation on an ordered traversal, not on a transform. Step 1's "done" also names the swapped-iris vote, but pairIrises is in pipeline.js and belongs to step 7. The precondition is asserted instead -- `:swap-iris` really does move both blocks -- so the vote will have a track that disagrees with it when it arrives. One finding, recorded in full in the test that measures it: smooth-transforms buys nothing on the synthetic track. Against jitter-free ground truth, radius 1 helps by 17% on one noise realisation and hurts by 0.5% on another, so its benefit is within noise; from radius 2 up the cost is unambiguous, and by radius 5 the filter is below the true motion's own high-frequency energy, i.e. smoothing away performance. The test pins the shape of the knob rather than a preferred value. This may say more about the synth's jitter being unrealistically small (+/-0.001 normalised) than about the knob; step 6 settles it on real footage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
082d8561d2
commit
eb06be005c
25 changed files with 4932 additions and 0 deletions
4
frontend/test/parity/.gitignore
vendored
Normal file
4
frontend/test/parity/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Generated by oracle.mjs on every `npm test`. Not committed: it is 1.2MB of
|
||||
# derived numbers, and a stale copy would make the parity suite pass against
|
||||
# yesterday's oracle.
|
||||
oracle.json
|
||||
107
frontend/test/parity/oracle.mjs
Normal file
107
frontend/test/parity/oracle.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// 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 { 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 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),
|
||||
})),
|
||||
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}`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue