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).
14 KiB
Phase 5 — True Laziness (jolt-c09)
Final phase of the jolt-1j0 clojure.core migration epic. Make jolt's sequence
generators and transformers genuinely lazy, so infinite seqs and lazy
compositions work and stop hanging the evaluator. This is the deepest and
riskiest phase — sub-stage it and gate every step.
Issue:
bd show jolt-c09. Depends on Phase 4 (jolt-ldf, done). Blocks nothing — it's the last phase.
1. Current state (what already works, what doesn't)
The LazySeq machinery exists and is sound. (src/jolt/phm.janet)
- A LazySeq is
@{:jolt/type :jolt/lazy-seq :fn thunk :realized false :val nil}. - A thunk returns
nil(empty) or a cons cell[first-val rest-thunk]. realize-lsforces one cell (memoized via:realized), with a:jolt/pendingsentinel that makes self-referential seqs work ((def ones (lazy-seq (cons 1 ones)))).ls-first/ls-rest/ls-seq/ls-countwalk it.lazy-seq?detects it.
Already lazy (keep):
- Infinite generators:
(range),(repeat x),(iterate f x),(cycle ...),repeatedlyreturn LazySeq. Bounded forms ((range n),(repeat n x)) are eager tuples/arrays — correct, they're finite. map/filterare hybrid: lazy when the input is a LazySeq, eager (and representation-preserving) when the input is a concrete collection.take/drop/take-whilepull lazily from a LazySeq input but return an eager array (fine for boundedtake, wrong for the others on infinite tails).- Conformance already covers the working cases (self-ref fib,
iterate,countoftake,filter/take-while/removeover(range)): seetest/integration/conformance-test.janetlines ~21–143.
The gaps (what hangs):
- Eager transformers that force their input even when it's infinite. Confirmed
callers of
realize-for-iterationin their bodies:remove,interpose,distinct,take-nth,map-indexed,keep-indexed,partition-all,partition-by,drop-while. Pluspartition,interleave,concat,dedupe,flatten,tree-seq,mapcat,keep,sequenceneed an infinite-input audit. map/filterover a concrete vector return an eager array, not a lazy seq. Clojure returns a lazy seq. This is a representation decision (§3 Step 6).realize-for-iterationis the universal forcing point (57 call sites). Many are legitimate realization boundaries (count,into,reduce,vec,pr), but any transformer that calls it on a lazy input loses laziness.- Evaluator eager assumptions — the interpreter/compiler may realize seqs in
places (apply arg spreading,
doseq, destructuring a seq). Audit needed. - CPU-bound hangs are uninterruptible. An infinite realization is a tight
Janet loop with no yield points, so
ev/with-deadlinecannot truncate it in-process — it pins the core. This is why the suite runs each file in a subprocess (os/spawn+ 6 sev/with-deadline, thenos/proc-kill). Phase 5 testing must do the same (see §7).
2. Design principles (the cardinal rules)
- A transformer never forces its input. It returns a LazySeq whose thunk pulls
one element at a time via
core-first/core-rest/seq-done?. Norealize-for-iterationinside a transformer. - Force only at realization boundaries. Exactly the operations that must see
all elements:
pr/print/strrendering,=,count,reduce,into,vec/seq/doall,doseq,nth/last(these pull only as far as needed),apply(spreads finitely). These are allowed to loop; on a genuinely infinite seq they hang — matching Clojure. - One-element-at-a-time, memoized. Reuse
make-lazy-seq/realize-ls; never re-walk.realize-ls's:jolt/pendingguard preserves self-reference. - Stack safety. A chain of N lazy wrappers must not consume N stack frames per
element. Realize iteratively (a
whileoverrealize-ls), not by deep recursion throughls-rest. Watchconcat/mapcat/lazy-catespecially. - Multi-arity stays correct.
map/mapcatover multiple colls advance each input one step per output element and stop at the shortest.
3. Step-by-step implementation
Order matters: build the helper layer, then convert transformers leaf-first, then fix boundaries, then the evaluator. Gate (§6) after every numbered step.
Step 0 — Safety net
- Record the baseline: conformance 229×3, clojure-test-suite
baseline-pass=3926, fixpoint stage1==2==3, self-host, all specs+unit,lazy-seqs-spec/sequences-spec/transducers-specgreen. - Build the infinite-seq harness first (see §6.2, "Deadlined infinite-seq spec") so every subsequent step is verified against hangs, not just values.
- Snapshot which clojure-test-suite files currently time out (the ~9). Save the list — it's the acceptance target.
Step 1 — Lazy combinator layer
Add a small set of internal lazy builders so transformers compose uniformly, rather than each re-implementing the thunk dance:
lazy-cons val thunk→ a LazySeq cell ofval+ a deferred rest.lazy-from coll→ coerce any seqable to a uniform lazy view without forcing (vector/list/set/map/string/LazySeq → a LazySeq that pulls element by element). This is the lazy analogue ofrealize-for-iterationand the key primitive: every transformer takes(lazy-from input)and walks it withcore-first/core-rest.seq-done?already exists — confirm it short-circuits without forcing the tail.- Decide placement: the lazy machinery is host-coupled (Janet thunks) so it stays
in
phm.janet/core.janet; transformers that are already in the overlay tiers call these as primitives.
Step 2 — Convert the core transformers (leaf-first)
Make each return a LazySeq over lazy-from input. Do them in dependency order, one
small batch per commit, each gated:
- 2a. Single-input maps/filters:
map(1-coll),filter,remove,keep,map-indexed,keep-indexed,take-while,drop-while,take-nth. - 2b. Structural:
cons,rest/nextover lazy,concat,lazy-cat(verify),mapcat,cycle(verify),interleave,interpose. - 2c. Windowing:
partition,partition-all,partition-by,dedupe,distinct,take/drop(return LazySeq, not eager array, when input is lazy). - 2d. Multi-input
map/mapcatover several colls (shortest-stops). - 2e. Tree/seq:
tree-seq,flatten,xml-seq,line-seq,sequence,iterator-seq,enumeration-seq. - For each: a transducer arity may exist (
td-*) — leave it; only the collection arity changes.
Step 3 — Realization boundaries
Audit the 57 realize-for-iteration call sites. Classify each as boundary
(keep, it must force) or transformer leak (remove, made lazy in Step 2):
- Boundaries that stay:
count,reduce,into,vec,seq,doall,dorun,=/equality,pr/print/str-render,sort/sort-by,reverse,frequencies,group-by,applyarg-spread,doseq. - Make sure
first/second/nth/last/take/getpull only as far as needed (they must not callrealize-for-iteration). realized?must report a LazySeq's:realizedflag (don't force to answer).
Step 4 — Evaluator / compiler eager assumptions
Grep the interpreter (src/jolt/evaluator.janet) and back end
(src/jolt/backend.janet, compiler.janet) for places that realize seqs:
apply/ variadic arg spreading — must finitely spread, not realize an infinite tail beyond the call.&-rest binding infn*/let*/loop*anddestructure— a rest param over a lazy seq should stay lazy, not eagerly slurp.doseq/fordesugaring (they go throughcount/mapcat— verify theforcomprehension stays lazy where Clojure's is).- Any
(each x (realize ...))in hot paths that assumes finiteness.
Step 5 — Laziness-coupled stragglers (the deferred Phase-5 list)
From jolt-c09 notes / MIGRATION.md: sequence, sequential?, seqable?,
realized?, line-seq, rand-int, random-uuid, trampoline, unreduced,
ensure-reduced, the transducer machinery (cat, eduction, transduce,
sequence, halt-when, dedupe/interpose/keep transducer arities). Move the
now-lazy ones to the overlay where feasible (Phase-4 style), keeping the
Reduced/thunk kernels native.
Step 6 — Representation decision (DO THIS DELIBERATELY, EARLY)
Clojure: (map inc [1 2 3]) returns a lazy seq, not a vector; (seq? (map ...))
is true, (vector? (map ...)) is false. Jolt currently returns an eager vector
(make-vec) to "preserve representation". Two options:
- (A) Full Clojure semantics:
map/filter/etc. always return a LazySeq, even over a vector. Most correct; but flipsvector?/seq?/printing on a lot of existing results and may shift many conformance/suite assertions. Budget for the churn. - (B) Hybrid (status quo extended): lazy over lazy/infinite input, eager
representation-preserving over concrete finite input. Less churn, but
(seq? (map inc [1 2 3]))stays wrong. Recommend (A) for correctness, but measure the blast radius first: run conformance
- suite with a throwaway always-lazy
mapand count newly-failing assertions before committing to it. Whichever you pick, write it down here and be consistent across all transformers.
4. Suggested commit cadence
One transformer family (a §3 sub-step) per commit. Each commit:
- Convert the fns (overlay or core as appropriate).
- Add infinite-seq spec cases (§6.2) + value cases.
- Run the full gate (§6.1). Commit only if green. Push.
Mirror the Phase 4 discipline: small, gated, reversible batches.
5. Risks & gotchas
- Uninterruptible hangs: never probe an infinite case in-process — it pins a core and can't be killed by a deadline. Always go through the subprocess harness.
- Self-reference:
(def s (lazy-seq (cons 1 s)))andlazy-catfib rely onrealize-ls's:jolt/pendingguard — don't bypassrealize-lswith a hand-rolled force. - Stack overflow from deep wrapper chains (
concat/mapcat/iterateofiterate) — realize iteratively. - Double realization / side effects: a lazy
mapfn with side effects must run once per element, in order, only when forced — assert with a counter (§7). - Performance: LazySeq has per-element allocation + thunk-call overhead. Watch
core-bench(test/bench/core-bench.janet) — the eager fast paths exist partly for speed. A heavy suite file slipping past the 6 s deadline = a regression (this already bit Phase 3's macro move). - Compile/self-host parity: every behavior must hold in interpret, compile, and self-host (conformance runs all three). Lazy thunks are closures — verify the back end compiles them.
chunkedseqs are out of scope —chunked-seq?staysfalse. Don't emulate chunking; one-at-a-time is fine.
6. Testing strategy
6.1 Per-step gate (every commit) — same as Phase 4
janet test/integration/conformance-test.janet # 229×3 (interpret/compile/self-host)
janet test/integration/bootstrap-fixpoint-test.janet # stage1==2==3
janet test/integration/self-host-test.janet
janet test/integration/sci-bootstrap-test.janet
janet test/integration/clojure-test-suite-test.janet # >= baseline (raise as it improves)
for f in test/spec/*.janet test/unit/*.janet; do janet "$f"; done
6.2 Deadlined infinite-seq spec (the Phase-5-specific harness)
Build this in Step 0. Plain in-process specs cannot test laziness — a wrong
answer hangs instead of failing. Mirror clojure-test-suite-test.janet's pattern:
- A new
test/integration/lazy-infinite-test.janetthat, for each case, spawns a worker (os/spawn ["janet" "test/support/lazy-eval.janet" expr]) and waits under(ev/with-deadline 5 (os/proc-wait proc)), killing on timeout. - A timed-out or crashed case = FAIL (it should have produced a value).
- Cases = the compositions that currently hang. Minimum set:
Add one row per transformer converted in Step 2.(nth (map inc (range)) 1000) => 1001 (first (filter even? (drop 3 (range)))) => 4 (take 3 (remove odd? (range))) => (0 2 4) (take 3 (drop-while #(< % 5) (range))) => (5 6 7) (take 4 (interleave (range) (iterate inc 10))) (take 3 (partition 2 (range))) => ((0 1) (2 3) (4 5)) (take 3 (partition-all 2 (range))) (take 3 (map-indexed vector (range))) (take 5 (distinct (cycle [1 2 1 3 1]))) (take 3 (mapcat (fn [x] [x x]) (range))) (take 3 (take-nth 2 (range))) (take 3 (interpose :x (range))) (take 3 (map vector (range) (iterate inc 100))) (second (cons :a (range)))
6.3 Laziness assertions (side-effect counting)
For each lazy transformer, assert it realizes only what's demanded — values alone don't prove laziness. Use a counter:
(let [n (atom 0)]
(take 3 (map (fn [x] (swap! n inc) x) (range)))
@n) ; => 3 (not "hang", not 1000)
Add these to test/spec/lazy-seqs-spec.janet. They run in-process safely because
they only ever force a bounded prefix.
6.4 Conformance extension
Add infinite-composition rows to conformance-test.janet (runs ×3 modes) — the
subset of §6.2 that returns a small concrete value, e.g.
["lazy compose" "(quote (1 3 5))" "(take 3 (filter odd? (map inc (range))))"].
These guard interpret/compile/self-host parity.
6.5 Acceptance target — the timed-out suite files
The 9 files that currently time out (snapshot in Step 0:
cycle/range/transducers-over-infinite tests) should stop timing out and start
contributing passes. Each phase-5 step should monotonically reduce the timed-out
count and raise baseline-pass in clojure-test-suite-test.janet:35. Final
target: 0 (or near-0) timeouts and a meaningfully higher baseline.
6.6 Regression guards
core-benchbefore/after (back-to-back, load-sensitive) — no large slowdown on the eager-collection paths.lazy-seqs-spec,sequences-spec,transducers-specstay green every step.
7. Done criteria
- All §6.2 infinite-seq cases return correct values under the deadline (0 hangs).
- §6.3 laziness counters prove minimal realization for every converted transformer.
- Conformance 229+×3, fixpoint, self-host, sci-bootstrap all green.
- clojure-test-suite: the ~9 infinite-seq files no longer time out;
baseline-passraised to the new steady-state; no per-file 6 s timeouts introduced. - Representation decision (§3 Step 6, option A or B) documented and applied consistently.
core-benchwithin noise of the Phase-4 baseline.bd close jolt-c09→ closes thejolt-1j0epic.