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).
This commit is contained in:
Yogthos 2026-06-22 09:05:35 -04:00
parent fe3fdf6b9c
commit 45876998ad
28 changed files with 253 additions and 2012 deletions

View file

@ -170,9 +170,9 @@ Signature(s), since-version
## Open questions
1. Numerics: the reference has longs/doubles/ratios/BigInt with promotion
rules; CLJS has JS numbers; jolt has Janet numbers. Likely answer: specify
an integer/float core with a host-numeric-tower extension point — needs
its own design note in §4.
rules; CLJS has JS numbers. Resolved: jolt carries the Scheme numeric tower
(exact integers/bignums, exact ratios, flonum doubles), matching the
reference's tower — see the numerics note in §4.
2. Where do `*print-length*`-style dynamic vars land — host-dependent
interface or portable with defaults?
3. License/venue if the spec outgrows this repo (likely CC-BY; separate repo

View file

@ -1,50 +1,49 @@
# RFC 0003: Transients — semantics and why they live in the Janet seed
# 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 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
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 tagged Janet table wrapping a *native* mutable host value
(`core.janet`, "Transients" section):
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 — `@{: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 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, 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).
`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 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).
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) branch on the transient tag.
`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 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
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))
@ -53,13 +52,12 @@ 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.
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. 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
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:
@ -68,51 +66,43 @@ zero args makes a fresh `(transient [])`, one arg returns it untouched.
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.
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 stay in the Janet seed
## Why transients live in the host
The migration ladder (jolt-tzo) moves anything expressible as *pure Clojure
over existing primitives* out of the seed. Transients fail that test on three
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 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.
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 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).
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 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.
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 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.
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
- 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.
- 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.

View file

@ -11,26 +11,19 @@ measured effect, so later work does not relitigate it.
## Background: why the lookup carries a guard
A Jolt map value has several runtime representations (see RFC on collections and
`src/jolt/core.janet`): a Janet struct for a small all-scalar-key literal map, a
persistent hash map (a table tagged `:jolt/type :jolt/phm`) when a key is a
collection or a value is nil, plus sorted maps, transients, and record/deftype
instances. A record instance is a Janet table tagged `:jolt/deftype` but, like a
struct, it carries no `:jolt/type`, so a raw Janet `(get inst :field)` reads its
fields directly.
`host/chez/collections.ss`): a persistent hash map (a bitmap HAMT) for the
general case, plus sorted maps, transients, and record/deftype instances. A
record instance is a Chez record (`jrec`) whose fields are read directly off the
record's storage, while a HAMT lookup runs the full `jolt=`/`jolt-hash`-keyed
collection path.
A constant-keyword lookup `(:k m)` compiles to a guarded form:
```janet
(if (get m :jolt/type) (core-get m k) (get m k))
```
The guard is one opcode. A non-nil `:jolt/type` routes phm/sorted/transient/
lazy-seq values to `core-get`'s full semantics; everything else (structs,
records, nil, scalars) takes the bare Janet `get`, which matches `core-get` for
keyword keys. The guard is correct and cheap, but on a struct it is a second
`get`: profiling the ray tracer (a naive all-maps program) found keyword lookups
are about half of a render, and the guard is the only avoidable part of each
one. A bare get is roughly 20ns where the guarded form is roughly 36ns.
A constant-keyword lookup `(:k m)` compiles to a guarded form: it inspects the
subject's representation and routes a HAMT/sorted/transient/lazy-seq value to the
full `jolt-get` semantics, while a record/raw-get-safe value takes the direct
field read, which matches `jolt-get` for keyword keys. The guard is correct and
cheap, but on a raw-get-safe value it is wasted work: profiling the ray tracer (a
naive all-maps program) found keyword lookups are about half of a render, and the
guard is the only avoidable part of each one.
Dropping the guard is only safe when the subject is known to be a plain
struct/record rather than a tagged collection. Jolt does not infer that
@ -59,27 +52,27 @@ optimization.
## How it flows
The reader already keeps `^hint` metadata on the binding symbol and is otherwise
transparent (`reader.janet`, `meta-form->map`). The change threads that fact to
the lookup site:
transparent (`host/chez/reader.ss`). The change threads that fact to the lookup
site:
1. The analyzer (`jolt-core/jolt/analyzer.clj`) records a `:struct` hint per
local in its env when a param or `let` binding carries `^:struct` or a
record-type tag, and attaches `:hint :struct` to that local's `:local` IR
node. Resolving a record-type tag uses a new host contract function
`record-type?` (`src/jolt/host_iface.janet`), which checks for the `->Name`
constructor.
2. The back end (`emit-kw-lookup` in `src/jolt/backend.janet`) emits the bare get
node. Resolving a record-type tag uses the host contract function
`record-type?` (`jolt.host`, backed by `host/chez/host-contract.ss`), which
checks for the `->Name` constructor.
2. The back end (`jolt-core/jolt/backend_scheme.clj`) emits the direct field read
when the lookup subject is a `:local` carrying the hint, and the guarded form
otherwise. The unhinted path is byte-identical to before.
otherwise. The unhinted path is identical to before.
3. The inline pass (`jolt-core/jolt/passes.clj`) propagates the hint: when it
binds a non-trivial call argument to a fresh local, it carries the called
function's parameter hint onto that local, so lookups inside the spliced body
keep the bare path. Without this, inlining a hinted function would erase the
keep the direct path. Without this, inlining a hinted function would erase the
benefit, because the hinted parameter is replaced by an unhinted temporary.
The same machinery covers both `(:k m)` and `(get m :k [default])` when the key
is a constant keyword. A `get` with a variable, numeric, or string key falls
through to `core-get` unchanged.
through to `jolt-get` unchanged.
## Record hints across namespaces, and as inference seeds
@ -98,15 +91,14 @@ the function where the hot reads actually happen.
**It resolves across namespaces.** A hint may name a record defined in another
namespace, in either spelling — `^Vec3` where the type is `:refer`-ed, or
`^v/Vec3` where the namespace is `:as`-aliased. Resolution (`record-ctor-key` in
`src/jolt/host_iface.janet`, backed by `record-hint-ctor-key` in
`src/jolt/evaluator.janet`) runs against the *compile* namespace and maps the
type to its home constructor key through a constructor-value index — keyed by the
constructor value, not a var's namespace, so a `:refer`-interned var (whose
namespace is the referring one) still resolves home. The reader keeps a tag's
namespace qualifier (`^v/Vec3``"v/Vec3"`, not `"Vec3"`) so the aliased
spelling has something to resolve. Both `defrecord` field hints and function
parameter hints use this resolution.
`^v/Vec3` where the namespace is `:as`-aliased. Resolution (`record-ctor-key`,
a `jolt.host` contract function backed by `host/chez/host-contract.ss`) runs
against the *compile* namespace and maps the type to its home constructor key
through a constructor-value index — keyed by the constructor value, not a var's
namespace, so a `:refer`-interned var (whose namespace is the referring one)
still resolves home. The reader keeps a tag's namespace qualifier (`^v/Vec3`
`"v/Vec3"`, not `"Vec3"`) so the aliased spelling has something to resolve. Both
`defrecord` field hints and function parameter hints use this resolution.
## Soundness and the checked mode
@ -120,8 +112,8 @@ To make a lie visible without taxing the fast path, `JOLT_CHECK_HINTS=1` keeps
the guard but throws on the tagged arm with a message naming the local and key:
```
type hint violated on `m`: (:a m) — value carries :jolt/type
(a phm/sorted/transient/lazy-seq), not the plain struct/record the
type hint violated on `m`: (:a m) — value is a
phm/sorted/transient/lazy-seq, not the plain struct/record the
^:struct/^Record hint asserts
```
@ -134,7 +126,7 @@ off). The flag is part of the image-cache fingerprint.
Type hints parse in every position Clojure accepts them and are inert except for
the optimization above. This matches Clojure's "parse and otherwise do nothing"
model, with the difference that Clojure additionally uses hints to avoid
reflection and select primitive arithmetic, which do not apply to a Janet host.
reflection and select primitive arithmetic, which do not apply to the Chez host.
## Measured effect

View file

@ -50,7 +50,7 @@ A type `T` is one of:
`:nonnil` for "provably not nil and not false", which is what the struct-vs-phm
decision needs; see below.)
- `:nil`.
- `{:struct {field -> T}}` — a raw-get-safe map (Janet struct or record) whose
- `{:struct {field -> T}}` — a raw-get-safe map (a record) whose
field `k` has type `(fields k)` or `:any` if absent. The degenerate
`{:struct {}}` is "a struct, fields unknown" and replaces today's
`:struct-map`.
@ -65,7 +65,7 @@ A type `T` is one of:
Types are immutable values comparable by structural equality, exactly like the
current `{:vec ELEM}` representation, so they flow across the portable
inference and the Janet orchestrator unchanged.
inference and the host unchanged.
### Join (least upper bound)