From 9bc7b272451c3f237dbf216c8a85036ab6eb6c85 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Sat, 13 Jun 2026 10:44:40 -0400 Subject: [PATCH] =?UTF-8?q?perf:=20structural=20type=20inference=20(RFC=20?= =?UTF-8?q?0005)=20=E2=80=94=20nested=20access=20typed,=20hint-free?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ad-hoc inference lattice (a flat :struct-map tag plus {:vec ELEM}) with one recursive structural type: {:struct {field -> T}}, {:vec T}, {:set T}, scalar tags, and :any. A keyword lookup now returns its field's type, so nested access like (:r (:direction ray)) is typed end to end and drops its guard. join is field-wise and element-wise with a depth cap of 4 so the inter-procedural fixpoint still terminates. The back end honors a struct hint on any subject node, not just locals, so an inferred field type on a nested lookup specializes. The orchestrator's fixpoint joins through the portable join-types so compound types no longer collapse to :any. Ray tracer goes 12.8s to 11.0s with no hints, matching the explicit ^:struct version (10.9s). Render checksum unchanged (1915337), full gate green, conformance x3 modes pass. jolt-5uj --- jolt-core/jolt/passes.clj | 137 ++++++++++++++---- src/jolt/backend.janet | 11 +- test/integration/type-infer-phase1-test.janet | 11 +- test/integration/type-infer-phase3-test.janet | 4 +- 4 files changed, 127 insertions(+), 36 deletions(-) diff --git a/jolt-core/jolt/passes.clj b/jolt-core/jolt/passes.clj index 54e8ab3..f035353 100644 --- a/jolt-core/jolt/passes.clj +++ b/jolt-core/jolt/passes.clj @@ -716,27 +716,72 @@ ;; dynamic guard in place. Sound by construction: a concrete type is assigned ;; only when proven, so a wrong bare get is impossible. ;; -;; Lattice values: :struct-map (raw-get-safe), :phm-map, :set, :truthy (a -;; provably non-nil/non-false scalar), :any (top), and a PARAMETRIC vector type -;; {:vec ELEM} (jolt-d6u, Phase 3) carrying its element type so a reduce/map -;; closure over it can type its element param. {:vec ELEM} is a small struct, so -;; it compares by value on both the Clojure and the Janet (orchestrator) side. +;; Recursive STRUCTURAL types (RFC 0005). A type mirrors the data tree: +;; compound: {:struct {field -> T}} (raw-get-safe map, field types) +;; {:vec T} (vector of T) +;; {:set T} (set of T) +;; scalar: :num :str :kw :truthy (all provably non-nil/non-false) +;; :phm (persistent hash map; NOT raw-get-safe) +;; :any (top), nil (bottom, identity for join). +;; Compound types are small jolt maps, so they compare by value on both the +;; Clojure and the Janet (orchestrator) side. struct/vec/set use distinct keys so +;; a type is recognised by which key it carries. +;; (get t :KEY) is nil for a keyword type and the child for a compound, so a +;; compound is detected by some? — no map?/contains? needed. (defn- velem [t] (get t :vec)) +(defn- selem [t] (get t :set)) +(defn- sfields [t] (get t :struct)) (defn- vec-type? [t] (some? (velem t))) +(defn- set-type? [t] (some? (selem t))) +(defn- struct-type? [t] (some? (sfields t))) (defn- mk-vec [t] {:vec (if t t :any)}) -(defn- join [a b] +(defn- mk-set [t] {:set (if t t :any)}) +(defn- mk-struct [fs] {:struct fs}) +(declare join-t) +(defn- merge-fields + "Per-field join of two field maps (a key in only one side joins with :any)." + [fa fb] + (let [m1 (reduce (fn [m k] (assoc m k (join-t (get fa k :any) (get fb k :any)))) {} (keys fa))] + (reduce (fn [m k] (if (get m k) m (assoc m k (join-t (get fa k :any) (get fb k :any))))) m1 (keys fb)))) +(defn- join-t [a b] (cond (= a b) a - (and (vec-type? a) (vec-type? b)) (mk-vec (join (velem a) (velem b))) + (nil? a) b + (nil? b) a + (and (struct-type? a) (struct-type? b)) (mk-struct (merge-fields (sfields a) (sfields b))) + (and (vec-type? a) (vec-type? b)) (mk-vec (join-t (velem a) (velem b))) + (and (set-type? a) (set-type? b)) (mk-set (join-t (selem a) (selem b))) :else :any)) -(defn- struct-safe? [t] (= t :struct-map)) -;; a value whose type guarantees it is neither nil nor false — the back end only -;; builds a struct (vs a phm) when every value is truthy, so a map literal is a -;; struct only when all its values have a truthy type. Collections are non-nil. +(defn- join [a b] (join-t a b)) +;; depth cap (RFC 0005): truncate a type below depth d to :any, so recursive data +;; can't make an infinite type and the inter-procedural fixpoint stays finite. +(def ^:private type-depth 4) +(defn- cap [t d] + (cond + (<= d 0) (if (or (struct-type? t) (vec-type? t) (set-type? t)) :any t) + (struct-type? t) (mk-struct (reduce (fn [m k] (assoc m k (cap (get (sfields t) k) (dec d)))) + {} (keys (sfields t)))) + (vec-type? t) (mk-vec (cap (velem t) (dec d))) + (set-type? t) (mk-set (cap (selem t) (dec d))) + :else t)) +;; raw-get-safe (a Janet struct / record): a struct type. The field type of key +;; k, if known, else :any. +(defn- struct-safe? [t] (struct-type? t)) +(defn- field-type [t k] (if (struct-type? t) (get (sfields t) k :any) :any)) +;; tag a node (any expression, not just a :local) so the back end can specialize +;; a lookup whose SUBJECT is that node — this is what makes nested access work: +;; (:direction ray) is tagged struct, so (:r (:direction ray)) drops its guard. +(defn- mark-hint [node h] (assoc node :hint h)) +;; a value provably neither nil nor false — the back end only builds a struct +;; (vs a phm) when every value is non-nil/non-false, so a map literal is a struct +;; only when all its values have such a type. Collections are non-nil. (defn- truthy-type? [t] - (or (= t :truthy) (= t :struct-map) (= t :phm-map) (= t :set) (vec-type? t))) + (or (= t :num) (= t :str) (= t :kw) (= t :truthy) (= t :phm) + (struct-type? t) (vec-type? t) (set-type? t))) -(def ^:private truthy-ret-fns +;; core fns whose result is a number (so it is non-nil/non-false and, for the +;; success-type checker, provably numeric). +(def ^:private num-ret-fns #{"+" "-" "*" "/" "inc" "dec" "mod" "rem" "quot" "min" "max" "abs" "bit-and" "bit-or" "bit-xor" "count"}) (def ^:private vector-ret-fns #{"vec" "vector" "mapv" "filterv" "subvec"}) @@ -767,11 +812,11 @@ (= op :var) (let [r (get @rtenv-box (var-key fnode))] (if r r (let [nm (and (= "clojure.core" (get fnode :ns)) (get fnode :name))] (cond (nil? nm) :any - (contains? truthy-ret-fns nm) :truthy + (contains? num-ret-fns nm) :num (contains? vector-ret-fns nm) (mk-vec :any) :else :any)))) (= op :host) (let [nm (get fnode :name)] - (cond (contains? truthy-ret-fns nm) :truthy + (cond (contains? num-ret-fns nm) :num (contains? vector-ret-fns nm) (mk-vec :any) :else :any)) :else :any))) @@ -814,7 +859,13 @@ (let [op (get node :op)] (cond (= op :const) - [(let [v (get node :val)] (if (or (nil? v) (= false v)) :any :truthy)) node] + [(let [v (get node :val)] + (cond (number? v) :num + (string? v) :str + (keyword? v) :kw + (or (nil? v) (= false v)) :any ; nil/false are not struct-eligible + :else :truthy)) ; true, char, ... -> non-nil + node] (= op :local) (let [t (get tenv (get node :name))] [(if t t :any) @@ -823,23 +874,29 @@ (vec-type? t) (assoc node :hint :vector) :else node)]) (= op :map) - (let [res (mapv (fn [pr] + (let [pairs (get node :pairs) + res (mapv (fn [pr] (let [kr (infer (nth pr 0) tenv) vr (infer (nth pr 1) tenv)] - [(nth kr 1) (nth vr 1) (nth vr 0)])) - (get node :pairs)) - t (if (and (> (count res) 0) - (every? (fn [pr] (scalar-const? (nth pr 0))) (get node :pairs)) - (every? (fn [r] (truthy-type? (nth r 2))) res)) - :struct-map :any)] + [(nth kr 1) (nth vr 1) (nth vr 0) (get (nth pr 0) :val)])) + pairs) + struct? (and (> (count res) 0) + (every? (fn [pr] (scalar-const? (nth pr 0))) pairs) + (every? (fn [r] (truthy-type? (nth r 2))) res)) + t (if struct? + (cap (mk-struct (reduce (fn [m r] (assoc m (nth r 3) (nth r 2))) {} res)) type-depth) + :any)] [t (assoc node :pairs (mapv (fn [r] [(nth r 0) (nth r 1)]) res))]) (= op :vector) (let [irs (mapv (fn [x] (infer x tenv)) (get node :items)) ets (mapv (fn [r] (nth r 0)) irs) el (if (empty? ets) :any (reduce join (first ets) (rest ets)))] - [(mk-vec el) (assoc node :items (mapv (fn [r] (nth r 1)) irs))]) + [(cap (mk-vec el) type-depth) (assoc node :items (mapv (fn [r] (nth r 1)) irs))]) (= op :set) - [:set (assoc node :items (mapv (fn [x] (nth (infer x tenv) 1)) (get node :items)))] + (let [irs (mapv (fn [x] (infer x tenv)) (get node :items)) + ets (mapv (fn [r] (nth r 0)) irs) + el (if (empty? ets) :any (reduce join (first ets) (rest ets)))] + [(cap (mk-set el) type-depth) (assoc node :items (mapv (fn [r] (nth r 1)) irs))]) (= op :if) (let [tr (infer (get node :test) tenv) thn (infer (get node :then) tenv) @@ -865,6 +922,29 @@ args (get node :args) n (count args)] (cond + ;; (:k m) / (:k m default): the result is m's field type, and if m is a + ;; struct the subject is tagged so the back end drops the guard — this + ;; types nested access end to end (RFC 0005). + (and (= :const (get fnode :op)) (keyword? (get fnode :val)) (>= n 1) (<= n 2)) + (let [mr (infer (nth args 0) tenv) + mt (nth mr 0) + msub (if (struct-safe? mt) (mark-hint (nth mr 1) :struct) (nth mr 1)) + ft (field-type mt (get fnode :val)) + dr (when (= n 2) (infer (nth args 1) tenv))] + [(if dr (join ft (nth dr 0)) ft) + (assoc node :args (if dr [msub (nth dr 1)] [msub]))]) + ;; (get m :k [default]): same, when the key is a constant keyword. + (and (or (and (= :var (get fnode :op)) (= "clojure.core" (get fnode :ns)) (= "get" (get fnode :name))) + (and (= :host (get fnode :op)) (= "get" (get fnode :name)))) + (>= n 2) (= :const (get (nth args 1) :op)) (keyword? (get (nth args 1) :val))) + (let [mr (infer (nth args 0) tenv) + mt (nth mr 0) + msub (if (struct-safe? mt) (mark-hint (nth mr 1) :struct) (nth mr 1)) + kr (infer (nth args 1) tenv) + ft (field-type mt (get (nth args 1) :val)) + dr (when (= n 3) (infer (nth args 2) tenv))] + [(if dr (join ft (nth dr 0)) ft) + (assoc node :args (if dr [msub (nth kr 1) (nth dr 1)] [msub (nth kr 1)]))]) ;; reduce over a typed vector with a fn-literal (jolt-d6u): seed the ;; closure's accumulator (param 0) to the init type and its element ;; (param 1) to the vector's element type, so its body — and any calls @@ -910,7 +990,7 @@ (when iscall-var (swap! calls-box conj [(var-key fnode) (mapv (fn [r] (nth r 0)) ares)])) [(cond - (= cn "range") (mk-vec :truthy) + (= cn "range") (mk-vec :num) ;; element-returning fn over a typed vector -> the element type (and cn (contains? elem-fns cn) (> n 0)) (let [a0 (nth (nth ares 0) 0)] (if (vec-type? a0) (velem a0) :any)) @@ -965,6 +1045,11 @@ (non-nil), def vars carry their inferred init type (jolt-d6u)." [m] (reset! vtype-box m)) +(defn join-types + "Public structural join (lub), used by the orchestrator's fixpoint so param/ + return types join field-wise/element-wise instead of collapsing to :any." + [a b] (join-t a b)) + (defn reset-escapes! [] (reset! escapes-box #{})) (defn collected-escapes [] (vec @escapes-box)) diff --git a/src/jolt/backend.janet b/src/jolt/backend.janet index 957c547..f9247e7 100644 --- a/src/jolt/backend.janet +++ b/src/jolt/backend.janet @@ -342,7 +342,11 @@ # - hinted + JOLT_CHECK_HINTS: keep the guard but THROW on the tagged arm, so a # lying hint surfaces a clear error (dev aid; off by default, no perf cost). (defn- emit-kw-lookup [subj-node m-expr k d-expr] - (def hinted (and subj-node (= :local (subj-node :op)) (= :struct (subj-node :hint)))) + # the subject is a struct (raw-get-safe) when hinted so — by an explicit + # ^:struct/^Record hint on a local, OR by inference tagging ANY subject + # expression it proved to be a struct (jolt-d6u/RFC 0005), which is what lets + # nested access like (:r (:direction ray)) drop its guard. + (def hinted (and subj-node (= :struct (subj-node :hint)))) (def checked (and hinted (os/getenv "JOLT_CHECK_HINTS"))) (def m (if (symbol? m-expr) m-expr (jsym))) (def wrap (fn [body] (if (symbol? m-expr) body ['let [m m-expr] body]))) @@ -799,13 +803,14 @@ (def pns (ctx-find-ns ctx "jolt.passes")) (def f-set-rtenv (and pns (ns-find pns "set-rtenv!"))) (def f-set-vtypes (and pns (ns-find pns "set-vtypes!"))) + (def f-join (and pns (ns-find pns "join-types"))) (def f-infer-body (and pns (ns-find pns "infer-body"))) (def f-reinfer (and pns (ns-find pns "reinfer-def"))) (def f-reset-esc (and pns (ns-find pns "reset-escapes!"))) (def f-get-esc (and pns (ns-find pns "collected-escapes"))) (def ns (ctx-find-ns ctx ns-name)) (def report @{}) - (when (and ns f-set-rtenv f-infer-body f-reinfer f-reset-esc f-get-esc) + (when (and ns f-set-rtenv f-set-vtypes f-join f-infer-body f-reinfer f-reset-esc f-get-esc) # gather single-fixed-arity fns AND non-fn defs that stashed a :def IR (def fns @[]) (def defs @[]) @@ -865,7 +870,7 @@ (def callee (get by-key (in cv 0))) (def ats (vview (in cv 1))) (def lim (min (length ats) (callee :np))) - (for i 0 lim (put npa i (itype-join (in npa i) (in ats i))))))) + (for i 0 lim (put npa i ((var-get f-join) (in npa i) (in ats i))))))) # commit + detect change (set changed false) (def nrt @{}) diff --git a/test/integration/type-infer-phase1-test.janet b/test/integration/type-infer-phase1-test.janet index 31735f8..e60fbc1 100644 --- a/test/integration/type-infer-phase1-test.janet +++ b/test/integration/type-infer-phase1-test.janet @@ -29,11 +29,12 @@ (def report (backend/infer-unit! ctx "p1")) # --- the fixpoint computed the right param types ----------------------------- -# rd's param v flows from mk's struct-map result (mk inlines to a struct literal -# in drv) and stays struct across the recursive self-call -> :struct-map -(assert (= :struct-map (in (get report "p1/rd") 0)) (string "rd param v: " (in (get report "p1/rd") 0))) +# rd's param v flows from mk's struct result (mk inlines to a struct literal in +# drv) and stays struct across the recursive self-call -> a {:struct ...} type +(defn struct-type? [t] (truthy? (get t :struct))) +(assert (struct-type? (in (get report "p1/rd") 0)) (string "rd param v: " (in (get report "p1/rd") 0))) # esc escaped (passed to mapv) -> param stays unknown (:any / nil), NOT struct -(assert (not= :struct-map (in (get report "p1/esc") 0)) "escaping fn param not inferred struct") +(assert (not (struct-type? (in (get report "p1/esc") 0))) "escaping fn param not inferred struct") # --- the seeded re-inference drops the guard for a struct param -------------- # (on a FRESH analysis, since infer-unit! re-stashes the already-specialized body) @@ -42,7 +43,7 @@ (def rd-def (backend/analyze-form ctx (reader/parse-string "(defn rdx [v n] (if (< n 1) (:r v) (rdx v (dec n))))"))) (defn guards-seeded [ptmap] (length (string/find-all ":jolt/type" (string/format "%p" (backend/emit-ir ctx ((types/var-get reinfer) rd-def ptmap)))))) -(assert (= 0 (guards-seeded @{"v" :struct-map})) "struct param -> bare lookup") +(assert (= 0 (guards-seeded @{"v" {:struct {}}})) "struct param -> bare lookup") (assert (= 1 (guards-seeded @{})) "no param type -> guard kept") # --- correctness: recompiled unit still computes the same -------------------- diff --git a/test/integration/type-infer-phase3-test.janet b/test/integration/type-infer-phase3-test.janet index 89ecfc8..e9dc1c5 100644 --- a/test/integration/type-infer-phase3-test.janet +++ b/test/integration/type-infer-phase3-test.janet @@ -21,13 +21,13 @@ # a reduce closure's element param gets the vector's element type (def red "(defn f [coll] (reduce (fn [acc h] (+ acc (:r h))) 0 coll))") -(assert (= 0 (guards red @{"coll" {:vec :struct-map}})) "reduce element typed -> bare lookup in closure") +(assert (= 0 (guards red @{"coll" {:vec {:struct {}}}})) "reduce element typed -> bare lookup in closure") (assert (= 1 (guards red @{"coll" {:vec :any}})) "reduce over vector of unknown -> guard kept") (assert (= 1 (guards red @{})) "untyped coll -> guard kept") # mapv over a vector-of-structs types the closure element too (def mp "(defn g [coll] (mapv (fn [h] (:r h)) coll))") -(assert (= 0 (guards mp @{"coll" {:vec :struct-map}})) "mapv element typed -> bare lookup") +(assert (= 0 (guards mp @{"coll" {:vec {:struct {}}}})) "mapv element typed -> bare lookup") (assert (= 1 (guards mp @{"coll" {:vec :any}})) "mapv over unknown element -> guard") # element type is DERIVED, not just seeded: a vector literal of structs, reduced