jolt/docs/rfc/0003-transients.md
Dmitri Sotnikov 307b65b45b
Fix -m arg drop under whole-program cache (jolt-4mui) + RFC 0003 sync (#138)
* Bind *command-line-args* after the deps-image cache swap (jolt-4mui)

Under whole-program (deps-image cache active), `jolt -m NS ARG` dropped ARG:
run-main set *command-line-args* on the current ctx, but a cache HIT then
replaced ctx with the saved image (via `set ctx cached`), whose *command-line-
args* was whatever got baked when the image was saved. The stale binding won at
`(apply NS/-main *command-line-args*)`, so -main ran with the wrong (usually
default) args — silently, for any optimized -m program.

Move set-command-line-args to AFTER the cache swap so it binds on the final ctx.
Repro/regression in deps-cache-args-test.janet: first run builds the image
(arg "first"), second run (cache hit) must echo "second", not the baked "first".

* docs: RFC 0003 — phm is a HAMT, sorted colls a red-black tree

The transients RFC described phm as "bucket-based copy-on-write" and mused about
"if it ever becomes a HAMT" — it is one now (jolt-684u), and sorted maps/sets are
a red-black tree (jolt-0hbr). Update the deviation/future-work notes accordingly.

---------

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 13:34:08 +00:00

6.2 KiB

RFC 0003: Transients — semantics and why they live in the Janet seed

Status: accepted (design note)

This note pins down what transients are in Jolt, where their behavior deviates from JVM Clojure and why, and why the transient machinery is part of the irreducible Janet seed rather than a candidate for the core-in-Clojure migration (jolt-tzo). It exists so the kernel-shrink ladder doesn't revisit transients every round.

What a transient is in Jolt

A transient is a tagged Janet table wrapping a native mutable host value (core.janet, "Transients" section):

  • transient vector — @{:jolt/type :jolt/transient :kind :vector :arr ARRAY}, a Janet array.
  • transient map — :kind :map :tbl TABLE, a Janet table mapping canon-key(k)@[k v]. Keying by canonical key keeps collection keys comparing by value across representations ([1 2] the pvec and [1 2] the tuple are one key), and storing the @[k v] pair preserves the original key for the rebuilt persistent map.
  • transient set — :kind :set :tbl TABLE mapping canon-key(x)x.

transient accepts pvecs, mutable-build arrays, tuples (reader vectors and map entries — added in the seed-shrink rounds so (into [] (first {:a 1})) works through the vector fast path), sets, phms, and untagged struct maps. Sorted collections are rejected, as on the JVM (not editable).

The bang ops (conj!, assoc!, dissoc!, disj!, pop!) mutate that host value in place and return the transient — O(1) per op (amortized for array push). persistent! rebuilds a persistent value from the host value and invalidates the transient (:jolt/persistent flag; any further bang op or a second persistent! throws "Transient used after persistent! call", matching Clojure's invalidation contract).

Read ops work on an active transient where Clojure supports them: get, contains?, count, and nth (vector kind) branch on the transient tag. 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 native array/table (O(n)) and persistent! rebuilds (O(n)). The bang ops in between are native-host 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 flattens the pvec trie / phm HAMT into a native array/table and persistent! rebuilds them.

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. Jolt code is fiber-concurrent; when real OS-thread futures land (jolt-ejx), 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's lists are Janet arrays underneath and fall into the mutable-build branch, yielding a transient vector. Harmless (the result of persistent! is a vector, never silently a list) but non-Clojure; tighten if it ever bites.

Why transients stay in the Janet seed

The migration ladder (jolt-tzo) moves anything expressible as pure Clojure over existing primitives out of the seed. Transients fail that test on three grounds:

  1. They are the mutation kernel. A transient's entire value is direct mutation of a host array/table. The overlay's only mutation seam is jolt.host/ref-put! (a single table-put). Re-expressing tr-conj! etc. in Clojure would mean either growing the host surface one-for-one (host-array-push!, host-table-put!, …, i.e. moving the same code behind more indirection) or simulating mutation over persistent values (defeating the point of transients). Either way the Janet line count moves, it doesn't shrink.

  2. They sit under the seed's own dispatch. conj/assoc/get/count/ contains? in the seed branch on the transient tag. Hoisting the transient ops above that dispatch (the hierarchy-port pattern of lazily-resolved overlay vars) would put an interpreted/compiled-Clojure call inside the hottest native paths for no semantic gain — transients have no semantics to fix (unlike hierarchy, which had real correctness gaps).

  3. The value layer is declared irreducible. The self-hosting design doc (docs/self-hosting-compiler.md, "The kernel") keeps the value/representation layer — persistent collections and, with them, their mutable scratch counterparts — in the host. Transients are representation, not library.

What CAN move (and mostly has): anything derived — e.g. into's transient-using fast path, or future update!-style conveniences — is plain Clojure over transient/bang-ops/persistent! and belongs in the overlay tiers as ordinary migration batches.

Future work

  • pvec is already a 32-way trie with structural sharing (pv.janet), so Clojure-style O(1) transient/persistent! via editable nodes is a real option for vectors — an internal change behind the same surface, not a semantics change. phm is now a HAMT with structural sharing too (jolt-684u), and sorted maps/sets are a red-black tree (jolt-0hbr), so the same editable- node trick is open for those as well — the transient surface here is still the copy-to-native-table flatten.
  • transient? (Jolt extension, useful in tests) stays; Clojure has no public predicate, so it must not leak into portability-sensitive code.