Keep one invalidation table, and derive the inverse a UI wants

There were two answers in the tree to "which stored bytes stop being valid when
this knob moves", and only one of them was checked.

`flow/address/block-knobs` is per block and asserted by biconditional —
`address-test` re-freezes the take once per knob and requires that the bytes
changed if and only if the key did. `domain/params`'s `:affects` was per area,
had no caller but a test asserting it returned what it was written as, and was
already wrong in both directions on the one entry where the two granularities
disagree: `:aperture-cut` claimed `#{:mouth}`, where it reaches no block, and
omitted the teeth, whose contour bytes it genuinely moves by gating
`condition/interior`'s smoothing. `:blink-cut` claimed `#{:eye}` and reaches no
block either, because a blink is `[:vis]` keys in tier 1.

So `:affects` and `affected-areas` are gone, and `address/knob-roles` is the
derived inverse of the table that is asserted — which is what a parameter panel
actually wants to ask. A knob absent from it invalidates no block, and that is
an answer rather than a gap.

Two new assertions keep the derivation from rotting at either edge: every role
in the table is reachable from some knob, and every knob a block declares is one
the registry defines. The second closes a real hole — `block-descriptor` checks
only that a knob was PASSED, and the freeze's `merge take/knobs` makes that true
of anything spelled like a keyword, so a typo in `block-knobs` would have named a
setting no slider can move.

228 CLJS tests, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-28 01:23:08 -04:00
parent 9cd5243983
commit 27bfe18bee
5 changed files with 85 additions and 13 deletions

View file

@ -204,6 +204,20 @@ bug waiting for a bigger project:
This table is the argument for the split, and it should be derivable from the This table is the argument for the split, and it should be derivable from the
sub graph rather than maintained by hand. sub graph rather than maintained by hand.
As built, half of it is, by a different route. `flow/address/block-knobs` is this
table for tier 2 — per block, the settings its bytes depend on — and
`address-test` asserts it by biconditional rather than deriving it, which is
stronger than a derivation would have been: a derived table is only as right as
the graph it reads. What is not written down anywhere is the rest of the column.
Stages 3 to 5 are one call chain in `flow/take/measure` rather than a chain of
subs, so there is no graph above `::base-scene` to read a dependency off, and the
tier-1 half of a re-freeze is therefore whole-clip.
An earlier arrangement had a second table in `domain/params`, per feature area,
saying which areas a knob affected. It disagreed with the tested one on the first
entry where the two granularities part company and it had no caller; see that
namespace for why one checked table beats two.
| Knob | Invalidates from | | Knob | Invalidates from |
| --- | --- | | --- | --- |
| model / footage | 2 detect | | model / footage | 2 detect |

View file

@ -1,12 +1,30 @@
(ns arthur.domain.params (ns arthur.domain.params
"Definitions for generated settings. Defaults, scope and value constraints live "Definitions for generated settings: defaults, scope and value constraints, once.
here once; UI metadata and regeneration rules can be added to each entry.")
WHAT IS NOT HERE. A knob's INVALIDATION — which stored bytes stop being valid
when it moves — is `arthur.flow.address/block-knobs`, and it is there rather
than here for two reasons that are not about layering.
It is per BLOCK and this registry is per AREA, and the difference is not
granularity, it is disagreement. `:aperture-cut` is a mouth setting that reaches
the TEETH block's bytes and does not reach the mouth's, because it gates the
contour smoothing and the mouth's own geometry is smoothed either way.
`:blink-cut` is an eye setting that reaches no block at all, because a blink is
`[:vis]` keys in tier 1. An `:affects #{:eye}` on that entry read as documentation
and was wrong in both directions.
And `block-knobs` is TESTED — `address-test` re-freezes the take once per knob
and asserts the biconditional — while a field here could only be believed. An
earlier version of this namespace carried `:affects` and an `affected-areas`
reading it, and the only thing that ever called it was a test asserting it
returned what it was written as. Two tables where one is checked and one is not
is worse than one table, because the unchecked one is the one a parameter UI
would reach for first. `address/invalidates` is the derived inverse, and it
cannot drift from the thing that is asserted.")
(def definitions (def definitions
{:anchor-avg {:area :subject :default 2 :type :integer :min 0 {:anchor-avg {:area :subject :default 2 :type :integer :min 0}
:affects #{:head :mouth :eye :brow :teeth}} :contour-avg {:area :subject :default 1 :type :integer :min 0}
:contour-avg {:area :subject :default 1 :type :integer :min 0
:affects #{:mouth :eye :brow}}
:verts {:area :mouth :default 8 :type :integer :min 4 :even? true} :verts {:area :mouth :default 8 :type :integer :min 4 :even? true}
:aperture-cut {:area :mouth :default 0.12 :type :number :min 0 :max 1} :aperture-cut {:area :mouth :default 0.12 :type :number :min 0 :max 1}
:eye-verts {:area :eye :default 8 :type :integer :min 4 :even? true} :eye-verts {:area :eye :default 8 :type :integer :min 4 :even? true}
@ -34,10 +52,6 @@
(def areas #{:subject :mouth :eye :brow :teeth}) (def areas #{:subject :mouth :eye :brow :teeth})
(defn affected-areas [id]
(when-let [spec (get definitions id)]
(or (:affects spec) #{(:area spec)})))
(defn for-area [wanted-area] (defn for-area [wanted-area]
(into {} (keep (fn [[id {:keys [area default]}]] (into {} (keep (fn [[id {:keys [area default]}]]
(when (= wanted-area area) [id default]))) (when (= wanted-area area) [id default])))

View file

@ -152,6 +152,28 @@
"teeth" [:anchor-avg :aperture-cut :blob-grow :cavity-erode :min-area "teeth" [:anchor-avg :aperture-cut :blob-grow :cavity-erode :min-area
:teeth-on :teeth-smooth :teeth-verts :tongue-reject :top-bias]}) :teeth-on :teeth-smooth :teeth-verts :tongue-reject :top-bias]})
(def knob-roles
"knob -> the roles whose bytes it moves. The inverse of `block-knobs`, DERIVED.
This is what a parameter UI wants — \"what stops being valid if I drag this\" —
and deriving it is the whole point: `block-knobs` is the table `address-test`
asserts by biconditional, so an answer computed from it cannot drift from an
answer that is checked, and an answer written down beside it could.
A knob absent from this map invalidates NO BLOCK, and that is a real answer
rather than a gap. `:blink-cut`, `:iris-size` and `:pupil-size` are all absent,
because a blink is `[:vis]` keys and the two sizes are framed channels: tier 1,
editable, and rewritten by a re-freeze without a byte of tier 2 moving."
(reduce (fn [m [role knobs]]
(reduce (fn [m knob] (update m knob (fnil conj #{}) role)) m knobs))
{}
block-knobs))
(defn invalidates
"The roles one knob's bytes depend on, or an empty set."
[knob]
(get knob-roles knob #{}))
(defn block-descriptor (defn block-descriptor
"The canonical text naming one dense block. "The canonical text naming one dense block.

View file

@ -26,9 +26,6 @@
(is (= :person-4 (get-in scene [:features :eye-8 :subject]))))) (is (= :person-4 (get-in scene [:features :eye-8 :subject])))))
(deftest parameter-definitions-drive-the-current-take-defaults (deftest parameter-definitions-drive-the-current-take-defaults
(is (= #{:eye} (params/affected-areas :blink-cut)))
(is (= #{:head :mouth :eye :brow :teeth}
(params/affected-areas :anchor-avg)))
(is (params/valid-settings? :eye {:blink-cut 0.2})) (is (params/valid-settings? :eye {:blink-cut 0.2}))
(is (not (params/valid-settings? :eye {:verts 8})))) (is (not (params/valid-settings? :eye {:verts 8}))))

View file

@ -113,6 +113,31 @@
(str "add " id " to block-knobs for " (pr-str role)) (str "add " id " to block-knobs for " (pr-str role))
(str "remove " id " from block-knobs for " (pr-str role))))))))))) (str "remove " id " from block-knobs for " (pr-str role)))))))))))
(deftest the-knob-inverse-is-derived-and-total
;; `invalidates` is the parameter UI's question — "what stops being valid if I
;; drag this" — and the reason it is derived rather than written down is that
;; the thing it is derived FROM is the thing the biconditional above asserts.
;; These two assertions are what keep the derivation honest in the only two ways
;; it could rot.
(testing "every role in the table is reachable from some knob"
(is (= (set (keys address/block-knobs))
(into #{} (mapcat val) address/knob-roles))))
(testing "every knob a block declares is a knob the registry defines"
;; The other direction of the same edge: a typo in `block-knobs` would name a
;; setting no slider can move, and `block-descriptor` only checks that the
;; knob was PASSED, which the freeze's `merge take/knobs` makes true of
;; anything spelled like a keyword.
(is (empty? (remove #(contains? params/definitions %)
(into #{} (mapcat val) address/block-knobs)))))
(testing "a knob that moves no bytes says so, rather than having no answer"
;; Tier 1 knobs. Each one rewrites keys or a framed value on a re-freeze and
;; no block's address changes, which is why the empty set is the right answer
;; and not a missing entry.
(doseq [knob [:blink-cut :iris-size :pupil-size]]
(is (= #{} (address/invalidates knob)) (str knob)))
(is (= #{"teeth"} (address/invalidates :aperture-cut))
"the mouth's threshold reaches the teeth block and no other")))
(deftest the-teeth-table-is-asserted-at-the-descriptor (deftest the-teeth-table-is-asserted-at-the-descriptor
;; Every knob the teeth block declares must reach its key. The other half — that ;; 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 ;; each one also moves its bytes — needs real pixels, because the crop, the otsu