feat: real transients backed by Janet arrays/tables (interop)

Replace the correctness-only transient aliases with real mutable scratch
collections via host interop:
- transient vector -> a Janet array; conj!/assoc!/pop! mutate in place
- transient map -> a Janet table keyed by canonical key (collection keys still
  compare by value); assoc!/dissoc!/conj! mutate in place
- transient set -> a Janet table; conj!/disj! mutate in place
- persistent! freezes back to a pvec / phm / phs
- count/nth/get/contains? work on transients; transient? predicate added

Building a map/set this way avoids the persistent path's per-step bucket-array
copying (transient map build ~35% faster at 20k here); vectors are comparable
since pvec conj is already ~O(1). The mutating ops return the transient and the
source collection is untouched.

spec/transients-spec (34 cases). conformance 218/218, jpm test green.
This commit is contained in:
Yogthos 2026-06-05 08:02:21 -04:00
parent a0943cb944
commit f38d402445
4 changed files with 140 additions and 13 deletions

View file

@ -80,7 +80,8 @@ Jolt targets Clojure semantics but runs on Janet, not the JVM. The notable diver
- **Concurrency / STM.** Single-threaded. No refs, `dosync`, agents, or `send`; `locking` evaluates its body without real locking. Atoms, volatiles, and delays are supported. - **Concurrency / STM.** Single-threaded. No refs, `dosync`, agents, or `send`; `locking` evaluates its body without real locking. Atoms, volatiles, and delays are supported.
- **Regex.** Compiled to Janet's PEG engine (Janet has no regex). Supported: capturing groups (`[whole g1 …]`), greedy and lazy quantifiers with backtracking, `(?:…)`, lookahead `(?=…)`/`(?!…)`, alternation, anchors `^ $ \b \B`, character classes, and the `(?i)` flag. Not supported: lookbehind, backreferences (`\1`), and named groups (`(?<name>…)`). - **Regex.** Compiled to Janet's PEG engine (Janet has no regex). Supported: capturing groups (`[whole g1 …]`), greedy and lazy quantifiers with backtracking, `(?:…)`, lookahead `(?=…)`/`(?!…)`, alternation, anchors `^ $ \b \B`, character classes, and the `(?i)` flag. Not supported: lookbehind, backreferences (`\1`), and named groups (`(?<name>…)`).
- **Arrays.** Java-style arrays map onto Janet's native types: `byte-array` is a Janet buffer (contiguous, C-backed); `object-array`/`int-array`/`double-array`/etc. are Janet arrays. `aget`/`aset`/`alength`/`aclone` work over both. - **Arrays.** Java-style arrays map onto Janet's native types: `byte-array` is a Janet buffer (contiguous, C-backed); `object-array`/`int-array`/`double-array`/etc. are Janet arrays. `aget`/`aset`/`alength`/`aclone` work over both.
- **Not implemented.** JVM reflection, `proxy`, and the `clojure.repl`/`clojure.template` namespaces. Transients (`transient`/`conj!`/`persistent!`) work but are correctness-only aliases over the persistent collections (no in-place speedup). - **Transients.** `transient`/`conj!`/`assoc!`/`dissoc!`/`disj!`/`pop!`/`persistent!` are real mutable scratch collections backed by Janet's native arrays and tables (vectors → arrays, maps/sets → tables), so building a collection with them avoids the per-step copying of the persistent path (notably for maps/sets). `persistent!` freezes back to a persistent value.
- **Not implemented.** JVM reflection, `proxy`, and the `clojure.repl`/`clojure.template` namespaces.
Supported and Clojure-compatible: chars as a distinct type, lazy/infinite sequences, transducers, destructuring, multimethods with hierarchies, protocols/records (`deftype`/`defrecord`/`reify`/`extend-protocol`), metadata, namespaces, and the reader (`#()`, `#_`, `#?`, tagged literals, `#"…"`). Supported and Clojure-compatible: chars as a distinct type, lazy/infinite sequences, transducers, destructuring, multimethods with hierarchies, protocols/records (`deftype`/`defrecord`/`reify`/`extend-protocol`), metadata, namespaces, and the reader (`#()`, `#_`, `#?`, tagged literals, `#"…"`).

View file

@ -40,6 +40,11 @@
[xs] [xs]
(if mutable? (array ;xs) (pv-from-indexed xs))) (if mutable? (array ;xs) (pv-from-indexed xs)))
(defn core-transient?
"True when x is a transient (a mutable scratch collection). See `transient`."
[x]
(and (table? x) (= :jolt/transient (get x :jolt/type))))
# Canonicalize a collection key/element to a value-hashable Janet struct/tuple so # Canonicalize a collection key/element to a value-hashable Janet struct/tuple so
# the PHM/PHS treat value-equal maps/vectors as the same key (Janet hashes tables # the PHM/PHS treat value-equal maps/vectors as the same key (Janet hashes tables
# by identity otherwise). Installed into phm via set-canonicalize-key!. # by identity otherwise). Installed into phm via set-canonicalize-key!.
@ -349,6 +354,11 @@
(defn core-get [m k &opt default] (defn core-get [m k &opt default]
(default default nil) (default default nil)
(if (nil? m) default (if (nil? m) default
(if (core-transient? m)
(case (m :kind)
:vector (if (and (number? k) (>= k 0) (< k (length (m :arr)))) (in (m :arr) k) default)
:map (let [p (get (m :tbl) (canon-key k))] (if p (in p 1) default))
:set (if (nil? (get (m :tbl) (canon-key k))) default k))
(if (set? m) (phs-get m k default) (if (set? m) (phs-get m k default)
(if (phm? m) (phm-get m k default) (if (phm? m) (phm-get m k default)
(if (pvec? m) (if (pvec? m)
@ -358,7 +368,7 @@
(if (nil? v) default v)) (if (nil? v) default v))
(if (and (or (tuple? m) (array? m)) (number? k) (>= k 0) (< k (length m))) (if (and (or (tuple? m) (array? m)) (number? k) (>= k 0) (< k (length m)))
(in m k) (in m k)
default))))))) default))))))))
# Runtime invoke dispatch for COMPILED code (interpreter uses evaluator's # Runtime invoke dispatch for COMPILED code (interpreter uses evaluator's
# jolt-invoke). Handles real functions plus Clojure IFn collections. # jolt-invoke). Handles real functions plus Clojure IFn collections.
@ -409,6 +419,10 @@
(if (nil? current) default current)) (if (nil? current) default current))
(defn core-contains? [coll key] (defn core-contains? [coll key]
(if (core-transient? coll)
(case (coll :kind)
:vector (and (number? key) (>= key 0) (< key (length (coll :arr))))
(not (nil? (get (coll :tbl) (canon-key key)))))
(if (set? coll) (phs-contains? coll key) (if (set? coll) (phs-contains? coll key)
(if (phm? coll) (let [b (get (coll :buckets) (phm-hash-key key))] (if b (phm-bucket-contains? b key) false)) (if (phm? coll) (let [b (get (coll :buckets) (phm-hash-key key))] (if b (phm-bucket-contains? b key) false))
(if (pvec? coll) (and (number? key) (>= key 0) (< key (pv-count coll))) (if (pvec? coll) (and (number? key) (>= key 0) (< key (pv-count coll)))
@ -416,7 +430,7 @@
(if (table? coll) (not (nil? (coll key))) (if (table? coll) (not (nil? (coll key)))
(if (or (tuple? coll) (array? coll)) (if (or (tuple? coll) (array? coll))
(and (number? key) (>= key 0) (< key (length coll))) (and (number? key) (>= key 0) (< key (length coll)))
false))))))) false))))))))
# Sorted collections — minimal: backed by a struct (map) / sorted array (set), # Sorted collections — minimal: backed by a struct (map) / sorted array (set),
# ordered by key/element on read. Defined early so seq/count/get can dispatch. # ordered by key/element on read. Defined early so seq/count/get can dispatch.
@ -437,6 +451,7 @@
(defn core-count [coll] (defn core-count [coll]
(cond (cond
(nil? coll) 0 (nil? coll) 0
(core-transient? coll) (length (if (= :vector (coll :kind)) (coll :arr) (coll :tbl)))
(core-sorted-map? coll) (length (keys (coll :map))) (core-sorted-map? coll) (length (keys (coll :map)))
(core-sorted-set? coll) (length (coll :items)) (core-sorted-set? coll) (length (coll :items))
(lazy-seq? coll) (ls-count coll) (lazy-seq? coll) (ls-count coll)
@ -945,6 +960,8 @@
(defn core-nth (defn core-nth
"Return the nth element of a sequential collection." "Return the nth element of a sequential collection."
[coll idx &opt default] [coll idx &opt default]
(if (core-transient? coll)
(let [a (coll :arr)] (if (and (>= idx 0) (< idx (length a))) (in a idx) default))
(if (plist? coll) (if (plist? coll)
(let [a (pl->array coll)] (let [a (pl->array coll)]
(if (and (>= idx 0) (< idx (length a))) (in a idx) (if (and (>= idx 0) (< idx (length a))) (in a idx)
@ -970,7 +987,7 @@
(if (string? c) (make-char (in c idx)) (in c idx)) (if (string? c) (make-char (in c idx)) (in c idx))
(if (nil? default) (if (nil? default)
(error (string "Index " idx " out of bounds, length: " (length c))) (error (string "Index " idx " out of bounds, length: " (length c)))
default))))))) default))))))))
(defn core-sort (defn core-sort
"(sort coll) or (sort comparator coll). Comparator may return a boolean or a "(sort coll) or (sort comparator coll). Comparator may return a boolean or a
@ -1374,6 +1391,7 @@
(lazy-seq? v) (pr-render-seq buf (realize-for-iteration v) "(" ")") (lazy-seq? v) (pr-render-seq buf (realize-for-iteration v) "(" ")")
(set? v) (pr-render-seq buf (phs-seq v) "#{" "}") (set? v) (pr-render-seq buf (phs-seq v) "#{" "}")
(phm? v) (pr-render-pairs buf (phm-entries v)) (phm? v) (pr-render-pairs buf (phm-entries v))
(core-transient? v) (buffer/push-string buf (string "#<transient " (v :kind) ">"))
(pvec? v) (pr-render-seq buf (pv->array v) "[" "]") (pvec? v) (pr-render-seq buf (pv->array v) "[" "]")
(plist? v) (pr-render-seq buf (pl->array v) "(" ")") (plist? v) (pr-render-seq buf (pl->array v) "(" ")")
(and (table? v) (get v :jolt/deftype)) (buffer/push-string buf (string v)) (and (table? v) (get v :jolt/deftype)) (buffer/push-string buf (string v))
@ -1591,7 +1609,6 @@
(case (length a) (case (length a)
0 (rf) 1 (rf (a 0)) 0 (rf) 1 (rf (a 0))
(do (var acc (a 0)) (each x (realize-for-iteration (a 1)) (set acc (rf acc x))) acc)))) (do (var acc (a 0)) (each x (realize-for-iteration (a 1)) (set acc (rf acc x))) acc))))
(defn core-disj! [s & ks] (apply core-disj s ks))
(defn core-rationalize [x] x) (defn core-rationalize [x] x)
(defn core-random-sample [prob & rest] (defn core-random-sample [prob & rest]
(if (= 0 (length rest)) (if (= 0 (length rest))
@ -3048,14 +3065,73 @@
(rf (in a 0) (in a 1)))))))) (rf (in a 0) (in a 1))))))))
(defn core-re-groups [m] (error "re-groups: stateful matchers are not supported in Jolt")) (defn core-re-groups [m] (error "re-groups: stateful matchers are not supported in Jolt"))
# Transients — Jolt's collections are persistent, so transients are correctness- # Transients — real mutable scratch collections backed by Janet's native arrays
# only aliases (no in-place optimization, but semantically correct). # and tables (host interop): O(1) conj!/assoc!/dissoc!/disj!/pop!, frozen back to
(defn core-transient [coll] coll) # a persistent value by persistent!. A transient is a tagged table holding either
(defn core-persistent! [coll] coll) # a Janet array (vectors) or a Janet table keyed by canonical key (maps/sets, so
(defn core-conj! [coll & xs] (apply core-conj coll xs)) # collection keys still compare by value). The mutating ops return the transient.
(defn core-assoc! [coll & kvs] (apply core-assoc coll kvs)) (defn core-transient [coll]
(defn core-dissoc! [coll & ks] (apply core-dissoc coll ks)) (cond
(defn core-pop! [coll] (core-pop coll)) (pvec? coll)
@{:jolt/type :jolt/transient :kind :vector :arr (pv->array coll)}
(set? coll)
(let [t @{}] (each e (phs-seq coll) (put t (canon-key e) e))
@{:jolt/type :jolt/transient :kind :set :tbl t})
(or (phm? coll) (and (struct? coll) (nil? (get coll :jolt/type))))
(let [t @{}]
(each pair (realize-for-iteration coll)
(put t (canon-key (in pair 0)) @[(in pair 0) (in pair 1)]))
@{:jolt/type :jolt/transient :kind :map :tbl t})
# mutable-build arrays (vectors/lists) — copy into a transient vector
(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)))))
(defn- tr-conj! [t x]
(case (t :kind)
:vector (array/push (t :arr) x)
:set (put (t :tbl) (canon-key x) x)
:map (let [k (vnth x 0)] (put (t :tbl) (canon-key k) @[k (vnth x 1)])))
t)
(defn- tr-assoc! [t k v]
(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])
(error "assoc! expects a transient vector or map"))
t)
(defn core-conj! [t & xs]
(if (core-transient? t)
(do (each x xs (tr-conj! t x)) t)
(apply core-conj t xs))) # lenient fallback for a persistent coll
(defn core-assoc! [t & kvs]
(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)
(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)
(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)
(apply core-disj t xs)))
(defn core-pop! [t]
(if (core-transient? t)
(do (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))
t))
# Unchecked arithmetic — Jolt numbers don't overflow, so these are plain ops. # Unchecked arithmetic — Jolt numbers don't overflow, so these are plain ops.
(defn core-unchecked-add [a b] (+ a b)) (defn core-unchecked-add [a b] (+ a b))
@ -3219,6 +3295,7 @@
"halt-when" core-halt-when "halt-when" core-halt-when
"re-groups" core-re-groups "re-groups" core-re-groups
"transient" core-transient "transient" core-transient
"transient?" core-transient?
"persistent!" core-persistent! "persistent!" core-persistent!
"conj!" core-conj! "conj!" core-conj!
"assoc!" core-assoc! "assoc!" core-assoc!

View file

@ -101,6 +101,9 @@
(write-value k buf)) (write-value k buf))
(push-str buf "}")) (push-str buf "}"))
(and (table? v) (= :jolt/transient (v :jolt/type)))
(push-str buf (string "#<transient " (v :kind) ">"))
(phm? v) (phm? v)
(do (do
(push-str buf "{") (push-str buf "{")

View file

@ -0,0 +1,46 @@
# Specification: transients (mutable scratch collections frozen by persistent!).
(use ../support/harness)
(defspec "transient / vector"
["conj! then persistent!" "[1 2]" "(persistent! (conj! (conj! (transient []) 1) 2))"]
["reduce conj!" "[0 1 2 3 4]" "(persistent! (reduce conj! (transient []) (range 5)))"]
["conj! many args" "[1 2 3]" "(persistent! (conj! (transient [1]) 2 3))"]
["assoc! existing" "[1 9 3]" "(persistent! (assoc! (transient [1 2 3]) 1 9))"]
["assoc! at count grows" "[1 2 3]" "(persistent! (assoc! (transient [1 2]) 2 3))"]
["pop!" "[1 2]" "(persistent! (pop! (transient [1 2 3])))"]
["from existing vector" "[1 2 3 4]" "(persistent! (conj! (transient [1 2 3]) 4))"]
["count" "3" "(count (transient [1 2 3]))"]
["nth" "2" "(nth (transient [1 2 3]) 1)"]
["get" "2" "(get (transient [1 2 3]) 1)"]
["persistent! is a vector" "true" "(vector? (persistent! (transient [1])))"]
["transient? true" "true" "(transient? (transient []))"]
["transient? false" "false" "(transient? [1 2])"])
(defspec "transient / map"
["assoc! then persistent!" "{:a 1, :b 2}" "(persistent! (assoc! (assoc! (transient {}) :a 1) :b 2))"]
["assoc! many" "{:a 1, :b 2}" "(persistent! (assoc! (transient {}) :a 1 :b 2))"]
["dissoc!" "{:b 2}" "(persistent! (dissoc! (transient {:a 1 :b 2}) :a))"]
["conj! map entry" "{:a 1}" "(persistent! (conj! (transient {}) [:a 1]))"]
["from existing map" "{:a 1, :b 2}" "(persistent! (assoc! (transient {:a 1}) :b 2))"]
["get" "1" "(get (transient {:a 1}) :a)"]
["get missing default" ":x" "(get (transient {:a 1}) :z :x)"]
["contains?" "true" "(contains? (transient {:a 1}) :a)"]
["count" "2" "(count (transient {:a 1 :b 2}))"]
["collection key by value" ":v" "(get (persistent! (assoc! (transient {}) [1 2] :v)) [1 2])"]
["persistent! is a map" "true" "(map? (persistent! (transient {:a 1})))"]
["reduce build" "{0 0, 1 1, 2 2}" "(persistent! (reduce (fn [t i] (assoc! t i i)) (transient {}) (range 3)))"])
(defspec "transient / set"
["conj! dedups" "#{1 2 3}" "(persistent! (conj! (transient #{}) 1 2 2 3))"]
["disj!" "#{1 3}" "(persistent! (disj! (transient #{1 2 3}) 2))"]
["from existing set" "#{1 2 3}" "(persistent! (conj! (transient #{1 2}) 3))"]
["contains?" "true" "(contains? (transient #{1 2}) 1)"]
["count" "2" "(count (transient #{1 2}))"]
["persistent! is a set" "true" "(set? (persistent! (transient #{1})))"]
["map elements by value" "1" "(count (persistent! (conj! (transient #{}) {:a 1} (hash-map :a 1))))"])
(defspec "transient / immutability of source"
["source vector unchanged" "true"
"(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}))"])