jolt/docs/rfc/0003-transients.md
Yogthos 45876998ad Docs: Chez-only, drop the Janet-era references and obsolete migration notes
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).
2026-06-22 09:05:35 -04:00

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:

  1. 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).

  2. 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.

  3. 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.