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:
parent
94c0a21de1
commit
72b57e3786
7 changed files with 297 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)))))))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue