fix: transient invokable lookup, assoc! odd args, use-after-persistent! invalidation

Real transient correctness gaps surfaced by the clojure-test-suite:

- Transients are now invokable for read-only lookup like their persistent forms:
  ((transient v) i), ((transient m) k [default]), (:k (transient m)),
  ((transient s) x). jolt-invoke/coll-lookup gained a transient branch
  (transient-lookup) that indexes :arr / canon-keyed :tbl. Added phm/canon as a
  public canonicalizer so collection keys compare by value here too.
- assoc! accepts an ODD arg count (a missing final value is nil), unlike assoc —
  core-assoc! now uses nil-safe get for the value instead of erroring.
- Using a transient after persistent! (or a second persistent!, or pop! on an
  empty transient vector) now throws, via a :jolt/persistent invalidation flag
  checked by the mutating ops. Catches the classic transient footgun.

spec: transients-spec gains invokable-lookup (7), assoc!-odd-args (4),
invalidation (4). clojure-test-suite pass 1704->1719.

Remaining transient fails are accepted lenient divergences (jolt doesn't throw
on bad-shape conj!/assoc!/pop! of wrong-typed args; nil set elements). jpm test green.
This commit is contained in:
Yogthos 2026-06-05 09:29:36 -04:00
parent e75771084e
commit 09532dac05
5 changed files with 83 additions and 10 deletions

View file

@ -3129,7 +3129,13 @@
(array? coll) @{:jolt/type :jolt/transient :kind :vector :arr (array/slice coll)}
(error (string "Don't know how to create a transient from " (type coll)))))
# A transient is invalidated by persistent!; using it afterwards is a bug.
(defn- tr-check-active! [t]
(when (get t :jolt/persistent)
(error "Transient used after persistent! call")))
(defn- tr-conj! [t x]
(tr-check-active! t)
(case (t :kind)
:vector (array/push (t :arr) x)
:set (put (t :tbl) (canon-key x) x)
@ -3137,6 +3143,7 @@
t)
(defn- tr-assoc! [t k v]
(tr-check-active! t)
(case (t :kind)
:vector (let [a (t :arr)] (if (= k (length a)) (array/push a v) (put a k v)))
:map (put (t :tbl) (canon-key k) @[k v])
@ -3149,31 +3156,42 @@
(apply core-conj t xs))) # lenient fallback for a persistent coll
(defn core-assoc! [t & kvs]
# Unlike assoc, assoc! accepts an ODD number of args — a missing final value
# is taken as nil (so (get kvs (+ i 1)) rather than (in ...), which would
# error on the dangling key).
(if (core-transient? t)
(do (var i 0) (while (< i (length kvs)) (tr-assoc! t (in kvs i) (in kvs (+ i 1))) (+= i 2)) t)
(do (var i 0) (while (< i (length kvs)) (tr-assoc! t (in kvs i) (get kvs (+ i 1))) (+= i 2)) t)
(apply core-assoc t kvs)))
(defn core-dissoc! [t & ks]
(if (core-transient? t)
(do (each k ks (put (t :tbl) (canon-key k) nil)) t)
(do (tr-check-active! t) (each k ks (put (t :tbl) (canon-key k) nil)) t)
(apply core-dissoc t ks)))
(defn core-disj! [t & xs]
(if (core-transient? t)
(do (each x xs (put (t :tbl) (canon-key x) nil)) t)
(do (tr-check-active! t) (each x xs (put (t :tbl) (canon-key x) nil)) t)
(apply core-disj t xs)))
(defn core-pop! [t]
(if (core-transient? t)
(do (array/pop (t :arr)) t)
(do (tr-check-active! t)
(when (= 0 (length (t :arr))) (error "Can't pop empty vector"))
(array/pop (t :arr)) t)
(core-pop t)))
(defn core-persistent! [t]
(if (core-transient? t)
(case (t :kind)
:vector (make-vec (t :arr))
:set (do (var s (make-phs)) (each [_ e] (pairs (t :tbl)) (set s (phs-conj s e))) s)
:map (do (var m (make-phm)) (each [_ pair] (pairs (t :tbl)) (set m (phm-assoc m (in pair 0) (in pair 1)))) m))
(do
(tr-check-active! t)
(def result
(case (t :kind)
:vector (make-vec (t :arr))
:set (do (var s (make-phs)) (each [_ e] (pairs (t :tbl)) (set s (phs-conj s e))) s)
:map (do (var m (make-phm)) (each [_ pair] (pairs (t :tbl)) (set m (phm-assoc m (in pair 0) (in pair 1)))) m)))
# Invalidate: any further bang op (or a second persistent!) now throws.
(put t :jolt/persistent true)
result)
t))
# Unchecked arithmetic — Jolt numbers don't overflow, so these are plain ops.

View file

@ -34,10 +34,27 @@
(var eval-form nil)
# A transient is a tagged mutable table @{:jolt/type :jolt/transient :kind ...}.
(defn- jolt-transient? [x]
(and (table? x) (= :jolt/transient (get x :jolt/type))))
# Read-only lookup over a transient (vector index / map key / set membership),
# mirroring core-get. Map/set backing tables are keyed by the same canon used
# by phm, so canonicalize collection keys here too.
(defn- transient-lookup [t k default]
(case (t :kind)
:vector (let [a (t :arr)]
(if (and (number? k) (= k (math/floor k)) (>= k 0) (< k (length a)))
(in a k) default))
:map (let [e (get (t :tbl) (canon k))] (if (nil? e) default (in e 1)))
:set (if (nil? (get (t :tbl) (canon k))) default k)
default))
(defn- coll-lookup
"Clojure `get` semantics over a jolt collection, used for collection-as-IFn."
[coll k default]
(cond
(jolt-transient? coll) (transient-lookup coll k default)
(phm? coll) (phm-get coll k default)
(set? coll) (if (phs-contains? coll k) k default)
(pvec? coll)
@ -59,6 +76,7 @@
[ctx f args]
(cond
(or (function? f) (cfunction? f)) (apply f args)
(jolt-transient? f) (transient-lookup f (get args 0) (get args 1))
(keyword? f) (coll-lookup (get args 0) f (get args 1))
(and (struct? f) (= :symbol (f :jolt/type)))
(coll-lookup (get args 0) f (get args 1))

View file

@ -22,6 +22,11 @@
(if (and canonicalize-key (or (table? k) (struct? k) (array? k) (tuple? k)))
(canonicalize-key k)
k))
(defn canon
"Public canonicalizer: maps a key to its value-hashable form (identity for
scalars). Used by callers that index the same canonicalized tables phm uses
(e.g. transient maps/sets)."
[k] (ck k))
(defn- key= [a b] (= (ck a) (ck b)))
(defn phm-hash-key [k]