arthur/frontend/test/arthur/parity_test.cljs
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

245 lines
11 KiB
Clojure

(ns arthur.parity-test
"Diffs the CLJS port against js/ — the numeric oracle — on the identical
synthetic track.
DELETABLE, and deliberately so. This namespace and test/parity/oracle.mjs go
together, in one commit, once the CLJS player renders the synthetic take
correctly (port-plan step 5).
Parity proves the port is FAITHFUL, not that the answer is RIGHT. The JS is a
prototype and several of its conclusions contradict each other; a parity test
pins behaviour while the code moves, and a correctness test asserts something
that has been decided and stays. Conflating the two bakes the prototype's
mistakes into the rewrite and makes them permanent — so nothing in here is
allowed to outlive the move, and nothing in here is evidence that a number is
the number we want.
Run test/parity/oracle.mjs first; `npm test` does."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.geom :as geom]
[arthur.domain.landmarks :as lm]
[arthur.domain.palette :as pal]
[arthur.domain.raster :as raster]
[arthur.domain.ring :as ring]
[arthur.flow.condition :as condition]
[arthur.flow.measure.anchor :as anchor]
[arthur.flow.measure.mouth :as mouth]
[arthur.synth :as synth]))
;; The port plan's number. A larger gap than this is a port bug, not float noise.
(def TOL 1e-9)
(def oracle
(delay
(let [fs (js/require "fs")
path (str js/__dirname "/../test/parity/oracle.json")]
(when-not (.existsSync fs path)
(throw (ex-info (str "no oracle at " path
" — run `node test/parity/oracle.mjs` first")
{:path path})))
(js->clj (js/JSON.parse (.readFileSync fs path "utf8")) :keywordize-keys true))))
;; Both sides get jitter of exactly zero, which is what makes the tracks
;; comparable at all: the JS uses Math.random and the CLJS a seeded generator.
(def track (delay (synth/synth-dense (:frames @oracle) {:rand-fn (constantly 0.5)})))
(defn- worst
"The largest absolute difference between two equally-shaped nested numeric
structures, and where it was, so a failure names the case."
[a b]
(let [seen (atom {:d -1 :at nil})]
(letfn [(walk [x y path]
(cond
(number? x)
(let [d (abs (- x y))]
(when (> d (:d @seen)) (reset! seen {:d d :at path :got x :want y})))
(map? x)
(doseq [k (keys x)] (walk (get x k) (get y k) (conj path k)))
(sequential? x)
(do (when (not= (count x) (count y))
(throw (ex-info "shape mismatch" {:at path :got (count x) :want (count y)})))
(dotimes [i (count x)] (walk (nth x i) (nth y i) (conj path i))))
:else nil))]
(walk a b []))
@seen))
(defn- agrees?
"Assert two structures agree to TOL, reporting the worst offender."
[label a b]
(let [{:keys [d at got want]} (worst a b)]
(is (< d TOL)
(str label ": worst gap " d " at " (pr-str at) " (" got " vs " want ")"))))
;; ---- the track itself ----
;;
;; Everything below is meaningless if the two synths disagree, so this is
;; asserted first and separately: a track mismatch would otherwise surface as a
;; dozen numeric failures pointing nowhere near the cause.
(deftest the-two-synths-produce-the-same-track
(let [js-track (:track @oracle)]
(is (= (count js-track) (count @track)))
(is (= (count (first js-track)) (count (first @track))))
(agrees? "synthetic track" @track js-track)))
;; ---- the tables ----
(deftest tables-were-transcribed-without-a-typo
(let [t (:tables @oracle)]
(is (= lm/RIGID (:RIGID t)))
(is (= lm/LIPS-OUTER (:LIPS_OUTER t)))
(is (= lm/EYE-R-RING (:EYE_R_RING t)))
(is (= lm/BROW-A-RING (:BROW_A_RING t)))
(is (= lm/FACE-OVAL (:FACE_OVAL t)))))
;; ---- domain/ring ----
(deftest subsample-slots-agrees
;; The oracle keys are "len/n", which js->clj reads as a NAMESPACED keyword —
;; so the length is the namespace, not the first half of the name.
(doseq [[k want] (:subsampleSlots @oracle)]
(let [len (js/parseInt (namespace k))
n (js/parseInt (name k))]
(is (= want (ring/subsample-slots len n))
(str "subsample-slots(" len "," n ")")))))
(deftest offset-ring-agrees
(doseq [{:keys [d ring shutLid collapsed]} (:offsetRing @oracle)]
(let [src (mapv #(nth (first @track) %) lm/LIPS-OUTER)]
(agrees? (str "offset-ring(lips, " d ")")
(mapv #(select-keys % [:x :y]) (ring/offset-ring src d))
(mapv #(select-keys % [:x :y]) ring)))
(agrees? (str "offset-ring(shut lid, " d ")")
(mapv #(select-keys % [:x :y])
(ring/offset-ring [{:x -10 :y 0} {:x 0 :y -0.02}
{:x 10 :y 0} {:x 0 :y 0.02}] d))
(mapv #(select-keys % [:x :y]) shutLid))
;; A vertex on the centroid has no outward direction. Both sides must leave
;; it alone rather than emit NaN, and NaN != NaN would slip past `worst`.
(let [got (ring/offset-ring [{:x 0 :y 0} {:x 0 :y 0} {:x 0 :y 0}] d)]
(is (every? #(and (not (js/isNaN (:x %))) (not (js/isNaN (:y %)))) got)
(str "offset-ring(collapsed, " d ") is NaN-free"))
(is (every? #(and (not (js/isNaN (:x %))) (not (js/isNaN (:y %)))) collapsed)
"...and the oracle's is too, so this is parity and not a shared bug"))))
;; ---- domain/geom: the two the port plan names ----
(deftest fit-similarity-agrees-on-a-known-transform
(let [{:keys [src dst fit]} (:known @oracle)]
(agrees? "fit-similarity (known transform)"
(geom/fit-similarity src dst)
fit)))
(deftest fit-similarity-agrees-over-the-whole-shot
(let [rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) @track)
ref (:rigidRef @oracle)]
;; Fitted against the ORACLE's reference, so this isolates fit-similarity
;; from procrustes-mean instead of compounding the two.
(agrees? "fit-similarity over 72 frames"
(mapv #(geom/fit-similarity % ref) rigid)
(:transforms @oracle))))
(deftest procrustes-mean-agrees
(let [rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) @track)]
(agrees? "procrustes-mean"
(mapv #(select-keys % [:x :y]) (geom/procrustes-mean rigid))
(mapv #(select-keys % [:x :y]) (:rigidRef @oracle)))))
(deftest fit-residual-agrees
(let [rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) @track)
ref (:rigidRef @oracle)]
(agrees? "fit-residual"
(mapv (fn [r tf] (geom/fit-residual tf r ref))
rigid (:transforms @oracle))
(:residuals @oracle))))
;; ---- domain/geom: the smoothing knobs ----
(deftest moving-average-agrees-at-every-radius
(let [tx (mapv :tx (:transforms @oracle))]
(doseq [{:keys [radius vals]} (:movingAverage @oracle)]
(agrees? (str "moving-average radius " radius)
(geom/moving-average tx radius)
vals))))
(deftest smooth-transforms-agrees-at-every-radius
(doseq [{:keys [radius tfs]} (:smoothed @oracle)]
(agrees? (str "smooth-transforms radius " radius)
(geom/smooth-transforms (:transforms @oracle) radius)
tfs)))
;; ---- domain/raster ----
(deftest raster-agrees-pixel-for-pixel
;; Integer output, so this is EXACT equality and not TOL. The whole buffer is
;; diffed rather than a pixel count: one scanline a pixel wide of the JS would
;; read as a seam between two parts, not as an error, and a count would miss it.
(let [{:keys [w h buf]} (:raster @oracle)
fr (first @track)
ras (-> (raster/make w h) (raster/clear! 0))]
(raster/fill-poly! ras (mapv (fn [i] {:x (- (* (:x (nth fr i)) 320) 100)
:y (- (* (:y (nth fr i)) 200) 40)})
lm/LIPS-OUTER) 2)
(raster/fill-poly! ras [{: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)
(raster/fill-disc! ras 30.4 24.6 9.2 3 1)
(raster/fill-disc! ras 5.5 44.5 4 4)
(raster/fill-rect! ras 30.49 24.51 3 5 3)
(raster/fill-rect! ras 1 1 0 6)
(let [got (vec (array-seq (:buf ras)))
diff (keep-indexed (fn [i v] (when (not= v (nth buf i))
{:at [(mod i w) (quot i w)]
:got v :want (nth buf i)}))
got)]
(is (empty? diff)
(str (count diff) " of " (* w h) " pixels differ, first few: "
(pr-str (vec (take 5 diff)))))
;; A buffer that agreed because both sides drew nothing would pass the
;; above, so check the drawing actually happened.
(is (> (count (distinct got)) 3)
(str "only " (pr-str (distinct got)) " indices present")))))
(deftest hex-to-rgb-agrees
(agrees? "palette rgb" pal/rgb (:paletteRgb @oracle)))
;; ---- flow: stage 3 measure + stage 4 condition ----
;;
;; The prototype's `stabilize` is three things: the anchor fit, the smoothing of
;; its parameters, and the mouth measured through the result. Here they are three
;; functions in two stages, so what is diffed is the COMPOSITION — a split that
;; agreed on every piece and not on the whole would be a split and not a port.
(deftest stabilize-agrees-across-the-split-stages
(doseq [{:keys [aspect radius ref rigid transforms residual outer inner aperture]}
(:stabilize @oracle)]
(let [label (str "stabilize(aspect " aspect ", radius " radius ")")
fitted (anchor/fit {:aspect aspect} {:dense @track})
anchd (condition/anchor {:anchor-avg radius} fitted)
got (mouth/measure {:aspect aspect}
{:dense @track :transforms (:transforms anchd)})]
(agrees? (str label " ref") (:ref fitted) ref)
(agrees? (str label " rigid") (:rigid fitted) rigid)
(agrees? (str label " transforms") (:transforms anchd) transforms)
(agrees? (str label " outer") (:outer got) outer)
(agrees? (str label " inner") (:inner got) inner)
(agrees? (str label " aperture") (:aperture got) aperture)
;; The prototype takes the residual against the SMOOTHED transforms, because
;; those were the ones in scope. `anchor/fit` takes it against the raw fit,
;; deliberately: the number's job is to say whether the footage is
;; stabilisable, and folding the smoothing error into it makes a setting look
;; like a property of the shot. Parity is on the function, handed what the
;; prototype handed it — so the divergence is a decision and not a drift.
(agrees? (str label " residual")
(anchor/residuals (:ref fitted) (:rigid fitted) (:transforms anchd))
residual)
(when (zero? radius)
(agrees? (str label " residual, as fit reports it") (:residual fitted) residual)))))
(deftest smooth-contours-agrees-at-every-radius
(let [fitted (anchor/fit {:aspect 0.5625} {:dense @track})
rings (:outer (mouth/measure {:aspect 0.5625}
{:dense @track :transforms (:transforms fitted)}))]
(doseq [{:keys [radius outer]} (:smoothContours @oracle)]
(agrees? (str "condition/contours radius " radius)
(condition/contours {:contour-avg radius} rings)
outer))))