lumbda/docs/tickets/0004-cl-compat.md
russell@unturf.com 192118388f cl-compat: run Zoë Trout's favorites unchanged (ticket 0004)
Zoë Trout's favorites at wedgewack.org/ursa.lisp.txt are Common Lisp:
iterative LOOP macros, setf cascades, defun with &optional, image-
based stone-lisp culture. Her first contribution to lumbda was a
question — "do we care for our programs, and how long are they alive
for?" — and the answer now extends beyond the RNG portal (§7.5) to
iteration style itself.

Four-phase delivery, all under ticket 0004:

  Phase A — idiomatic Scheme ports at examples/ursa-scheme.lsp.
    Every Zoë defun rewritten as named-let + tail recursion + list-
    backed work queue + type-predicate dispatch.

  Phase B — CL compat shim at cl-compat.lsp.
    defun (with &optional), setf (simple vars, multi-pair), flet,
    multiple-value-bind, t / nil (nil=#f so cond/if compose),
    evenp/oddp/plusp/minusp/zerop, mod/ash/logbitp/nreverse,
    cl-when/cl-unless (plain when is a void-returning lumbda special
    form), declare (no-op), cddddr (missing accessor).

  Phase C — cl-loop macro covering 14 patterns.
    while/until/repeat, for VAR from A to/below/downto B, for VAR =
    INIT [then STEP], for VAR across VEC, of-type T, do, when/unless
    return, finally (return VAL). Sequential do*-style stepping via
    gensym + cl-subst. Look-ahead termination so `repeat 4 for s = 4
    then (- (* s s) 2) finally (return s)` returns 37634 (pre-step)
    rather than 1416317954 (post-step). Every expansion ends in a
    named-let tail call — TCO holds for loops of any length.

  Phase D — load examples/ursa.lisp.txt with minimal annotation.
    Preserves Zoë's CL. Minimal edits documented in file header:
    load cl-compat.lsp, loop→cl-loop, when→cl-when, random→random-int,
    &key→&optional. rho/digits omitted (need make-array/CLOS — see
    ticket 0004 for scope boundary).

Defect uncovered along the way (c/types.c env_lookup): a "global
shortcut" checked global env immediately after missing the local
frame, SKIPPING intermediate parent scopes. Broke lexical scoping
whenever a parent scope shadowed a global. Reproduced with
  (define s 4)
  (let ((s 100)) (let ((m 0)) s))  ; returned 4, should return 100
Any nested let whose body referenced a shadowed name silently read
the global. Fix: remove the shortcut, walk the parent chain end-to-
end. 1255 assertions across five suites pass unchanged after fix —
surfaced only because cl-loop iterator names routinely collide with
globals accumulated in a stone-lisp image.

Whitepaper §9.2 documents the CL-in-Scheme design and the guarantees
that survive (TCO, portal determinism, cross-impl reproducibility).
Zoë added to authors + acknowledgments; reacknowledgment reframes
her first contribution as the deeper program-lifetime question, with
RNG portal as a derivative (§7.5) and cl-loop as the follow-up.

Tests: tests/cl-compat.lsp (44 assertions) and tests/ursa.lsp (28
assertions) exercise both paths under Python + C via tests/zoe-
favorites-test.sh, wired into make test-all.

MOAD notes: unmoad flags memq/assq in cl-compat.lsp over cl-loop-
keywords (~30 elements, constant) and var->new (≤4 state vars per
loop). Both are macro-expansion-time, bounded-small-N — not runtime
hot paths. Pre-existing c/types.c findings (strcmp-in-loop for
record-type lookup) are not from this change.
2026-04-24 07:02:21 -04:00

7.9 KiB

0004 — Common Lisp compatibility for Zoë Trout's favorites

Status: resolved Reporter: Zoë Trout (via fox) Implementer: blackops Opened: 2026-04-23 Resolved: 2026-04-24

Problem

Zoë Trout asked a question most systems people never think to ask: do we care for our programs, and how long are they alive for? Common Lisp culture — stone-lisp, image-based development — produces processes that outlive the developer's attention by days, months, sometimes years. Killing them is almost never on purpose.

Her favorite programs (https://wedgewack.org/ursa.lisp.txt) carry that DNA. They lean on CL's loop macro for iteration, setf for mutation, defun with &optional / &key, defgeneric/defmethod for type dispatch, and dynamic arrays (make-array :adjustable :fill-pointer, vector-push-extend, vector-pop) for work queues.

None of these exist in Scheme. A strict port would require Zoë to rewrite every iterative loop as tail-recursion, every setf as a set! cascade, every defgeneric as a cond by type. That strips her programs of the shape she likes.

Goal: keep Zoë's iterative style intact while running her code on lumbda, without compromising the guarantees lumbda already carries (TCO, portal determinism, cross-impl reproducibility).

Scope — four phases

Phase A — idiomatic Scheme ports

Ship examples/ursa-scheme.lsp: every Zoë defun rewritten as named- let + tail recursion + list-backed stacks + type-predicate dispatch. Keeps downstream lumbda machinery pristine. Tests verify correctness against known primes (Mersenne M₃, M₅, M₇, M₁₃), known factorizations (12, 1001, 97), and digit round-trips.

Phase B — CL compat shim (cl-compat.lsp)

A loadable file that provides CL spellings on top of lumbda primitives:

CL form Expansion Notes
defun define macro supports &optional (var default)
setf set! cascade simple variables only; multi-pair supported
flet let + lambda local function binding
multiple-value-bind call-with-values built on lumbda's values
declare no-op compile-time directives ignored
t, nil #t, #f nil = #f (not '()) so cond/if work
evenp, oddp, plusp, minusp, zerop aliases CL -p → R5RS ?
mod, ash, logbitp, nreverse aliases / derived bit ops derived from expt 2 + odd?
cl-when, cl-unless if with #f on false plain when/unless in lumbda are special forms returning #<void>, which is truthy in Scheme — cl-when returns #f so CL "nil on false" idioms compose with Scheme cond/if

Not supported (see Phase A for hand-ports):

  • &key / &rest / &aux / &body (only &optional)
  • defgeneric / defmethod (CLOS)
  • make-array :adjustable :fill-pointer, vector-push-extend, vector-pop, fill-pointer (dynamic arrays)
  • sbit / bit vectors
  • coerce to arbitrary types
  • Generalized setf (on car, vector-ref, etc.)

Phase C — cl-loop macro

define-macro implementation of a CL LOOP subset, covering every pattern used in ursa.lisp.txt:

Clause Example
with VAR = INIT pre-loop binding
for VAR from A to B ascending range, inclusive
for VAR from A below B ascending range, exclusive
for VAR from A downto B descending range, inclusive
for VAR = INIT re-evaluated each iter
for VAR = INIT then STEP do*-style sequential stepping
for VAR across VEC vector traversal by index
for VAR of-type T type hint ignored (same as for VAR)
while TEST pre-body continuation test
until TEST pre-body termination test
repeat N counted iterations (look-ahead termination)
do EXPR ... body forms
when TEST return VAL mid-loop early exit
unless TEST return VAL mid-loop early exit (inverted)
finally (return EXPR) final value expression
finally EXPR ... final body (no return value)

Every expansion ends in a tail call to a named-let, preserving TCO.

The macro does look-ahead termination for repeat and for ... from ... to/below/downto: after the body runs, we compute new step values into fresh gensym bindings, check termination against those, and either recurse with new values or invoke the finalizer with CURRENT (unstepped) state. This matches CL's "would step past the limit" semantics and matters for patterns like (loop repeat 4 for s = 4 then (- (* s s) 2) finally (return s)) — terminates with s = 37634 after 4 body-runs, not s = 1416317954 after 5.

Phase D — load ursa.lisp.txt with minimal annotations

examples/ursa.lisp.txt preserves Zoë's CL. Minimal edits documented in its header:

  • prepend (load "cl-compat.lsp")
  • loopcl-loop (reserved-name avoidance)
  • whencl-when (plain when is a void-returning special form)
  • randomrandom-int (lumbda's RNG)
  • &key&optional (positional args only in the shim)
  • omit rho and digits (need make-array / CLOS — see Phase A)

Every other line — every loop for ... from ... to, every setf a 1 b 2 c 3, every declare (optimize ...) — loads and runs unchanged.

Guarantees — what survives

Every shim is syntactic sugar over lumbda's existing functional core. After macroexpansion, all code becomes standard Scheme:

  • defundefine
  • setf on simple var → set! (existing lumbda capability)
  • fletlet + lambda
  • cl-looplet* + named-let + cond + tail recursion

Therefore:

  • TCO — every emitted form terminates in a named-let tail call. Stack stays flat for loops of any length.
  • Portal determinism — no new mutable types introduced (the queue in rho is a list; adjustable vectors would need new portal serialization and are deliberately out of scope).
  • Cross-impl reproducibilitycl-compat.lsp is pure Scheme and loads identically in Python + C. Every Zoë defun runs bit-for-bit identically across tiers (within each tier's integer domain — see ticket 0003).

Defect fixed along the way

Building Phase C surfaced a real lumbda-C defect: env_lookup had a "global shortcut" that checked the global env immediately after missing the local frame, SKIPPING intermediate parent scopes. For code like:

(define s 4)
(let ((s 100))
  (let ((m 0))
    s))   ; returned 4 (global) instead of 100 (outer let)

the inner let body resolved s to the global binding because the shortcut fired before the parent chain was walked. Any code that shadowed a global inside a let, then opened another let whose body referenced the shadowed name, silently read the global instead. This broke cl-loop whenever a state var name collided with a global — common under the stone-lisp development pattern that Zoë's culture leans on.

Fix: remove the global shortcut; walk the parent chain end-to-end. Complexity is O(scope-depth), bounded by code structure not runtime. Full test suite (571 + 83 + 137 + 196 + 196 + 12 + 60 = 1255 assertions) passes unchanged.

File: c/types.c. Commit lands in same drop as this ticket.

Test strategy

  • tests/cl-compat.lsp — 44 assertions covering every compat shim and 13 cl-loop patterns. Runs in Python + C.
  • tests/ursa.lsp — 28 assertions covering Scheme port (15) and CL-via-shim (13) side-by-side. Runs in Python + C.
  • tests/zoe-favorites-test.sh — bundles both above, wired into make test-all via target zoe-favorites-test.

Victory condition: make test-all green; Zoë's original CL programs produce matching answers across Python, C, and the Scheme port path.

Files

  • cl-compat.lsp — compat shim (~240 lines)
  • examples/ursa.lisp.txt — Zoë's CL (minimally annotated)
  • examples/ursa-scheme.lsp — idiomatic Scheme ports (Phase A)
  • tests/cl-compat.lsp — shim acceptance tests
  • tests/ursa.lsp — Zoë-favorites acceptance tests
  • tests/zoe-favorites-test.sh — test runner
  • c/types.c — env_lookup defect fix