Regenerate the base, keep the hand work, and say when you cannot

The loop the layer design exists for, tested for the first time: correct a
generated channel by hand, turn the generator's knob, and get the new base with
the correction still on it. `replace-feature` already carried `:over` across —
somebody anticipated this — so the feature path needed a test and not a fix.
The head path needed a fix, and there was a second fault of my own making.

`regenerate-head` leaves the head's authored channels alone once somebody has
placed it by hand, and decided that by `(= (:channels old) (:measured old))`.
Sound, until a correction exists: an `:over` layer makes those unequal, so the
FIRST correction anyone made would have stopped the head following
re-measurement for good — the exact opposite of what a layer is for. It compares
the channels without their layers now. The test fails against the old guard,
which is how I know the bug was real and not a story about one.

The other fault was mine, from the commit before this one. An `:offset` whose
shape does not match its base threw, which is right for authored data — the
validator catches it — but WRONG for the case the model actually names: turn the
mouth's `:verts` knob and the re-freeze gives it a different number of points,
so a correction that was correct when it was made stops fitting through nobody's
error, and a throw in the read path takes the stage down.

So a base that has outgrown a correction is a CONFLICT, and a conflict is the
third thing beside applied and discarded. The regeneration records `:conflict`
on the layer; the layer stays exactly where it is; `over-at` skips it, so the
picture is the base meanwhile; and `clip/conflicts` lists them for a view to
offer. A later regeneration that restores the shape clears the mark, so
resolving one can be as simple as putting the knob back.

Deliberately NOT `problems`. A document with a conflict loads, evaluates and
saves — it contains a decision nobody has made yet, and refusing to open it
would be the persistence layer taking a side in an editing question. The
distinction in the validator is one line: a shape mismatch nobody has recorded
is an authoring bug, and one a regeneration recorded is a conflict.

`channel/conflict-with` is the single rule for "can this layer apply to this
base", used by the validator, by `conflicts`, and by the regeneration that marks
them. Only `:offset` can conflict, since `:replace` states a whole value and has
nothing to agree with; a shape that cannot be read yet — an empty key map — is
not a disagreement. `value-shape` answers it without sampling anything.

414 tests, 5,696 assertions, and `:verts` in the test is a real topology change
rather than a synthetic one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-30 16:22:02 -04:00
parent 94c0a21de1
commit 72b57e3786
7 changed files with 297 additions and 11 deletions

View file

@ -364,3 +364,45 @@
with (assoc base :generated {:by :roto/lips-outer :params {:verts 8}})]
(is (= (mapv #(ch/value-at base % store) (range 3))
(mapv #(ch/value-at with % store) (range 3))))))
(deftest a-correction-the-base-outgrew-is-a-conflict-and-not-a-problem
;; What a topology change does: a re-freeze gives the mouth a different number
;; of points, and a correction that was right when it was made can no longer
;; be added component by component. That is not a broken document — it is a
;; decision waiting for a person.
(let [pts (fn [n] {:animated? true :interp :hold :keys {0 (vec (repeat n 1))}})
nudge (ch/layer :nudge [0 4] :offset (ch/framed [1 1 1 1]))
fits (assoc (pts 4) :over [nudge])
outgrown (assoc (pts 6) :over [nudge])]
(is (nil? (ch/conflict-with (pts 4) nudge)))
(is (empty? (ch/conflicts fits)))
(is (empty? (ch/problems fits)))
(is (re-find #"different shape" (ch/conflict-with (pts 6) nudge)))
;; Unrecorded, it is an authoring bug and says so.
(is (seq (ch/problems outgrown)))
(is (= [:nudge] (mapv :id (ch/conflicts outgrown))))
;; Recorded, the document is sound and the correction is simply not applied.
(let [marked (assoc (pts 6) :over [(assoc nudge :conflict "outgrown")])]
(is (empty? (ch/problems marked)) "a recorded conflict is not a reason not to load")
(is (= [:nudge] (mapv :id (ch/conflicts marked))))
(is (= [1 1 1 1 1 1] (vec (ch/value-at marked 0 nil)))
"the base alone — neither misapplied nor silently dropped")
(is (= (ch/value-at marked 0 nil) (first (via-cursor marked [0])))
"and the cursor skips it too"))))
(deftest replace-never-conflicts-and-an-opaque-value-cannot-be-offset
(let [flag (ch/keyed {0 true} :hold)]
(is (nil? (ch/conflict-with flag (ch/layer :r [0 2] :replace (ch/framed false))))
"replace states a whole value, so it has nothing to agree with")
(is (re-find #"not a number" (ch/conflict-with flag (ch/layer :o [0 2] :offset (ch/framed 1)))))
(is (nil? (ch/conflict-with (ch/keyed {} :hold) (ch/layer :o [0 2] :offset (ch/framed 1))))
"an empty key map is not a disagreement")))
(deftest the-shape-of-a-channels-values-is-readable-without-sampling-it
(is (= :scalar (ch/value-shape (ch/framed 3))))
(is (= 2 (ch/value-shape (ch/framed [1 2]))))
(is (= :scalar (ch/value-shape {:animated? true :dense {:stride 1}})))
(is (= 40 (ch/value-shape {:animated? true :dense {:stride 40}})))
(is (= 2 (ch/value-shape (ch/keyed {0 [1 2], 4 [3 4]} :linear))))
(is (= :opaque (ch/value-shape (ch/keyed {0 :a} :hold))))
(is (nil? (ch/value-shape (ch/keyed {} :hold))) "nothing to read it off"))

View file

@ -2,6 +2,7 @@
(:require [cljs.test :refer [deftest is testing]]
[clojure.walk :as walk]
[arthur.demo.stage :as stage]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.params :as params]
[arthur.domain.project :as project]
@ -256,3 +257,98 @@
(doseq [[_ n] (filter (comp #{:audio} :kind val) nodes)]
(is (contains? nodes (:linked-to n))
(str "the voice " (:id n) " still links to a node that is there")))))
;; ---- corrections survive the thing they are corrections to ----
(defn- corrected
"Put one offset correction on a node's channel, as a hand edit would."
[entry node path values]
(update-in entry [:clip :symbols :face-1 :nodes node :channels path :over]
(fnil conj []) (ch/layer :by-hand [2 6] :offset values)))
(deftest regenerating-replaces-the-base-and-keeps-the-hand-correction
;; The loop the whole layer design exists for: generate motion, correct it by
;; hand, turn the generator's knob, keep the correction.
(let [width (count (:keys (channel @initial :iris-r [:xform :pos])))
before (corrected @initial :iris-r [:xform :pos] (ch/framed [3 -3]))
after (regenerate/change before
{:scope :feature :id :face-1/eye-r :knob :gaze-gain :value 2})
base (fn [entry] (dissoc (channel entry :iris-r [:xform :pos]) :over))]
(is (not= (base before) (base after)) "the base was regenerated")
(is (= (base (regenerate/change @initial
{:scope :feature :id :face-1/eye-r :knob :gaze-gain :value 2}))
(base after))
"and regenerated to exactly what it would have been without the correction")
(is (= [(ch/layer :by-hand [2 6] :offset (ch/framed [3 -3]))]
(:over (channel after :iris-r [:xform :pos])))
"while the correction came across untouched, and unconflicted")
(is (empty? (ch/conflicts (channel after :iris-r [:xform :pos]))))
(is (empty? (clip/problems (:clip after))))
(is (= width (count (:keys (channel after :iris-r [:xform :pos]))))
"sanity: this channel is keyed, so the correction rides a keyed base")))
(deftest a-correction-does-not-stop-the-head-following-its-measurement
;; `regenerate-head` leaves the authored channels alone once somebody has
;; PLACED the head by hand — but a correction is not a placement. Comparing
;; the bases is what keeps the first correction from freezing the part it was
;; made to adjust.
(let [plain (regenerate/change @initial
{:scope :subject :id :face-1 :knob :anchor-avg :value 4})
path [:clip :symbols :face-1 :nodes :head]
prop (first (keys (get-in @initial (conj path :measured))))
shape (ch/value-shape (get-in @initial (conj path :channels prop)))
nudge (ch/layer :by-hand [2 6] :offset
(ch/framed (if (= :scalar shape) 1 (vec (repeat shape 0.5)))))
before (update-in @initial (conj path :channels prop :over) (fnil conj []) nudge)
after (regenerate/change before
{:scope :subject :id :face-1 :knob :anchor-avg :value 4})
base (fn [entry] (dissoc (get-in entry (conj path :channels prop)) :over))]
(is (= (base plain) (base after))
"the head's base followed the re-measurement, correction and all")
(is (= [nudge] (:over (get-in after (conj path :channels prop))))
"and the correction is the one that was made, unmarked")
(is (empty? (ch/conflicts (get-in after (conj path :channels prop)))))
(is (empty? (clip/problems (:clip after))))))
(deftest a-regeneration-that-outgrows-a-correction-records-the-conflict
;; `:verts` is the mouth's vertex count, so turning it IS the topology change
;; the lane model names. A geometry correction is a row of components, and a
;; base with a different number of them cannot take it. Marked, not dropped:
;; the hand work stays in the document for a person to move, and the picture
;; meanwhile is the base.
(let [path [:geom :pts]
fitted (fn [entry]
(ch/layer :by-hand [2 6] :offset
(ch/framed (vec (repeat (ch/value-shape (channel entry :mouth path))
0.5)))))
with (fn [layer] (update-in @initial
[:clip :symbols :face-1 :nodes :mouth :channels path :over]
(fnil conj []) layer))
before (with (fitted @initial))
after (regenerate/change before
{:scope :feature :id :face-1/mouth :knob :verts :value 10})
layer (first (:over (channel after :mouth path)))]
(is (not= (ch/value-shape (channel @initial :mouth path))
(ch/value-shape (channel after :mouth path)))
"the mouth really does have a different number of points now")
(is (= :by-hand (:id layer)) "the correction is still in the document")
(is (re-find #"different shape" (:conflict layer)))
(is (= [:by-hand] (mapv :id (ch/conflicts (channel after :mouth path)))))
(is (empty? (clip/problems (:clip after)))
"a recorded conflict does not make the document unloadable")
(is (= (dissoc (channel (regenerate/change @initial
{:scope :feature :id :face-1/mouth :knob :verts :value 10})
:mouth path)
:over)
(dissoc (channel after :mouth path) :over))
"and the base is what it would have been with no correction at all")
;; And the document says so once, for a view to offer.
(is (= [{:id :by-hand :symbol :face-1 :node :mouth :channel [:geom :pts]}]
(mapv #(dissoc % :why) (clip/conflicts (:clip after)))))
;; A mouth edit that does not change the vertex count leaves it applying.
(let [fine (regenerate/change before
{:scope :feature :id :face-1/mouth :knob :aperture-cut :value 0.2})]
(is (nil? (:conflict (first (:over (channel fine :mouth path))))))
(is (empty? (ch/conflicts (channel fine :mouth path))))
(is (empty? (clip/conflicts (:clip fine))))
(is (empty? (clip/problems (:clip fine)))))))