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

@ -132,6 +132,63 @@
[v]
(cond (number? v) nil (vector? v) (count v) :else (.-length v)))
(defn- shape
"What kind of value this is, for asking whether one can be added to another:
`:scalar`, a component count, or `:opaque` for a value that is neither — a
`[:vis]` boolean is opaque, and can be replaced but not offset."
[v]
(cond
(number? v) :scalar
(vector? v) (count v)
(and (some? v) (number? (.-length v))) (.-length v)
:else :opaque))
(defn value-shape
"The shape of the values a channel yields, without sampling it, or nil where
there is nothing to read it off — an empty key map says nothing about what its
values would have been, and nil must not be taken for a scalar."
[ch]
(cond
(not (:animated? ch)) (when (some? (:value ch)) (shape (:value ch)))
(:dense ch) (if (= 1 (:stride (:dense ch))) :scalar (:stride (:dense ch)))
(seq (:keys ch)) (shape (val (first (:keys ch))))
:else nil))
(defn conflict-with
"Why correction `l` cannot apply to base channel `base`, or nil.
ONLY `:offset` can conflict. It adds component by component, so it needs the
base to have the components it has — which is what a topology change takes
away when a re-freeze gives a mouth a different number of points. `:replace`
states a whole value and so has nothing to agree with.
Shapes that cannot be read yet do not conflict: an empty key map is not a
disagreement, it is a channel with nothing in it."
[base l]
(when (= :offset (:op l))
(let [b (value-shape base) v (value-shape (:values l))]
(cond
(or (nil? b) (nil? v)) nil
(= :opaque b) "the base is not a number or a row of components"
(= :opaque v) "the correction is not a number or a row of components"
(not= b v) (str "the base has " (pr-str b) " and the correction "
(pr-str v) " — a correction cannot offset a value of"
" a different shape")))))
(defn conflicts
"Corrections on `ch` that cannot apply to its base, as `[{:id :why}]`.
NOT `problems`. A conflict is a legitimate state for a document to be in: a
regeneration changed the topology under a correction that was right when it was
made, and resolving it is a person's decision, not a reason the document will
not load. `flow/regenerate` records one on the layer, a conflicted layer is not
applied, and this is how a view finds them to offer."
[ch]
(vec (for [l (:over ch)
:let [why (or (:conflict l) (conflict-with ch l))]
:when why]
{:id (:id l) :why why})))
(defn- offset-onto
"`base` plus `v`, component-wise. A vector, never a write into `base`, which
for a dense channel is a view onto the block itself."
@ -150,8 +207,11 @@
and is the only thing that differs between the specification and the cursor."
[ch f base read]
(reduce-kv
(fn [v i {:keys [support op values]}]
(if-not (covers? support f)
(fn [v i {:keys [support op values conflict]}]
;; A conflicted correction is neither applied nor forgotten: it stays in
;; the document, `conflicts` reports it, and a person decides. Applying it
;; would misapply it; removing it would throw away hand work.
(if (or conflict (not (covers? support f)))
v
(let [x (read i values f)]
(cond
@ -462,6 +522,17 @@
(first (problems (dissoc values :over))))))]
(str "correction " (pr-str id) " " p)))
;; A shape mismatch NOBODY HAS RECORDED is an authoring bug; one a
;; regeneration recorded is a conflict awaiting a person, and `conflicts`
;; reports those. The distinction is what keeps a topology change from
;; making a document that will not load.
(and (map? ch) (vector? (:over ch)))
(into (for [l (:over ch)
:when (not (:conflict l))
:let [why (conflict-with ch l)]
:when why]
(str "correction " (pr-str (:id l)) " " why)))
;; A scale of zero divides every value in the block by zero, and a negative
;; one mirrors the geometry. Both are authored-data bugs that present as a
;; part drawn nowhere or inside out, not as an error.

View file

@ -366,6 +366,23 @@
(map #(keyword (namespace wanted) (str (name wanted) "-" %))
(iterate inc 2))))))
(defn conflicts
"Every hand correction in the document that its base has outgrown, as
`[{:symbol :node :channel :id :why}]`.
SEPARATE FROM `problems` on purpose. A conflict is a document a person still
has to make a decision about — a regeneration changed the topology under a
correction that was right when it was made — and not a reason the document
will not load. Nothing is dropped and nothing is misapplied meanwhile: the
layer stays where it is, the picture is the base, and this is the list a view
offers to resolve."
[clip]
(vec (for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
[prop c] (:channels n)
{:keys [why] :as x} (ch/conflicts c)]
(assoc (select-keys x [:id]) :symbol sid :node id :channel prop :why why))))
(defn problems
"Human-readable reasons this clip will not evaluate or save."
[clip]

View file

@ -1,7 +1,8 @@
(ns arthur.flow.regenerate
"Recompute a changed feature from retained source tracks, then replace only
channels owned by that feature. Upload remains project/save's ordinary job."
(:require [arthur.domain.feature :as feature]
(:require [arthur.domain.channel :as ch]
[arthur.domain.feature :as feature]
[arthur.domain.params :as params]
[arthur.flow.address :as address]
[arthur.flow.freeze :as freeze]
@ -33,6 +34,35 @@
(select-keys (settings clip fid)
(address/area-knobs (get-in clip [:features fid :area]))))
(defn- rebased
"`fresh` in place of `old`, carrying `old`'s corrections across.
THIS IS WHAT A LAYER IS FOR. The base is regenerated and the hand work is not,
which is the whole reason a correction is stored over a channel rather than
written into it. A correction the new base no longer fits is MARKED rather than
dropped or misapplied — `channel/conflicts` is how a view finds it — and one
that fits again has its mark cleared, because a regeneration that restores the
topology has resolved it."
[old fresh]
(if-let [over (seq (:over old))]
(assoc fresh :over
(mapv (fn [l]
(if-let [why (ch/conflict-with fresh l)]
(assoc l :conflict why)
(dissoc l :conflict)))
over))
fresh))
(defn- bases
"A node's channels without their corrections.
For asking whether the authored channels still ARE the measurement: a
correction is not a hand PLACEMENT. The base under it has to go on following
re-measurement, or the first correction anyone makes would freeze the part it
was meant to adjust."
[channels]
(into {} (map (fn [[p c]] [p (dissoc c :over)])) channels))
(defn- replace-feature [entry fragment fid]
(let [paths (for [id (get-in entry [:clip :features fid :nodes])
[prop channel] (get-in fragment [:nodes id :channels])
@ -42,9 +72,7 @@
(let [at [:clip :symbols (get-in entry [:clip :features fid :symbol])
:nodes id :channels prop]
old (get-in entry at)]
(assoc-in entry at
(cond-> channel
(contains? old :over) (assoc :over (:over old))))))
(assoc-in entry at (rebased old channel))))
entry paths)
(update :store merge (:store fragment)))))
@ -109,8 +137,10 @@
(cond-> (-> entry
(assoc-in (conj at :measured) measured)
(update :store merge (:store baked)))
(= (:channels old) (:measured old))
(assoc-in (conj at :channels) measured))))
(= (bases (:channels old)) (bases (:measured old)))
(assoc-in (conj at :channels)
(into {} (map (fn [[p c]] [p (rebased (get-in old [:channels p]) c)]))
measured)))))
(defn change
"One scoped static edit. `source-inputs` holds dense landmarks and, when the

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)))))))