Chez Phase 2 (inc M): dynamic var binding (jolt-2o7x)

Add the dynamic-binding cluster on the Chez RT: a per-thread binding stack
(host/chez/dyn-binding.ss) backing binding / with-bindings* / var-set /
thread-bound? / with-local-vars / with-redefs / bound-fn* /
get-thread-bindings / alter-var-root + the __local-var seam.

Frames are stored innermost-first as identity-keyed alists of mutable
(cell . value) pairs, so var-set updates the current binding in place. The
two var-read paths — var-deref (compiled code) and jolt-var-get (var-get /
deref on a cell) — are chained onto the stack so a `binding` frame is seen
by every read, with a fast path when the stack is empty. var-cells now hash
by ns/name so a var works as a map key (with-redefs builds (hash-map (var f)
v); get-thread-bindings returns a var-keyed map).

Fix a latent bug exposed once with-in-str could bind *in*: seq? didn't
recognize a lazy-seq. predicates.ss's jolt-seq? predates the lazyseq record,
and unlike the native-op dispatchers it's reached via var-deref, so the
patch must re-def-var! the var, not just set! the top-level binding.

Note: with-bindings* over a hash-map literal now returns the correct Clojure
value where the seed returns a stale one — the seed's PHM can't find a var
key (which is why its `binding` uses array-map); on Chez frames look up by
cell identity.

Prelude corpus parity 1972 -> 2000, floor raised. Gate: _dynbind 24/24,
prelude corpus 0 divergences, full jpm test, conformance 355x3.
This commit is contained in:
Yogthos 2026-06-18 18:29:55 -04:00
parent 737288bff5
commit fc6551f877
5 changed files with 238 additions and 1 deletions

139
host/chez/dyn-binding.ss Normal file
View file

@ -0,0 +1,139 @@
;; dynamic var binding (jolt-2o7x, Phase 2) — binding / with-bindings* / var-set /
;; thread-bound? / with-local-vars / with-redefs / bound-fn* / get-thread-bindings.
;;
;; A per-thread dynamic-binding stack: a list of frames, innermost (most recently
;; pushed) at the HEAD. Each frame is an alist of (var-cell . value) MUTABLE pairs
;; — so var-set can update the innermost binding in place (set-cdr!), matching
;; Clojure where var-set targets the current binding, not the root.
;;
;; The binding macro builds a frame as a jolt map (array-map of (var x) -> value);
;; push-thread-bindings folds it into the alist. Lookups walk frames by cell
;; IDENTITY (eq?) — vars are interned, so (var x) always yields the same cell, and
;; this sidesteps the seed's persistent-hash-map-can't-find-a-var-key quirk.
;;
;; var reads (var-deref in compiled code, jolt-var-get / deref on a cell) consult
;; the stack before falling back to the cell root. Loaded LAST (after vars.ss and
;; ns.ss) so it chains the fully-extended jolt-var-get and overrides rt.ss var-deref.
(define dyn-binding-stack '())
;; find the innermost (cell . value) pair binding CELL, or #f.
(define (dyn-find-binding cell)
(let loop ((frames dyn-binding-stack))
(and (pair? frames)
(or (assq cell (car frames))
(loop (cdr frames))))))
;; a unique sentinel: distinguishes "no thread binding" from a binding whose
;; value happens to be jolt-nil.
(define dyn-no-binding (list 'no-binding))
(define (dyn-binding-value cell)
(if (pair? dyn-binding-stack)
(let ((p (dyn-find-binding cell)))
(if p
(let ((val (cdr p)))
(if (var-cell? val) (jolt-var-get val) val)) ; nested var deref (Clojure)
dyn-no-binding))
dyn-no-binding))
;; push-thread-bindings: frame is a jolt map of var-cell -> value. Fold it into an
;; identity-keyed alist of mutable pairs and push.
(define (jolt-push-thread-bindings frame)
(set! dyn-binding-stack
(cons (pmap-fold frame (lambda (k v acc) (cons (cons k v) acc)) '())
dyn-binding-stack))
jolt-nil)
(define (jolt-pop-thread-bindings)
(when (pair? dyn-binding-stack)
(set! dyn-binding-stack (cdr dyn-binding-stack)))
jolt-nil)
;; get-thread-bindings: a jolt map of every currently-bound cell -> value,
;; innermost wins. Merge oldest-frame-first (the stack head is innermost). The
;; result can be re-pushed by with-bindings* / bound-fn*.
(define (jolt-get-thread-bindings)
(let loop ((frames (reverse dyn-binding-stack)) (m (jolt-hash-map)))
(if (null? frames)
m
(loop (cdr frames)
(let frame-loop ((alist (car frames)) (m m))
(if (null? alist)
m
(frame-loop (cdr alist)
(pmap-assoc m (caar alist) (cdar alist)))))))))
;; __thread-bound? — single var; true iff it has a thread binding.
(define (jolt-thread-bound? v)
(and (var-cell? v) (dyn-find-binding v) #t))
;; var-set: update the innermost frame that binds v (in place); else set the root.
(define (jolt-var-set v val)
(if (var-cell? v)
(let ((p (dyn-find-binding v)))
(if p
(begin (set-cdr! p val) val)
(begin (var-cell-root-set! v val) (var-cell-defined?-set! v #t) val)))
(error #f "var-set: not a var" v)))
;; alter-var-root: atomically apply f to the current root plus args.
(define (jolt-alter-var-root v f . args)
(let ((new (apply jolt-invoke f (var-cell-root v) args)))
(var-cell-root-set! v new)
(var-cell-defined?-set! v #t)
new))
;; __local-var: a fresh free-standing var cell (not interned). with-local-vars
;; binds these as lexical locals; var-get/var-set read/write the root. Each gets a
;; unique name so two locals never compare/hash equal as map keys.
(define local-var-counter 0)
(define (jolt-local-var . args)
(set! local-var-counter (fx+ local-var-counter 1))
(make-var-cell "" (string-append "local-" (number->string local-var-counter))
(if (pair? args) (car args) jolt-nil)
#t))
;; --- chain the var-read paths onto the binding stack -------------------------
;; var-deref (rt.ss): the compiled-code read path for every clojure.core var
;; reference. Consult the stack first; fall straight back to the root (NOT through
;; jolt-var-get's unbound-error path) so undefined-var reads keep prior behaviour.
(define %dyn-rt-var-deref var-deref)
(set! var-deref
(lambda (ns name)
(let ((cell (jolt-var ns name)))
(let ((bv (dyn-binding-value cell)))
(if (eq? bv dyn-no-binding) (var-cell-root cell) bv)))))
;; jolt-var-get (vars.ss): the var-get fn + deref/@ on a cell. Stack first, then
;; the original (which errors on an unbound root, matching Clojure).
(define %dyn-var-get jolt-var-get)
(set! jolt-var-get
(lambda (v)
(if (var-cell? v)
(let ((bv (dyn-binding-value v)))
(if (eq? bv dyn-no-binding) (%dyn-var-get v) bv))
(%dyn-var-get v))))
;; var-cell keys hash/compare by ns/name (jolt=2 in vars.ss already compares
;; ns/name) — stable under root mutation, so a var works as a map key (with-redefs
;; builds (hash-map (var f) v); get-thread-bindings returns a var-keyed map).
(define %dyn-hash jolt-hash)
(set! jolt-hash
(lambda (x)
(if (var-cell? x)
(equal-hash (cons (var-cell-ns x) (var-cell-name x)))
(%dyn-hash x))))
;; --- bind the host seams the overlay references -----------------------------
(def-var! "clojure.core" "push-thread-bindings" jolt-push-thread-bindings)
(def-var! "clojure.core" "pop-thread-bindings" jolt-pop-thread-bindings)
(def-var! "clojure.core" "get-thread-bindings" jolt-get-thread-bindings)
(def-var! "clojure.core" "__thread-bound?" jolt-thread-bound?)
(def-var! "clojure.core" "var-set" jolt-var-set)
(def-var! "clojure.core" "alter-var-root" jolt-alter-var-root)
(def-var! "clojure.core" "__local-var" jolt-local-var)
;; re-assert var-get / deref to the new (stack-aware) closures (vars.ss captured
;; the pre-chain values).
(def-var! "clojure.core" "var-get" jolt-var-get)
(def-var! "clojure.core" "deref" jolt-deref)

View file

@ -74,5 +74,14 @@
(define %ls-str-render-one jolt-str-render-one)
(set! jolt-str-render-one (lambda (x) (if (jolt-lazyseq? x) (%ls-str-render-one (jolt-seq x)) (%ls-str-render-one x))))
;; 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
;; reference, so the set! is enough), seq? is reached through var-deref, which
;; reads the var-cell root — so the patched closure must be re-def-var!'d, not just
;; set!. (Exposed once dynamic binding let with-in-str/line-seq reach seq?.)
(define %ls-seq? jolt-seq?)
(set! jolt-seq? (lambda (x) (or (jolt-lazyseq? x) (%ls-seq? x))))
(def-var! "clojure.core" "seq?" jolt-seq?)
(def-var! "clojure.core" "make-lazy-seq" jolt-make-lazy-seq)
(def-var! "clojure.core" "coll->cells" jolt-coll->cells)

View file

@ -258,3 +258,10 @@
;; var-cell + var-cell-defined?, jolt-symbol/jolt-hash-map/jolt-assoc, chez-current-ns
;; (multimethods.ss), list->cseq (seq.ss), and the fully-patched printers (vars.ss).
(load "host/chez/ns.ss")
;; dynamic var binding (jolt-2o7x, Phase 2): the per-thread binding stack +
;; push/pop/get-thread-bindings/__thread-bound?/var-set/alter-var-root/__local-var.
;; Chains var-deref (rt.ss) and jolt-var-get (vars.ss) onto the stack, so a `binding`
;; frame is seen by every var read. Loaded LAST: needs the fully-extended var-read
;; paths + jolt-hash-map/pmap-fold/pmap-assoc (collections.ss).
(load "host/chez/dyn-binding.ss")