From 09532dac05de76a1f3c62e3485891249df07db96 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 5 Jun 2026 09:29:36 -0400 Subject: [PATCH] fix: transient invokable lookup, assoc! odd args, use-after-persistent! invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .beads/issues.jsonl | 4 ++-- src/jolt/core.janet | 34 +++++++++++++++++++++++++-------- src/jolt/evaluator.janet | 18 +++++++++++++++++ src/jolt/phm.janet | 5 +++++ test/spec/transients-spec.janet | 32 +++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 17d11e2..f1c5e0d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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} diff --git a/src/jolt/core.janet b/src/jolt/core.janet index 8fa9091..96816b0 100644 --- a/src/jolt/core.janet +++ b/src/jolt/core.janet @@ -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. diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index 783a6a4..95dc859 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -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)) diff --git a/src/jolt/phm.janet b/src/jolt/phm.janet index 2a1e1ee..a4a24bd 100644 --- a/src/jolt/phm.janet +++ b/src/jolt/phm.janet @@ -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] diff --git a/test/spec/transients-spec.janet b/test/spec/transients-spec.janet index c5017a2..008bfc9 100644 --- a/test/spec/transients-spec.janet +++ b/test/spec/transients-spec.janet @@ -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 []))"])