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:
parent
e75771084e
commit
09532dac05
5 changed files with 83 additions and 10 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
(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))
|
||||
: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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 []))"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue