Bring the docs in line with the actual implementation now that Chez is the sole substrate. Deleted the migration/spike/handoff artifacts that only documented the Janet era or the port effort: the port plan, phase-0 and foundational-runtime spike writeups (+ the stray root-level copy), the self-hosting design notes, the architecture-refactor plan, and spike/chez/RESULTS.md. Rewrote the current reference docs against the Chez facts: building-and-deps and tools-deps (no jpm/build step — bin/joltc off the checked-in seed, deps via jolt.deps into ~/.jolt/gitlibs), libraries (SQLite is built-in jdbc.core over libsqlite3, not a Janet driver), the conformance/spec test-flow docs (the Chez corpus runner + certify, no .janet harnesses), and the transient / type-hint / seed-overlay design notes (Chez representations: mutable transients, flat copy-on-write vectors, HAMT maps, the seed/overlay twin). Fixed the README collections line (vectors aren't 32-way tries) and added the ffi/transient gate targets. rfc 0001's numerics open-question is resolved (the Scheme tower). Renamed the built-in HTTP adapter to jolt.http.server only (dropped the ring-janet.adapter alias — a Janet-era name).
5.4 KiB
RFC 0003: Transients — semantics and the Chez mutable backing
Status: accepted (design note)
This note pins down what transients are in Jolt, where their behavior deviates from JVM Clojure and why, and how the transient machinery is represented in the Chez runtime. It exists so the design doesn't revisit transients every round.
What a transient is in Jolt
A transient is a Chez record (jolt-transient, host/chez/transients.ss)
wrapping true mutable host backing, snapshotted to the immutable collection on
persistent!. The backing is per kind:
- transient vector — a growable Scheme vector (a capacity buffer plus a fill
count
n).conj!/pop!are in-place, amortized O(1); the buffer doubles on growth. - transient map — a Chez hashtable keyed by
key-hash/jolt=(value-equality, nil-safe). Hashing by value keeps collection keys comparing across representations. - transient set — a Chez hashtable of elements.
cow— a copy-on-write fallback for anything else (e.g. a sorted coll).
transient accepts pvecs, pmaps, psets, and the exotic colls handled by the
cow path. Each kind copies its source into the matching mutable backing once.
The bang ops (conj!, assoc!, dissoc!, disj!, pop!) mutate that backing
in place and return the transient — O(1) per op (amortized for the vector push).
persistent! snapshots a persistent value from the backing (folding the
hashtable into a pmap/pset, handing off the buffer as a pvec) and invalidates the
transient (the record's active flag clears; any further bang op or a second
persistent! throws "transient used after persistent!", matching Clojure's
invalidation contract).
Read ops work on an active transient where Clojure supports them: get,
contains?, count, and nth (vector kind) see through the transient.
seq on a transient is not supported, as in Clojure.
Deviations from JVM Clojure (deliberate)
O(n) edges, O(1) middle. Clojure's (transient v) is O(1) — the transient
shares the persistent trie and marks nodes editable; persistent! is O(1)
too. Jolt's transient copies the source into a mutable buffer/hashtable (O(n))
and persistent! snapshots back (O(n)). The bang ops in between are host-mutable
O(1), which is faster per-op than trie editing. So the asymptotics of the usual
pattern
(persistent! (reduce conj! (transient []) coll))
are identical (O(n) total either way) with a better constant in the loop and a
worse constant at the two edges. The pattern transients exist for — batch
construction — is fully served. What is NOT served is transient-editing a
large collection to change a few keys: that's O(n) in Jolt vs O(log n) in
Clojure, because transient copies the source into a growable Scheme vector /
Chez hashtable and persistent! snapshots it back.
No thread-ownership check. JVM Clojure ≥1.7 also dropped the owner-thread assertion (for fork/join), keeping only "don't use after persistent!", which Jolt enforces. A transient handed across threads is a data race exactly as in Clojure — documented, not checked, same as the JVM.
(conj!) / (conj! t) arities follow Clojure's transducer-era contract:
zero args makes a fresh (transient []), one arg returns it untouched.
assoc! tolerates a dangling final key (treated as k nil), matching the
lenient kvs walk of Jolt's assoc.
No transient sorted variants — same as Clojure. One leniency: Clojure
throws on (transient '(1)), but Jolt routes a list through the cow fallback
path, yielding a transient. Harmless but non-Clojure; tighten if it ever
bites.
Why transients live in the host
Transients are part of the value/representation layer in the Chez runtime
(host/chez/transients.ss), not the portable clojure.core overlay, on three
grounds:
-
They are the mutation kernel. A transient's entire value is direct mutation of a host buffer/hashtable. The overlay has no mutation seam of its own. Re-expressing the bang ops in Clojure would mean either growing the host surface one-for-one (a host-vector-push, a host-hashtable-put, …, i.e. moving the same code behind more indirection) or simulating mutation over persistent values (defeating the point of transients).
-
They sit under the collection dispatch.
conj/assoc/get/count/contains?see through a transient. Hoisting the transient ops above that dispatch would put a compiled-Clojure call inside the hottest paths for no semantic gain — transients have no semantics to fix. -
The value layer is the host's job. The persistent collections and, with them, their mutable scratch counterparts, live in the Chez runtime alongside the value model. Transients are representation, not library.
What lives in the overlay: anything derived — e.g. into's transient-using
fast path, or update!-style conveniences — is plain Clojure over
transient/bang-ops/persistent!.
Future work
- The persistent map/set are a bitmap HAMT with structural sharing
(
host/chez/collections.ss), so Clojure-style O(1)transient/persistent!via editable nodes is a real option there — an internal change behind the same surface, not a semantics change. The persistent vector is a flat copy-on-write Scheme vector rather than a trie, so the transient surface for it stays the copy-to-growable-vector path. transient?(Jolt extension, useful in tests) stays; Clojure has no public predicate, so it must not leak into portability-sensitive code.