feat: bounded union types in the RFC 0005 lattice (jolt-pz5)
The success checker (RFC 0006) used to lose differing if-branches to :any
and accept the use. (inc (if c "a" :k)) typed the if as :any — sound but
imprecise, since the value is provably {:str | :kw}, every member of which
is in inc's error domain.
Adds {:union #{T...}} to the lattice: join-t forms a scalar union of
differing branches instead of collapsing to :any, capped at 4 distinct
scalars (the member space is the five scalar tags, so the lattice stays
finite and the inter-procedural fixpoint still terminates). The checker's
not-number?/not-seqable? report a union only when EVERY member is in the
error domain — any valid member accepts the call, so still no false
positives. type-name renders "a string or a keyword".
Unions are scalar-only and carry no :struct/:vec/:set key, so every
structural predicate already treats them as opaque — specialization sees
them exactly as :any and codegen is unchanged. Gate green, suite 4718,
conformance 335/335, bench even.
This commit is contained in:
parent
1c0b3fe9bd
commit
9f076937af
2 changed files with 75 additions and 7 deletions
|
|
@ -737,6 +737,40 @@
|
||||||
(defn- mk-vec [t] {:vec (if t t :any)})
|
(defn- mk-vec [t] {:vec (if t t :any)})
|
||||||
(defn- mk-set [t] {:set (if t t :any)})
|
(defn- mk-set [t] {:set (if t t :any)})
|
||||||
(defn- mk-struct [fs] {:struct fs})
|
(defn- mk-struct [fs] {:struct fs})
|
||||||
|
|
||||||
|
;; Bounded union types (RFC 0006 / jolt-pz5). A union {:union #{T...}} records
|
||||||
|
;; that a value is provably one of a small, fixed set of SCALAR types — what
|
||||||
|
;; differing if-branches used to collapse to :any. It exists so the success
|
||||||
|
;; checker can reject a use where EVERY member is in the op's error domain
|
||||||
|
;; ((inc (if c "a" :k))) while still accepting one where any member is valid
|
||||||
|
;; ((inc (if c 1 "x"))). Scalars only, capped cardinality: the member space is
|
||||||
|
;; the five scalar tags, so the lattice stays finite and the inter-procedural
|
||||||
|
;; fixpoint terminates. A union is opaque to every STRUCTURAL predicate
|
||||||
|
;; (struct-type?/vec-type?/set-type? key on :struct/:vec/:set, which a union
|
||||||
|
;; lacks), so specialization treats it exactly like :any — codegen is
|
||||||
|
;; unchanged; only the checker reads inside it.
|
||||||
|
(def ^:private union-cap 4)
|
||||||
|
(defn- scalar-t? [t] (or (= t :num) (= t :str) (= t :kw) (= t :truthy) (= t :phm)))
|
||||||
|
(defn- union-type? [t] (some? (get t :union)))
|
||||||
|
(defn- umembers [t] (get t :union))
|
||||||
|
(defn- union-of
|
||||||
|
"Normalize a seq of member types into a lattice value: flatten nested unions,
|
||||||
|
keep only scalars (any non-scalar member collapses the whole thing to :any,
|
||||||
|
the conservative top), then return the lone member if one, {:union #{...}}
|
||||||
|
for 2..cap distinct scalars, or :any past the cap."
|
||||||
|
[ts]
|
||||||
|
(let [flat (reduce (fn [acc t]
|
||||||
|
(if (union-type? t)
|
||||||
|
(reduce conj acc (umembers t))
|
||||||
|
(conj acc t)))
|
||||||
|
#{} ts)]
|
||||||
|
(cond
|
||||||
|
(not (every? scalar-t? flat)) :any
|
||||||
|
(= 0 (count flat)) :any
|
||||||
|
(= 1 (count flat)) (first flat)
|
||||||
|
(> (count flat) union-cap) :any
|
||||||
|
:else {:union flat})))
|
||||||
|
|
||||||
(declare join-t)
|
(declare join-t)
|
||||||
(defn- merge-fields
|
(defn- merge-fields
|
||||||
"Per-field join of two field maps (a key in only one side joins with :any)."
|
"Per-field join of two field maps (a key in only one side joins with :any)."
|
||||||
|
|
@ -751,7 +785,11 @@
|
||||||
(and (struct-type? a) (struct-type? b)) (mk-struct (merge-fields (sfields a) (sfields b)))
|
(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 (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)))
|
(and (set-type? a) (set-type? b)) (mk-set (join-t (selem a) (selem b)))
|
||||||
:else :any))
|
;; differing kinds: form a scalar union when both sides reduce to scalars
|
||||||
|
;; (or scalar unions); anything compound on either side stays :any (jolt-pz5)
|
||||||
|
:else (let [ma (cond (union-type? a) (umembers a) (scalar-t? a) #{a} :else nil)
|
||||||
|
mb (cond (union-type? b) (umembers b) (scalar-t? b) #{b} :else nil)]
|
||||||
|
(if (and ma mb) (union-of (reduce conj ma mb)) :any))))
|
||||||
(defn- join [a b] (join-t a b))
|
(defn- join [a b] (join-t a b))
|
||||||
;; depth cap (RFC 0005): truncate a type below depth d to :any, so recursive data
|
;; 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.
|
;; can't make an infinite type and the inter-procedural fixpoint stays finite.
|
||||||
|
|
@ -1043,15 +1081,22 @@
|
||||||
;; there are no false positives. The table is curated to genuinely-throwing
|
;; there are no false positives. The table is curated to genuinely-throwing
|
||||||
;; cases; lenient ops ((get 5 :k) -> nil, (:k 5) -> nil) are NOT listed.
|
;; cases; lenient ops ((get 5 :k) -> nil, (:k 5) -> nil) are NOT listed.
|
||||||
|
|
||||||
;; concrete non-numbers: arithmetic provably throws on these.
|
;; concrete non-numbers: arithmetic provably throws on these. A union is in the
|
||||||
|
;; error domain only when EVERY member is (jolt-pz5) — if any member is an
|
||||||
|
;; accepted type the call is accepted (no false positive).
|
||||||
(defn- not-number? [t]
|
(defn- not-number? [t]
|
||||||
(or (= t :str) (= t :kw) (= t :phm)
|
(if (union-type? t)
|
||||||
(struct-type? t) (vec-type? t) (set-type? t)))
|
(every? not-number? (umembers t))
|
||||||
|
(or (= t :str) (= t :kw) (= t :phm)
|
||||||
|
(struct-type? t) (vec-type? t) (set-type? t))))
|
||||||
|
|
||||||
;; concrete non-seqable scalars: seq/count/first/nth provably throw on these.
|
;; concrete non-seqable scalars: seq/count/first/nth provably throw on these.
|
||||||
;; (Strings and collections ARE seqable/countable; :truthy is ambiguous; :nil
|
;; (Strings and collections ARE seqable/countable; :truthy is ambiguous; :nil
|
||||||
;; and :any are accepted.)
|
;; and :any are accepted.) A union throws only when every member does.
|
||||||
(defn- not-seqable? [t] (or (= t :num) (= t :kw)))
|
(defn- not-seqable? [t]
|
||||||
|
(if (union-type? t)
|
||||||
|
(every? not-seqable? (umembers t))
|
||||||
|
(or (= t :num) (= t :kw))))
|
||||||
|
|
||||||
;; arithmetic / numeric ops: EVERY argument must be a number.
|
;; arithmetic / numeric ops: EVERY argument must be a number.
|
||||||
(def ^:private num-ops
|
(def ^:private num-ops
|
||||||
|
|
@ -1063,7 +1108,10 @@
|
||||||
(defn- type-name
|
(defn- type-name
|
||||||
"Render an inferred type for an error message."
|
"Render an inferred type for an error message."
|
||||||
[t]
|
[t]
|
||||||
(cond (struct-type? t) "a map"
|
(cond (union-type? t)
|
||||||
|
(reduce (fn [s m] (if (= s "") (type-name m) (str s " or " (type-name m))))
|
||||||
|
"" (umembers t))
|
||||||
|
(struct-type? t) "a map"
|
||||||
(vec-type? t) "a vector"
|
(vec-type? t) "a vector"
|
||||||
(set-type? t) "a set"
|
(set-type? t) "a set"
|
||||||
(= t :str) "a string"
|
(= t :str) "a string"
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,26 @@
|
||||||
(assert (= 0 (nd "(inc (count [1 2 3]))")) "count of vector + inc of :num both fine")
|
(assert (= 0 (nd "(inc (count [1 2 3]))")) "count of vector + inc of :num both fine")
|
||||||
(assert (= 0 (nd "(inc (first [1 2 3]))")) "first of vector -> :num, inc fine")
|
(assert (= 0 (nd "(inc (first [1 2 3]))")) "first of vector -> :num, inc fine")
|
||||||
|
|
||||||
|
# --- bounded unions (jolt-pz5): report only when EVERY member is in the error
|
||||||
|
# domain; accept when any member is valid. Differing branches used to collapse
|
||||||
|
# to :any (accepted); now they form {:union #{...}} and are checked per-member.
|
||||||
|
(assert (= 1 (nd "(fn [c] (inc (if c \"a\" :k)))"))
|
||||||
|
"inc of {:str | :kw} — every member non-number — reported")
|
||||||
|
(assert (= 0 (nd "(fn [c] (inc (if c 1 \"x\")))"))
|
||||||
|
"inc of {:num | :str} — :num is fine — still accepted")
|
||||||
|
(assert (= 1 (nd "(fn [c] (count (if c :k 5)))"))
|
||||||
|
"count of {:kw | :num} — both non-seqable — reported")
|
||||||
|
(assert (= 0 (nd "(fn [c] (count (if c :k \"ab\")))"))
|
||||||
|
"count of {:kw | :str} — :str is seqable — accepted")
|
||||||
|
(assert (= 1 (nd "(fn [c] (inc (if c \"a\" (if c :k :j))))"))
|
||||||
|
"inc of nested all-non-number union reported")
|
||||||
|
(assert (= 0 (nd "(fn [c] (inc (if c \"a\" (if c :k 1))))"))
|
||||||
|
"inc of union with a buried :num member accepted")
|
||||||
|
# a union is opaque to structural specialization — it keeps the dynamic guard,
|
||||||
|
# exactly like :any, so a keyword lookup over it is never mis-specialized.
|
||||||
|
(assert (= 0 (nd "(fn [c] (:r (if c {:r 1} {:g 2})))"))
|
||||||
|
"keyword lookup over a struct union is accepted (no false positive)")
|
||||||
|
|
||||||
# --- the diagnostic carries op + type + a message ----------------------------
|
# --- the diagnostic carries op + type + a message ----------------------------
|
||||||
(def one (in (diags "(inc \"x\")") 0))
|
(def one (in (diags "(inc \"x\")") 0))
|
||||||
(assert (= "inc" (get one :op)) "diagnostic names the op")
|
(assert (= "inc" (get one :op)) "diagnostic names the op")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue