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

@ -14,8 +14,8 @@
{"_type":"issue","id":"jolt-40n","title":"HIGH: update/update-in/assoc-in fail on map literals (struct)","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-04T18:09:56Z","created_by":"Yogthos","updated_at":"2026-06-04T18:40:42Z","closed_at":"2026-06-04T18:40:42Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-ytt","title":"HIGH: str semantics — (str nil)='nil' should be '', (str :b)='b' should be ':b'","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-04T18:09:56Z","created_by":"Yogthos","updated_at":"2026-06-04T18:33:45Z","closed_at":"2026-06-04T18:33:45Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-9sb","title":"HIGH: destructuring gaps (nested seq, :strs, nested map, fn-param)","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-04T18:09:55Z","created_by":"Yogthos","updated_at":"2026-06-04T18:31:24Z","closed_at":"2026-06-04T18:31:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-dxx","title":"clojure-test-suite: merge nil/empty + collection-key semantics","description":"merge.cljc has 18 fail / 11 err — highest in suite. Investigate nil/empty-map merge return values and collection-key behavior.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-06-05T13:05:17Z","created_by":"Yogthos","updated_at":"2026-06-05T13:18:50Z","started_at":"2026-06-05T13:18:50Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-1uw","title":"clojure-test-suite: transient strictness gaps","description":"Transient ops (conj!/assoc!/disj!/pop!) don't throw on misuse (bad shapes, use-after-persistent!) where Clojure does. Surfaced by conj_bang/assoc_bang/pop_bang/persistent_bang/transient suite files (~50 fails). Lenient behavior; decide whether to enforce.","status":"open","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-05T13:05:16Z","created_by":"Yogthos","updated_at":"2026-06-05T13:05:16Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-dxx","title":"clojure-test-suite: merge nil/empty + collection-key semantics","description":"merge.cljc has 18 fail / 11 err — highest in suite. Investigate nil/empty-map merge return values and collection-key behavior.","status":"closed","priority":3,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-06-05T13:05:17Z","created_by":"Yogthos","updated_at":"2026-06-05T13:22:29Z","started_at":"2026-06-05T13:18:50Z","closed_at":"2026-06-05T13:22:29Z","close_reason":"merge follows Clojure (when some identity) (reduce conj ...) semantics; nil/empty/conj-entry cases fixed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-1uw","title":"clojure-test-suite: transient strictness gaps","description":"Transient ops (conj!/assoc!/disj!/pop!) don't throw on misuse (bad shapes, use-after-persistent!) where Clojure does. Surfaced by conj_bang/assoc_bang/pop_bang/persistent_bang/transient suite files (~50 fails). Lenient behavior; decide whether to enforce.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-06-05T13:05:16Z","created_by":"Yogthos","updated_at":"2026-06-05T13:22:51Z","started_at":"2026-06-05T13:22:51Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-do7","title":"phm-typed maps as set elements / map keys hash by identity","description":"A map produced via hash-map/assoc (a Janet table/phm) used as a set element or map key hashes by identity, so #{{:a 1}} (struct elem) != #{(hash-map :a 1)} (phm elem) and such elements aren't retrievable by value. Map literals (structs) work. Needs value-based hashing for phm keys in PHM/PHS. Edge case; spec covers literal-map cases.","status":"closed","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-05T04:07:50Z","created_by":"Yogthos","updated_at":"2026-06-05T05:02:01Z","closed_at":"2026-06-05T05:02:01Z","close_reason":"Fixed: PHM/PHS canonicalize collection keys for value-based hashing/comparison; map literals \u0026 assoc promote to phm on collection keys; frequencies/group-by use phm base; set = is value-based. Also fixed set literals not evaluating elements and the REPL phm printer. Spec cases added. (commit pushed next)","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-7dy","title":"MED: compiler path lacks IFn dispatch (collections-as-fn) — only interpreter fixed","status":"closed","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-04T18:10:00Z","created_by":"Yogthos","updated_at":"2026-06-04T19:56:36Z","closed_at":"2026-06-04T19:56:36Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-90c","title":"MED: filter/take-while not lazy — infinite-seq (take N (filter p (range))) loops","status":"closed","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-04T18:10:00Z","created_by":"Yogthos","updated_at":"2026-06-04T18:56:26Z","closed_at":"2026-06-04T18:56:26Z","dependency_count":0,"dependent_count":0,"comment_count":0}

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]

View file

@ -44,3 +44,35 @@
"(let [v [1 2 3] _ (persistent! (conj! (transient v) 4))] (= v [1 2 3]))"]
["source map unchanged" "true"
"(let [m {:a 1} _ (persistent! (assoc! (transient m) :b 2))] (= m {:a 1}))"])
# Transients are invokable for read-only lookup, like their persistent forms.
(defspec "transient / invokable lookup"
["vector index" "20" "((transient [10 20 30]) 1)"]
["map key as fn" "7" "((transient {:x 7}) :x)"]
["map key default" "99" "((transient {:x 7}) :z 99)"]
["keyword on transient" "7" "(:x (transient {:x 7}))"]
["set membership" "2" "((transient #{1 2 3}) 2)"]
["set miss default" ":no" "((transient #{1 2 3}) 42 :no)"]
["collection key" ":v" "((transient {[1 2] :v}) [1 2])"])
# assoc! (unlike assoc) accepts an odd arg count — a missing final value is nil.
# (A struct literal can't express an explicit nil value, so assert via contains?.)
(defspec "transient / assoc! odd args"
["odd arg key present" "true"
"(contains? (persistent! (assoc! (transient {}) :a 1 :b)) :b)"]
["odd arg value is nil" "true"
"(nil? (get (persistent! (assoc! (transient {}) :a 1 :b)) :b))"]
["odd arg keeps prior" "1"
"(get (persistent! (assoc! (transient {}) :a 1 :b)) :a)"]
["vector odd arg" "[9 nil]"
"(persistent! (apply assoc! (transient []) [0 9 1]))"])
# Using a transient after persistent! (or popping an empty one) throws.
(defspec "transient / invalidation"
["conj! after persistent!" :throws
"(let [t (transient [])] (persistent! t) (conj! t 1))"]
["assoc! after persistent!" :throws
"(let [t (transient {})] (persistent! t) (assoc! t :a 1))"]
["persistent! twice" :throws
"(let [t (transient [])] (persistent! t) (persistent! t))"]
["pop! empty" :throws "(pop! (transient []))"])