From 27bfe18beef48454d6675153f8a27f5b45ffdcc9 Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Mon, 28 Sep 2026 01:23:08 -0400 Subject: [PATCH] Keep one invalidation table, and derive the inverse a UI wants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/architecture.md | 14 ++++++++ frontend/src/arthur/domain/params.cljs | 34 +++++++++++++------ frontend/src/arthur/flow/address.cljs | 22 ++++++++++++ frontend/test/arthur/domain/feature_test.cljs | 3 -- frontend/test/arthur/flow/address_test.cljs | 25 ++++++++++++++ 5 files changed, 85 insertions(+), 13 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b1ffaf2..1b1e9fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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 | | --- | --- | | model / footage | 2 detect | diff --git a/frontend/src/arthur/domain/params.cljs b/frontend/src/arthur/domain/params.cljs index 483431e..3d7e8bb 100644 --- a/frontend/src/arthur/domain/params.cljs +++ b/frontend/src/arthur/domain/params.cljs @@ -1,12 +1,30 @@ (ns arthur.domain.params - "Definitions for generated settings. Defaults, scope and value constraints live - here once; UI metadata and regeneration rules can be added to each entry.") + "Definitions for generated settings: defaults, scope and value constraints, once. + + 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 - {: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 - :affects #{:mouth :eye :brow}} + {:anchor-avg {:area :subject :default 2 :type :integer :min 0} + :contour-avg {:area :subject :default 1 :type :integer :min 0} :verts {:area :mouth :default 8 :type :integer :min 4 :even? true} :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} @@ -34,10 +52,6 @@ (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] (into {} (keep (fn [[id {:keys [area default]}]] (when (= wanted-area area) [id default]))) diff --git a/frontend/src/arthur/flow/address.cljs b/frontend/src/arthur/flow/address.cljs index 809380b..5822f35 100644 --- a/frontend/src/arthur/flow/address.cljs +++ b/frontend/src/arthur/flow/address.cljs @@ -152,6 +152,28 @@ "teeth" [:anchor-avg :aperture-cut :blob-grow :cavity-erode :min-area :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 "The canonical text naming one dense block. diff --git a/frontend/test/arthur/domain/feature_test.cljs b/frontend/test/arthur/domain/feature_test.cljs index 1c063f0..ce4ec99 100644 --- a/frontend/test/arthur/domain/feature_test.cljs +++ b/frontend/test/arthur/domain/feature_test.cljs @@ -26,9 +26,6 @@ (is (= :person-4 (get-in scene [:features :eye-8 :subject]))))) (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 (not (params/valid-settings? :eye {:verts 8})))) diff --git a/frontend/test/arthur/flow/address_test.cljs b/frontend/test/arthur/flow/address_test.cljs index 0e4327b..e9a49d4 100644 --- a/frontend/test/arthur/flow/address_test.cljs +++ b/frontend/test/arthur/flow/address_test.cljs @@ -113,6 +113,31 @@ (str "add " id " to 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 ;; 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