Nilable record types + flow-sensitive nil narrowing (#235)

A record-or-nil (a protocol method whose impls return a record in one branch and
nil in another, or an `if` over a ctor and nil) now types as a NILABLE record
instead of widening to :any. A nilable record still bare-indexes its field reads
(jrec-field-at falls back to jolt-get on nil), but some?/nil? do NOT fold on it, so
a runtime guard is preserved — and inside (if (some? x) ..) / (if x ..) the then-
branch narrows x to the non-nil record, so its reads bare-index AND unbox there.

This is what lets the bounced ray type without a hint: scatter returns
ScatterResult-or-nil (Metal absorbs some rays), and the consumer reads
(:ray scattered) only under (if (some? scattered) ..). The narrowing proves
scattered non-nil there.

lattice: :nil type; :nil ∨ struct -> nilable struct, ∨ anything else -> :any;
nilability is contagious through a struct join, which also now preserves :type when
both sides agree (needed so a record ∨ its nilable self stays that record).
truthy-type?/field-type/pred-on treat a nilable struct as maybe-nil. types: nil
literal -> :nil; an `if` whose test is (some? x)/(nil? x)/x narrows the nilable
local x in the proven branch.

Ray tracer with NO hints: 38.4s -> 23.9s (~1.6x) — hit-sphere now types fully
(0 jolt-get, 57 jrec-field-at, 38 fl-ops), identical to the hand-hinted build.

run-narrow.ss gate, incl. the load-bearing check that the nil case still takes the
else branch (the guard is not folded away). make test / shakesmoke green, selfhost
holds, 0 new divergences.

Co-authored-by: Yogthos <yogthos@gmail.com>
This commit is contained in:
Dmitri Sotnikov 2026-06-26 17:16:16 +00:00 committed by GitHub
parent f124701393
commit f920ff6ea2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 420 additions and 280 deletions

View file

@ -13,7 +13,7 @@
[velem selem sfields vec-type? set-type? struct-type? mk-vec mk-set
mk-struct union-cap scalar-t? union-type? umembers union-of merge-fields
join-t join type-depth cap struct-safe? field-type shape-order type-shape
mark-struct truthy-type? num-ret-fns vector-ret-fns]]))
mark-struct truthy-type? num-ret-fns vector-ret-fns nilable? strip-nilable]]))
;; --- engine state ------------------------------------------------------------
;; The walk threads an immutable `env` (mk-env) instead of reading scattered
@ -143,6 +143,9 @@
(defn- pred-on [pname t]
(cond
(or (= t :any) (= t :truthy)) nil
;; a nilable struct might be nil — nil?/some?/record? can't be proven, so the
;; runtime guard must stay (this is what makes the narrowing sound).
(nilable? t) nil
;; a bounded scalar union folds only when every member agrees
(union-type? t)
(let [vs (map (fn [m] (pred-on pname m)) (umembers t))]
@ -160,6 +163,28 @@
;; folds away (a wider purity analysis can broaden this later).
(defn- pure-node? [n] (let [op (get n :op)] (or (= op :const) (= op :local))))
;; Flow-sensitive nil narrowing: in (if (some? x) ..) / (if x ..) / (if (nil? x) ..)
;; a nilable-struct local x is proven non-nil in one branch, so its field reads
;; bare-index and unbox there. Only a nilable local narrows — nothing else changes.
(defn- test-local [test pred-name]
(when (= :invoke (get test :op))
(let [f (get test :fn) args (get test :args)]
(when (and (= :var (get f :op)) (= "clojure.core" (get f :ns))
(= pred-name (get f :name))
(= 1 (count args)) (= :local (get (nth args 0) :op)))
(get (nth args 0) :name)))))
(defn- narrow-nonnil [tenv nm]
(let [t (get tenv nm)] (if (nilable? t) (assoc tenv nm (strip-nilable t)) tenv)))
;; [then-tenv else-tenv] for an `if` whose test narrows a nilable local.
(defn- if-narrow [test tenv]
(let [somev (test-local test "some?")
nilv (test-local test "nil?")]
(cond
(= :local (get test :op)) [(narrow-nonnil tenv (get test :name)) tenv]
somev [(narrow-nonnil tenv somev) tenv]
nilv [tenv (narrow-nonnil tenv nilv)]
:else [tenv tenv])))
(declare infer)
;; infer (and infer-fn-seeded) return a [type node'] tuple — the result type plus
@ -423,7 +448,8 @@
(number? v) :num
(string? v) :str
(keyword? v) :kw
(or (nil? v) (= false v)) :any ; nil/false are not struct-eligible
(nil? v) :nil ; a record|nil branch types as a nilable record
(= false v) :any ; false is not struct-eligible
:else :truthy)) ; true, char, ... -> non-nil
node]
(= op :local)
@ -463,9 +489,11 @@
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 env)
thn (infer (get node :then) tenv env)
els (infer (get node :else) tenv env)]
(let [test (get node :test)
tr (infer test tenv env)
nr (if-narrow test tenv) ; narrow a nilable local in the proven branch
thn (infer (get node :then) (nth nr 0) env)
els (infer (get node :else) (nth nr 1) env)]
[(join (nth thn 0) (nth els 0))
(assoc node :test (nth tr 1) :then (nth thn 1) :else (nth els 1))])
(= op :do)

View file

@ -79,6 +79,15 @@
(= a b) a
(nil? a) b
(nil? b) a
;; :nil is the type of a literal nil. With a struct it forms a NILABLE struct —
;; field reads still bare-index (jrec-field-at falls back to jolt-get on nil), but
;; some?/nil? won't fold and a guard narrows it back to non-nil. With anything
;; else it widens to :any (nil is not a safe scalar/vec — no fl/fx, no bare elem).
(or (= a :nil) (= b :nil))
(let [o (if (= a :nil) b a)]
(cond (= o :nil) :nil
(struct-type? o) (assoc o :nilable true)
:else :any))
;; :double is a flonum refinement of :num: two doubles stay :double (caught by
;; = above), but a double joined with anything else loses the flonum guarantee
;; and widens to :num before joining — so a param is :double only when EVERY
@ -86,13 +95,18 @@
(or (= a :double) (= b :double))
(join-t (if (= a :double) :num a) (if (= b :double) :num b))
(and (struct-type? a) (struct-type? b))
(let [merged (mk-struct (merge-fields (sfields a) (sfields b)))]
;; joining two values of the SAME complete shape preserves it — the
;; merged struct has the same key set. Different shapes
;; (or an incomplete side) drop it, as the layout is no longer proven.
(if (and (get a :shape) (= (get a :shape) (get b :shape)))
(assoc merged :shape (get a :shape))
merged))
(let [merged (mk-struct (merge-fields (sfields a) (sfields b)))
;; joining two values of the SAME complete shape / record type preserves
;; it — the merged struct has the same key set. Different shapes/types
;; (or an incomplete side) drop it, as the layout is no longer proven.
merged (if (and (get a :shape) (= (get a :shape) (get b :shape)))
(assoc merged :shape (get a :shape)) merged)
merged (if (and (get a :type) (= (get a :type) (get b :type)))
(assoc merged :type (get a :type)) merged)
;; nilability is contagious: a nilable side makes the join nilable.
merged (if (or (get a :nilable) (get b :nilable))
(assoc merged :nilable true) merged)]
merged)
(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)))
;; differing kinds: form a scalar union when both sides reduce to scalars
@ -127,7 +141,12 @@
;; raw-get-safe (a 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))
;; a nilable struct yields :any for every field (the whole value might be nil, so a
;; field read can be nil) — conservative + sound. A guard narrows it to non-nil first
;; (strip-nilable), after which the real field types flow.
(defn field-type [t k] (if (and (struct-type? t) (not (get t :nilable))) (get (sfields t) k :any) :any))
(defn nilable? [t] (and (map? t) (get t :nilable) true))
(defn strip-nilable [t] (if (and (map? t) (get t :nilable)) (dissoc t :nilable) t))
;; Shape (hidden class). A struct type built from a map LITERAL carries
;; its complete layout — :shape, the canonical (str-sorted) key vector. The back
;; end represents such a map as a shape tuple and reads a field by bare index.
@ -152,7 +171,8 @@
;; only when all its values have such a type. Collections are non-nil.
(defn truthy-type? [t]
(or (= t :num) (= t :double) (= t :str) (= t :kw) (= t :truthy) (= t :phm)
(struct-type? t) (vec-type? t) (set-type? t)))
(and (struct-type? t) (not (get t :nilable))) ; a nilable struct may be nil
(vec-type? t) (set-type? t)))
;; core fns whose result is a number (so it is non-nil/non-false and, for the
;; success-type checker, provably numeric).