Serve the document from a Django backend, split into three tiers

Step 9. The tier split was the work; Django was the easy half.

Tier 1 — the authored scene — is the document, and it is addressed as
independently versioned leaves rather than saved whole, so one vertex drag
cannot clobber a collaborator's keying. `domain/leaf` is the document as
path -> value; `domain/wire` puts it on the wire as transit, because JSON
has neither integer map keys nor keywords and a save would quietly turn
`{0 v}` into `{"0" v}`.

Tier 2 — the dense channel blocks — is content-addressed by a hash over
every input, with the detector version inside every key through the
analysis the block descriptor names. `flow/address`'s `block-knobs` is the
invalidation table, and `address-test` does not trust it: it re-freezes the
take once per knob and asserts the biconditional, that a block's bytes
changed if and only if its key changed. That found `brow-pos` not depending
on `contour-avg` — the brow ring is smoothed, the raise is not.

Tier 3 — frames and audio — is served by the hash of its bytes out of the
same store. A manifest now names frames and carries a URL for each, so the
frame layout stopped being a shared secret between a shell script and a
ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's
name is the hash of its contents, so a stale copy is not a thing that can
happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an
asset the project owns, not an extraction that churns.

The server verifies rather than trusting a name it was handed: it
recomputes every key from the descriptor stored beside it, refuses an
analysis that declares no detector version, and refuses a document naming
blocks it does not hold. It hashes the descriptor TEXT, because JS prints
an integral double as `1` and Python as `1.0`, and a scheme where both ends
re-render the numbers disagrees on the first parameter that happens to be
whole.

Two loose ends from step 8 closed on the way. `pack` no longer takes a
`(track, frame)` predicate whose call sites each re-derived a feature from
an index — every track names the feature it follows, which deleted five
hand-maintained mappings. And `:dev-http` is gone: Django serves the page,
shadow-cljs only builds into the staticfiles tree.

227 CLJS tests, 31 Django tests, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-28 01:11:41 -04:00
parent b6517f837a
commit 9cd5243983
61 changed files with 4694 additions and 269 deletions

View file

@ -0,0 +1,65 @@
(ns arthur.domain.canon-test
"A content address is only an address if the same inputs always write the same
bytes, so these are the ways that could stop being true."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.canon :as canon]))
(deftest key-order-does-not-change-the-text
;; The reason this namespace exists. Two maps that are `=` must hash alike, and
;; CLJS map iteration order is not part of `=`.
(is (= (canon/write {:b 2 :a 1 :c 3})
(canon/write {:c 3 :a 1 :b 2})
(canon/write (into {} [[:c 3] [:b 2] [:a 1]]))))
(is (= "{\"a\":1,\"b\":2,\"c\":3}" (canon/write {:a 1 :b 2 :c 3}))))
(deftest the-text-is-valid-json-because-the-server-reads-two-fields-out-of-it
(let [text (canon/write {:detector "mediapipe" :version "1.0.1"
:params {:anchor-avg 2 :contour-avg 1}
:tracks ["outer" "inner"] :scale 16384})
back (js->clj (js/JSON.parse text))]
(is (= "mediapipe" (get back "detector")))
(is (= "1.0.1" (get back "version")))
(is (= 2 (get-in back ["params" "anchor-avg"])))))
(deftest an-integral-double-is-written-without-a-point
;; JS and Python disagree here — `1` against `1.0` — which is exactly why the
;; server hashes the text it was sent instead of re-rendering the values.
(is (= "1" (canon/write 1.0)))
(is (= "1" (canon/write 1)))
(is (= "0.12" (canon/write 0.12)))
(is (= "-0.5" (canon/write -0.5)))
;; And a double that needs all its digits keeps them: shortest round-trip, not
;; a fixed precision, or two different takes would share a key.
(is (= "0.5625" (canon/write 0.5625)))
(is (= (str (/ 1 3)) (canon/write (/ 1 3)))))
(deftest a-namespaced-key-keeps-its-namespace
(is (= "{\"roto/lips-outer\":1}" (canon/write {:roto/lips-outer 1}))))
(deftest strings-are-escaped-by-a-json-writer-and-not-by-hand
(is (= "{\"source\":\"a \\\"quoted\\\" clip.mov\"}"
(canon/write {:source "a \"quoted\" clip.mov"})))
(is (= "{\"source\":\"café.mov\"}" (canon/write {:source "café.mov"}))))
(deftest nil-and-the-booleans-are-json-and-not-omitted
;; Omitting a nil would make {:presence nil} and {} the same address, and those
;; are a take with no absence data and a take whose absence data was forgotten.
(is (= "{\"a\":null,\"b\":false,\"c\":true}" (canon/write {:a nil :b false :c true})))
(is (not= (canon/write {:presence nil}) (canon/write {}))))
(deftest a-keyword-value-is-refused-rather-than-named
;; Because then :mouth and "mouth" would address the same block, and the field
;; the server reads would have a type that depended on the caller.
(is (thrown-with-msg? ExceptionInfo #"keyword VALUE" (canon/write {:role :eyes})))
(is (thrown-with-msg? ExceptionInfo #"keyword VALUE" (canon/write {:roles [:eyes]}))))
(deftest a-set-is-refused-because-it-has-no-one-text
(is (thrown-with-msg? ExceptionInfo #"sort it into a vector" (canon/write {:kept #{1 2}}))))
(deftest nan-is-refused-because-it-would-cache-a-measurement-that-went-wrong
(is (thrown-with-msg? ExceptionInfo #"NaN or infinity" (canon/write {:scale js/NaN})))
(is (thrown-with-msg? ExceptionInfo #"NaN or infinity" (canon/write {:scale js/Infinity}))))
(deftest nesting-is-ordered-all-the-way-down
(is (= (canon/write {:a {:z 1 :y [{:q 1 :p 2}]}})
(canon/write {:a {:y [{:p 2 :q 1}] :z 1}}))))

View file

@ -0,0 +1,110 @@
(ns arthur.domain.leaf-test
"Leaf addressing has one property that matters above every other: nothing is
lost. A persistence layer that drops a field saves a document which comes back
subtly smaller, and the loss is discovered later, by somebody whose work is
already gone.
So the assertion is exact equality on the real scenes — the frozen take in both
head modes, the hand-written demo, the swarm — rather than on a fixture, and
`scene-keys` makes a field added without a leaf fail loudly instead."
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo :as demo]
[arthur.demo.swarm :as swarm]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.leaf :as leaf]))
(deftest every-real-scene-survives-the-split-exactly
(doseq [[label scene] [["the frozen take" @take/scene]
["the locked take" @take/locked]
["the hand-written demo" demo/scene]
["the swarm" @swarm/scene]]]
(testing label
(is (= scene (leaf/scene :c1 (leaf/leaves :c1 scene)))))))
(deftest the-leaves-are-the-paths-the-sync-design-names
(let [ls (leaf/leaves :c7 @take/scene)]
(is (contains? ls "clip/c7/timing"))
(is (contains? ls "clip/c7/stage"))
(is (contains? ls "clip/c7/source"))
(is (contains? ls "clip/c7/node/mouth"))
(is (contains? ls "clip/c7/channel/mouth/geom.pts"))
(is (contains? ls "clip/c7/channel/mouth-in/vis"))
(is (contains? ls "clip/c7/feature/eye-r"))
(is (contains? ls "clip/c7/group/eyes-1"))
(is (contains? ls "clip/c7/subject/face-1"))
;; `:head`'s measured channels are written together by a freeze and replaced
;; together by a re-freeze, so they are one leaf and not three.
(is (contains? ls "clip/c7/measured/head"))
(is (= 3 (count (get ls "clip/c7/measured/head"))))))
(deftest a-node-and-its-channels-are-different-leaves
;; The boundary that lets two people key different parts without meeting. A node
;; leaf carries structure and no geometry.
(let [ls (leaf/leaves :c1 @take/scene)
n (get ls "clip/c1/node/mouth")]
(is (= {:id :mouth :name "mouth" :kind :poly :parent :head :z "a1"} n))
(is (nil? (:channels n)))
(is (:animated? (get ls "clip/c1/channel/mouth/geom.pts")))))
(deftest a-field-with-no-leaf-is-refused-rather-than-dropped
;; The invariant that keeps the round trip exact as the model grows: a scene
;; field nobody gave a leaf to would save silently and come back missing.
(is (thrown-with-msg? ExceptionInfo #"no leaf to save it in"
(leaf/leaves :c1 (assoc @take/scene :sequences []))))
(is (= leaf/scene-keys (set (keys (assoc @take/scene :name "x"))))
"scene-keys has drifted from what a frozen scene actually holds"))
(deftest an-absent-field-stays-absent
;; A scene with no fps must not come back with `:fps nil`. `=` is the test, and
;; the demo scene is the case: it has no analysis record and its root has no
;; channels.
(let [ls (leaf/leaves :c1 demo/scene)]
(is (not (contains? ls "clip/c1/source")))
(is (not (contains? (leaf/scene :c1 ls) :analysis)))
(is (not (contains? (get-in (leaf/scene :c1 ls) [:nodes :root]) :channels)))))
(deftest a-namespaced-id-is-one-path-segment
;; docs/architecture.md draws a node as `:eye-r/iris`, and a leaf path is
;; "/"-delimited, so the two have to be reconciled somewhere.
(let [scene {:nodes {:eye-r/iris {:id :eye-r/iris :kind :disc :parent nil :z "a1"
:channels {[:geom :radius] (ch/framed 2)}}}}
ls (leaf/leaves :c1 scene)]
(is (contains? ls "clip/c1/node/eye-r~iris"))
(is (= scene (leaf/scene :c1 ls))))
;; `(keyword "a~b")` rather than a literal: ~ is unquote in CLJS source.
(is (thrown-with-msg? ExceptionInfo #"cannot contain ~"
(leaf/segment (keyword "a~b")))))
(deftest another-clips-leaves-are-ignored-rather-than-merged
;; A project's whole leaf map can be handed in for one clip, which is what makes
;; a two-clip project one fetch.
(let [a (leaf/leaves :a @take/scene)
b (leaf/leaves :b demo/scene)]
(is (= @take/scene (leaf/scene :a (merge a b))))
(is (= demo/scene (leaf/scene :b (merge a b))))))
;; ---------------------------------------------------------------------------
;; what a document may not contain
(deftest a-placeholder-tier-2-key-is-refused
;; `demo/swarm` names its blocks "swarm/pos", which is exactly the descriptive
;; key content addressing replaced: a handle that only means something on the
;; machine that made it. It is a fine load test and not a document.
(let [ps (leaf/problems (leaf/leaves :c1 @swarm/scene))]
(is (seq ps))
(is (some #(re-find #"names tier 2 as \"swarm/pos\"" %) ps) (pr-str (first ps)))))
(deftest a-frozen-clip-has-no-problems
(is (empty? (leaf/problems (leaf/leaves :c1 @take/scene))))
(is (empty? (leaf/problems (leaf/leaves :c1 @take/locked)))))
(deftest a-channel-leaf-for-a-node-that-is-not-there-is-named
(let [ls (dissoc (leaf/leaves :c1 @take/scene) "clip/c1/node/mouth")]
(is (some #(re-find #"node with no node leaf" %) (leaf/problems ls)))))
(deftest a-property-with-path-punctuation-in-it-is-refused
(is (thrown-with-msg?
ExceptionInfo #"cannot contain . or /"
(leaf/leaves :c1 {:nodes {:a {:id :a :kind :poly :parent nil :z "a1"
:channels {[:geom :pts.x] (ch/framed [0 0])}}}}))))

View file

@ -0,0 +1,146 @@
(ns arthur.domain.project-test
"The done criterion of port-plan step 9, made mechanical.
\"Round-tripping a project through the server is the proof the model
serialises\" — and the proof has to be an assertion rather than a look, because
the ways a document survives a round trip LOOKING correct are the interesting
ones: a frame key that came back a string, an absence mask that came back all
zeroes, a dense block read as the wrong element type. Every one of those plays
back as a slightly wrong performance rather than as an error.
So what is compared is the OPS, frame for frame, through both evaluators, in
every frame order — the same machinery scene-test uses to hold `eval-frame` and
`resolver` to each other, which is the strictest statement available about two
scenes being the same scene.
This runs the conversion the network runs — `JSON.parse(JSON.stringify(...))` —
and not the network. `clips/tests.py` puts the same document through Django, and
the browser suite drives the real thing end to end; what is asserted here is the
half that does not need a server to be wrong."
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.project :as project]
[arthur.domain.scene :as scene]
[arthur.flow.freeze :as freeze]
[arthur.support.ops :as ops]))
(defn- wired
"A clip out and back, over a wire that is really only JSON."
[cid clip]
(project/load cid (js/JSON.parse (js/JSON.stringify (project/save cid clip)))))
(def ^:private before (delay @take/frozen))
(def ^:private after (delay (wired :c1 @before)))
(deftest what-comes-back-is-a-valid-scene
(let [ps (scene/problems (:scene @after))]
(is (empty? ps) (pr-str ps))))
(deftest the-document-comes-back-equal
;; Stronger than it needs to be and worth having: not merely equivalent, EQUAL.
;; Any drift here is a field the codec is rewriting, and a field that is
;; rewritten once is rewritten again on every save.
(is (= (:scene @before) (:scene @after))))
(deftest every-frame-resolves-to-the-same-ops-before-and-after
;; The assertion. Both evaluators, both scenes, every frame order — so a block
;; that came back with its offsets shifted, or a cursor that seeks differently
;; over a rebuilt key map, has nowhere to hide.
(let [n (:frames (:scene @before))
paths {"specification" [ops/specified ops/specified]
"playback" [ops/resolved ops/resolved]
"spec vs playback, after" [ops/specified ops/resolved]}]
(doseq [[label [f g]] paths
[order fs] (ops/orders n)]
(let [a (f (:scene @before) (:store @before))
b (g (:scene @after) (:store @after))]
(testing (str label ", " order)
(doseq [frame fs]
(is (= (a frame) (b frame))
(str label " disagrees at frame " frame " going " order))))))))
(deftest the-blocks-come-back-byte-for-byte
;; A handle that names a sha256 has to name the bytes you actually hold.
(is (= (set (keys (:store @before))) (set (keys (:store @after)))))
(doseq [[k entry] (:store @before)]
(let [back (get (:store @after) k)]
(is (= (.-constructor (:data entry)) (.-constructor (:data back)))
(str k " came back as a different element type"))
(is (= (vec (array-seq (:data entry))) (vec (array-seq (:data back))))
(str k " came back with different numbers"))
(is (= (some? (:state entry)) (some? (:state back)))
(str k " gained or lost its state mask")))))
(deftest the-locked-take-round-trips-too
;; The other head mode, because it is the one whose `:head` channels are FRAMED
;; rather than dense: a codec that only handled dense channels would pass
;; everything above and lose the locked take's identity transform.
(let [locked {:scene @take/locked :store @take/store}
back (wired :c1 locked)
a (ops/resolved (:scene locked) (:store locked))
b (ops/resolved (:scene back) (:store back))]
(is (= (:scene locked) (:scene back)))
(doseq [frame (range 0 take/frames 7)]
(is (= (a frame) (b frame)) (str "frame " frame)))))
;; ---------------------------------------------------------------------------
;; the state masks
;;
;; freeze-test's presence assertions, re-run on the other side of the wire. This
;; is the part most likely to survive looking correct: a mask lost in transit
;; shows up as a part that is drawn on a frame it was not observed on, which is a
;; pose invented out of its neighbours rather than a blank or an error.
(def ^:private windows
{:eye-r (set (range 10 15))
:eye-l (set (range 20 25))
:brow-r (set (range 30 35))
:brow-l (set (range 40 45))})
(def ^:private gappy
(delay (freeze/clip (assoc take/params :name "gappy")
(assoc @take/measured
:presence
(into {} (map (fn [[id gap]]
[id (mapv #(not (contains? gap %))
(range take/frames))]))
windows)))))
(deftest an-absence-mask-survives-the-wire
(let [back (wired :c1 @gappy)
at (fn [clip id path f]
(ch/value-at (get-in (:scene clip) [:nodes id :channels path])
f (:store clip)))
;; Every dense track of the eye, iris, brow and brow-position blocks, and
;; the feature whose gap it must follow — the same table
;; `each-dense-track-follows-its-own-features-presence` pins.
tracks [[:eye-r [:geom :pts] :eye-r]
[:eye-r-in [:geom :pts] :eye-r]
[:eye-l [:geom :pts] :eye-l]
[:eye-l-in [:geom :pts] :eye-l]
[:iris-r [:xform :pos] :eye-r]
[:iris-l [:xform :pos] :eye-l]
[:brow-r [:geom :pts] :brow-r]
[:brow-l [:geom :pts] :brow-l]
[:brow-r [:xform :pos] :brow-r]
[:brow-l [:xform :pos] :brow-l]]]
(doseq [[id path owner] tracks
[feature gap] windows
f gap]
(if (= feature owner)
(is (ch/nothing? (at back id path f))
(str id " " path " is present at " f " after the round trip, with "
feature " occluded"))
(is (not (ch/nothing? (at back id path f)))
(str id " " path " follows " feature "'s gap at frame " f
" after the round trip"))))))
(deftest an-occluded-feature-is-still-not-drawn-after-the-wire
;; Presence is not visibility, on the far side too: the node is dropped from the
;; frame rather than hidden, and its partner is not.
(let [back (wired :c1 @gappy)
drawn (into #{} (map :node) ((scene/resolver (:scene back) (:store back)) 12))]
(is (not (contains? drawn :eye-r)))
(is (contains? drawn :eye-l))
(is (contains? drawn :mouth))))

View file

@ -13,7 +13,8 @@
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.raster :as raster]
[arthur.domain.scene :as scene]))
[arthur.domain.scene :as scene]
[arthur.support.ops :as ops]))
(defn- poly [id parent z pts color & [extra]]
(merge {:id id :kind :poly :parent parent :z z
@ -27,9 +28,7 @@
(defn- ids-at [scene f]
(mapv :node (scene/eval-frame scene f)))
(defn- pts-of [op]
(mapv (fn [i] [(aget (:pts op) (* 2 i)) (aget (:pts op) (inc (* 2 i)))])
(range (:n op))))
(def ^:private pts-of ops/points)
;; ---- structure ----
@ -264,22 +263,16 @@
;; z paths, holds a cursor per channel and reuses one point buffer per node, and
;; every one of those is a way to be subtly wrong on some frames and not others
;; — which presents as a bad take rather than as an error.
(let [s demo/scene
res (scene/resolver s)
n (:frames s)
snapshot (fn [ops]
(mapv (fn [op]
(cond-> (dissoc op :pts :i)
(:pts op) (assoc :points (pts-of op))))
ops))]
(doseq [[label fs] [["forward" (range n)]
["backward" (reverse (range n))]
["random access" [0 71 5 5 40 6 70 1 23 24 25 24 23 0 47 48]]
["every third" (range 0 n 3)]]]
;; The frame orders and the snapshot live in `arthur.support.ops`, because the
;; same comparison is what proves a scene survived the server — see
;; flow/project-test.
(let [s demo/scene
spec (ops/specified s nil)
fast (ops/resolved s nil)]
(doseq [[label fs] (ops/orders (:frames s))]
(testing label
(doseq [f fs]
(is (= (snapshot (scene/eval-frame s f)) (snapshot (res f)))
(str label " at frame " f)))))))
(is (= (spec f) (fast f)) (str label " at frame " f)))))))
(deftest the-resolver-reuses-one-buffer-per-node
;; At 30fps per-frame allocation is the only thing that will make this stutter,

View file

@ -0,0 +1,51 @@
(ns arthur.domain.sha256-test
"The digests below came out of Python's `hashlib`, which is the implementation
this one has to agree with: the server recomputes the key of every block and
every analysis it is handed and refuses a mismatch, so a disagreement between
the two languages is an upload that fails with nothing wrong.
The lengths are chosen, not arbitrary. 55 and 56 bytes are either side of the
point where the length field no longer fits in the first block, and 63/64 and
119/120 are the block boundaries themselves. A hand-written SHA-256 that is
wrong is almost always wrong exactly there, or wrong about sign — see the
namespace docstring — and a sign bug is invisible on short inputs."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.sha256 :as sha]))
(defn- a [n] (apply str (repeat n "a")))
(deftest the-digests-are-the-ones-hashlib-gives
(doseq [[input expect]
[["" "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"]
["abc" "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"]
["abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"]
[(a 55) "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318"]
[(a 56) "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"]
[(a 63) "7d3e74a05d7db15bce4ad9ec0658ea98e3f06eeecf16b4c6fff2da457ddc2f34"]
[(a 64) "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb"]
[(a 119) "31eba51c313a5c08226adf18d4a359cfdfd8d2e816b13f4af952f7ea6584dcfb"]
[(a 120) "2f3d335432c70b580af0e8e1b3674a7c020d683aa5f73aaaedfdc55af904c21c"]
["arthur" "befa156f0283eb0062beb9b86e16a413e1cf8c5135e5518d5c4fa321ce0c7b6b"]]]
(is (= expect (sha/of-string input))
(str (count input) " bytes"))))
(deftest a-non-ascii-descriptor-hashes-as-utf-8
;; A descriptor holds source filenames, so this is reachable from a clip called
;; "café.mov" and not a curiosity. UTF-16 code units would give another answer.
(is (= "3392aa2b9d70af3b1de0c4d4f7bc6fcd02e1df8079263bb7cda7b1b71704a90a"
(sha/of-string "café — naïve ✓"))))
(deftest raw-bytes-hash-too-because-a-block-is-bytes
(is (= "40aff2e9d2d8922e47afd4648e6967497158785fbd1da870e7110266bf944880"
(sha/of-bytes (js/Uint8Array. (into-array (range 256)))))))
(deftest a-key-says-which-algorithm-produced-it
(is (= (str "sha256:" (sha/of-string "abc")) (sha/key-of "abc")))
;; The point of the prefix: a key cannot be mistaken for the descriptive
;; placeholder — "take/geom" — that content addressing replaced.
(is (re-matches #"sha256:[0-9a-f]{64}" (sha/key-of "abc"))))
(deftest one-changed-bit-changes-the-key
;; The whole property everything above this file relies on, asserted once.
(is (not= (sha/of-string "anchor-avg:2") (sha/of-string "anchor-avg:3"))))

View file

@ -0,0 +1,91 @@
(ns arthur.domain.wire-test
"What the wire format has to carry, stated as the things JSON would have lost."
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.leaf :as leaf]
[arthur.domain.wire :as wire]))
(defn- round [v] (wire/decode (wire/encode v)))
(defn- round-json [v] (wire/decode-json (wire/encode-json v)))
(deftest a-frame-key-comes-back-a-number
;; THE reason this is transit. Keys are a map by FRAME, and `{"0" v}` is not
;; `{0 v}`: `value-at` would find no key at frame 0 and the part would hold its
;; first pose forever, on a document that looked fine.
(let [c (ch/keyed {0 true 4 false 12 true})]
(is (= c (round c)))
(is (every? number? (keys (:keys (round c)))))
(is (= true (ch/value-at (round c) 13)))))
(deftest an-id-comes-back-a-keyword
(is (= {:id :mouth-in :kind :poly :parent :mouth :z "a2"}
(round {:id :mouth-in :kind :poly :parent :mouth :z "a2"})))
(is (keyword? (:id (round {:id :mouth})))))
(deftest a-channels-property-vector-survives-as-a-map-key
;; `:channels` is keyed by `[:geom :pts]`, and a format with string keys only
;; would have to invent an encoding for that — which is what leaf paths do for
;; addressing and what the wire format must NOT have to do for values.
(let [m {[:geom :pts] (ch/framed [0 1]) [:vis] (ch/framed true)}]
(is (= m (round m)))
(is (vector? (first (keys (round m)))))))
(deftest a-keyed-channel-comes-back-a-plain-map-and-not-a-sorted-one
;; Transit loses sortedness, which is why `domain/channel` says keys are a PLAIN
;; map and builds the sorted index at read time. Asserted so that nobody
;; "improves" the codec into a sorted map that works until the first round trip.
(let [c (round (ch/keyed (into {} (map (juxt identity str)) (range 20))))]
(is (map? (:keys c)))
(is (not (sorted? (:keys c))))
(is (= (vec (range 20)) (ch/frames c)))))
(deftest the-numbers-come-back-as-themselves
(is (= {:a 0.5625 :b -1 :c 1e-9 :d 0 :e 16384}
(round {:a 0.5625 :b -1 :c 1e-9 :d 0 :e 16384}))))
(deftest an-empty-vector-stays-an-empty-vector
;; `:over` is present and empty by design — port-plan step 2 scope — and
;; `channel/check-unimplemented!` throws on a NON-empty one, so a codec that
;; turned `[]` into nil or into `[nil]` would either lose the field or refuse to
;; play the document back.
(is (= {:over []} (round {:over []})))
(is (= [] (:over (round (ch/keyed {0 1}))))))
(deftest a-whole-leaf-map-round-trips-through-parsed-json
;; What a save actually does: transit, then parsed so the column holds JSON.
(let [ls (leaf/leaves :c1 @take/scene)]
(is (= ls (into {} (map (fn [[p v]] [p (round-json v)])) ls)))))
;; ---------------------------------------------------------------------------
;; the bytes
(deftest a-block-comes-back-byte-for-byte
(doseq [[label array] [["int16" (js/Int16Array. #js [0 1 -1 32767 -32768 12345])]
["float32" (js/Float32Array. #js [0 1.5 -0.25 1e-8])]
["uint8" (js/Uint8Array. #js [0 1 255])]]]
(testing label
(let [back (wire/typed label (wire/base64 array))]
(is (= (vec (array-seq array)) (vec (array-seq back))))
(is (= (.-constructor array) (.-constructor back)))))))
(deftest a-real-sized-block-does-not-overflow-the-stack
;; `String.fromCharCode.apply` with a few hundred thousand arguments throws a
;; RangeError from inside a save, pointing nowhere near the array that caused it.
;; A 600-frame geometry block is that size, so the chunking is load-bearing.
(let [big (js/Int16Array. 600000)]
(dotimes [i 600000] (aset big i (- (mod i 65536) 32768)))
(let [back (wire/typed "int16" (wire/base64 big))]
(is (= 600000 (.-length back)))
(is (= (aget big 599999) (aget back 599999))))))
(deftest a-view-into-a-block-encodes-only-its-own-bytes
;; `dense-at` hands out SUBARRAYS, so a caller can reach this with a view whose
;; byteOffset is not zero. Encoding the whole underlying buffer would silently
;; store the neighbouring tracks too.
(let [whole (js/Int16Array. #js [1 2 3 4 5 6])
view (.subarray whole 2 4)]
(is (= [3 4] (vec (array-seq (wire/typed "int16" (wire/base64 view))))))))
(deftest an-unknown-element-type-is-refused
(is (thrown-with-msg? ExceptionInfo #"int16" (wire/typed "float64" "AA=="))))

View file

@ -0,0 +1,217 @@
(ns arthur.flow.address-test
"Content addressing has one failure mode in each direction, and reading the
table in `flow/address` will catch neither.
A KNOB MISSING from a block's table gives two different sets of bytes the same
name. The symptom is not an error: it is a slider that appears to do nothing
until something else forces a reload, and then does everything at once. That is
the bug docs/architecture.md describes as \"the tool got worse\" with no event to
attach it to.
A KNOB TOO MANY throws away a good bake on an unrelated tweak. Invisible while
everything is fast, and the reason baking exists once it is not.
So the table is not trusted. `every-knob-that-moves-a-block-renames-it` freezes
the same synthetic take once per knob and asserts the biconditional per block:
the bytes changed if and only if the key changed. It is the same shape of
assertion as `each-dense-track-follows-its-own-features-presence` in
freeze-test, for the same reason — a hand-maintained mapping needs a test that
fails when the hand is wrong."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.params :as params]
[arthur.domain.sha256 :as sha]
[arthur.flow.address :as address]
[arthur.flow.freeze :as freeze]
[arthur.flow.take :as take]
[arthur.synth :as synth]))
;; Short on purpose: the property is about which inputs reach which block, and it
;; holds at any length. Twenty-four freezes of the 229-frame take would be a
;; minute of test time to assert nothing extra.
(def ^:private frames 48)
(def ^:private fps 30)
(def ^:private dense-track (delay (synth/synth-dense frames {:seed 3})))
(defn- params-at [overrides]
(merge take/knobs
{:name "addr" :fps fps :aspect 1 :stage [320 200]
:expose 1 :head :as-filmed}
overrides))
(defn- freeze-at
"Measure AND freeze at these settings, which is what a re-freeze does: several
of the knobs act in stage 4, so a fixture that only re-froze would hold most of
them still and pass whatever the table said."
[overrides]
(let [p (params-at overrides)
p (assoc p :analysis (address/analysis
(merge {:detector "synth" :version "mulberry32"
:seed 3 :frames frames :fps (:fps p)
:aspect (:aspect p)}
(:detector overrides))))]
(freeze/clip p (take/measure p {:dense @dense-track}))))
(defn- bytes-of [{:keys [data state]}]
(str (sha/of-bytes (js/Uint8Array. (.-buffer data)))
"/" (if state (sha/of-bytes state) "-")))
(defn- by-role
"role -> {:key :bytes}. The role comes out of the block's own descriptor, which
is how a block is identified across two freezes that renamed it."
[clip]
(into {}
(map (fn [[k entry]]
[(get (js->clj (js/JSON.parse (:descriptor entry))) "role")
{:key k :bytes (bytes-of entry)}]))
(:store clip)))
(def ^:private base (delay (by-role (freeze-at {}))))
;; The knobs whose effect is upstream of `flow/take/measure`: they are read by
;; `measure/interior`, which runs over SOURCE PIXELS in events/footage before any
;; of this. A synthetic fixture has no pixels to move, so the biconditional cannot
;; be asserted for them here and `the-teeth-table-is-asserted-at-the-descriptor`
;; asserts the half that is assertable instead.
(def ^:private pixel-knobs
#{:cavity-erode :tongue-reject :blob-grow :top-bias :teeth-verts :min-area
:teeth-on :teeth-smooth})
(defn- bump
"A different, still valid value for a knob — and a LARGE difference, which is
not laziness about picking one.
Several of these knobs feed `condition/quantize-snap`, which rounds onto a grid:
gaze and brow cells, the blink hold. A 3% change to `gaze-gain` moves every
sample inside the cell it was already in, so the bytes come out identical and
the biconditional reports the knob as an input the block does not have — which
is true of that perturbation and false of the knob. A 75% change crosses cells.
The first version of this test asserted the fixture's resolution rather than the
invalidation table."
[id]
(let [{:keys [type default] must-even? :even?} (get params/definitions id)]
(cond
(and (= :integer type) must-even?) (+ default 2)
(= :integer type) (+ default 1)
(zero? default) 0.5
:else (* default 1.75))))
(deftest every-knob-that-moves-a-block-renames-it
(doseq [id (sort (remove pixel-knobs (keys params/definitions)))]
(let [moved (by-role (freeze-at {id (bump id)}))]
(testing (str id " " (get params/defaults id) " -> " (bump id))
(is (= (set (keys @base)) (set (keys moved)))
"a knob changed which blocks exist at all")
(doseq [role (sort (keys @base))]
(let [a (get @base role)
b (get moved role)]
(is (= (not= (:bytes a) (:bytes b))
(not= (:key a) (:key b)))
(str role ": bytes " (if (= (:bytes a) (:bytes b)) "same" "differ")
" but key " (if (= (:key a) (:key b)) "same" "differs")
" — " (if (= (:key a) (:key b))
(str "add " id " to block-knobs for " (pr-str role))
(str "remove " id " from block-knobs for " (pr-str role)))))))))))
(deftest the-teeth-table-is-asserted-at-the-descriptor
;; Every knob the teeth block declares must reach its key. The other half — that
;; each one also moves its bytes — needs real pixels, because the crop, the otsu
;; threshold and the radial contour all happen in `measure/interior` above the
;; stage this fixture starts at. Stated rather than quietly skipped.
(let [spec {:role "teeth" :analysis "sha256:0" :params params/defaults
:tracks ["contour"] :features [:teeth]
:observation nil
:layout {:type "int16" :scale 16384 :stride 20 :frames 48 :tracks 1}}
base (:key (address/block spec))]
(doseq [id (get address/block-knobs "teeth")]
(is (not= base (:key (address/block (assoc-in spec [:params id] (bump id)))))
(str id " does not reach the teeth block's key")))))
(deftest a-block-whose-role-has-no-table-is-refused
;; The table cannot be forgotten for a new block: without an entry there is no
;; answer to "which knobs rename this", and a key that never changes is worse
;; than no key at all.
(is (thrown-with-msg?
ExceptionInfo #"no invalidation table"
(address/block {:role "plate" :analysis "sha256:0" :params {} :tracks []
:features [] :observation nil :layout {}}))))
(deftest a-knob-the-freeze-was-not-handed-is-refused
(is (thrown-with-msg?
ExceptionInfo #"knob this block's bytes depend on"
(address/block {:role "geom" :analysis "sha256:0"
:params {:anchor-avg 2} :tracks ["outer"]
:features [:mouth] :observation nil :layout {}}))))
;; ---------------------------------------------------------------------------
;; the detector version
(deftest the-detector-version-reaches-every-block
;; THE requirement, and the reason a key is a hash over inputs rather than over
;; bytes: after a model upgrade the old blocks must be UNREACHABLE, not merely
;; different. Every block, because a version in one key and not another is the
;; same bug with a smaller blast radius.
(let [upgraded (by-role (freeze-at {:detector {:version "1.0.2"}}))]
(is (= (set (keys @base)) (set (keys upgraded))))
(doseq [role (sort (keys @base))]
(is (not= (:key (get @base role)) (:key (get upgraded role)))
(str role " survived a detector upgrade under its old name"))
;; And nothing was recomputed: same numbers, new name. That is what makes
;; this a cache-key change and not a re-analysis.
(is (= (:bytes (get @base role)) (:bytes (get upgraded role)))))))
(deftest an-analysis-without-a-version-is-refused
(is (thrown-with-msg? ExceptionInfo #"detector's VERSION"
(address/analysis {:detector "mediapipe" :frames 1})))
(is (thrown-with-msg? ExceptionInfo #"detector's VERSION"
(address/analysis {:version "1.0.1" :frames 1}))))
(deftest the-analysis-id-is-a-hash-of-its-own-descriptor
(let [a (address/analysis {:detector "synth" :version "mulberry32" :seed 1
:frames 229 :fps 30 :aspect 1})]
(is (= (:id a) (sha/key-of (address/analysis-descriptor a))))
(is (sha/key? (:id a)))))
;; ---------------------------------------------------------------------------
;; the rest of what a key is made of
(deftest the-same-inputs-give-the-same-keys
;; Twice, independently — not the same clip read twice. Map order, `delay`s and
;; `Math.round` are all places a second run could differ, and a key that is not
;; reproducible addresses nothing.
(is (= (into {} (map (juxt key (comp :key val))) (by-role (freeze-at {})))
(into {} (map (juxt key (comp :key val))) (by-role (freeze-at {}))))))
(deftest a-key-is-the-hash-of-the-descriptor-stored-beside-it
;; What the server checks on upload, checked here too, because if it can only
;; fail on the wire it fails as a 409 with nothing wrong.
(doseq [[k entry] (:store (freeze-at {}))]
(is (sha/key? k))
(is (= k (sha/key-of (:descriptor entry)))
(str "the descriptor stored under " k " is not what that key hashes"))))
(deftest a-presence-gap-renames-only-the-blocks-that-read-it
;; The observation digest is per block. A brow occlusion must not rename the
;; mouth's block: that would be correct and useless, throwing away every bake in
;; the clip on one feature's gap.
(let [gap (set (range 10 15))
with (by-role (freeze/clip
(assoc (params-at {}) :analysis
(address/analysis {:detector "synth" :version "mulberry32"
:seed 3 :frames frames :fps fps :aspect 1}))
(assoc (take/measure (params-at {}) {:dense @dense-track})
:presence {:brow-r (mapv #(not (contains? gap %))
(range frames))})))]
(is (not= (:key (get @base "brows")) (:key (get with "brows"))))
(is (not= (:key (get @base "brow-pos")) (:key (get with "brow-pos"))))
(doseq [role ["geom" "eyes" "iris-pos" "head-pos" "head-rot" "head-scale"]]
(is (= (:key (get @base role)) (:key (get with role)))
(str role " was renamed by a gap in a brow")))))
(deftest the-clips-name-and-stage-are-not-in-any-key
;; Tier 1 facts. Two clips of one take under two names, at two stage sizes, are
;; the same analysis over the same bytes, and sharing them is the return on
;; addressing. A name in the key would make every rename a re-freeze.
(let [other (by-role (freeze-at {:name "another take" :stage [640 480]}))]
(doseq [role (sort (keys @base))]
(is (= (:key (get @base role)) (:key (get other role))) role))))

View file

@ -33,6 +33,11 @@
(defn- node [id] (get-in @scene* [:nodes id]))
(defn- chan [id path] (get-in (node id) [:channels path]))
(defn- block-of
"The typed array a dense channel reads, through its own handle."
[ch]
(:data (get @store (:store (:dense ch)))))
(defn- pts-at
"The mouth's [:geom :pts] at frame f, as a flat CLJS vector."
[id f]
@ -127,8 +132,11 @@
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale]]]
(is (nil? (:scale (:dense (get-in (node :head) [:measured path]))))
(str path " should be plain Float32")))
(is (instance? js/Int16Array (:data (get @store "take/geom"))))
(is (instance? js/Float32Array (:data (get @store "take/head-pos")))))
;; Reached through the channel's own handle rather than by naming a key. A key
;; is a hash now, so a test that wrote one out would be asserting a digest.
(is (instance? js/Int16Array (block-of (chan :mouth [:geom :pts]))))
(is (instance? js/Float32Array
(block-of (get-in (node :head) [:measured [:xform :pos]])))))
(deftest a-value-past-the-block-s-range-is-refused-rather-than-saturated
;; Saturating reads as articulation flattening off at the extremes — a bad
@ -305,10 +313,17 @@
;; asking for a different stage moves and rescales the same geometry rather than
;; re-measuring anything.
(let [big (freeze/clip (assoc take/params :stage [640 480] :name "big")
@take/measured)]
@take/measured)
key-of (fn [sc] (:store (:dense (get-in sc [:nodes :mouth :channels [:geom :pts]]))))]
(is (= [640 480] [(:width (:scene big)) (:height (:scene big))]))
(is (= (vec (array-seq (:data (get @store "take/geom"))))
(vec (array-seq (:data (get (:store big) "big/geom")))))
;; Stronger than it was, and for free: the stage is not an input to tier 2, so
;; the two clips do not merely hold equal bytes — they name the SAME BLOCK, and
;; a stage change cannot invalidate a bake. The clip's name is not an input
;; either, which is why "big" and "take" still agree.
(is (= (key-of @scene*) (key-of (:scene big)))
"a different stage is a different document over the same tier 2")
(is (= (vec (array-seq (:data (get @store (key-of @scene*)))))
(vec (array-seq (:data (get (:store big) (key-of (:scene big)))))))
"the geometry is the same numbers at either stage size")
(is (not= (:value (get-in (:scene big) [:nodes :face :channels [:xform :scale]]))
(:value (chan :face [:xform :scale]))))))

View file

@ -0,0 +1,59 @@
(ns arthur.support.ops
"Comparing two evaluations of a scene, frame for frame.
`domain/scene` has two evaluators on purpose — `eval-frame` is the
specification and `resolver` is what playback uses — and scene-test's central
assertion is that they agree in forward, backward and random frame order.
Step 9 needs the same comparison for a different question: that a scene which
has been through the server produces the same ops as the one that went in.
Shared rather than copied, because the interesting part is not the equality —
it is the FRAME ORDERS. The resolver holds a cursor per channel and reuses one
point buffer per node, so it can agree on a forward pass and disagree on a
scrub, and a copy of this list that forgot 'backward' would test the easy half.
`snapshot` is what makes ops comparable at all: a resolved op carries `:pts` as
a VIEW into a reused buffer, so two ops from different frames can be `=` while
naming the same array, and holding one and then asking for the next frame
changes what the first one says. Reading the points out is what pins the frame."
(:require [arthur.domain.palette :as pal]
[arthur.domain.scene :as scene]))
(defn points
"An op's points as a vector of [x y], read out of its buffer."
[op]
(mapv (fn [i] [(aget (:pts op) (* 2 i)) (aget (:pts op) (inc (* 2 i)))])
(range (:n op))))
(defn snapshot
"Ops -> comparable data. `:i` goes too: it is the draw-order index, and it is a
function of the scene rather than of the frame."
[ops]
(mapv (fn [op]
(cond-> (dissoc op :pts :i)
(:pts op) (assoc :points (points op))))
ops))
(defn orders
"The frame orders any two evaluators have to agree in, over `n` frames.
Random access is a fixed list rather than a shuffle: a failure that only
reproduces one run in five is worse than no test."
[n]
[["forward" (range n)]
["backward" (reverse (range n))]
["random access" (filterv #(< % n) [0 71 5 5 40 6 70 1 23 24 25 24 23 0 47 48])]
["every third" (range 0 n 3)]])
(defn specified
"(fn [f] -> snapshot) through `eval-frame`, the specification."
([scene store] (specified scene store pal/index-of))
([scene store palette]
(fn [f] (snapshot (scene/eval-frame scene f store palette)))))
(defn resolved
"(fn [f] -> snapshot) through `resolver`, the playback path."
([scene store] (resolved scene store pal/index-of))
([scene store palette]
(let [res (scene/resolver scene store palette)]
(fn [f] (snapshot (res f))))))

View file

@ -1,5 +1,7 @@
// Drives a real Chrome at the running dev server and checks that the frozen
// take is a MOVING MOUTH on a canvas.
// Drives a real Chrome at the running server and checks two things that no
// assertion in cljs.test can: that the frozen take is a MOVING MOUTH on a canvas,
// and that a document which has been through the server comes back as the same
// picture.
//
// This exists because port-plan step 5 is the first step whose done-criterion is
// a picture, and a picture cannot be asserted from cljs.test. A take that
@ -11,10 +13,13 @@
// nothing: `node --experimental-websocket` has a global WebSocket and
// `--headless=new --remote-debugging-port=N` is the whole of the other side.
//
// cd frontend
// mise exec -- npx shadow-cljs compile app
// mise exec -- npx shadow-cljs watch app # or `server`, for :dev-http
// mise exec -- node --experimental-websocket test/browser/take.mjs
// Since step 9 the page is Django's, so the suite needs the backend up rather than
// shadow-cljs's `:dev-http`, which is gone. ARTHUR_URL is unchanged because the
// port is unchanged — 8778 was never 8777, which is still the old JS tool's.
//
// mise exec -- python manage.py runserver 8778 # from the REPO ROOT
// cd frontend && mise exec -- npx shadow-cljs watch app
// cd frontend && mise exec -- node --experimental-websocket test/browser/take.mjs
//
// Writes a PNG per sampled frame into test/browser/out/ so that "it drew
// something" can be checked by eye as well as by pixel count.
@ -198,6 +203,11 @@ const SEEK = (f) => `(() => {
return el.value;
})()`;
// Everything the page has to say about loading, saving and opening. Read off the
// page rather than out of app-db, for the same reason the playhead is: what the
// page SHOWS is what a person would check.
const STATUS = `[...document.querySelectorAll('.load-status')].map((d) => d.textContent).join(' | ')`;
const CLICK = (label) => `(() => {
const b = [...document.querySelectorAll('.transport button')]
.find((b) => b.textContent.trim() === ${JSON.stringify(label)});
@ -312,6 +322,71 @@ async function main() {
`${new Set(during.map((p) => p.hash)).size} distinct of ${during.length}`);
await page.shot('take-playing');
// --- it round-trips through the server ---
//
// THE DONE CRITERION of port-plan step 9, end to end: tier 1 over HTTP, tier 2
// as content-addressed blocks, and the same frames on the far side. The ops are
// compared frame for frame in arthur.domain.project-test, which is the strict
// version of this; what only a browser can check is that the whole path — the
// CSRF header, the block upload, the leaf write, the reload, the typed arrays
// rebuilt out of base64 — draws the same pixels at the end of it.
async function statusMatching(pattern, tries = 120) {
for (let i = 0; i < tries; i++) {
const text = await page.eval(STATUS);
if (pattern.test(text)) return text;
await sleep(250);
}
return null;
}
async function sample(frames) {
const out = [];
for (const f of frames) {
await page.eval(SEEK(f));
await sleep(120);
out.push(await page.eval(PROBE));
}
return out;
}
const FRAMES = [0, 10, 28, 80, 160];
check(await page.eval(CLICK('take')), 'back to the take, for the round trip');
await sleep(150);
const sent = await sample(FRAMES);
check(await page.eval(CLICK('save')), 'save is clickable');
const saved = await statusMatching(/saved r\d+/);
check(saved !== null, 'the document saves', saved ?? (await page.eval(STATUS)));
// Eleven blocks the first time. The COUNT is not asserted — that is a fact
// about the freeze, not about saving — but that some went up is.
check(/· [1-9]\d* blocks?/.test(saved ?? ''), 'and its tier 2 went with it', saved ?? '');
// Again, unchanged. Content addressing means the second save uploads nothing
// and rewrites nothing: this is the assertion that the keys are stable across
// two independent freezes of the same take, and that an unchanged leaf keeps
// its version rather than being rewritten.
check(await page.eval(CLICK('save')), 'save is clickable again');
const resaved = await statusMatching(/saved r\d+ · 0 leaves · 0 blocks/);
check(resaved !== null, 'saving an unchanged document writes nothing',
resaved ?? (await page.eval(STATUS)));
check(await page.eval(CLICK('open')), 'open is clickable');
const opened = await statusMatching(/opened /);
check(opened !== null, 'the project opens', opened ?? (await page.eval(STATUS)));
const back = await sample(FRAMES);
await page.shot('take-round-trip');
check(back.every((p) => p.drawn > 200), 'the reopened take draws',
back.map((p) => p.drawn).join(','));
check(FRAMES.every((f, i) => sent[i].hash === back[i].hash),
'every sampled frame is the same picture after the round trip',
FRAMES.filter((f, i) => sent[i].hash !== back[i].hash).join(',') || 'all identical');
check(back[1].toneSet.includes(MOUTH_DARK),
'and the open mouth still has an interior on the far side');
// The reopened clip is not one of the built-ins: this is the document that came
// back from the server, not the one that was in the page all along.
check(!back[0].scene.includes('take'), 'the picture is the reopened document',
JSON.stringify(back[0].scene));
check(page.logs.length === 0, 'no errors on the console',
page.logs.slice(0, 3).join(' | '));
} finally {