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.
24 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 ✓ (commit e2e189a)
- 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. ✓
→
test/support/lazy-eval.janet(subprocess worker) +test/integration/lazy-infinite-test.janet(os/spawn + 5s deadline) - Snapshot which clojure-test-suite files currently time out (the ~9). Save the list — it's the acceptance target. ⚠ 9 files recorded but not yet re-verified post-conversion.
Step 1 — Lazy combinator layer ✓ (commit e2e189a)
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. ✓ →src/jolt/phm.janetline 208; registered in core-bindings as"lazy-cons".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. ✓ →src/jolt/core.janetline 1112; registered in core-bindings as"lazy-from".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) ✓ (commits e2e189a, d16e1f4, 97781b3, ff8ffb8)
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) ✓ (already lazy),filter✓ (already lazy),remove✓ (delegates to filter),keep✓,map-indexed✓,keep-indexed✓,take-while✓ (already lazy),drop-while✓,take-nth✓. - 2b. Structural:
cons✓ (already O(1) lazy cell),rest/nextover lazy ✓,concat✓ + zero-arg returns @[],lazy-cat✓ (verify),mapcat✓ (standard(apply concat (apply map f colls))+ transducer arity. Lazy step-based overlay attempted but reverted — compile-mode splice errors when used by defrecord's~@syntax-quote. Needs Step 4 apply fix or defrecord rewrite),cycle✓ (already lazy),interleave✓ (lazy multi-arity in overlay),interpose✓. - 2c. Windowing:
partition✓,partition-all✓,partition-by⚠ (still eager),dedupe⚠ (still eager in overlay),distinct✓,take/drop⚠ (return eager array, not LazySeq — representation decision, §3 Step 6). - 2d. Multi-input
map/mapcatover several colls (shortest-stops). ✓ → 9 new tests added tosequences-spec.janet, verified against Clojure & CLJS reference implementations. Multi-inputmapalready correct;mapcatuses the standard overlay impl. No code changes needed. - 2e. Tree/seq:
tree-seq⚠ (kept eager; lazy via mapcat triggers compile-mode splice errors — documented with lazy version in comments),flatten✓ (already correct in overlay),xml-seq✓ (added to overlay, matches Clojure),line-seq✓ (Janet stub — Java-specific API),sequence✓ (Janet stub),iterator-seq✓ (Janet stub — Java-specific API),enumeration-seq✓ (Janet stub — Java-specific API). - For each: a transducer arity may exist (
td-*) — leave it; only the collection arity changes. ✓
Step 3 — Realization boundaries ✔ audit complete (documented in phase-5.md)
Audit of 56 realize-for-iteration call sites in src/jolt/core.janet (excludes the definition at line 96). Each site classified below.
Boundary (must force — correct)
These functions require seeing all elements by contract.
| Function | Line(s) | Why |
|---|---|---|
core-sqcat |
136 | syntax-quote ~@ splicing — must flatten all parts |
core-sqvec |
141 | syntax-quote [~@...] — must flatten all parts |
core-every? |
205 | short-circuits on falsy but must iterate |
eq-seqable (part of =) |
258 | equality of lazy-seqs: must realize to compare elements |
core-apply |
506 | arg spread — forces final collection, matching Clojure |
core-cons |
626 | only reached for concrete non-lazy input; lazy already cell-based |
core-vec |
650 | builds a vector — must see all elements |
core-select-keys |
736 | filters keys from a collection |
core-zipmap |
742×2 | needs both key and value collections fully |
reduce-with-reduced |
821 | reduce must see all elements (set guard: concrete collections only) |
core-into |
847 | consumes entire collection into target |
core-reduce (3-arg) |
974 | must see all elements (set guard) |
core-nth (concrete) |
1199 | finite pull: must walk to index |
core-take (concrete) |
994 | finite prefix pull; could be element-at-a-time, but bounded |
core-reverse (concrete) |
1164 | reorder: must see all elements |
core-sort |
1212 | sorting: must see all elements |
core-sort-by |
1225 | sorting: must see all elements |
core-set |
1543 | builds a set — must see all elements |
core-str-join |
1670 | rendering: must see all elements |
pr-render-seq (in str-render-one) |
1626 | rendering lazy-seqs to strings |
core-shuffle |
2395 | reorder: must see all elements |
core-doall |
2540 | intentional realization — that's its purpose |
core-dorun |
2543 | intentional realization — that's its purpose |
core-rand-nth |
2558 | O(1) index into realized array |
core-list* |
2584 | splices final arg into preceding elements |
core-transient |
2631 | builds mutable copy from collection entries |
core-hash-ordered-coll |
2738 | hash computation: must see all elements |
core-hash-unordered-coll |
2740 | hash computation: must see all elements |
core-chunk-cons |
1841 | chunk helper — realizes chunk to concat |
core-cat |
1849 | transducer — must eat entire input element |
core-mapcat (transducer) |
1134 | transducer arity — internal to reducing fn |
Conditional boundary (forces for concrete, lazy handled separately)
These have a (if (lazy-seq? coll) ...) guard. The realize-for-iteration is only reached for concrete collections. Correct pattern.
| Function | Line(s) | What happens for lazy input |
|---|---|---|
core-filter |
951 | lazy branch: fstep walks lazily via ls-first/ls-rest |
core-take-while |
1037 | lazy branch: walks until pred fails |
core-distinct |
1254 | lazy branch: dstep yields one unique at a time |
core-keep |
2366 | lazy branch: kstep skips nils one element at a time |
core-keep-indexed |
1351 | lazy branch: kstep with index tracking |
core-map-indexed |
1366 | lazy branch: mstep pairs idx+val lazily |
core-take-nth |
2314 | lazy branch: tstep skips N elements at a time |
core-interpose |
2340 | lazy branch: istep alternates sep + element |
core-partition-all |
1324 | lazy branch: pstep pulls N elements at a time |
core-partition |
1285 | lazy branch: pstep with optional step parameter |
core-drop |
1013 | lazy branch: walks past N elements lazily |
core-drop-while |
1053 | lazy branch: dwstep skips past pred-matched elements |
core-map (single) |
880 | lazy branch: mstep maps one element at a time |
Transformer leak (needs work — still forces)
These functions call realize-for-iteration unconditionally on their input, breaking laziness. Each has a target Step for resolution.
| Function | Line(s) | Severity | Target Step |
|---|---|---|---|
core-mapcat (collection) |
1141 | HIGH | Step 4 — apply fix needed to avoid forcing core-map result. Currently (apply concat ...) forces via realize-for-iteration. Lazy overlay exists in 10-seq.clj but reverted (compile-mode splice errors). |
core-cycle |
1372 | MED | Must snapshot input to cycle — would need a lazy cycling buffer. Low priority (cycle of finite coll). |
core-partition-by |
1299 | MED | Has no lazy branch yet. Needs Step 2c completion. |
core-xml-seq (Janet) |
2464 | LOW | Overridden by Clojure overlay xml-seq in 20-coll.clj (uses tree-seq). The Janet stub remains for direct Janet-level callers but is rarely hit. Counted in Internal helpers below. |
Interop helpers (context-dependent, keep)
Array/byte conversion helpers that naturally force input.
| Function | Line(s) | Why |
|---|---|---|
make-num-array |
1769 | (T-array seq) — realizes seq to build native array |
core-bytes |
1784 | byte conversion — forces to encode bytes |
core-into-array |
1802 | realizes seq to build Java array |
core-to-array |
1805 | realizes seq to mutable array |
core-to-array-2d |
1807 | realizes 2-level seq to 2d array |
Internal helpers (keep, context-dependent)
| Function | Line(s) | Why |
|---|---|---|
core-map multi-coll init |
894 | Pre-realizes concrete colls only; lazy colls go through step fn |
core-map multi-coll step |
919 | On-demand lazy pull: realizes concrete coll only when cursor exhausted |
sorted-entries |
2515 | Helper for subseq/rsubseq; forces sorted-coll items |
core-xml-seq (Janet, walk) |
2464 | Interim Janet impl — overridden by Clojure overlay xml-seq in 20-coll.clj |
Summary
| Category | Count |
|---|---|
| Boundary (correct) | 31 |
| Conditional boundary (lazy branch exists) | 13 |
| Transformer leak (needs work) | 3 |
| Interop helper (keep) | 5 |
| Internal helper (keep) | 4 |
| Total verified | 56 |
| Leaks remaining | 3 (mapcat, cycle, partition-by) |
Of the 3 leaks:
mapcatis the critical remaining leak — blocked on Step 4applyfix.partition-byandcycleare low-to-medium priority.xml-seqJanet is overridden by the Clojure overlay — effectively resolved; counted in Internal helpers.
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.
3b. Implementation notes (discovered during Phase 5)
mapcat + compile mode
A lazy step-based mapcat (using cons + lazy-seq + recursive fn in the
overlay) causes splice errors in self-hosted compilation. The defrecord macro
in 30-macros.clj uses (vec (mapcat …)) inside syntax-quote, and ~@ cannot
splice lazy-seqs. Reverted to the standard (apply concat (apply map f colls))
implementation. Two possible fixes for the future:
- Fix
applyto spread lazy-seqs without forcing (Step 4 proper) — the root cause. - Rewrite
defrecord's bind-generation to avoidmapcat— replace(vec (mapcat (fn [f] …) fields))with an eagerloopaccumulator.
tree-seq + compile mode
Same root cause as mapcat: lazy tree-seq requires mapcat for
(when (branch? node) (mapcat walk (children node))). Kept eager; lazy version
documented in 20-coll.clj comments. Will switch when mapcat is resolved.
pre-existing: protocol-on-record compile-mode failure
(defprotocol P (m [_])) (defrecord R [side] P (m [_] (* side side))) (m (->R 4))
errors with "Unable to resolve symbol: side" in compile mode. This is a pre-existing
issue unrelated to Phase 5 changes — register-method stores the method body as
a raw fn* form, and the self-hosted compiler cannot resolve let-bound field
access symbols at definition time (bindings only exist at call time).
Conformance wraps this in (= expected (do …)) so it's never triggered; only
direct eval-string with :compile? true hits it. Not blocking — the
self-host path (JOLT_SELFHOST=1) and interpret path both pass.
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). ✓ 18/18 (mapcat infinite deferred — needs Step 4 apply fix)
- §6.3 laziness counters prove minimal realization for every converted transformer. ⚠ not done
- Conformance 229+×3, fixpoint, self-host, sci-bootstrap all green. ✓ (interpret+self-host 229×2; compile 228/229 — 1 pre-existing protocol-on-record error, see §3b)
- 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. ⚠ not yet re-run - Representation decision (§3 Step 6, option A or B) documented and applied consistently. ⚠ deferred
core-benchwithin noise of the Phase-4 baseline. ⚠ not runbd close jolt-c09→ closes thejolt-1j0epic. ⚠ not done (waiting on remaining items)