From 448611a5df95942c9e7a5691555f76f7260b8eb0 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 26 Jun 2026 19:41:02 -0400 Subject: [PATCH] Match Clojure's lazy seq realization model jolt's seq layer realized one element ahead of Clojure, so a side-effecting lazy seq ran its producer too eagerly. Four changes bring it in line: - rest is Clojure's more(): it returns the tail without realizing it. An unforced tail (vector / string / lazy-seq cell) comes back as a deferred seq, so (rest (iterate f x)) does not call f. next still realizes one. - iterate applies f lazily, inside the tail thunk, so (first (iterate f x)) is x with no call to f (clojure.lang.Iterate parity). - take realizes exactly n: the last element terminates without touching the rest, instead of forcing one more element of the source. - an empty realized lazy seq is still a sequence value, printing "()" not "nil" (a JVM LazySeq is never nil). Also: the map transducer's step fn now takes multiple inputs ([result input & inputs]) so a multi-collection transduce applies f across all of them. Fixes medley's join/window/sequence-padded laziness and multi-input transducer tests (now 293/293). The rest change also fixed a latent overrun in distinct/dedupe over a map's empty tail. iterate is a seed source, re-minted. --- host/chez/lazy-bridge.ss | 13 +++++++---- host/chez/natives-seq.ss | 5 ++++- host/chez/seed/prelude.ss | 2 +- host/chez/seq.ss | 35 +++++++++++++++++++++++++----- jolt-core/clojure/core/40-lazy.clj | 6 ++++- test/chez/corpus.edn | 8 +++++++ 6 files changed, 57 insertions(+), 12 deletions(-) diff --git a/host/chez/lazy-bridge.ss b/host/chez/lazy-bridge.ss index 86ed2df..acd7dd9 100644 --- a/host/chez/lazy-bridge.ss +++ b/host/chez/lazy-bridge.ss @@ -65,10 +65,15 @@ (set! jolt-nth (case-lambda ((coll i) (if (jolt-lazyseq? coll) (%ls-nth (jolt-seq coll) i) (%ls-nth coll i))) ((coll i d) (if (jolt-lazyseq? coll) (%ls-nth (jolt-seq coll) i d) (%ls-nth coll i d))))) -;; a lazy seq prints as its realized seq — force, then re-dispatch through the printer. -(register-pr-str-arm! jolt-lazyseq? (lambda (x) (jolt-pr-str (jolt-seq x)))) -(register-pr-readable-arm! jolt-lazyseq? (lambda (x) (jolt-pr-readable (jolt-seq x)))) -(register-str-render! jolt-lazyseq? (lambda (x) (jolt-str-render-one (jolt-seq x)))) +;; a lazy seq prints as its realized seq — force, then re-dispatch through the +;; printer. An empty realized lazy seq is still a sequence, printing "()" (like a +;; JVM LazySeq), not "nil" — so (lazy-seq nil) and (rest '(1)) render "()". +(register-pr-str-arm! jolt-lazyseq? + (lambda (x) (let ((s (jolt-seq x))) (if (jolt-nil? s) "()" (jolt-pr-str s))))) +(register-pr-readable-arm! jolt-lazyseq? + (lambda (x) (let ((s (jolt-seq x))) (if (jolt-nil? s) "()" (jolt-pr-readable s))))) +(register-str-render! jolt-lazyseq? + (lambda (x) (let ((s (jolt-seq x))) (if (jolt-nil? s) "()" (jolt-str-render-one s))))) ;; seq? — a lazy seq IS a seq (predicates.ss's jolt-seq? predates the lazyseq ;; record). Unlike the native-op dispatchers above (called via a direct top-level diff --git a/host/chez/natives-seq.ss b/host/chez/natives-seq.ss index d4a76b5..d79a6f3 100644 --- a/host/chez/natives-seq.ss +++ b/host/chez/natives-seq.ss @@ -17,13 +17,16 @@ ;; call routes through jolt-invoke. A `reduced` step stops the fold — reduce-seq ;; (seq.ss) already short-circuits on a jolt-reduced. ;; ============================================================================ +;; The map transducer's step fn supports multiple inputs ([result input & inputs]), +;; so a multi-collection sequence/transduce — or medley's sequence-padded, which +;; calls (f acc i1 i2 …) — applies f across all of them: (rf result (apply f inputs)). (define (td-map f) (lambda (rf) (lambda a (case (length a) ((0) (jolt-invoke rf)) ((1) (jolt-invoke rf (car a))) - (else (jolt-invoke rf (car a) (jolt-invoke f (cadr a)))))))) + (else (jolt-invoke rf (car a) (apply jolt-invoke f (cdr a)))))))) (define (td-filter pred) (lambda (rf) (lambda a diff --git a/host/chez/seed/prelude.ss b/host/chez/seed/prelude.ss index bf61667..d9cbc5c 100644 --- a/host/chez/seed/prelude.ss +++ b/host/chez/seed/prelude.ss @@ -933,7 +933,7 @@ (guard (e (#t #f)) (def-var! "clojure.core" "repeat" (letrec ((repeat (case-lambda ((x) (let fnrec2319 ((x x)) (jolt-invoke (var-deref "clojure.core" "make-lazy-seq") (lambda () (let fnrec2320 () (jolt-invoke (var-deref "clojure.core" "coll->cells") (jolt-cons x (repeat x)))))))) ((n x) (let fnrec2321 ((n n) (x x)) (jolt-take n (repeat x))))))) repeat))) (guard (e (#t #f)) - (def-var! "clojure.core" "iterate" (letrec ((iterate (lambda (f x) (let fnrec2322 ((f f) (x x)) (jolt-invoke (var-deref "clojure.core" "make-lazy-seq") (lambda () (let fnrec2323 () (jolt-invoke (var-deref "clojure.core" "coll->cells") (jolt-cons x (iterate f (jolt-invoke f x))))))))))) iterate))) + (def-var! "clojure.core" "iterate" (letrec ((iterate (lambda (f x) (let fnrec2322 ((f f) (x x)) (jolt-cons x (jolt-invoke (var-deref "clojure.core" "make-lazy-seq") (lambda () (let fnrec2323 () (jolt-invoke (var-deref "clojure.core" "coll->cells") (iterate f (jolt-invoke f x))))))))))) iterate))) (guard (e (#t #f)) (def-var! "clojure.core" "partition-all" (letrec ((partition-all (case-lambda ((n) (let fnrec2324 ((n n)) (lambda (rf) (let fnrec2325 ((rf rf)) (let* ((a (jolt-invoke (var-deref "clojure.core" "volatile!") (jolt-vector)))) (case-lambda (() (let fnrec2326 () (jolt-invoke rf))) ((result) (let fnrec2327 ((result result)) (let* ((result (if (jolt-zero? (jolt-count (jolt-invoke (var-deref "clojure.core" "deref") a))) result (let* ((v (jolt-invoke (var-deref "clojure.core" "deref") a))) (begin (jolt-invoke (var-deref "clojure.core" "vreset!") a (jolt-vector)) (jolt-invoke (var-deref "clojure.core" "unreduced") (jolt-invoke rf result v))))))) (jolt-invoke rf result)))) ((result input) (let fnrec2328 ((result result) (input input)) (begin (jolt-invoke (var-deref "clojure.core" "vswap!") a jolt-conj input) (if (jolt= n (jolt-count (jolt-invoke (var-deref "clojure.core" "deref") a))) (let* ((v (jolt-invoke (var-deref "clojure.core" "deref") a))) (begin (jolt-invoke (var-deref "clojure.core" "vreset!") a (jolt-vector)) (jolt-invoke rf result v))) result)))))))))) ((n coll) (let fnrec2329 ((n n) (coll coll)) (letrec* ((go (letrec ((go (lambda (s) (let fnrec2330 ((s s)) (jolt-invoke (var-deref "clojure.core" "make-lazy-seq") (lambda () (let fnrec2331 () (jolt-invoke (var-deref "clojure.core" "coll->cells") (if (jolt-truthy? (jolt-seq s)) (let* ((i 0) (acc (jolt-list )) (cur s)) (let loop2332 ((i i) (acc acc) (cur cur)) (if (jolt-truthy? (let* ((and__25__auto (< i n))) (if (jolt-truthy? and__25__auto) (jolt-seq cur) and__25__auto))) (let* ((_a$2333 (jolt-inc i)) (_a$2334 (jolt-cons (jolt-first cur) acc)) (_a$2335 (jolt-rest cur))) (loop2332 _a$2333 _a$2334 _a$2335)) (let* ((_a$2336 (jolt-reverse acc)) (_a$2337 (go cur))) (jolt-cons _a$2336 _a$2337))))) jolt-nil))))))))) go))) (jolt-invoke go coll)))) ((n step coll) (let fnrec2338 ((n n) (step step) (coll coll)) (letrec* ((go (letrec ((go (lambda (s) (let fnrec2339 ((s s)) (jolt-invoke (var-deref "clojure.core" "make-lazy-seq") (lambda () (let fnrec2340 () (jolt-invoke (var-deref "clojure.core" "coll->cells") (if (jolt-truthy? (jolt-seq s)) (let* ((_a$2341 (jolt-take n s)) (_a$2342 (go (jolt-invoke (var-deref "clojure.core" "nthrest") s step)))) (jolt-cons _a$2341 _a$2342)) jolt-nil))))))))) go))) (jolt-invoke go coll))))))) partition-all))) (guard (e (#t #f)) diff --git a/host/chez/seq.ss b/host/chez/seq.ss index 4cbceb2..7ac020a 100644 --- a/host/chez/seq.ss +++ b/host/chez/seq.ss @@ -85,10 +85,25 @@ ;; the seq leaf ops the emitter lowers core fns to ;; ============================================================================ (define (jolt-first x) (let ((s (jolt-seq x))) (if (jolt-nil? s) jolt-nil (seq-first s)))) -(define (jolt-rest x) ; () when the seq has 0/1 elements (NOT nil) +;; rest = Clojure's more(): the tail as a (possibly empty) seq, NOT nil, and +;; WITHOUT realizing it. A forced cseq (list / realized chain) hands back its tail +;; directly. An UNFORCED tail (vector / string / lazy-seq cell) is returned as a +;; deferred seq so (rest s) does not realize the next node — matching Clojure, +;; where (rest (iterate f x)) does not call f and a side-effecting lazy seq is +;; realized one element at a time. next = (seq (rest s)) still realizes one. +;; jolt-make-lazy-seq (lazy-bridge.ss) resolves at call time. +(define (jolt-rest x) (let ((s (jolt-seq x))) - (if (jolt-nil? s) jolt-empty-list - (let ((m (seq-more s))) (if (jolt-nil? m) jolt-empty-list m))))) + (cond + ((jolt-nil? s) jolt-empty-list) + ((cseq-forced? s) (let ((m (cseq-tail s))) (if (jolt-nil? m) jolt-empty-list m))) + ;; the lazyseq forces to a seq (cseq | nil); an empty realized lazyseq is + ;; still a sequence value, printing "()" (see lazy-bridge.ss), so (rest s) + ;; is never nil even when the tail is empty. jolt-seq coerces seq-more's + ;; result (which may be jolt-empty-list, e.g. map's tail) back to cseq | nil, + ;; the contract force-lazyseq relies on — else (seq (rest s)) of an empty + ;; tail yields a truthy empty-list and walkers (distinct, dedupe) overrun. + (else (jolt-make-lazy-seq (lambda () (jolt-seq (seq-more s)))))))) (define (jolt-next x) ; nil when the rest is empty ;; next = (seq (rest x)): the rest must be RE-SEQ'd so an empty tail collapses to ;; nil. seq-more on a lazy seq (e.g. map's) forces to jolt-empty-list, which is @@ -246,11 +261,21 @@ ((start end) (range-bounded start end 1)) ((start end step) (range-bounded start end step)))) +;; An empty take result is () (jolt-empty-list), NOT nil — (take 0 coll) and +;; (take n []) are empty seqs in Clojure, so (= () (take 0 [:a])) and printing +;; "()" hold. jolt-empty-list seqs back to nil, so it also terminates the lazy +;; tail when n hits 0 mid-stream (see map-seq). +;; The LAST element (n=1) terminates without touching the rest, so (take n s) +;; realizes exactly n elements of a side-effecting seq — matching Clojure, where +;; (take 0 (rest s)) never seqs coll. Realizing one more, as forcing seq-more at +;; the boundary would, over-runs the source by one (medley's sequence-padded). (define (jolt-take n coll) (let ((n (->idx n))) (let loop ((n n) (s (jolt-seq coll))) - (if (or (fx<=? n 0) (jolt-nil? s)) jolt-nil - (cseq-lazy (seq-first s) (lambda () (loop (fx- n 1) (jolt-seq (seq-more s))))))))) + (cond + ((or (fx<=? n 0) (jolt-nil? s)) jolt-empty-list) + ((fx=? n 1) (cseq-lazy (seq-first s) (lambda () jolt-empty-list))) + (else (cseq-lazy (seq-first s) (lambda () (loop (fx- n 1) (jolt-seq (seq-more s)))))))))) (define (jolt-drop n coll) (let loop ((n (->idx n)) (s (jolt-seq coll))) (if (or (fx<=? n 0) (jolt-nil? s)) (if (jolt-nil? s) jolt-empty-list s) diff --git a/jolt-core/clojure/core/40-lazy.clj b/jolt-core/clojure/core/40-lazy.clj index 1e974956..880fb48 100644 --- a/jolt-core/clojure/core/40-lazy.clj +++ b/jolt-core/clojure/core/40-lazy.clj @@ -96,8 +96,12 @@ ([n x] (take n (repeat x)))) ;; --- iterate --- +;; f is applied lazily, inside the tail thunk — (first (iterate f x)) is x with no +;; call to f, matching clojure.lang.Iterate. Wrapping the whole body in lazy-seq +;; instead would force (f x) the moment the head realizes (it is an eager argument +;; to cons), realizing one step ahead. (defn iterate [f x] - (lazy-seq (cons x (iterate f (f x))))) + (cons x (lazy-seq (iterate f (f x))))) ;; --- partition-all --- (transducer + [n coll] + [n step coll]) diff --git a/test/chez/corpus.edn b/test/chez/corpus.edn index 649d819..da134d4 100644 --- a/test/chez/corpus.edn +++ b/test/chez/corpus.edn @@ -3261,4 +3261,12 @@ {:suite "printer / print-level" :label "scalars print regardless of level" :expected "true" :actual "(= \"[1 2]\" (binding [*print-level* 1] (pr-str [1 2])))"} {:suite "reader / default-data-reader-fn" :label "consulted for an unregistered tag" :expected "true" :actual "(= [(quote foo) 42] (binding [*default-data-reader-fn* (fn [tag v] [tag v])] (read-string \"#foo 42\")))"} {:suite "printer / print-vars" :label "print-length / print-level / default-data-reader-fn default to nil" :expected "[true true true]" :actual "[(nil? *print-length*) (nil? *print-level*) (nil? *default-data-reader-fn*)]"} + {:suite "seqs / laziness" :label "an empty lazy seq is () not nil" :expected "true" :actual "(= () (lazy-seq nil))"} + {:suite "seqs / laziness" :label "rest of a one-element coll is ()" :expected "true" :actual "(= () (rest [1]))"} + {:suite "seqs / laziness" :label "first of iterate does not call f" :expected "true" :actual "(let [a (atom 0)] (first (iterate (fn [x] (swap! a inc) (inc x)) 0)) (= 0 @a))"} + {:suite "seqs / laziness" :label "rest of iterate does not realize the next element" :expected "true" :actual "(let [a (atom 0)] (rest (iterate (fn [x] (swap! a inc) (inc x)) 0)) (= 0 @a))"} + {:suite "seqs / laziness" :label "take from an infinite iterate" :expected "[0 1 2 3 4]" :actual "(vec (take 5 (iterate inc 0)))"} + {:suite "seqs / laziness" :label "distinct over a rest-derived seq does not overrun" :expected "2" :actual "(count (distinct (map inc (rest [10 20 30]))))"} + {:suite "transducers / map multi-input" :label "the map transducer applies f across all inputs" :expected "[6]" :actual "(((map +) conj) [] 1 2 3)"} + {:suite "transducers / map multi-input" :label "single input is unchanged" :expected "[3]" :actual "(((map +) conj) [] 3)"} ]