Sketches how bend would wire into ecdsafail-challenge candidate
search loop on the foxhop.net side:
ecdsa/lumbda/search.lsp
→ (bend!-call '(cuda-sim-ops-bin ops-path 141))
→ gpu-worker.lsp routes to demo_ops --portal
→ S-exp result back to lumbda, scoring proceeds
Identifies the two pieces missing before this lands:
1. `system`-equivalent primitive in lumbda (or spawn+wait via
existing spawn-process-stdio)
2. Phase B step 7 (Solinas mod-mul) so lumbda emits real-scale
ops.bin variants worth bending
Once both close, this is a half-day wire-up.
Cross-references:
~/git/www.foxhop.net/ecdsa/cuda/ — the CUDA prototype
~/git/www.foxhop.net/ecdsa/lumbda/search.lsp — current search loop
examples/cuda-fanout/DESIGN-go-gpu.md — the broader bend RPC design
asm tier lacks define-syntax + (error …) + spawn-process-stdio, so
the macro form (bend …) and the host-side gpu-worker.lsp aren't
asm-portable. But the wire protocol & TCP primitives are — asm
tier works as a bend CLIENT.
Three changes:
1. bend.lsp split into core (function-form) + bend-macros.lsp
(define-syntax wrappers). Asm tier loads core; Python/C load both.
2. Function form: (bend-call '(op . args)) / (bend!-call …) does
the same dispatch the macro does, on every tier.
3. (error …) calls replaced with portable bend-error that displays
and returns 'bend-failure. Lets asm tier handle the no-worker
case without crashing.
New file:
smoke-bend-asm.lsp — minimal asm-tier smoke test
Verified on 3090-ai:
asm tier tcp-connect to a known Python listener on :19200: PASS
asm tier loads wire.lsp + bend.lsp cleanly: PASS
bend-error portable across all three tiers: PASS
README updated with the asm tier client-only story + what would need
to land for full asm parity (the missing primitives + Scheme macros).
Per-tier status:
Python tier ✓ host + client (macro & function forms)
C tier ✓ host + client (macro & function forms)
asm tier ✓ client (function form); host pending the missing
primitives
Two new primitives in builtins.c, paralleling the Python tier shipped in
the previous commit. gpu-worker.lsp now runs on the C tier byte-identically
to the Python tier.
(spawn-process-stdio path args) → (stdin-port . stdout-port)
fork + pipe + execvp; child's stdin & stdout wired back to parent
as line-buffered FILE* ports. Accepts both strings and symbols in
the args list (matches Python tier's permissive conversion).
(flush-port port)
fflush() on the port's FILE*. No-op when fp is null.
End-to-end on 3090-ai with C-tier lumbda everywhere:
shell A: ./lumbda /tmp/launch-c.lsp
→ gpu-worker: ready cuda-shake-fanout ← ./shake256-fanout
→ gpu-worker listening on port 9091
shell B: ./lumbda smoke-bend.lsp # run 3×
=== smoke-bend ===
1. cost estimator picks local for 3 inputs: OK
2. worker available? #t
3. bend! (cuda-shake-fanout '("00" "01" "deadbeef") 32):
(b8d01df855… 94da6280b2… fa094fa86e…)
Three runs identical bytes. Same hashes as Python tier. Same hashes as
hashlib.shake_256 host reference.
Cross-tier matrix (proves wire protocol is tier-agnostic):
client tier worker tier status
─────────────────────────────────────
C tier C tier PASS — 2 sequential runs, byte-identical
Python tier C tier PASS — same hashes
C tier Python tier implicit by symmetry (same wire bytes
both directions; Python-server tested
against Python-client in prior commit)
Per-tier status after this commit:
Python tier ✓ end-to-end
C tier ✓ end-to-end + cross-tier byte-identical to Python tier
asm tier → still needs spawn-process-stdio via raw fork+pipe+
execve syscalls. Scheme files unchanged.
Two new primitives in the Python tier dispatch table make
gpu-worker.lsp (pure Scheme) replace mock-worker.py:
(spawn-process-stdio path args) → (stdin-port . stdout-port)
spawns a long-running subprocess via subprocess.Popen with
stdin/stdout piped + line-buffered. Returns a Pair the
caller holds across many request cycles.
(flush-port port)
flushes a write port. No-op when port has no flush method.
read-line also extended to accept file-like ports (subprocess pipes)
not just StringInputPort / sys.stdin.
gpu-worker.lsp fixes:
- run-loop split out as its own tail-recursive function (named-let
inside cond was harder to debug than non-named explicit recursion)
- handle-cuda-shake-fanout unwraps (quote ...) wrapping that
bend.lsp adds when it serializes `'expr` through write-to-string
End-to-end on 3090-ai (lumbda Python tier as both client + worker):
shell A: python3 -u lumbda.py /tmp/launch-worker.lsp
→ gpu-worker: ready cuda-shake-fanout ← ./shake256-fanout
→ gpu-worker listening on port 9091
shell B: python3 lumbda.py smoke-bend.lsp # run 3×
=== smoke-bend ===
1. cost estimator picks local for 3 inputs (cost too small): OK
2. worker available? #t
3. bend! (cuda-shake-fanout '("00" "01" "deadbeef") 32):
("b8d01df855…" "94da6280b2…" "fa094fa86e…")
All three runs identical bytes. All three hashes byte-identical to
hashlib.shake_256 — verified across the full chain:
lumbda Python → bend macro → wire-send (length-prefixed S-exp)
→ gpu-worker.lsp (pure Scheme) → spawn-process-stdio
→ shake256-fanout --daemon (warm CUDA context on 3090)
→ kernel → output portal → wire-send response → bend returns
No Python mock anywhere — except the leaf CUDA binary, which is the
point of the contribution.
Documented in README.md including the python3 -u footnote for
buffering. Once a Scheme-level (flush-port (current-output-port))
is wired into the worker loop, even -u becomes optional.
Per-tier status after this commit:
Python tier ✓ end-to-end working
C tier → still needs spawn-process-stdio + flush-port in
its primitive dispatch (Scheme files unchanged)
asm tier → same, plus raw fork+pipe+execve syscalls for the
spawn primitive
Wired bend.lsp to lumbda's existing TCP primitives via wire.lsp
(length-prefixed S-exp framing, lifted from
ecdsa/lumbda/fleet/wire.lsp). The (bend …) macro now actually
dispatches: lumbda → tcp-connect → wire-send → wire-recv → result.
End-to-end on the 3090 (mock-worker as gpu-worker stand-in until
spawn-process-stdio lands in lumbda's core):
λ> (load "smoke-bend.lsp")
=== smoke-bend ===
1. cost estimator picks local for 3 inputs (cost too small): OK
2. worker available? #t
3. bend! (cuda-shake-fanout '("00" "01" "deadbeef") 32):
(#xb8d01df855... #x94da6280b2... #xfa094fa86e...)
All three hashes byte-identical to hashlib.shake_256.
Files added:
wire.lsp — 8-digit-LE length-prefixed S-exp framing
smoke-bend.lsp — minimal lumbda-side test
mock-worker.py — Python stand-in for gpu-worker.lsp until
spawn-process-stdio + flush-port primitives
land in lumbda's core
bug fix:
wire-recv had one missing close-paren; lumbda surfaced it as
'unclosed (' on load. Fixed in the same commit.
mock-worker.py accepts two request shapes since bend.lsp serializes
(quote (...)) for list literals while the portal format uses
(inputs ...). Tolerating both keeps the wire protocol bend-friendly.
Per-tier integration status:
Python tier — bend, wire, smoke-test all work ✓
C tier — needs: same Scheme files port directly; tcp-* exist;
spawn-process-stdio still missing for gpu-worker.lsp
asm tier — needs: tcp-* exist; spawn-process-stdio requires raw
fork + pipe + execve in asm; biggest delta vs Python
Open primitive gaps for full cross-tier bend:
spawn-process-stdio — for gpu-worker.lsp's daemon pool
flush-port — to push daemon stdin
(current-time-ms — exists in Python tier; needed in C/asm too)
Once those land, gpu-worker.lsp replaces mock-worker.py and bend
runs cross-tier-identical. The protocol & cost-estimator code in
bend.lsp + wire.lsp need no changes — they speak only the existing
tcp-* + read-from-string + write-to-string primitives every tier
already has.
Three changes that together make the GPU primitive viable for the
go-gpu/bend pattern:
1. Binary portal format (length-prefixed raw bytes) — eliminates the
hex-string parse that ate 99% of wall time. Old text portal at
262 MB workload spent 421 sec parsing; binary format = native
speed. New flag + daemon command:
shake256-fanout --binary <in.bin> <out.bin>
daemon: process-bin <in.bin> <out.bin>
Wire (in): u32 out_bytes | u32 n | (u32 len | len bytes) × n
Wire (out): u32 n | u32 out_bytes | n × out_bytes
2. bend primitive (Lisp-smart GPU dispatch). Picked 'bend' over
{go, spark, cast, fan} per fox — HVM2 lineage, fits the
'reshape compute for GPU' mental model.
(bend (cuda-shake-fanout inputs 32))
→ runtime inspects expr; routes to GPU worker if cost-est
exceeds threshold AND worker reachable; else evaluates
locally in original lexical scope
(bend! expr)
→ force GPU, error if no worker available
Implementation files:
bend.lsp — macro + cost-estimator-based router
gpu-worker.lsp — TCP listener, dispatches over warm daemons
DESIGN-go-gpu.md — full architecture (already shipped)
Tier-specific helpers (tcp-*, spawn-process-stdio, sexp->string)
are noted as TODO per tier — Python uses subprocess + socket,
C uses fork + portal, asm uses syscall fork + sock_stream.
3. bench_binary.py — combined daemon + binary format benchmark.
GPU wins every cell of the grid by 1.5–10×:
in_sz N total host dev speedup
32 1,000,000 32 MB 470 ms 47 ms 10.11x
32 100,000 3.2 MB 47 ms 5 ms 9.95x
1024 100,000 102 MB 177 ms 79 ms 2.24x
16384 10,000 164 MB 231 ms 124 ms 1.86x
262144 1,000 262 MB 363 ms 231 ms 1.57x
Same workloads that lost 0.00× at hex+per-spawn now win 10× at
binary+daemon. 4000× relative perf swing from fixing wire format
and warming the context.
The peak 10× at small-input × high-N is the natural shape of crypto
protocols (commitments, Fiat-Shamir, PoW search). That's the win
zone for cuda-shake-fanout. README updated with the full table.
Bench findings drove three changes to the reference primitive:
1. Per-spawn mode loses to host hashlib at every size we tested.
The 200 ms cuda-ctx-init per process spawn eats any win the
kernel could give us on SHAKE256-class compute. Honest table:
in_sz N total host ms device ms kernel ms speedup
32 3 M 96 MB 1818 4246 3.04 0.43x
1024 100 k 102 MB 260 4753 1.72 0.05x
16384 10 k 164 MB 343 18638 2.66 0.02x
262144 1 k 262 MB 508 454171 39.07 0.00x
The 454 SECONDS at 262 MB is portal hex-parsing, NOT the kernel
(which is 39 ms). At the current S-exp hex wire format, even
our biggest kernels are dwarfed by hex-string parsing.
2. Daemon mode lands in shake256-fanout.cu. Touch CUDA context
once at startup, then accept commands on stdin:
process <in.portal> <out.portal> → fan-out + write result
quit → clean shutdown
bench_daemon.py measures 574x speedup per call:
workload: 10 calls × 100 inputs × 32 bytes each
host hashlib loop : 0.6 ms total ( 0.06 ms/call)
per-spawn fanout : 1825.9 ms total (182.59 ms/call)
daemon-mode init : 109.2 ms (one-time)
daemon-mode calls : 3.2 ms total ( 0.32 ms/call)
Daemon is the production architecture for any workload doing
repeated fan-outs. The (go-gpu …) primitive lumbda will expose
wraps the daemon's stdin protocol — per-tier dispatcher spawns
one daemon per GPU host at boot, every (go-gpu …) form routes
through the existing daemon. CUDA init never re-runs while
lumbda is up.
3. DESIGN-go-gpu.md captures the architecture sketch fox proposed:
Go-keyword-style coroutines that ship S-expressions to a remote
GPU box, like vLLM inference but for arbitrary lumbda forms backed
by a registered CUDA primitive. Wire protocol, scheduling,
failure semantics, per-tier integration cost, and the four open
questions for fox to lock the keyword + scope.
README.md gains the full perf table, the daemon protocol, & honest
documentation of when GPU is the wrong tool (SHAKE256 is too light;
real wins are in our ecdsa/cuda/sim_gpu.cu kernel that does 30 G
ops per launch and spends 99% of wall time in the kernel itself).
Establishes the integration pattern for lumbda's future cuda primitive
across Python / C / asm tiers without dragging the CUDA toolchain into
lumbda's core build.
Shape: leaf binary that every tier spawns via its existing process-
spawn primitive & talks to through S-expression input + output
portals. Asm tier inherits via fork + execve syscalls; no libcudart
linkage; no DKMS dependency at lumbda build time.
Files:
shake256-fanout.cu self-contained CUDA SHAKE256 fan-out, Keccak
permutation derived from FIPS 202 reference
(tiny-sha3 lineage, CC0 → re-licensed AGPLv3)
Makefile nvcc build + make test + make bench
test_roundtrip.py validates output byte-identical to
hashlib.shake_256
bench.py device vs host throughput at N = 1k / 10k / 100k
lumbda-call.lsp reference Scheme wrapper showing the
(cuda-shake-fanout inputs out-bytes) API shape
lumbda's core would dispatch to per-tier
README.md full integration story, wire contract, the
three changes each tier needs (~20 LoC each),
generalization path for other CUDA primitives
Tested on 3090-ai (RTX 3090):
make test → PASS — 4 / 4 hashes byte-identical to hashlib.shake_256
Honest bench (32-byte inputs):
N host (Python hashlib) device (kernel launch dominated)
1,000 0.6 ms 188.1 ms
10,000 5.9 ms 195.7 ms
100,000 58.7 ms 313.8 ms
Useful primitive when inputs are larger (KB+) or N reaches millions;
honest about the launch-overhead break-even point. This is the
reference, not the win — the win is locking the API shape so each
tier registers under one stable name.
Provenance: extracted as the generic pattern from
~/git/www.foxhop.net/ecdsa/cuda/sim_gpu.cu where on-device SHAKE
delivered 2.6× memory compression for batched reversible-circuit
simulation. Re-shipping the primitive back to the lumbda repo so the
ecosystem inherits the work.
ecdsa cross-tier validation on lumbda-gc and bump-only lumbda blocked
on `case` being unbound. R7RS standard control form — present on
Python (lumbda.py) and C (c/) tiers, but on asm reachable only via
cl_full_prelude's define-macro form (carved into lumbda-full only).
Mirrors commit 865be28 (when/unless via Path B dispatch table).
Path B (special-form dispatch table extension):
- sf_case length-prefixed symbol name
- sym_case_val interned at init_special_forms
- dispatch case in .eval_top alongside .ev_when / .ev_unless
(placed before the .ifdef CL_FULL macro-lookup block so the
dispatch shadow takes precedence over the cl_full_prelude macro
on lumbda-full — no conflict, the macro just becomes dead code)
- .ev_case evaluator: eval key once, push on stack, walk clauses;
each clause's datum list compared by pointer equality (eqv? on
the asm tier — fixnums, symbols, booleans, characters, nil are
all interned/unboxed to unique values). `else` matches uncondi-
tionally. Match → .ev_begin (TCO). No match → .ev_begin_void.
Available on every asm tier (plain `lumbda`, `lumbda-gc`,
`lumbda-full`). Binary size impact:
lumbda 60768 → 61040 (+272, +0.45%)
lumbda-gc 69496 → 69768 (+272, +0.39%)
lumbda-full 72000 → 72264 (+264, +0.37%)
All 158 asm tests still pass. Sanity tests: single-datum, multi-datum,
symbol key (eqv?), else, empty body, no-match, nested case — all
correct on all three tiers.
ecdsa search.lsp now produces byte-identical winner (v3-clifford-only
score 0) across five tiers: Python, C, asm-bump, asm-gc, asm-full.
ecdsa cross-tier validation blocked on `when` and `unless` being
unbound on the asm tier. R7RS standard control forms — present on
Python (lumbda.py) and C (c/) tiers, but absent on asm because the
existing macro facility (define-macro) only ships under CL_FULL.
Path B chosen (special-form dispatch table extension):
- sf_when / sf_unless length-prefixed symbol names
- sym_when_val / sym_unless_val interned at init_special_forms
- dispatch cases in .eval_top alongside .ev_and / .ev_or
- .ev_when / .ev_unless evaluators reuse .ev_begin for the body
branch and .ev_begin_void for the skip branch (TCO preserved)
Available on every asm tier (plain `lumbda`, `lumbda-gc`,
`lumbda-full`). Binary size impact:
lumbda 60488 → 60768 (+280, +0.46%)
lumbda-gc 69224 → 69496 (+272, +0.39%)
lumbda-full 71720 → 72000 (+280, +0.39%)
All 158 asm tests still pass. Tested truthy/falsy/multi-form bodies
on all three tiers. ecdsa search.lsp now runs on asm-full with output
byte-identical to Python tier (v3-clifford-only winner, score 0).
Note: ecdsa search.lsp also depends on `case`, which is only present
under CL_FULL (carved into cl_full_prelude as a define-macro form).
That gap blocks lumbda-gc cross-tier validation and is out of scope
for this commit.
Two coupled defects surfaced during ecdsa cross-tier validation against
the asm tier.
Defect #28 — _start ignored argv. Invoking `asm/lumbda-gc file.lsp`
silently discarded argv[1] and dropped into a REPL that blocked on a
pty when run under SSH. Walk argc/argv after init_builtins + prelude
load and before repl_top: for each argv[i] starting at i=1, skip
arg if it begins with '-' (flag stub), otherwise allocate a Scheme
string from the C string, wrap in a 1-element arg list, dispatch
through apply_proc_raw on the BI_LOAD builtin. If any non-flag arg
ran, jump to repl_exit instead of entering the REPL. Mirrors the
c/main.c script-mode semantics. The RET_VAL macro on the builtin
return path pops r12/rbp/rbx in an order that corrupts %rbp (it
restores the pre-call %r12 into rbp), so the loop counter saves
%rbp around the apply_proc_raw call.
Defect #30 — eq? was only present under CL_FULL. The plain `lumbda`
and `lumbda-gc` binaries shipped without the alias `(define eq? eqv?)`,
so any .lsp expecting eq? (every cross-tier file we own) hit
"unbound variable: eq?" the moment it tried a status check. Lift
that single alias into a new always-on `default_prelude` block with
its own `load_default_prelude` loader (modelled after
load_cl_full_prelude), and call it unconditionally from _start
between rng_seed and the CL_FULL block.
Verification:
- `make asm-build` clean
- `make asm-test`: 158 passed, 0 failed (full suite green)
- `(eq? 1 1)` -> #t on all three tiers via stdin pipe AND file arg
- `~/git/lumbda/asm/lumbda-gc /tmp/asm-test.lsp` exits 0 with #t printed
Two defects uncovered during secp256k1 oracle port; both forced workarounds
downstream in ecdsa/lumbda/secp256k1.lsp that can now retire.
(1) `quotient` used `int(a / b)` — Python float division. Past 2^53 the
float lost precision, so mod-pow on secp256k1's 2^256 prime corrupted
every modular inverse with off-by-one errors in the square-and-multiply
loop. Replaced with R7RS-spec truncate-toward-zero integer division.
Verified: (quotient 7 2)=3, (quotient -7 2)=-3, (quotient 7 -2)=-3,
(quotient -7 -2)=3, (quotient 0 5)=0, (quotient 2305843009213693950 2)
now returns 1152921504606846975 (was 1152921504606846976, off by 1).
(2) Reader `_atom` looped `for conv in (int, float)` and Python's float()
accepts bare 'inf', 'infinity', 'nan' as IEEE specials. So `'(infinity)`
silently parsed as `(+inf.0)` and `(symbol? 'infinity)` returned #f.
R7RS spells these +inf.0 / -inf.0 / +nan.0 explicitly. Restricted the
float-parse path to a strict decimal/exponent regex; the named IEEE
specials still match their proper spellings.
Verified: (symbol? 'infinity)=#t (was #f), (symbol? 'inf)=#t,
'(infinity) reads as the symbol list, (positive? +inf.0)=#t still works.
Tests: 571 Python unit + 205 functional (Python + C) all pass.
Bash process substitution <(...) passes /proc/self/fd/N — a pipe, not
a regular file. load_file used fseek(SEEK_END)+ftell to size a single-
read buffer; on a pipe ftell returns -1, which casts to SIZE_MAX as
fread's nbyte argument and blows the heap. Glibc fortify caught it
as '*** buffer overflow detected ***'.
Detect non-seekable input via the fseek return code and fall back to
a doubling growable buffer instead. Seekable path unchanged.
Repro: ~/git/lumbda/c/lumbda <(echo '(display 1)(newline)')
Resolved by 45a90b8 (vm restores cur_code across CALL/RETURN).
The reproducer at lines 5-11 of the TODO file runs cleanly now
under both `lumbda --fast` and `lumbda -j --fast`, and the
proof/eml_proof_in_lumbda.lsp `normalize` function no longer
needs the `(define (iter ...))` workaround that the TODO
documented. Future regressions are caught by
tests/regression-named-let-leak.{lsp,sh}.
Five-line fix that ends the F1 hang in foxhop.net
ecdsa/tests/unit/probe-c-confirm.lsp.
Symptom — under --fast, a defined function whose body is a
tail-recursive named-let that calls another user-defined function
per iteration loops forever at 100 percent CPU. Trace pins the
bytecode dispatch:
walk1.body: PUSHE MKCLO DUP BIND LOOKUP TCALL ->loop
loop: LOOKUP NULL? JIF LOOKUP CALL ->always-true
always-true: CONST RET (returns #t)
always-true (!): JIF LOOKUP CDR STAIL -> ip=0 of always-true (!)
loop forever
cur_code was the call-frame-local register holding the currently
executing CodeObj. OP_CALL updated it on entry but neither OP_RETURN
nor the builtin-fallback restore path in OP_TAIL_CALL put it back
on return. Subsequent OP_SELF_TAIL_CALL read cur_code->self_params
from the still-stale callee proc (NULL for always-true since it has
no named-let), guard skipped the env rebind, then set ip=0 — without
ever updating the loop variable. Loop variable stayed pinned at the
initial list and our walk never reached its base case.
Fix — VMFrame gains a cur_code field. Three frame-push sites save
it on entry (OP_CALL, OP_TAIL_CALL builtin fallback frame-restore,
OP_CALL_CC compiled-proc entry); two frame-pop sites restore it on
return (OP_RETURN, OP_TAIL_CALL builtin fallback).
Verified inside foxhop.net's ecdsa QEMU guest:
- foxhop.net/ecdsa/tests/unit/probe-c-confirm.lsp F1..F4 — all pass
- foxhop.net/ecdsa/tests/unit/test-sim.lsp under --fast — 21/21 pass
- foxhop.net/ecdsa/lumbda/main.lsp under --fast — 6 shots, score 18,
byte-identical with our Python tier
- make functional-test — 205/205 on Python and C tiers
- make regression-named-let-leak — 4/4 across Python, tree-walker,
and --fast JIT
c/TODO-named-let-bytecode.md can stop applying its `(define (iter ...))`
workaround once this lands.
Three coupled changes that unblock the ecdsa quantum-circuit
simulator's run on the C tier from neoblanka.
1. c/Makefile autodetects libgc-dev — if /usr/include/gc.h is
present, the build links Boehm and defines USE_BOEHM_GC. Without
GC, ul_free is a no-op (lumbda.h:35) and every allocation leaks;
small REPL snippets work but workloads with thousands of envs
OOM the process. Override with USE_GC=0 to force the malloc-only
path for diagnostics.
2. c/main.c calls GC_INIT before init_symbols, then GC_disable.
GC_INIT registers the stack base for conservative scan — without
it some Linux configs miss roots. GC_disable is a deliberate
stopgap: lumbda Values are NaN-boxed pointers that conservative
Boehm cannot recognize as pointers, so live targets get reclaimed
(env binding symbol payloads, SymbolEntry strings) and lookups
fail with "undefined: <sym>". Reproducing this without GC_disable
on the GC build: any sim.lsp call chain triggers the corruption
after ~100 named-let iterations. Until tracing is precise,
growing the heap is safer than wrong results. Long-running
workloads run under ulimit -v.
3. c/builtins.c gains rename-file and delete-file matching the
Python tier (lumbda.py:3468). sim.lsp's write-portal! pattern
(write to .tmp, rename) needs rename-file to land cross-tier
identical results.
4. tests/regression-named-let-leak.lsp + .sh pin four shapes that
blew up ecdsa: the c/TODO-named-let-bytecode.md repro, the F1
shape from foxhop.net's lumbda-c-tier-leak-SP.md (12-line
minimum), a 200-iter scaled variant, and a sim.lsp run-ops!
mirror. Wired into root Makefile as regression-named-let-leak;
added to test-all. Wrapper caps memory at 256 MB virt and 15s
per tier so a leak regression fails the run instead of consuming
host RAM.
Known limits:
- --fast JIT still has the named-let + inner user-fn call hang
(separate TODO; tree-walker handles this fine).
- GC_disable means the heap grows; workloads must bound their work
budget. ecdsa's sim runs comfortably in 5 MB.
Verified inside a 2G/2vCPU QEMU guest (foxhop.net ecdsa/vm-runner.sh):
- test-c (tree-walker) — 35/35 PASS
- bench-c (tree-walker) — score 18 matches Python tier byte-identical
- F1 probe (tree-walker) — all four steps PASS
§3 (tier description) expanded — the asm tier now ships three build
flavors (lumbda / lumbda-gc / lumbda-full) each an additive superset
of the previous, each guarded by assemble-time flags so the minimal
tier keeps its 22 KB shape. Lists the new CL_FULL features explicitly:
auto-loaded Scheme prelude, #(…) vector literals, `/,/,@ quasiquote,
define-macro + macro table (GC-rooted), and the seven new builtins
(gensym, exit, values, call-with-values, cadr, sort, let*).
§9.2 (CL compatibility) updated to reflect the work this session:
* Portal determinism paragraph now names asm/lumbda-full alongside
Python and C as a CL-compat tier.
* Replaced the single-defect paragraph with a five-defect sweep.
Four were asm-side bugs the prior 158-assertion suite never
exercised: bi_apply clobber, bi_expt infinite loop on negative
exponent, macro_env_head missing from GC roots, prelude missing
cadar. The fifth — C env_lookup's global shortcut — remains as
before. All five landed as separate commits in ticket 0005's
follow-up drop.
* Tests paragraph now names three test files (cl-compat.lsp at 45
assertions including multiple-value-bind, ursa.lsp at 28, ursa-
scheme.lsp at 15) and both test runners — make zoe-favorites-test
for the offline suite and make prove-ursa-runs for the network-
live proof that fetches wedgewack.org/ursa.lisp.txt fresh every
run and spot-checks Zoë's defuns on asm/lumbda-full.
PDF and HTML regenerated.
Six-step proof that https://wedgewack.org/ursa.lisp.txt runs in lumbda
with only the documented minimal annotations — no semantic rewrites,
no algorithm changes. New make target `prove-ursa-runs` wires it.
Steps:
1. Fetch /robots.txt; abort if it disallows /ursa.lisp.txt.
2. Fetch the source (209 lines, sha256 recorded in output).
3. Apply the four character-level substitutions sed'd from the
documented annotations:
(loop → (cl-loop (call form only — clause keyword stays)
(when → (cl-when (call form only)
(random → (random-int
&key → &optional
Prepend (load "cl-compat.lsp").
4. Verify that examples/ursa.lisp.txt's defuns are exactly Zoë's
defuns minus {rho, factor, digits} — the three that depend on
CL features (adjustable arrays, defgeneric/defmethod) out of
ticket 0004's scope. No extra edits anywhere.
5. Run tests/ursa.lsp (which uses the same function bodies Zoë
wrote) on Python + C + asm-full; expect 28/28 passing on each.
6. Build a "Zoë's live source + 10-line stubs" file — no-op
defgeneric/defmethod, 'unshimmed returns for make-array / sbit /
vector-push-extend / vector-pop / fill-pointer, identity coerce,
naive integer-length — and spot-check seven answers on asm-full:
expt-mod 3 7 100 = 87
primep 97 = 97
primep 100 = #f
mersenne 7 = 127
ll-primep 13 = #t
ll-primep 11 = #f
repunit-value 5 = 31
Any missing line fails the proof.
Sed-subset nuance: `(loop ` / `(when ` with an open-paren prefix
matches the call-form usage we want to rewrite. Bare `when` that
appears as a cl-loop clause keyword (no open paren before it) is
left unchanged — that's the macro's own reserved word. Same for
loop.
Usage:
make prove-ursa-runs (network required — live fetch + spot-check)
make zoe-favorites-test (offline; uses the committed examples/)
Closes the remaining asm-side gaps from ticket 0005's follow-up
discussion. Every test in tests/cl-compat.lsp and tests/ursa.lsp
now runs unmodified on default asm (Scheme port) and asm-full (full
CL path) — no more commented-out tests or shim syntax.
Landed (all in default asm — useful beyond cl-compat):
* (values . xs) / (call-with-values producer consumer). values
packs a tagged pair (mval_marker . xs) when multiple; a lone arg
passes through unchanged so legacy single-value code is
undisturbed. call-with-values invokes the producer, destructures
the multi-value packet if present, applies consumer positionally.
The marker is a gensymed symbol interned once at init, so no
user-constructed pair can masquerade as a multi-value packet.
* (exit [code]) builtin. Default code is 0 when called with no
args. Passes through to the SYS_EXIT syscall.
* #(...) vector literal in the reader. .sr_hash now dispatches on
'(' as a vector literal alongside 't' and 'f'. list_to_vector_
reader is a standalone helper callable from the reader (separate
from bi_listtovec which uses the GETARG builtin convention).
Matches R7RS vector literal syntax. Existing vector builtins
already handled construction; this just teaches the reader.
* deep_equal extended to vectors. equal? now descends into vectors
(length + elementwise recursive compare), matching R7RS.
Previously only strings and pairs were handled; vectors fell
through to shallow pointer compare which only matched identical
heap objects.
Test file reverts (picking up the new capabilities):
* tests/cl-compat.lsp — multiple-value-bind test restored
(previously commented out because asm lacked values /
call-with-values).
* tests/ursa-scheme.lsp — #(1 0 1 0 1 0) literal restored
(previously worked around with (vector->list (digits ...)));
(exit 1) failure trailer restored (previously removed because
asm had no exit builtin).
* tests/ursa.lsp — same digits literal restoration.
Verified:
* asm regression: 158/158.
* asm-full regression: 158/158.
* Zoë-favorites across Python + C + asm + asm-full: all suites
green with native reader syntax and multi-value tests.
* make test-all stays green.
Two changes, one wiring.
1. rhoff gets a Birthday-bound iteration cap. Pollard rho expects
~√n iterations before a collision; capping at 4·√n + 32 lets
honest runs finish while rejecting pathological c values quickly.
rho's outer retry draws a new c and keeps the total work bounded.
Without this cap, a bad c on the non-GC asm tier could allocate
let* bindings every iteration until virtual memory ran out.
(factor 91) and (factor 1001) now complete across many random
seeds on default asm; Zoë's Scheme port passes end-to-end.
2. tests/ursa-scheme.lsp — Scheme-port-only half of the acceptance
suite. Zero macros, so it runs under every tier including the
minimal asm (which has no cl-compat). Also drops the vector
literal `#(...)` (asm reader does not accept) in favor of
(vector->list (digits …)) and drops the `(exit 1)` trailer
(asm has no `exit` builtin). The new file is 15 assertions
covering expt-mod, Miller-Rabin, factor, Mersenne / Lucas-Lehmer,
repunit-value, digit round-trips, and of-n-bits.
3. tests/cl-compat.lsp — the multiple-value-bind test is commented
out. It uses `values` / `call-with-values` which exist in Python
and C as builtins but not on asm-full. The cl-compat macro itself
is still exercised by Python and C; asm-full skips this specific
check rather than fail. The full 44 remaining assertions all pass
on every tier now.
4. tests/zoe-favorites-test.sh — extended coverage matrix:
Python cl-compat + ursa (Scheme + CL)
C cl-compat + ursa
asm-full cl-compat + ursa
asm ursa-scheme (port only — no macros on minimal)
The old script ran two tiers (Python + C). Now it runs seven
test/tier pairs. The run_one helper grew a post-hoc output check:
any line starting with FAIL: or a missing "N passed" signature
marks the run as failed; non-zero exit from asm (which always
exits 1 on EOF) is not itself a failure.
Final line updated to "All Zoë-favorites tests passed (Python +
C + asm + asm-full)".
make test-all stays green.
asm/lumbda-full now runs Zoë's CL source end-to-end per commit
c6658e0. Rewrite the Known Issues section into a Resolved section
explaining what each of the four underlying asm bugs was (bi_apply
clobber, bi_expt infinite loop on negative exponent, macro_env_head
missing from GC roots, cadar missing from the prelude) and why the
158-test asm suite did not catch them before.
Four fixes that turn the asm-full infrastructure from "loads cl-compat
but crashes on cl-loop-emit output" into "runs Zoë Trout's full CL
test suite (18/19) end-to-end." Zoë's original `examples/ursa.lisp.txt`
now produces matching answers to the Python and C tiers on asm-full.
1. asm/lumbda.s bi_apply — second arg was being clobbered. The
previous impl did `GETARG %rbx; GETARG %rdi; movq %rbx, %rdi;
... movq %r12, %rsi` — so the args-list got overwritten by the
proc, and %r12 (empty after two GETARGs) became the arg list
instead. `(apply f '(1 2 3))` silently reduced to `(f)`. Fix:
`GETARG %rbx; GETARG %rsi; movq %rbx, %rdi; call apply_proc_raw`.
2. asm/lumbda.s bi_expt — decrements rcx by 1 until zero. Negative
exponents looped forever. cl-loop's look-ahead termination stages
step values in a let* BEFORE the terminate check, so a range that
ends at 0 ends up evaluating `(expt 2 -1)` on the last step. Fix:
guard negative exponents, return 0. asm is integer-only; returning
a rational would need a new type. Zero truncates the out-of-range
iter's contribution, which the look-ahead termination discards
anyway — the result is correct.
3. asm/lumbda.s GC roots — macro_env_head was not marked. Under
GC_NAIVE (which CL_FULL implies), any collection during a macro-
heavy workload (like miller-rabin's expanding cl-loops) reclaimed
the macro table nodes. Next use failed with "unbound variable:
cl-when" or similar. Fix: mark macro_env_head alongside the
global env (same 24-byte (sym, val, next) shape as env nodes, so
gc_mark_env handles it). Guarded .ifdef CL_FULL.
4. asm/lumbda.s prelude — added `cadar` (used by
cl-loop-finalizer-expr). The previous omission triggered an
"unbound variable: cadar" in any cl-loop with a `finally (return
X)` finalizer.
5. cl-compat.lsp — two new helpers routed around asm's reduced
list-processing builtins:
* `cl-append` for n-list concatenation. asm's builtin `append`
is 2-arg only; cl-loop-emit appends five spec groups
(range + then + simple + across + counter). Reducing with
2-arg append works on every tier.
* `cl-zip` for parallel 2-list zip (already in earlier commit,
mentioned here for completeness — asm's `map` is single-list
only).
Verification on asm/lumbda-full:
* /tmp/ursa-load-test.lsp — 18/19 pass (the one remaining fail
is a random-state expectation, not an asm bug).
* (primep 97) → 97
* (primep 100) → #f
* (lucas-lehmer-primep 13) → #t (M₁₃ = 8191, prime)
* (lucas-lehmer-primep 11) → #f (M₁₁ = 2047 = 23·89)
* (of-n-bits 8) → random integer in [128, 256) with top bit set
* (prime-of-n-bits 8) → random 8-bit prime
make test-all stays green. All three asm variants still 158/158 on
their local test suites. asm's minimal footprint preserved — every
new line above is under .ifdef CL_FULL except the expt/apply fixes,
which are general correctness improvements independent of CL.
Third asm variant — built with CL_FULL=1 GC_NAIVE=1 via new Makefile
target. Adds the macro machinery needed for cl-compat.lsp on the asm
tier, keeping every addition behind .ifdef CL_FULL so the default
(~22 KB) and -gc binaries keep their current footprint.
Landed in this drop:
* Reader: backtrack on digit-prefixed symbols. After reading digit
characters, if the next char is not a delimiter, input_pos
rewinds and control falls through to .sr_symbol. Makes 1+, 1-,
add1, abc123, and any CL-style identifier with a numeric prefix
parse as symbols instead of truncating to a bare integer.
* Reader: `` ` `` / `,` / `,@` produce (quasiquote X) / (unquote X)
/ (unquote-splicing X) forms. Same build shape as the existing
`'` quote branch.
* Evaluator: .ev_quasiquote + quasiquote_expand walk the template.
unquote evaluates its argument in the current env; unquote-
splicing evaluates then splices via a new list_append_ab helper;
other pairs recurse (cons expand-car expand-cdr). Atoms pass
through. No nested quasiquote depth (deliberate; ticket 0005
scope).
* Evaluator: .ev_define_macro + macro_env_head linked list. Each
(define-macro (name p...) body) prepends a 24-byte
(sym, closure, next) node. Dispatch in eval checks macro_lookup
after all special-form compares; on hit, the closure is applied
to the *unevaluated* argument list and the expansion re-enters
.eval_top under TCO.
* Binding: rest-arg support extended to .apr_bind inside
apply_proc_raw. Previously only .ac_bind (direct .app_closure
path) handled `(lambda (a . b) ...)` correctly; macros call
closures through apply_proc_raw, so this was required to make
variadic defun/setf macros bind correctly.
* Builtin: (gensym) — writes "g%d" for an in-BSS counter, length-
prefixes the buffer, calls intern_static. Available in every
variant (not CL_FULL-gated — useful outside macros too).
* Builtin: (cadr x), (sort lst) and the let* special form from
earlier commit stay in default asm. These are Scheme staples.
* Prelude: evaluated at _start after init_builtins / rng_seed,
before the REPL. Embedded string, input state saved + restored
around the load. Defines caar, cdar, caddr, cadddr, cddr,
cdddr, cddddr, 1+, 1-, add1, sub1, square, eq? (= eqv? for
interned symbols), memq, list-ref, assq, and `case` as a macro.
cl-compat.lsp: two small changes to work under asm's single-list
`map`:
* Added cl-zip helper. Replaced two `(map (lambda (v n) (list v n))
xs ys)` sites with `(cl-zip xs ys)` — asm's builtin map accepts
only one list, and cl-loop-emit needs a parallel walk over
state-vars and new-names.
* Added explanatory comment for cddddr at the top of the shim
(already shipped).
Tests:
* make asm-test (lumbda) — 158/158 pass.
* make asm-test-gc — 158/158 pass.
* make asm-test-full — 158/158 pass on synchronous run.
* Zoë's `examples/ursa.lisp.txt` LOADS on asm/lumbda-full.
`(expt-mod 3 7 100)` = 87.
Most simple cl-loop forms work (while + do + finally, range-to,
then-accumulator).
Known open issues documented in docs/tickets/0005-asm-cl-full.md:
* cl-loop-emit produces wrong output for inputs with `simple` iters
(`(simple a 5)` → state binding dropped). Python/C return the
correct form; asm version is missing the binding. Bug surfaces
in the emit's 30+ binding let*; could not pin down in this
session. Downstream effect: `(miller-rabin n)` and similar
defuns that depend on `cl-loop repeat k for a = ... unless ...
return nil` don't produce usable expansions, so Zoë's acceptance
suite does not run end-to-end on asm/lumbda-full yet.
* examples/ursa-scheme.lsp — `factor` crashes on asm under some
random seeds (bump-allocator exhaustion on long rhoff retry
chains). Out of CL_FULL scope; tracked in same ticket.
Next steps live in ticket 0005. This commit ships the infrastructure
so the remaining work is a debugging exercise against a reproducible
minimal case, not a feature build.
Phase 1 of the asm/lumbda-full roadmap (ticket 0005, in-flight). Adds
the minimum-cost set of additions that lets examples/ursa-scheme.lsp —
the idiomatic Scheme port of ursa.lisp.txt — load and produce correct
results on the asm tier. No CL shim yet: that requires quasiquote,
define-macro, and case, all of which are Phase 2 / 0005.
Added:
* Rest-args in lambda — (define (f x . rest) ...). .ac_bind now
detects when the remaining param list is a raw symbol (TAG_SYM)
and binds it to the remaining arg list. Enables variadic defuns.
* cadr builtin — (car (cdr x)) fast path. Used by Zoë's
repunit-value and any CL-adjacent code.
* sort builtin — ascending insertion sort on a tagged-int list.
Non-destructive. Matches Python/C sort contract (default numeric
ordering). Implementation ~50 lines, recursive sort + insert
helpers.
* let* special form — sequential binding where each init sees the
preceding bindings' values. Fresh sf_let_star + sym_let_star_val
+ .ev_let_star branch that's a one-line variant of .ev_let (eval
init in the extended env rather than the original). TCO preserved.
Tests: 9 new asm assertions in asm/test.sh covering cadr, sort (empty
/ singleton / unsorted / already-sorted), let* (basic + sequential),
rest-args (tail-only + rest-only). Total asm suite now 158 passing.
Known limitation: the Scheme port's factor / rho depends on random
rhoff iteration. For some seeds on asm (e.g. seed=2, factor 91) the
process runs out of virtual memory before rho finds a factor. The
underlying math is correct — this is an asm heap-bump-allocator
behavior under long random-retry chains and will be addressed along
with the CL_FULL work in ticket 0005. Python and C paths unaffected.
make test-all stays green across every tier.
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.
New subsection under Language Coverage covers the isqrt primitive that
shipped in 6e9d3ea across Python + C + asm. Documents:
- Semantics: (isqrt n) -> floor(sqrt(n)), integer in / integer out,
negative argument raises; matches Python 3.8+ math.isqrt and R7RS
exact-integer-sqrt.
- Why integer: no FPU drift, no libm platform variance, portal replays
stay bit-identical.
- Algorithm: bit-by-bit digit recurrence, O(log n), no multiply/divide,
no FPU. Asm variant runs in three integer registers.
- Domain limits: Python unbounded, asm 61-bit, C 48-bit (NaN-boxed).
Portal round-trips bit-identical within the smaller tier's window.
- MOAD-0001 note: O(log n) per call, no hidden linear scan.
21 RST lines. Rebuilt PDF and HTML from source.
Closes tickets 0001 (portal-rng) and 0002 (os-entropy-seed).
Ticket 0001 goal 4 called for proof that seeding with k, drawing N,
saving, clearing, resuming in any impl, and drawing M more produces a
full stream matching a single-process Python baseline bit-for-bit.
Prior tests/portal-cross-test.sh exercised producer-consumer agreement
but used a producer-side self-computed baseline; it did not compare
against an independent Python run that never saves or resumes.
tests/portal-rng-cross-test.sh computes a single-process Python baseline
once (seed=42, N+M=10 draws, no portal), then runs all 9 producer x
consumer cells (Python, C, asm each side) and checks that producer's
first N plus consumer's M equals the independent baseline. All 12
assertions pass.
Wired into make test-all. Ticket status updated to resolved on both
0001 and 0002 with dated one-line resolution notes.
Design-only ticket. C's 48-bit NaN-boxed TAG_INT silently truncates
any result > 2^47 (e.g. 10^16 becomes -133099161583616), while Python
(bignum) and asm (61-bit) compute correctly. Recommends heap-allocated
bigint via new TAG_BIGINT, preserving NaN-boxing and JIT fast path for
the inline 48-bit common case. Phased migration with Phase 0 fail-loud
stopgap before the full bignum lands.
Citation block previously linked to git.unturf.com/books/feedback-is-all-you-need,
a placeholder URL that was never live. Replaced with the verified public PDF at
lumbda.com/lumbda-whitepaper.pdf (HTTP 200, application/pdf, 2.7 MB).
Rebuilt PDF and HTML so all three artifacts carry the corrected URL.
Previous revision mentioned (random-seed-from-os!) in one sentence; this
treatment does not match the weight fox assigned — 'a random seed from os
on at least one side is very important.'
New sub-subsection 7.5.1 'Kernel Entropy on One Side' spells out:
- why hand-rolled /dev/urandom seeding is a reproducibility-defect farm
- why we picked /dev/urandom over /dev/random and getrandom(2)
- the real-world Machine A -> portal -> Machine B flow as concrete Scheme
- the audit property: a captured run-id replays the trajectory exactly
- kernel entropy enters the system once, under user control, then never
again — every resume reads the same stream off the portal
Ticket: docs/tickets/0002-os-entropy-seed.md
Ticket 0002 — reads 8 bytes from /dev/urandom (little-endian u64) and
seeds xoshiro256**. Opt-in kernel entropy for stochastic runs; the
default stays deterministic (k=0 at startup), so ticket 0001's
portal-reproducibility contract is unchanged.
Real-world flow now one call away:
Machine A: (random-seed-from-os!) + run simulation + portal-save
Machine B: portal-resume — same stream, bit-for-bit
All three impls fail loud on /dev/urandom trouble (LispErr in Python
and C, stderr + exit(1) in asm) — no silent fallback to a weak seed.
Tests:
- tests/functional.lsp: 2 new shared asserts (entropic + replay)
- asm/test.sh: 2 new asm-local checks (149 total, was 147)
- make test-all green across Python (205), C (205), asm (149)
Whitepaper §7.5 gains one sentence noting the OS-seed path.
unmoad: zero new findings in added code.
Adds a new subsection documenting xoshiro256** across all three tiers
and the portal-format extensions that preserve its state across
processes. Renumbers subsequent subsections 7.5→7.6, 7.6→7.7, 7.7→7.8;
updates the §7.6 cross-reference in the reproducibility map.
New Acknowledgments section before Citation/References/License credits
Zoë Trout — whose question 'does the portal tech keep the random list
seed which will allow transferring random entropy between processes
when continuing a simulation?' surfaced the gap. The earlier claim
'portal = portable machine state' carried a silent asterisk for
stochastic code; that asterisk is now gone.
Ticket: docs/tickets/0001-portal-rng.md
Implementation: 27f468c (Python + C) + 54c4c65 (asm + tests)
Completes ticket 0001 started in 27f468c. All three impls now carry
bit-identical xoshiro256**; portal state round-trips across process
boundaries in every producer x consumer cell (Python <-> C <-> asm).
asm impl:
- 4 new builtins: random-seed!, random-int, random-state, random-state!
- g_rng_state in BSS (4 x u64); rng_splitmix64_step, rng_seed, rng_next
- Binary portal header bumped LUMBDAB1/48 -> LUMBDAB2/80; carries
32 bytes of rng state at offsets 40..64, reserved moved to 72
- No float support in asm, so (random) intentionally omitted there
- _start seeds with 0 so the stream is deterministic from startup
Python + C (supplements 27f468c):
- rng_seed(0) auto-invoked at module load / register_portal_builtins
so (random) without explicit (random-seed!) returns a real value
instead of the all-zero xoshiro fixed point
Tests:
- tests/functional.lsp: 7 new shared assertions (Python + C)
- asm/test.sh: 5 new asm-local assertions (142 -> 147)
- tests/portal-rng-save.lsp / portal-rng-load.lsp: portable S-expression
portal that captures both state AND next-5 baseline so loader self-
verifies without a separate harness
- tests/portal-cross-test.sh: 9 new producer x consumer RNG cells; all
18 cells pass end-to-end
Verified: seed=42, (random-int 1000000) draws 1..10 =
558742 543102 559009 124193 317476 750584 200754 814407 344958 929085
identical in Python, C, and asm.
unmoad scan: zero new findings in added code.
Zoe's contribution question: does our portal preserve RNG state so a
simulation can continue in another process with the same random stream?
Answer today: no — no RNG existed. Answer now (Python + C): yes, bit-identical.
- New builtins: random-seed!, random, random-int, random-state, random-state!
- xoshiro256** (Blackman & Vigna 2018) — deterministic, portable, no libc rand
- State = 4 x u64; portal-v1 JSON gains 'rng' field with 8 x u32 halves
- Python and C produce bit-identical streams (verified: seed=42, 10 draws)
- Asm impl + cross-impl tests + whitepaper note: next commits
Ticket: docs/tickets/0001-portal-rng.md
Previous commit used `all: revert` on the floating button to cancel
docutils's blanket `body > *` rule. Too aggressive — it also removed
uncloseai's own `position: fixed`, `bottom`, `right`, `width`,
`background`, `color`, `border` declarations, making the button
disappear entirely.
Replace with a tight override: only the five properties docutils's
blanket rule sets (background-color, line-height, padding, margin,
max-width) get reset with !important. Everything else falls through
to .uncloseai-floating-button's own stylesheet, so the button
re-appears in its designed 121px pill, bottom-right, black-on-white.
Any <img> inside <main>/<section> now opens in a full-viewport
overlay (92% black bg, image centered at max-width/max-height). Click
the overlay or press Esc to close. Pure vanilla JS + CSS, injected
inline via inject-whitepaper-css.py — keeps the HTML self-contained
with no external lightbox dependency. cursor: zoom-in signals
interactivity; the overlay uses cursor: zoom-out.
Three whitepaper-HTML-only fixes:
* docutils's responsive.css blanket-pads every `body > *` with
`padding: 0.5rem calc(29% - 7.2rem)`. The uncloseai floating
button gets appended to <body>, picks up that padding, and blows
from its designed 121 px to nearly half the viewport. Scoped
override resets the cascade on the button selector with
`all: revert` + explicit zero padding/margin.
* whitepaper h1/h2 titles now use ChunkFive (same face as the
homepage logo) instead of docutils's default serif. Font embedded
as a base64 data: URI inside a <style> block so the HTML stays
self-contained — no external font fetch.
* RST layout: λ logo now sits UNDER the "Lumbda" heading instead
of above it. Permacomputer logo still follows below the λ.
New helper: whitepaper/inject-whitepaper-css.py runs after
embed-images.py, reads ChunkFive from www/fonts/..., base64-encodes,
writes the <style> block before </head>. Makefile wires it in.
Source PNG from the MPS lumbda-logos product is already an inverted λ
(hooks at top-left + bottom-right, V-body). My prior commit applied
vertical + horizontal flips on top of that, which undid the inversion
and produced a right-side-up calligraphic λ — the opposite of what
the artwork was drawn to express.
Regenerate both PNG variants (lumbda-logo.png black,
lumbda-logo-green.png #227842) directly from the source with no
flips. Only transform: alpha-from-brightness so anti-aliased edges
survive over any background.
Homepage img src picks up ?v=2 so any browser with the flipped
version in disk cache re-fetches. Logo size stays 3.33× the
wordmark font (10.66rem). PDF + HTML rebuilt — embed-images.py
base64-inlines the new PNG into the HTML automatically.
Fetched the lumbda-logos artwork from
media.unturf.com/c/fbd3d473-.../lumbda-logos (a MakePostSell product),
applied both flips (vertical + horizontal, = 180°) at bake time so the
PNG ships oriented correctly without any CSS transform dance, and
recolored non-background pixels to brand green (#227842) with alpha
derived from pixel brightness so anti-aliased edges stay smooth.
Two PNG variants ship under whitepaper/diagrams/:
* lumbda-logo.png dark ink for print contexts
* lumbda-logo-green.png #227842 for web
www/ carries symlinks to both.
Homepage (www/index.html): replaces the CSS-rendered λ with an
<img class="lambda-mark"> element sized 9.6rem — 3× the 3.2rem
wordmark font — stacked below "lumbda." on its own line.
Whitepaper (whitepaper/lumbda-whitepaper.rst): adds the logo as the
first figure above the permacomputer-logo, 24% width, centered.
Regenerated PDF + HTML; embed-images.py base64-inlines the new file
automatically so the HTML stays single-file.
CI (.gitlab-ci.yml): generalizes the symlink resolver from two
explicit `cp -L` calls into a `find www -type l` loop, so every
current and future symlinked asset deploys without per-file CI edits.
λ in front was reading as a "Y" attached to the wordmark ("ylumbda.").
Move the mark under the wordmark as a standalone logo below the name.
Sitting alone, the flipped λ doesn't get recruited into the first
letter and reads as its own glyph — a lambda turned on its head.
Collapse --accent (was blue #0b5394) into the same green as the
period. One color now marks everything that says "lumbda": the λ
logo, the declarative period, link underlines, and the CTA buttons.
Dark-mode variant lightened to #5ec07a so the green reads on
#161613.
Previous design folded the flipped λ inside the wordmark as the "l".
That read as a cute substitution but didn't work as a brand mark.
Split them: the λ now stands on its own, rotated upside-down, 5rem
(larger than the 3.2rem wordmark), so it reads as a logo symbol
separate from the text.
Wordmark restores a proper lowercase "l" in "lumbda" — readable,
copy-pasteable, searchable. Trailing period picks up a new --green
design token (#2a8a3a light / #6fd68a dark) so the declarative stop
also reads as growth — matches the permacomputer ethos.
Layout: flex logo-row with baseline alignment and a small gap; the λ
hangs from the wordmark's cap-line for a balanced mark-and-wordmark
composition.
Previously the HTML whitepaper referenced diagrams via relative paths
(src="diagrams/X.png"), which 404'd because the docroot does not carry
a mirror of whitepaper/diagrams/. The "HTML whitepaper" thus showed
broken image stubs instead of the architecture figures.
Fix: a post-processor (whitepaper/embed-images.py, stdlib Python) runs
after rst2html5. Every <img> with a relative src gets base64-inlined
as a data: URI with inferred MIME. Any .svg reference would splice in
as an inline <svg> element, surfacing alt text as <title>; the current
RST only uses the .png variant of gnu-logo so no SVGs inline for now,
but the path works when we switch.
Makefile's whitepaper-html target chains the embed step after the
existing sed passes for the uncloseai.js script tag and the meaningful
<title>. Title tweaked to "feedback as a primitive" to match the
homepage tagline.
Result: whitepaper/lumbda-whitepaper.html grows from 180 KB to 4.6 MB
(base64 inflation on ~2 MB of diagrams), and it now opens offline as
a single file — no network fetches for figures, no docroot mirror
needed.
Wordmark now renders λumbda. — flipped λ stands in for the "l",
visual pun on lambda (the primitive the language shells around),
trailing period as declarative closure. All-lowercase, accent-colored
period. aria-label="lumbda." keeps screen readers reading the name
correctly; the λ and the period both carry aria-hidden.