General orientation for a fresh agent: the two execution paths (interpreter + self-hosted analyzer/IR/backend), the seed/overlay split and how clojure.core loads in tiers, the jolt.host contract, the jolt-1j0 migration arc (phases 1-5, now complete), the representation/macro-authoring traps, the test gate, and where to pick up next.
12 KiB
Jolt — Self-Hosted Clojure on Janet · Handoff
Onboarding for a fresh agent picking up this work. Read this, then
bd prime and bd memories for the live issue/knowledge state.
1. What this project is
Jolt is a Clojure implementation written in Janet. It has two execution paths and a self-hosting compiler:
- Interpreter —
src/jolt/evaluator.janet. A tree-walking evaluator over reader forms. Always correct; the fallback for anything the compiler can't yet handle. The live path for stateful/context-modifying forms. - Self-hosted compiler — the portable front end lives in Clojure under
jolt-core/jolt/(analyzer.cljreader-form → host-neutral IRir.clj), and a Janet back end (src/jolt/backend.janet) emits Janet from that IR. This is the default compile path. It is self-hosted: the compiler that compiles clojure.core is itself (mostly) Clojure compiled by jolt. - Bootstrap compiler —
src/jolt/compiler.janet. A Janet-native compiler used only to bootstrap-compile the kernel tier before the self-hosted analyzer exists. Not the main path. - Hybrid fallback — the analyzer throws
:jolt/uncompilableon forms it can't handle; the loader catches that and interprets instead. Three "uncompilable" lists are kept in sync (see compile-pipeline notes in the code).
Entry point: src/jolt/api.janet — (init opts) builds a context, installs the
host contract, and loads clojure.core (seed + overlay). :compile? true enables
the self-hosted pipeline; off = interpret.
2. The architecture that matters: seed + overlay
clojure.core is split into a shrinking Janet seed and a growing Clojure overlay. This split is the project's main arc.
The Janet seed — src/jolt/core.janet (~3200 lines, ~365 core-* fns)
The irreducible base: the core-renames primitives the compiler emits directly
(first/nth/conj/get/…) plus genuinely host-coupled fns (atoms, vars,
transients, arrays, futures, meta, print, the persistent-collection kernel). Each
fn is core-<name> and interned into the clojure.core namespace via the
core-bindings table near the bottom of the file.
The Clojure overlay — jolt-core/clojure/core/NN-*.clj (loaded in order)
Plain Clojure expressing the rest of clojure.core on top of the seed. Tiers:
| Tier | Role |
|---|---|
00-syntax.clj |
control macros (when/cond/and/or/let/loop/fn/for/…), destructure, when-let. Interpreted, loaded first so macros exist before any code compiles. |
00-kernel.clj |
structural fns the analyzer itself needs (second/peek/subvec/mapv/update). Bootstrap-compiled into clojure.core before the analyzer is built. |
10-seq.clj |
seq-tier fns |
20-coll.clj |
pure collection/misc fns + the Phase-4 host-primitive wrappers |
30-macros.clj |
the remaining user-facing macros |
40-lazy.clj |
lazy seq transformers (Phase 5) |
Loader: api.janet → load-core-overlay! / core-tiers. Sources are read fresh
from disk at startup when running from the repo (stdlib_embed.janet collects
jolt-core/ and src/jolt/clojure/), so editing a .clj tier takes effect with
no rebuild. (A jpm build bakes them into the image; that can go stale — tests
run from source.)
The host contract — src/jolt/host_iface.janet (ns jolt.host)
The portability seam. jolt-core (analyzer/IR/overlay) calls only jolt.host
fns, never Janet directly. Originally compiler-facing (form-sym?, form-list?,
resolve-global, …). Phase 4 added the first runtime primitive: ref-put!
(set/remove a key on a mutable reference cell) — the minimal mutation kernel the
overlay uses for atom watches/validators, volatiles, and aset. The overlay calls
these qualified, e.g. (jolt.host/ref-put! ...).
3. The migration epic (jolt-1j0) — essentially COMPLETE
Goal: shrink the Janet seed to core-renames + genuinely host-coupled fns;
express everything else (pure fns, macros, lazy machinery) in the self-hosted
overlay. Started at core.janet = 4145 lines / 421 core-* fns.
Phases (all done):
- Phase 1 — compiler-dependency kernel tier. (Was found already essentially complete — the analyzer needs nothing beyond the kernel tier + atom/swap!/reset!.)
- Phase 2 — ~193 movable pure-eager fns → overlay.
- Phase 3 (
jolt-461, closed) — ~46 core macros →defmacroin the overlay. Last one waswhen-let. - Phase 4 (
jolt-ldf, closed) — host-coupled fns. ~27 moved over theref-put!primitive + pure composition (vary-meta, reduce-kv, ex-info accessors, tagged-value predicates, atom peripheral ops, volatiles, future predicates, ns-name, array reads/aset). The rest stay native by design (atom/swap!/reset!/ deref, transients, var cells, meta tables, namespace, constructors, proxy, print dispatch). - Phase 5 (
jolt-c09, closed) — true laziness. Lazy seq generators + transformers, the40-lazy.cljtier, realization-boundary discipline. Seephase-5.mdfor the full implementation + testing plan and what landed (representation decision = Option B / hybrid: lazy over lazy input, eager representation-preserving over concrete finite collections).
The epic issue (jolt-1j0) may still read IN_PROGRESS — verify with bd show jolt-1j0 and close it if all five phase issues are closed and gates are green.
Where to confirm current state: phase-5.md (detailed, step-annotated),
jolt-core/clojure/core/MIGRATION.md (the worklist + bucket classification), and
the bd memories phase4-host-primitive-pattern / phase4-movable-classification.
4. Representation facts you MUST know (the trap floor)
Jolt's value/form representations bite every time. The essentials:
- Reader forms: a call/list
(f x)is a Janet array; a vector literal[a b]is a Janet tuple; a map literal{..}is a Janet struct (or a phm when a key/val is nil or a key is a collection). A symbol is a struct{:jolt/type :symbol :ns _ :name _}; a keyword is a Janet keyword. - Runtime values: vectors are persistent-vectors (
pvec, tagged tables) or tuples; lists/seq-results are Janet arrays orplist; sets arephs; maps are struct-or-phm.vector?is true for tuple and pvec.seq?is arrays/plists/ lazy-seqs (not vectors). InJOLT_MUTABLEbuilds vectors are plain arrays — sovector?/array?collapse (this is whyifn?couldn't move — seejolt-1vx). - Tagged values carry their kind in
:jolt/type(atoms, volatiles, delays, futures, ex-info, reader-conditional, lazy-seq) or:jolt/deftype(records). The overlay can read these via(get x :jolt/type)/(get x :field)—getreturns nil on non-tables, no error. It cannot construct them without a host primitive. This is the Phase-4 movability rule: accessors/predicates move, constructors stay. canon-key(core.janet ~line 51) is the canonical-hashing kernel of the whole persistent-collection system — woven intoget/count/contains?. This is why transients are irreducibly host.- LazySeq (
phm.janet):@{:jolt/type :jolt/lazy-seq :fn thunk ...}; thunk →nilor[first rest-thunk];realize-lsmemoizes with a:jolt/pendingguard that makes self-referential seqs (lazy-catfib) work.
Macro/overlay-authoring gotchas (learned the hard way)
- Build binding/forms via syntax-quote templates
`[~@xs](a tuple form), notconj/list(those make pvecs/plists the analyzer/compiler rejects). - A fresh symbol inside a macro body:
(symbol (str (gensym)))— a bare(gensym)returns a Janet symbol the destructurer rejects. - A
.cljtier is Clojure (;;comments). A.janettest/spec is Janet (#comments —;is splice!). Mixing them is a frequent self-inflicted error. - In a tier, a fn must be defined after the macro it uses is defined; use
def+fn*if you need it beforedefnexists (asdestructuredoes in 00-syntax).
5. Build, run, and the test gate
No special build needed to run from source — Janet reads the tiers off disk.
# Smoke
janet -e '(use ./src/jolt/api) (pp (eval-string (init) "(+ 1 2)"))'
# THE GATE — run all of these green before committing any core change:
janet test/integration/conformance-test.janet # 229 cases × 3 modes (interpret/compile/self-host)
janet test/integration/bootstrap-fixpoint-test.janet # stage1 == stage2 == stage3
janet test/integration/self-host-test.janet
janet test/integration/sci-bootstrap-test.janet # loads vendored SCI through jolt
janet test/integration/clojure-test-suite-test.janet # battery; baseline-pass=3971, clean-files=45
for f in test/spec/*.janet test/unit/*.janet; do janet "$f"; done # all must exit 0
- Specs (
test/spec/*-spec.janet) — data-drivendefspectables, behavioral. - Conformance — real-Clojure-semantics assertions, run in all 3 execution modes.
- clojure-test-suite — runs
lread/clojure-test-suite(from~/src/clojure- test-suite) via a per-file subprocess under a 6 s deadline (infinite seqs are CPU-bound and uninterruptible in-process — never probe them inline). Skips if the suite dir is absent. Raisebaseline-passas jolt improves; never lower it. - Laziness must be tested via the deadlined subprocess harness, not in-process.
Per-change workflow (mirror this)
- Make a small, single-purpose change.
- Add/extend spec + (for subtle behavior) 3-mode conformance cases.
- Run the full gate. Commit only if green.
git push(the project's session-close protocol requires pushed work).
6. Conventions
- Issue tracker: beads (
bd), not TodoWrite/markdown.bd ready,bd show <id>,bd create,bd update <id> --status=…,bd close.bd remember --key … "…"for durable knowledge;bd memoriesto recall. The.beads/dir is git-ignored and auto-synced — don'tgit addit. - Commits/PRs: terse, factual, human-dev tone. No marketing words, no emoji, no "This commit…". Say what changed and why it matters.
- Branch: work happens on
compiler-research(main ismain). - Don't lower
baseline-pass. If a moved fn surfaces a latent bug, fix it to match Clojure and add a regression test rather than preserving the bug (this happened withreduce-kvon vectors andifn?on lists).
7. Where to pick up
The migration epic is functionally complete; the seed is at its intended floor (core-renames + genuinely host-coupled). Candidate next work:
- Close out
jolt-1j0if not already closed (verify all phase issues closed, gates green). jolt-1vx(filed) —ifn?is wrongly true for lists; move to overlay but it's representation-mode-sensitive (JOLT_MUTABLE). Needs both-mode verification.- Phase-5 loose ends (see
phase-5.md): a few transformers were kept eager or reverted due to compile-mode~@/defrecord splice issues (partition-by,dedupe,tree-seq, lazymapcat). Re-verify the ~9 previously-timing-out suite files actually stopped timing out. The Step 4 "apply/~@over lazy" fix would unblock the reverted lazymapcat. - Bigger lifts not attempted (deliberately): the
print-method/pr-strdispatch machinery and thedeftype/defrecord/defprotocol/multimethod surface — both substantial and host-entangled. - Open issues:
bd readyfor the current actionable list (CI, edn/walk/zip stdlib,into #{}bug, recur-into-variadic hang, real futures via ev/thread, etc. — these predate the migration).
Map of the territory
src/jolt/core.janet— the Janet seed (core-*fns,core-bindings,core-renames).src/jolt/evaluator.janet— interpreter.src/jolt/compiler.janet— bootstrap compiler.src/jolt/backend.janet— IR → Janet emitter.src/jolt/host_iface.janet—jolt.host.src/jolt/phm.janet— persistent maps/sets/vectors + LazySeq.src/jolt/api.janet— context init + tier loading.src/jolt/reader.janet— reader.jolt-core/jolt/{analyzer,ir}.clj— portable self-hosted front end.jolt-core/clojure/core/*.clj— the overlay tiers +MIGRATION.md.phase-5.md— the laziness plan, annotated with what landed.CLAUDE.md/AGENTS.md— project agent instructions (beads, session-close).