A correction is a layer, and a layer's values are a channel

`:over` was specified in animation-model.md, refused in two places, and
produced by nothing: `check-unimplemented!` threw on read and `channel/problems`
reported it. It reads now. This is the part of the model the rotoscoping half
depends on — generate motion, correct it by hand, turn the knob, keep the
correction — and it was the last thing in the design that had never been tried.

The shape that made it small: A LAYER'S VALUES ARE A CHANNEL.

  {:id :nudge :support [88 98] :op :offset
   :values {:animated? true :interp :linear :keys {88 [2 0], 96 [0 0]}}}

So the three commands the lane model asks for over a selected range — a
constant adjustment, a ramp, a return motion — are one mechanism and not three:
framed values say the same thing on every frame they cover, keyed values move,
and neither needs a new way to say what a value is over time. A layer reads
through `value-at` and `cursor` like any channel, which is also what stopped
blending from becoming two implementations: `over-at` is shared, and the
specification and the playback path differ only in how they READ a layer —
recursively through `value-at`, or through a reading head of its own. One level
deep; a layer's values may not carry layers, which the stack already orders.

That was the risk worth spiking for. A cursor that drifts produces the wrong
pose rather than an error, and a stack means several reading heads per channel
where there was one. The agreement test that holds the cursor to the
specification in forward, backward and random frame order now covers stacked
channels too — including a layer whose head is asked for nothing across the long
stretches outside its support and then asked again, which is where drift would
hide.

`:support` is half-open and explicit. Outside it the base evaluates exactly as
it did before, which is the whole difference between a bounded correction and
inserting boundary keys: the latter alters the neighbouring segments, and the
lane model says so.

A LAYER HAS NO TIME SPACE OF ITS OWN, and this is the design question the doc
left open. Its support and its values' keys are in the frames the base channel's
keys are in — the node's. A correction on a lane is therefore in lane frames and
reaches across the drawings exposed beneath it; one on a single occurrence is in
that occurrence's frames and travels with it when the exposure moves. Ownership
had already answered it, so there is no field to disagree with, and both halves
are under test at lane level.

Two things cost nothing, which is worth recording. A channel is ONE LEAF, so a
correction persists inside it with no codec change at all. And `node/problems`
already reports every channel's problems, so a malformed layer surfaces at the
document level and in the sequence commands' post-check without plumbing.

What is still missing is a command that MAKES one, and with it the question of
how a view offers a constant, a ramp and a return over a selected range. The
evaluator no longer has an opinion about that, which was the point.

`offset` adds component-wise and never writes into a dense value, which is a
view onto the block itself; a shape mismatch throws rather than being dropped,
since a correction that silently does not take is the failure this design exists
to prevent. `replace` can supply a value over an absent base and `offset`
cannot, as animation-model.md required.

408 tests, 5,655 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-30 16:07:38 -04:00
parent 26517af2fd
commit 94c0a21de1
5 changed files with 365 additions and 71 deletions

View file

@ -159,7 +159,26 @@
(doseq [[label c] [["sparse" (ch/keyed {0 :a, 4 :b, 12 :c, 13 :d, 40 :e} :hold)]
["one key" (ch/keyed {7 :only} :hold)]
["dense-ish" (ch/keyed (into {} (map (juxt identity #(* 10 %))) (range 40)) :hold)]
["framed" (ch/framed :static)]]]
["framed" (ch/framed :static)]
;; A stacked channel has a reading head per key map, and a
;; layer's head is asked for nothing at all across the long
;; stretches outside its support — then asked again. That is
;; the drift this test exists to catch, now squared.
["corrected"
(assoc (ch/keyed {0 0, 20 200, 44 440} :linear)
:over [(ch/layer :a [10 20] :offset
(ch/keyed {10 1, 19 90} :linear))])]
["corrected twice, and over a constant"
(assoc (ch/keyed {0 0, 30 300} :linear)
:over [(ch/layer :a [5 15] :offset (ch/framed 7))
(ch/layer :b [12 25] :replace
(ch/keyed {12 -1, 24 -12} :linear))
(ch/layer :c [20 44] :offset
(ch/keyed {20 0, 43 23} :linear))])]
["a corrected framed base"
(assoc (ch/framed 3)
:over [(ch/layer :a [8 36] :offset
(ch/keyed {8 0, 35 27} :linear))])]]]
(let [spec #(ch/value-at c % nil)
forward (range 0 45)
back (reverse forward)
@ -213,17 +232,92 @@
(is (= [1 2 3 4 5] (mapv #(ch/sample! cur %) (range 5))))
(is (= [5 1] (mapv #(ch/sample! cur %) [4 0])) "and seeks")))
;; ---- what is deliberately not built has to fail loudly ----
;; ---- correction layers ----
(deftest an-override-layer-is-refused-rather-than-ignored
;; :over is specified in docs/animation-model.md and out of scope for this
;; step. Dropping one silently would present as a hand correction that did not
;; take — a correction the user made once, watched fail, and has no reason to
;; trust again.
(let [c (assoc (ch/keyed {0 [0 0]} :hold) :over [{:blend :offset :keys {0 [2 0]}}])]
(is (thrown-with-msg? ExceptionInfo #":over" (ch/value-at c 0 nil)))
(is (thrown-with-msg? ExceptionInfo #":over" (ch/cursor c nil)))
(is (seq (ch/problems c)))))
(defn- corrected
"A base channel with layers over it."
[base & layers]
(assoc base :over (vec layers)))
(deftest a-correction-applies-only-over-its-support
;; THE property. A range says where an edit applies, so outside it the
;; underlying animation must evaluate exactly as it did before — which is what
;; makes a bounded correction different from inserting boundary keys, and the
;; reason the stack exists at all.
(let [base (ch/keyed {0 0, 10 100} :linear)
c (corrected base (ch/layer :nudge [3 6] :offset (ch/framed 5)))
plain (mapv #(ch/value-at base % nil) (range 11))
with (mapv #(ch/value-at c % nil) (range 11))]
(is (= (concat (take 3 plain) [35 45 55] (drop 6 plain)) with))
(is (= (assoc plain 3 35 4 45 5 55) with) "and nothing else moved at all")))
(deftest a-return-motion-is-a-keyed-layer-and-a-constant-is-a-framed-one
;; The three commands the lane model asks for over one selected range, and
;; none of them needs a new way to say what a value is over time.
(let [base (ch/keyed {0 0} :hold)
at (fn [c] (mapv #(ch/value-at c % nil) (range 6)))]
(is (= [0 0 10 10 10 0]
(at (corrected base (ch/layer :flat [2 5] :offset (ch/framed 10)))))
"a constant adjustment")
(is (= [0 0 0 5 10 0]
(at (corrected base (ch/layer :ramp [2 5] :offset
(ch/keyed {2 0, 4 10} :linear)))))
"a ramp")
(is (= [0 0 0 10 0 0]
(at (corrected base (ch/layer :return [2 5] :offset
(ch/keyed {2 0, 3 10, 4 0} :linear)))))
"and a return motion, which is three samples and no new mechanism")))
(deftest the-stack-is-ordered-and-replace-wins-where-it-covers
(let [base (ch/keyed {0 1} :hold)
two (fn [a b] (mapv #(ch/value-at (corrected base a b) % nil) (range 4)))
add2 (ch/layer :a [0 4] :offset (ch/framed 2))
put9 (ch/layer :b [1 3] :replace (ch/framed 9))]
(is (= [3 9 9 3] (two add2 put9)) "the later layer sees the earlier one's result")
(is (= [3 11 11 3] (two put9 add2)) "and order therefore matters")))
(deftest replace-can-supply-a-value-where-offset-has-nothing-to-add-to
;; An absent value is not zero. `replace` states a pose; `offset` cannot
;; offset a pose that was never measured, and must not invent one.
(let [gone (ch/keyed {} :hold)]
(is (ch/nothing? (ch/value-at gone 0 nil)))
(is (ch/nothing? (ch/value-at (corrected gone (ch/layer :o [0 2] :offset (ch/framed 5))) 0 nil)))
(is (= 5 (ch/value-at (corrected gone (ch/layer :r [0 2] :replace (ch/framed 5))) 0 nil)))
;; And outside the support it is still absent, not the layer's value.
(is (ch/nothing? (ch/value-at (corrected gone (ch/layer :r [0 2] :replace (ch/framed 5))) 7 nil)))))
(deftest a-correction-offsets-geometry-component-wise-over-a-dense-base
;; The base a correction matters most for is generated, and a dense value is a
;; VIEW onto the block: offsetting must not write into it.
(let [data (js/Int16Array. #js [10 20, 30 40])
store {"blk" {:data data :state nil}}
base {:animated? true :dense {:store "blk" :offset 0 :stride 2 :frames 2}}
c (corrected base (ch/layer :nudge [1 2] :offset (ch/framed [5 -5])))]
(is (= [[10 20] [35 35]]
(mapv (fn [f] (let [v (ch/value-at c f store)]
[(ch/component v 0) (ch/component v 1)]))
(range 2))))
(is (= [10 20 30 40] (vec data)) "the block itself is untouched")
(is (thrown-with-msg?
ExceptionInfo #"different shape"
(ch/value-at (corrected base (ch/layer :bad [0 2] :offset (ch/framed 1))) 0 store))
"a correction of the wrong shape is loud, not silently dropped")))
(deftest a-malformed-correction-is-reported-rather-than-read
(let [ok (ch/layer :a [0 2] :offset (ch/framed 1))
base (ch/keyed {0 1} :hold)]
(is (empty? (ch/problems (corrected base ok))))
(is (seq (ch/problems (assoc base :over (list ok)))) "an ordered stack is a vector")
(doseq [[label bad] [["no id" (dissoc ok :id)]
["backwards support" (assoc ok :support [5 2])]
["open-ended support" (assoc ok :support [0 ##Inf])]
["a support that is not a pair" (assoc ok :support 3)]
["an unknown op" (assoc ok :op :multiply)]
["no values" (dissoc ok :values)]
["values that are not a channel" (assoc ok :values {:keys {0 1}})]
["layers under a layer"
(assoc ok :values (corrected base ok))]]]
(is (seq (ch/problems (corrected base bad))) label))))
(deftest an-empty-over-is-fine-and-is-what-scenes-carry
(is (empty? (ch/problems (ch/keyed {0 1} :hold))))

View file

@ -427,3 +427,60 @@
(get-in after [:symbols :main :nodes :girl :channels]))
"the performance is still timed the way it was authored")
(is (empty? (clip/problems after)))))
(deftest a-three-frame-correction-crosses-a-drawing-boundary
;; The lane model's worked example. The correction belongs to the GIRL, so it
;; applies across whichever drawings are showing under it, and outside its
;; three frames the animation evaluates exactly as it did before.
(let [doc (document)
fs (range 12)
before (drawn doc fs)
beat (ch/layer :beat [3 6] :offset (ch/framed [30 0]))
c (update-in doc [:symbols :main :nodes :girl :channels [:xform :pos] :over]
(fnil conj []) beat)
after (drawn c fs)
outside [0 1 2 6 7 8 9 10 11]]
(is (empty? (clip/problems c)))
(is (= (mapv before outside) (mapv after outside))
"outside the support, frame for frame identical")
(let [at (sample c [3 4 5])]
;; Frame 3 shows drawing A and frames 4 and 5 show drawing B: one
;; correction, reaching across the cut between them.
(is (= 70 (get-in at [3 [:a :mark]])))
(is (= 90 (get-in at [4 [:b :mark]])))
(is (= 102 (get-in at [5 [:b :mark]])) "and B's own correction still applies under it")
(is (= [-10 0 10] (mapv (fn [f] (js/Math.round (get-in at [f :plate]))) [3 4 5]))
"while the background, which is not in the lane, does not move"))
;; One document change: one step, and it persists in the channel's own leaf.
(let [b (leaf/leaves :p doc)
a (leaf/leaves :p c)
h (-> nil history/hold (history/record b a 0) history/settle)]
(is (= 1 (count (:done h))))
(is (= b (:leaves (history/undo h a))))
(is (= c (leaf/clip :p a)) "a correction needs no codec of its own"))))
(deftest a-correction-on-one-exposure-travels-with-it
;; The other half of ownership: a layer on an occurrence is in that
;; occurrence's own frames, so moving the exposure moves the correction and
;; nothing has to say so.
(let [beat (ch/layer :beat [0 2] :offset (ch/framed [7 0]))
doc (update-in (document) [:symbols :main :nodes :b :channels [:xform :pos] :over]
(fnil conj []) beat)
moved (:clip (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol}))]
;; Stated as the difference from the same document without the correction,
;; so the claim is about WHERE the layer applies and not about arithmetic.
(let [nudge (fn [with without f]
(- (get-in (sample with [f]) [f [:b :mark]])
(get-in (sample without [f]) [f [:b :mark]])))]
(is (= [7 7 0 0] (mapv #(nudge doc (document) %) [4 5 6 7]))
"B's first two frames, which are lane frames 4 and 5")
(is (= [7 7 0 0]
(mapv #(nudge moved (:clip (sequence/extend-hold (document) :main :a 2
{:extent :grow-symbol}))
%)
[6 7 8 9]))
"and after A's hold grows, B's first two frames, which are now 6 and 7"))
(is (= (get-in doc [:symbols :main :nodes :b :channels])
(get-in moved [:symbols :main :nodes :b :channels]))
"the layer itself was not touched by the retiming")
(is (empty? (clip/problems moved)))))