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.
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 vectorscoerceto arbitrary types- Generalized
setf(oncar,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") loop→cl-loop(reserved-name avoidance)when→cl-when(plainwhenis a void-returning special form)random→random-int(lumbda's RNG)&key→&optional(positional args only in the shim)- omit
rhoanddigits(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:
defun→definesetfon simple var →set!(existing lumbda capability)flet→let+lambdacl-loop→let*+ 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
rhois a list; adjustable vectors would need new portal serialization and are deliberately out of scope). - Cross-impl reproducibility —
cl-compat.lspis 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 intomake test-allvia targetzoe-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 teststests/ursa.lsp— Zoë-favorites acceptance teststests/zoe-favorites-test.sh— test runnerc/types.c— env_lookup defect fix