Root cause: lazy-seq/lazy-cat were defined in 30-macros, which loads AFTER the
seq/coll tiers (10-seq, 20-coll) that use them. In compile mode a tier's forms
are compiled as the tier loads, so (lazy-seq …) in those tiers was compiled when
lazy-seq was not yet a registered macro — i.e. as a CALL to the macro-as-function,
which at runtime returns its own expansion `(make-lazy-seq (fn* [] …))` as data.
That leaked form then flowed into ops like `odd?` (partition-by) → type errors,
or silently produced wrong structure. Interpret/self-host masked it (expand at
call time); the eager fallbacks and the earlier letfn versions masked it by
falling back to the interpreter.
Fix: define lazy-seq/lazy-cat in 00-syntax (loaded first), exactly as when-let
already is for the same reason. They use only seed fns (make-lazy-seq/coll->cells/
concat) + map. With the macro registered early, the seq/coll tiers compile
(lazy-seq …) correctly.
With the root fixed, interleave/reductions/tree-seq drop their letfn workarounds
and use the canonical recursive Clojure forms (top-level / fn-self-name recursion
inside lazy-seq), verified leak-free in compile mode with strict probes.
Regression guards added: partition-by with odd? (the strict pred that exposed the
leak; the prior case used identity which masked it), reductions over an infinite
range, tree-seq summed through a strict filter — all ×3 modes.
Gate: conformance 249x3, lazy-infinite 40/40, fixpoint, self-host, specs+unit green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes the Option B (hybrid) decision. Records that the earlier Option A
attempt failed for lack of the boundary fixes (cons-cell leak, nth/next/rest
over lazy via seq-done?, coll->cells maps/sets + cell disambiguation, ~@ splice
+ normalize-pvecs realization), and that re-attempting with those fixes makes
Option A pass all gates (conformance 246x3, lazy-infinite 40/40). All
transformers lazy in 3 modes; interleave/reductions/tree-seq use letfn recursion
to avoid jolt-r81.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These three were the last eager transformers, blocked by jolt-r81: a self-
recursive lazy-seq in the overlay leaks its macro expansion under :compile? when
recursion goes through a top-level name or (fn name …) self-name. Rewriting the
recursion as letfn-bound (the form partition-by/mapcat/dedupe already use, which
compiles cleanly) sidesteps the bug. All three are now lazy in interpret,
compile, and self-host — completing Option A for every transformer.
interleave: canonical lazy cons-recursion (2-arity) + map/concat (n-arity).
reductions: letfn step accumulator. tree-seq: letfn walk + lazy mapcat.
Gate: conformance 246x3, lazy-infinite 40/40 (+interleave/reductions/tree-seq
infinite cases), fixpoint, self-host, specs+unit green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Option A makes `take` lazy, so the §6.3 counter tests must force the take
result (dorun) to drive realization — an unconsumed take correctly realizes
nothing. With that, all 37 lazy-infinite cases pass and the minimal-realization
counts match (proving the Option A transformers realize exactly the demanded
prefix).
interleave kept eager: a lazy (cons-recursive) version leaks its lazy-seq macro
expansion under :compile? — the same jolt-r81 bug that blocks lazy reductions/
tree-seq. Documented in 20-coll.clj.
Gate: conformance 246x3, lazy-infinite 37/37, fixpoint, self-host, specs+unit
green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Converts the rest of the lazy transformers to return a LazySeq even over a
concrete collection (dropping the eager "preserve representation" branch, which
returned a vector over vector input): drop, distinct, partition, partition-all,
map-indexed, keep, keep-indexed, take-nth, interpose. Each collapses to its
existing lazy branch run over (lazy-from coll); transducer arities unchanged.
Fixes a latent nth bug exposed by this: core-nth's lazy branch walked with
(ls-first cur) truthiness as the end-of-seq test, so a legitimate false/nil
element was mistaken for the end — (nth (map identity [false 1 2]) 0) threw
instead of returning false, and cond-> (whose macro nths over a now-lazy
(drop 2 clauses) containing the boolean clause tests) failed. nth now walks
with seq-done? and reads via core-first.
Gate: conformance 246x3 (+7 cases), lazy-infinite 18/18, fixpoint, self-host,
all specs+unit green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lazy transformers now return a LazySeq even over a concrete vector, matching
Clojure: (seq? (map inc [1 2 3])) is true, (vector? ...) false. Replaces the
"preserve representation" eager branch (which returned a vector over vector
input) by routing concrete colls through lazy-from + the lazy step machinery.
Flipping these surfaced four boundary bugs, all fixed here:
- cons over a lazy-seq returned a raw cell @[x thunk]; a cons-of-a-cons then
treated it as a plain 2-array and leaked the rest-thunk as an element (broke
interleave). cons over lazy now returns a proper LazySeq.
- coll->cells mistook a user vector whose 2nd elem is a function ([first last]
from juxt) for a cons cell. Cons cells are mutable arrays; user data is
immutable — route pvec/plist/tuple through immutable tuples and apply the
[val,fn] cell heuristic only to mutable arrays. Also coerce set/map/string/
buffer via core-seq.
- ~@ splice over a lazy map result iterated a LazySeq as a Janet table (broke
lazy-cat / self-ref fib). syntax-quote* now realizes via d-realize before
splicing; core-sqcat (self-host) already realized.
- core-next did (length r) on a lazy rest (never 0 on a table) and ls-rest
could return nil → (length nil) crash. core-rest never returns nil; core-next
uses seq-done? (realizes one cell). seq-done? moved above core-rest.
normalize-pvecs (test helper) realizes lazy-seqs so Janet-= comparisons work.
Gate: conformance 239x3 (interpret/compile/self-host, +10 Option A cases),
lazy-infinite 18/18, fixpoint, self-host, all specs+unit green. (sci-bootstrap
and clojure-test-suite skip — vendored dirs absent in this checkout.)
Remaining for full Option A consistency (jolt-7w4): drop/map-indexed/keep/
keep-indexed/take-nth/interpose/distinct/partition/partition-all still eager
over concrete input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Added 16 atom-counter laziness tests to lazy-infinite-test.janet:
map, filter, remove, take-while, drop-while, distinct, take-nth,
map-indexed, keep, keep-indexed, interpose, partition, partition-all,
mapcat, dedupe, repeated-inc
Each test wraps input elements in (swap! c inc) and proves only the
exact minimal number of elements are realized. 37/37 pass, 0 timeouts.
Updated phase-5.md §7: "22/22" → "21/21" (interleave commented out),
§6.3 marked Done.
Final gate results:
lazy-infinite: 37/37 (21 value + 16 counter)
conformance: 229/229 ×3
specs: 32/32 files, 0 failures
clojure-test-suite: 3971 pass (↑45), 6 timeouts (↓3)
core-bench: TOTAL 2531 ms (no Phase-4 baseline for comparison)
Representation decision: Option B (hybrid). Option A blast-radius
measured — always-lazy map breaks lazy-infinite (0/21) and crashes
conformance completely. Hybrid (lazy over lazy input, eager over
concrete) is the only viable approach with current lazy-seq machinery.
Gates verified:
- lazy-infinite: 21/21 (0 timeouts)
- conformance: 229/229 x3
- specs: 32/32 files, 0 failures
- clojure-test-suite: 3971 pass (up from 3926), 6 timeouts (down from 9)
- core-bench: TOTAL 2531 ms
Root cause: lazy interleave in 20-coll.clj uses lazy-seq macro which
expands to (make-lazy-seq (fn* [] (coll->cells ...))) in compile mode.
This produces raw AST forms that crash suite file loading — same issue
as mapcat and partition+concat overlay attempts.
Fix: revert interleave to eager (vec-based loop). dedupe stays lazy
(uses make-lazy-seq directly, not lazy-seq macro). xml-seq stays
(uses tree-seq which is eager in overlay).
Suite: 3971 pass (up from 3926), 6 timeouts (down from 9), 4628 assertions.
Lazy-infinite: 21/21 (interleave infinite case commented out).
Conformance: 229x3.
22/22 lazy-infinite, 229x3 conformance, 32/32 specs — all green.
clojure-test-suite: 849 pass (regression from 3926). Top failing
files: lazy_seq, mapcat, map, group-by, keys, vals, subs. Root cause
is the 40-lazy.clj overlay tier — moving distinct/keep/map-indexed/
cycle/repeat/iterate/repeatedly/partition-all to the overlay broke
suite file loading. All individual smoke tests and the 229 conformance
cases pass; the issue is in the clojure.test shim + .cljc loading path.
core-bench: TOTAL 2471 ms (fib 128, seq-pipe 93, reduce 404,
into-vec 212, map-build 728, map-read 6, str-join 255, hof 644).
No Phase-4 baseline exists for comparison.
§6.3 laziness counters + representation decision deferred.
1. partition+concat removed from 10-seq.clj overlay — they use
lazy-seq macro which produces raw make-lazy-seq forms in compile
mode (same issue as mapcat overlay reversal). Must stay native.
2. Dead defns removed from core.janet: core-map-indexed,
core-iterate, core-repeatedly (30 lines). Bindings already
removed in prior commit.
3. dedupe removed from 40-lazy.clj — belongs in 20-coll.clj.
Gates: conformance 229×3, lazy-infinite 22/22, specs 32/32.
Defns were already present (core-rand-int at line 1407, core-trampoline
at line 1408) but bindings were missing from core-bindings map. Added
both entries. Conformance 229x3, lazy-infinite 22/22.
The lazy-seq macro (30-macros.clj) is not available when 20-coll
loads. Use explicit (make-lazy-seq (fn* [] (coll->cells ...)))
expansion. Also fix outer cons leaking raw @[val thunk] cell.
22/22 lazy-infinite, conformance 229 interpret + self-host.
The git stash pop restored the old lazy-seq version of dedupe.
Re-applied the make-lazy-seq + coll->cells fix for the 20-coll
tier where lazy-seq macro is not yet available.
core-trampoline calls apply internally. In compile mode, apply must
be in core-renames for the compiler to emit a direct call to
core-apply. Without this, apply resolves to nil at compile time.
Add "trampoline" "core-trampoline" to core-renames so compile
mode emits direct calls. Without this entry the compiler falls
back to resolving trampoline in Clojure namespace, which fails
since trampoline was removed from overlay.
Fix 20-coll.clj extra close paren from trampoline removal.
Overlay trampoline fails arity dispatch — (trampoline f 5) with [f]
and [f & args] arities dispatches incorrectly in Jolt. The Janet
core-trampoline is 4 lines and works correctly. Kept native.
Results: 22/22 lazy-infinite, 229x3 conformance, 32/32 specs.
dedupe: use make-lazy-seq + coll->cells directly — lazy-seq
macro is not available at 20-coll tier (defined in 30-macros.clj).
trampoline: use ifn? instead of fn? (not available), fix
single-arity to call (f) directly instead of recursive (trampoline f (f)).
rand-int: removed from overlay, stays native in core.janet
(math/floor and math/random not available in Clojure namespace).
Fix & rest destructuring (evaluator.janet): when the original
value is a lazy-seq, derive & rest by walking ls-rest vi steps
instead of slicing the eagerly-realized array from d-realize.
Rewrite core-mapcat as a lazy state machine (core.janet): step
through input colls one element at a time, call f on each tuple,
then yield from f's result collection. No apply-forcing —
all input colls are walked lazily via lazy-from + seq-done?.
Add mapcat to core-renames (compiler.janet) and remove from
Clojure overlay (10-seq.clj) — compile mode now emits direct
calls to the lazy core-mapcat, fixing the pre-existing
protocol-on-record compile error.
Tests: re-enabled mapcat infinite harness case, added 2 & rest
laziness cases. 21/21 lazy-infinite, 229x3 conformance, 32/32 specs.
core-drop-while (core.janet): return cell via realize-ls instead
of raw LazySeq. The previous impl returned the remaining cursor
directly, which realize-ls handled via recursive realization at
one extra thunk-call-per-element cost. Now returns the realized
cell directly, matching the cell format contract.
mapcat (10-seq.clj): revert to standard (apply concat (apply map
f colls)). The lazy overlay (mapcat-step + lazy-seq + cons) broke
the defrecord macro: ~@ cannot splice lazy-seqs during syntax-quote
expansion, causing splice errors at init time. A lazy mapcat
requires either fixing apply to handle lazy spreading (Step 4)
or rewriting defrecord to avoid mapcat entirely.
lazy-infinite harness: comment out infinite mapcat case with note
explaining the apply limitation (Step 4 needed).
Gates verified:
conformance 229/229 x3 (interpret/compile/self-host)
lazy-infinite 18/18
specs 32/32 files, 0 failures
xml-seq added to 20-coll.clj, matching Clojure reference:
(tree-seq (complement string?) (comp seq :content) root)
tree-seq kept eager with lazy version documented in comments.
The lazy tree-seq (using lazy-seq + cons + mapcat) triggers
splice errors in self-hosted compilation mode — the self-hosted
compiler does not handle nested lazy-seq macros with recursive fn
bindings correctly. The mapcat overlay in 10-seq.clj has the same
limitation. This is a known compiler issue, not a design problem;
documented for future fix.
line-seq, iterator-seq, enumeration-seq remain Janet stubs —
Java-specific APIs with no Janet equivalent.
flatten already correct in Clojure overlay (unchanged).
mapcat in 10-seq.clj now uses a defn- mapcat-step helper that walks
lazy map results element-by-element (cons + lazy-seq), with explicit
arities for 1/2/3/4+ colls. The 4+-coll arity still uses apply to
spread colls to map, but apply no longer forces the inner map result.
Previously (apply concat (apply map f colls)) forced the lazy map
result through core-apply realize-for-iteration, which hung on
infinite inputs. The new step-based impl walks one element at a time
via first/rest/seq primitives.
Re-enabled deferred mapcat infinite-input harness case. 19/19 pass.
Gates: lazy-infinite 19/19, conformance 229/229 interpret+self-host
(compile 228/229 — 1 pre-existing protocol error), specs 32/32.
Step-by-step plan for jolt-c09 (Phase 5 of the clojure.core migration):
current state of the LazySeq machinery and the eager gaps, the cardinal
laziness rules, leaf-first transformer conversion order, realization-boundary
audit, the representation decision (lazy-seq vs eager-vector for map over a
vector), and a testing strategy built around a deadlined subprocess harness
(infinite realizations are CPU-bound and uninterruptible in-process).
ns-name is pure over get + symbol. aget/alength read jolt's mutable backing
arrays (nth/count), and aset writes a slot through jolt.host/ref-put!; both
handle the multi-dimensional form by walking. The array constructors
(object-array/make-array/to-array/...) stay native — they build the mutable
backing. Added array spec cases; ns-name already covered.
Pure reads of the future's :cached/:cancelled slots over future? + get.
future? stays native (deref/future-cancel/realized? call it), as do future-call
and future-cancel (OS threads). Covered by the existing futures spec.
vreset! sets the volatile's :val slot through the jolt.host/ref-put! primitive
added in the last batch; vswap! is pure over vreset! + get. The volatile!
constructor stays native (it builds the mutable box). Covered by the existing
state spec.
First host-primitive batch. Adds jolt.host/ref-put! — the minimal mutation
kernel (set/remove a key on a mutable reference cell) the overlay can't express
over core fns — and moves seven atom ops out of the seed:
swap-vals!/reset-vals!/compare-and-set! compose the native swap!/reset!/deref
(which already validate and notify watches); get-validator is a slot read;
add-watch/remove-watch/set-validator! mutate the atom (or its watches table)
via ref-put!.
atom/swap!/reset!/deref and atom-validate/atom-notify-watches stay native — the
compiler depends on them and they're hot. compare-and-set! keeps value-equality
semantics, matching the prior Janet behavior. Covered by the existing state spec.
atom?/volatile?/reader-conditional?/tagged-literal?/record?/chunked-seq? all
just read a value's kind from :jolt/type (records from :jolt/deftype), and get
returns nil on non-tables, so the predicates are pure over get with no host
surface. Constructors stay native. delay?/transient? keep their Janet defns —
they have internal callers (force, conj/count/persistent! etc.). chunked-seq?
is always false (no chunked seqs until Phase 5). Added a tagged-value spec
group covering positive and negative cases for each.
The ex-info value exposes :jolt/type/:message/:data/:cause via get (it's the
caught value too), so the accessors are pure over get — no host surface. The
constructor (ex-info) stays in Janet since it wires into throw. A thrown
non-ex-info arrives wrapped as {:jolt/type :jolt/exception :value v}; the
overlay unwraps that, matching the old Janet helper. ex-cause now unwraps and
returns nil (not false) on non-ex values, matching Clojure. Added non-ex-info
and string regression cases.
reduce-kv is pure over reduce/keys/get/nth, so it moves to 20-coll with no
host surface. Both branches fold through reduce, which fixes two latent bugs
in the Janet version: on a vector it saw the pvec as a table and folded over
its internal keys instead of the indices, and it ignored reduced entirely.
nil folds to init. Added vector-index, reduced, and nil spec cases plus a
3-mode conformance case for the vector fix.
First Phase 4 batch: the host-coupled fns whose logic is pure composition of
existing core primitives, so no new jolt.host surface is needed. vary-meta is
just (with-meta obj (apply f (meta obj) args)); namespace-munge is a char map
over str. Both land in the 20-coll tier; the Janet core-* defns and bindings
are gone. Added namespace-munge spec cases and a vary-meta extra-args case.
The heavily host-coupled fns (atom/var/transient internals, arrays, proxy,
futures) stay in Janet pending a thin host-primitive expansion.
Last core macro still in Janet. Goes in 00-syntax (not 30-macros with its
if-let/if-some/when-some siblings) because 20-coll's not-empty uses it and
20-coll loads before 30. Via `let` so the binding form may destructure,
matching Clojure — the old Janet version used let* and wouldn't.
Drops core-when-let + the sym* helper + the intern entry, and empties
core-macro-names (no seed binding is a macro anymore).
Port clojure.core/destructure into jolt-core/clojure/core/00-syntax.clj
and drop core-destructure (plus the d-* helpers) from core.janet. The
expander is now self-hosted, shrinking the seed per the jolt-1j0 epic.
It's def+fn* rather than defn because defn isn't defined yet at that
point in the syntax tier. Only the let macro consumes its output, so let
now splices [~@(destructure bindings)] to keep a tuple binding form.
Also fix a gap the old Janet version papered over via Janet's
keyword->string: :keys accepts keyword elements ({:keys [:major :minor]}),
so use name/namespace for the local + ns instead of str (which keeps the
colon). This was breaking sci's impl/namespaces.cljc. Added spec cases.
fn now desugars destructuring params like Clojure's maybe-destructured: each
non-symbol param becomes a gensym and the body is wrapped in a let that rebinds the
pattern, so fn* only ever sees plain params and the COMPILER handles it (it rejected
patterns before, falling back to the interpreter). loop follows Clojure too: gensym
one loop var per binding, loop* over those, destructure via an inner let, with an
outer let so later inits see earlier destructured names — recur arity stays correct.
Two representation gotchas: build the param/binding vectors via [~@..] so they're
tuple forms (conj yields a pvec the analyzer rejects), and use (symbol (str
(gensym))) since a bare (gensym) in an overlay macro body is a Janet symbol the
destructurer rejects.
Closes the fn/loop destructuring gaps. conformance 228x3, fixpoint, clojure-test-
suite 3930, full suite green, bench flat.
lazy-seq wraps its body in (make-lazy-seq (fn* [] (coll->cells (do ...)))); lazy-cat
wraps each coll in lazy-seq and concats (concat is already lazy). Both user-facing,
in 30-macros. Also drop two now-stale comments. Laziness preserved (lazy-seqs-spec
20/20, including infinite/self-referential).
conformance 228x3, fixpoint, clojure-test-suite 3930, full suite green.
defprotocol/defrecord/extend-type/extend-protocol/reify + the extend/proxy/
definterface stubs move to 30-macros (user-facing, not used by the compiler). They
emit Jolt's protocol/type special forms (protocol-dispatch/register-method/
make-reified/deftype).
The one subtlety: a protocol value is a :jolt/type-tagged struct, and the
interpreter can't tell an embedded opaque value from a tagged map literal being
constructed. So defprotocol builds it via a make-protocol fn call (exposed from
core) instead of an embedded literal — a fn result evaluates normally and even
compiles. reify emits a {kw (fn* ...)} map literal that make-reified evaluates
(build-eval-map yields a struct it can iterate, unlike a hash-map phm). defrecord
vecs its spliced field-let bindings (a lazy mapcat seq won't splice) and uses an
explicit fresh-sym for the map-> param (auto-gensym doesn't cross nested
syntax-quotes).
protocols-spec 21/21, conformance 228x3, fixpoint, clojure-test-suite 3930, full
suite green.
defn drops an optional leading docstring/attr-map then emits (def name (fn* ...)).
Single- and multi-arity both reduce to (fn* ~@body) so no arity branching is needed.
map? is true for symbol forms in Jolt, so the attr-map strip is guarded with symbol?.
defn- delegates to defn (privacy isn't enforced, as in Clojure's own defn-). Placed
before fresh-sym, which is itself a defn- now.
All five fundamental macros (fn/let/loop/defn/defn-) are now in the overlay.
conformance 228x3, fixpoint, clojure-test-suite 3930, full suite green.
let -> let* with destructuring pre-expanded via destructure (now exposed as a
clojure.core fn, which it is in Clojure too) so the compiler sees plain bindings —
analyze-bindings rejects patterns as uncompilable. loop -> loop* with raw bindings,
matching the prior Janet macro: loop can't pre-destructure without breaking recur
arity, so the interpreter handles pattern loops and the compiler falls back.
conformance 228x3, fixpoint, clojure-test-suite 3930.
fn -> fn* is a one-line head-swap (the analyzer treats fn* as the primitive). It's
in 00-syntax since the analyzer, kernel, and other overlay macro bodies all use fn.
First of the fundamental macros to move.
conformance 228x3, fixpoint, clojure-test-suite 3930.
Port of core-for: desugar a comprehension to nested map/mapcat over the binding
colls, :let -> let*, :while -> take-while on the coll. Lives in 00-syntax because
doseq (already there) expands to it; the macro body uses only kernel/seed fns so it
runs at analyzer-build time. doseq no longer depends on a Janet macro.
Fixed a latent bug the Janet macro had: :when wrapped the inner form in (list ...)
unconditionally, but for an outer binding group the inner form is already a seq, so
mapcat produced a seq-of-seqs instead of flattening. e.g.
(for [x [0 1] :when (odd? x) y [:a :b]] [x y])
gave ((... ...)) instead of ([1 :a] [1 :b]). Now the (list ...) wrap is only applied
to the last group's scalar body; outer groups contribute their seq directly. Added
:let+:when, multi-group :when, and destructuring regression cases.
conformance 228x3, fixpoint, clojure-test-suite 3930, full suite green, bench flat.