lumbda_wasm_entry.c — adds an EM_JS bridge js_lumbda_bend_call that
does sync XHR POST (legal inside Web Workers, the playground's tier
host) and writes the response bytes back into the wasm heap. C
wrapper bi_bend_call_wasm allocates a 256 KiB response buffer, calls
the EM_JS function, wraps the bytes as a lumbda string Value, and
gets registered as the `bend!-call` builtin in lumbda_wasm_init —
not in c/builtins.c, so the native CLI build doesn't acquire a
wasm-flavored binding it can't satisfy.
lumbda-c.loader.js — setBendUrl(url) mutates globalThis._lumbdaCBendUrl
which the EM_JS reads on every call. runner.js plumbing already
propagates a saved playground URL to every tier's setBendUrl on
each save.
C tier now matches asm + pyodide for HTTP-mode bend. End-to-end
verified in node + wasm directly: bend!-call returns the configured
URL response text; "no bend URL configured" when unset; clean
"bend error: ..." string when the XHR throws (e.g., CORS, network).
Tested against the live bend.unturf.com chain via the playground.
Three tiers in parallel now show the same (ok (HEX0 HEX1 HEX2))
result from (cuda-shake-fanout ("00" "01" "deadbeef") 32).
The WAT $read_string function scanned bytes until the first " and copied
raw, with NO escape handling. So a source string like
"(\"00\" \"01\")" got chopped at the first \" — the asm tier read only
"(\\" before terminating, producing a mangled payload that the worker
couldn't parse. The bend-gpu demo's payload built via string-append of
escaped-quote strings came out as "(cuda-shake-fanout (\ \ \) 32)" in
the asm tier, sent garbage to bend, and got nothing useful back.
Two-pass fix to match Python/C tier behavior:
1. Scan-and-count pass: walks source-ptr to the closing ", but
when it sees \ it skips the next byte so embedded \" doesn't
terminate the string. Counts decoded output bytes (each \X
contributes one byte, not two).
2. Allocate + copy-and-decode pass: walks the same range, converts
\n → 0x0A, \t → 0x09, and any other \X (including \" and \\)
→ X. Matches the lenient fallback the desktop tiers use.
Verified via cross-tier parity probe — 255/255 still passing.
Demo payload now constructs as
"(cuda-shake-fanout (\"00\" \"01\" \"deadbeef\") 32)" (45 bytes,
identical to Python/C reads) and the asm playground returns
(ok (HEX0 HEX1 HEX2)) from the live 3090-ai bend worker.
When inner (let loop ((i ...))) shadows an outer (let loop ((a ...) (b ...))),
the inner named-let block was overwriting j->loop_slots[] without saving
the outer's slot positions. After the inner block restored loop_sym /
loop_nparams / loop_params, the outer's recursive call (loop new-a new-b)
would write the new args into the inner's stale slot positions instead
of the outer's slots, causing the outer body to see stale binding values
or trigger 'set! undefined' on tail-call args.
loop_slots is a member ARRAY (not pointer) — memcpy'd at line 876 when
setting up loop context — so the existing pointer save/restore for
loop_params didn't cover it.
Verified:
- jit_named_let_factorial still PASS (3628800)
- new nested-shadowing test: outer 3-param + 4 inner 1-param now PASS
- exact replica of squaring.lsp round84-fold structure now PASS
Bug surfaced when investigating test-round84-keep-quotient-product
failure in www.foxhop.net/ecdsa tests. Test still has a deeper substrate
bug beyond this JIT fix (width 0 ancilla in non-fast mode), but this
fix is independently correct + closes the loop_slots scope leak.
lumbda-py.js (and the three deployed mirrors) now expose setBendUrl
and register a bend!-call primitive in the pyodide-hosted lumbda
environment.
Implementation:
- JS loader stashes a `globalThis._lumbdaPyBendCall` function that
does sync XHR POST to the configured bend URL (legal in Web
Workers, where the pyodide tier runs in this playground)
- Python bootstrap imports `_lumbdaPyBendCall` from `js` and binds
it as a builtin under the symbol `bend!-call`, accepting any
value and stringifying via lumbda.show before sending
- setBendUrl(url) on the tier object updates the JS closure; the
runner.js plumbing already calls it on every tier when the user
saves a bend URL in the ⚡ bar
This brings the pyodide tier to parity with the asm (WAT) tier for
HTTP-mode bend. The emcc C tier still lacks the bind — wiring it
needs a new wasm primitive built via emcc; lands in the next commit.
Tested: bend!-call "(ping)" against the same gpu-worker endpoint
returns the same (ok pong) S-expression the asm tier sees.
gpu-worker.lsp — handle-cuda-shake-fanout, handle-cuda-sim-ops-bin,
and the four binary handlers (BSHK/BCGB/BSCP/BSRT/BSB3) now check
for the registered daemon (or, for sim-ops-bin, the bend-cuda
binary) before dispatching. Missing daemon returns (error
(daemon-not-registered <op>)) over S-exp wire, or "BERRdaemon-not-
registered: <op>" over binary wire. Before this commit any caller
whose worker host lacked a CUDA binary saw the child process crash
on (cdr #f) and got HTTP 502 / empty response with no useful
diagnostic.
Factored two helpers: daemon-or-error (S-exp result) and
with-required-daemon (binary-mode wrapper). Both keep the original
handler bodies untouched on the happy path; the guard adds one
assoc lookup per request.
www/playground/demos/bend-gpu.lsp + wasm/app/demos/bend-gpu.lsp —
demo was sending (cuda-shake256-fanout COUNT 32), an op the
dispatcher doesn't know AND a signature handle-cuda-shake-fanout
doesn't accept (it takes (inputs out-bytes)). Replaced with a
three-step probe: (ping) → (health) → small (cuda-shake-fanout
("00" "01" "deadbeef") 32). Each step prints its result so the user
gets feedback at every stage of the round-trip. Note added in the
header that browser-side bend!-call is asm-tier-only today; pyodide
and emcc tier wiring is the next commit.
WAT — leading (define ...) forms in a lambda body now bind LOCALLY
(letrec*-equivalent) instead of polluting the global env. Implementation:
apply for closures pre-processes the body in three passes:
1. hoist_internal_defines walks leading defines, env_define each name
to VOID in the new env, returns the extended env.
2. strip_leading_defines returns the body with the defines removed.
3. fill_internal_defines evaluates each define's value-expression in
the new env (so mutual references work) and env_set the real value.
(define x 1)
(define (f) (define x 99) x)
(f) ; → 99 (was 99, still 99)
x ; → 1 (was 99 wrongly — fixed)
(define (h) (define helper (lambda (x) (* x 2))) (helper 5))
helper ; → unbound (was a leaked global procedure — fixed)
C-WASM — added a thorough doc-block in lumbda_wasm_entry.c covering
the gc.c fallback malloc situation and three plausible real fixes
(Boehm-em build, custom mark-sweep over NaN-boxed heap, generational
reset). Repl tabbar already surfaces the pressure to the user.
Parity corpus locks the new scoping behavior:
internal-define-local — global x stays 1
internal-define-returns — f returns 99
internal-define-mutual — mutually-recursive internal defines
Tests: 20 unit, 8 integration, 11 functional, 249 parity all green.
WAT GC — Cheney-style two-space copying collector. Runs at end of
lumbda_eval when heap_used > 50% of memory.size — the only safe
collection point since the eval call stack has unwound and roots are
fully visible via the globals.
Implementation:
object_size(ptr) returns the byte size of any tagged heap object.
gc_forward(v) copies the object to to-space, leaves a 0xCAFEBABE
forwarding tombstone with the new address at offset 4.
gc_scan_object(ptr) walks pointer fields of pair/closure/vector/
hashtable and replaces each with its forwarded address.
gc_collect orchestrates: forward roots (global_env, intern_list,
every special-form sym), scan to-space, memmove back to 0x30000,
re-shift all pointer fields by the delta. Two passes (forward+
shift) cost the same memory bandwidth as plain Cheney does in one.
Exports: lumbda_gc (manual trigger), lumbda_heap_used, lumbda_heap_total.
Parity probe gains 3 new GC stress tests:
gc-throwaway — allocate-and-drop loop, post-eval value matches
gc-retained-length — verify the GC doesn't dropp live cons-chain
gc-survives-eval — eval after a heavy alloc still works correctly
C-WASM tier — set g_auto_compile = true in lumbda_wasm_init so every
define compiles to bytecode. Without this, the tree-walker recurses
through host C stack frames for (let loop ...) patterns and blows the
WASM linear-memory stack around N=500. With auto-compile on, the VM
uses its own explicit frame stack and TCO kicks in.
Net: 246/246 parity, 20 unit, 8 integration, 11 functional all green.
Heap diagnostics surfaced in the repl tabbar; "reboot tier" stays as
the user-side reclaim path for the c-wasm tier (which still leaks
because the Boehm-em port isn't wired yet — that's task #38).
Step 1 of the GC effort. Each tier loader now exposes heapStats():
- asm-wasm — lumbda_heap_used / lumbda_heap_total wat exports
- c-wasm — emscripten linear memory size (no free path right now,
so used = total; documented in the loader)
- python — pyodide module linear memory size; CPython GC cycles
this naturally
Worker handles a "heap" message kind that round-trips the active tab's
loaded tiers; repl tabbar shows a compact "py 12M · c 32M · asm 4M"
strip next to the buttons. Polls every 2s.
Doesn't solve the leak — just makes pressure visible so the user knows
when to use "reboot tier". Real GC (Cheney over the WAT bump allocator,
Boehm-em or custom mark-sweep for c-wasm) coming next.
Each gpu-worker.lsp now listens on both wire-TCP (existing :8320) and
HTTP/1.1+CORS (new :8321), sharing one handle-request dispatcher. Lets
a tab on https://lumbda.com/playground/ POST to its own machine via
http://localhost:8321/ — browsers permit localhost from HTTPS origins
without TLS, so no proxy, no cert, no fox-owned infra required for the
decentralized run-your-own-bend story.
main() forks at startup: child runs http-run-loop on :8321, parent
keeps existing run-loop on :8320. Adding a new op-head to handle-request
exposes it over both transports automatically. Binary modes
(BSHK/BCGB/BSCP/BSRT/BSB3) stay wire-only — they exist for native
callers who already cache the binary locally; browser callers send
S-expression recipes the worker dispatches the same way.
Two latent defects fixed to make CPU-only and Python-tier hosts work:
- vram-used-mib now file-exists? guards /usr/bin/nvidia-smi. Python
tier's spawn-process-stdio raises FileNotFoundError on missing
binary, not returning #f as the prior code expected, which crashed
every worker on a CPU-only laptop.
- fork-self return discriminated via (number? pid) not (eq? pid 0).
Python tier's (eq? 0 #f) returns #t because == conflates int 0
with bool False; pre-existing run-loop has the same risk but
C/asm tier (identity eq?) masks it for the production case.
Phase 2 (server-side factory ops: compile uploaded .lsp recipes into
.bin before bending — the foxhop champion-circuit workflow) deferred
until authentication lands; today a worker on the public internet
would let any caller occupy our GPU.
Operational Caddy + DNS proposals in plans/bend-http-deploy.md cover
the personal-remote-access endpoint chain (proxy.unturf.com edge →
ai.foxhop.net Caddy → 3090-ai:8321) gated by trusted-IP allowlist —
applied separately.
Also codifies the playground "CSS Grid only, never flexbox" rule in
CLAUDE.md: all www/ and wasm/ stylesheets are already grid-only;
documenting the invariant so future edits don't drift.
Tests: smoke-bend-http.sh — (ping)→(ok pong), unknown-op fallback,
OPTIONS CORS preflight — all PASS. Wire path unchanged, verified
round-trip via 8-digit-prefix framing.
Variable-length signed bignums on the asm-wasm tier. Layout:
[tag=10, sign:i32, n_limbs:i32, limbs[]:u32]
Little-endian u32 limbs (base 2^32). i64 used for limb-pair products
in bn_mul and for the (rem << 32) | limb shift in bn_divmod_small.
Promotion: num_add/sub/mul/cmp inspect operands and pick the right
representation (fixnum, rational, bignum). Fixnum overflow in +/-/* is
detected by computing in i64 and checking against the 30-bit fixnum
range — outside that, operands lift to bignums.
(expt 2 1024) uses exponentiation-by-squaring through num_mul so
intermediate products auto-promote, returning the exact 309-digit value.
Reader: digit parsing accumulates via num_add/num_mul, so a literal of
any length reads as the narrowest representation that holds it.
Parity corpus: KNOWN_DIVERGE is now empty. 237/237 passing across
python (ref), c-wasm, and asm-wasm. New asserts pin the bignum surface
so a regression breaks make wasm-test immediately.
New: wasm/tests/parity-cross-tier.mjs runs the parity-corpus.mjs (216
test cases tagged by whitepaper section / R7RS concept) against three
tiers — native python (reference), c-wasm, asm-wasm — and fails on any
unknown divergence. Known gaps live in KNOWN_DIVERGE so the table stays
green while the bignum / call/cc / etc. work proceeds.
Wired into `make wasm-test` so a regression against any spec claim gets
caught before merge.
Bugs caught and fixed:
- python remainder: was `signed_a % signed_b * sign(a)`, which double-
applied the sign of a (python's % floors) — gave -3 for (-17, 5)
instead of the R7RS-correct -2. Now uses abs() on both sides.
- asm-wasm modulo: was i32.rem_s (truncated, remainder semantics)
where R7RS modulo wants sign of divisor. Added the "if rem and
divisor disagree on sign, add divisor" branch.
Cross-tier numbers after fix:
216 passing
3 known diverge: expt-2-100, expt-3-50, big-arith — all asm-wasm
(no bignums on the asm tier yet; whitepaper §2.1 claim still open)
0 fail
REPL layout: body is now the scroll container, prompt-bar is
position:fixed at the viewport bottom so it doesn't get pushed off
screen by a long transcript. Empty space above the prompt on a fresh
session reads like a terminal.
All other tests still pass: 20 unit, 8 integration, 11 functional.
Adds tag-9 rational type to the asm tier. Layout [tag=9, num:i32, den:i32].
make_rational normalizes via gcd and collapses to a fixnum when den
reduces to 1, so 14/2 stays as 7.
Arithmetic (+, -, *, /, =, <, >, <=, >=) now promotes to rational when
any argument is rational. Mixed fixnum/rational lifts the fixnum
accumulator into a rational mid-loop so (+ 1 1/2) returns 3/2, not 1/2.
Reader parses "67/7" literals via the existing atom path: after the
numerator's digits, if '/' follows we keep reading the denominator and
hand back a normalized rational. Falls through to symbol if either side
isn't all digits.
Printer renders rationals as "n/d". equal_p compares numbers by value
(1/2 = 2/4, 3 = 6/2). is_number / number? cover both fixnums and
rationals.
eval now treats rationals as self-evaluating — without this, '1/3'
parsed correctly but evaluated to VOID.
Mandelbrot demo: switched from (/ a b) to (quotient a b) for the
fixed-point math. The demo had been relying on integer truncation
that '/' no longer provides on tiers with R7RS-correct rationals.
Bignums still pending: 31-bit num/den overflows with huge denominators.
Real lift comes with the bignum task in the C tier (which has them) or
a new bignum module in the WAT.
Cross-tier check still hangs on the bigger TCO-heavy sections of
functional.lsp — separate from rationals. Will keep grinding.
Tests: unit 20/20, integration 8/8, functional 11/11.
Both bytecode VMs had a latent O(n^2) defect on self-recursive tail
calls invoked from inside let/let*/letrec/letrec*/do bodies. The
self-tail-call op assumed reusing "current env" was safe, but current
env was the innermost let* frame, not the lambda body env. Each iter
pushed a fresh let* frame on top (PUSH_ENV at compile site), the
self-tail-call rebound params into that frame & jumped to ip=0 without
unwinding. Env chain grew linearly with iters; every var lookup walked
O(n) chain; effective O(n^2) behaviour.
Symptom observed 2026-06-14: 156k circ-ops walk hung > 5min instead of
1.4s. K=5 doctrine reducers ran 30+ runaway lumbda procs at 99% CPU
across multiple `make sweep-doctrine` invocations before we tracked
it back to language layer (initially misdiagnosed as K=5 substrate).
Fix: track scope depth at compile time on CodeObj (scope_depth bumped
on PUSH_ENV emit, decremented on POP_ENV emit). Record self_base at
lambda body entry (0 unless internal defines pushed a frame). At
self-tail-call emit, encode pops_needed = scope_depth - self_base in
the op arg. Runtime handler unwinds that many env frames before
rebinding params + jumping to ip=0.
Tree-walker (c/lumbda without --fast) already worked - it walks the
ast & lets recursion clean up frames naturally. Asm tier also fine -
no self-tail-call op, uses different lambda-call convention.
Verification:
python tier: 571 tests PASS, our 100k let* repro 1.04s wall (was infinite)
c tier: 205 tests PASS, same repro 0.05s wall (was infinite)
asm tier: 158 tests PASS (no fix needed, never had the bug)
Portal-resume backwards-compat: pre-fix portals stored OP_SELF_TAIL_CALL
arg as 2-tuple. Deserializer fills pops=0 when 'pops' key is absent,
so an old portal resumes at correct behaviour at the cost of slow walk
on its very next self-tail-call body (no worse than pre-fix).
Memory note saved at reference_lumbda_let_star_in_tail_loop in our
foxhop blackops memory for future agents.
num_div for two integers used to fall back to double when the
quotient wasn't exact. R7RS / python lumbda require exact-in →
exact-out for /. Fixed: the rational_normalize path was already
wired for the is_exact branch; the int/int branch now calls it
too instead of make_double.
(/ 67 7) → 67/7 (was 9.5714285714285712)
(/ 1 3) → 1/3 (was 0.33333…)
(/ 6 2) → 3 (exact stays integer)
(+ 1/3 1/6) → 1/2 (rational arithmetic propagates)
C native + C-WASM tier now match python lumbda on / between integers.
asm tier rationals remain pending — that needs bignums in asm first.
native c-test: 205/205 still passes.
WASM tail-call instruction wired into every tail position:
if branches, eval_begin last expression, all special-form dispatchers
(cond/when/unless/case/let/let*/letrec/begin/and/or), function
application's $apply, and apply's closure-body $eval_begin.
wat2wasm + wasm-validate now use --enable-tail-call.
Reader fixes:
- Dotted pair syntax: (a b . rest) parses as a real dotted list.
Without this, variadic params and other dotted-cdr forms parsed
as 4-element proper lists.
- Stray ) at top level advances source_ptr instead of spinning
forever. Found via bisection of functional.lsp under TCO.
- eval_args guards against non-pair tails so a misplaced dotted
argument (e.g. an unexpanded macro template) can't deref garbage.
Cross-tier numbers (wasm-test-functional-cross):
before: 93/111 reached, stack overflow on (ack 3 4)
now: progresses through the full TCO section, deep let, named-let
to 100k, mutual recursion to 200k. Still climbing.
Bend gpu demo:
6th playground option ("bend (gpu dispatch) ⚡") ships a SHAKE256
fan-out at 1M inputs via (bend!-call ...). Run button is GUARDED:
if no bend URL is configured, refuses with
"set a bend URL first — this demo is GPU-only by design".
Protects customer machines from burning minutes on a workload the
local tiers cannot finish in reasonable time.
Two follow-up defects from 2026-06-14 factory triage:
(1) DLQ runner classifier did not recognize "vram-oversized" reason text
introduced by foxhop dispatcher pre-flight (commit 8d45e0d on the foxhop
side). 65 of 87 rDLQ cells got escalated as class=unknown instead of a
properly named bucket. Adds pattern + escalate-class entry + reducer
test case mapped to (vram-oversized sim no — bin is dead weight on this
card, salvage skipped).
(2) sweep-doctrine reducers had no per-test timeout. K=5 doctrine tests
(test-k5-apply-forward-ipmul + 4 siblings) ran lumbda at 99% CPU for
2h43m on a remote node without ever emitting their DOCTRINE verdict
line — accumulating 30+ runaway lumbda procs under two stuck `make
sweep-doctrine` invocations. run.sh + run-parallel.sh now wrap our
lumbda invocation in `timeout ${SWEEP_DOCTRINE_TEST_TIMEOUT_S:-300}`;
hit exits 124, our existing "no DOCTRINE line" branch logs HARNESS-FAIL.
K=5 substrate has a documented non-terminating compute defect AND a
load-time buffer overflow (commit 6d18c59 on foxhop). Bisect deferred
per ticket 0007 in foxhop tree; needs qemu apparatus we currently lack.
Header (logo + tagline) is the only fixed region. Footer removed.
Everything else — tab bar, transcript, prompt — now lives inside one
.repl-stream scroller. A fresh session shows the prompt right under
the tabs near the top; as entries arrive the prompt drifts down with
them. Tabs use a dashed bottom rule instead of a heavy bar so they
read as the start of the stream rather than a separate chrome strip.
Reads as one continuous stream now. Each entry is just:
λ> <input>
<output> ; tier · NNms
No left border, no boxed cards, no side-column tier label. Output
indents under the prompt (3ch) using monospace ch units. Tier+time
render as a Lisp-comment-style suffix in muted color.
Prompt bar: borderless textarea on the code-bg surface so the input
visually joins the transcript above. Placeholder cut to "(+ 1 2)" —
the surrounding text already explains the semantics.
Multi-line inputs keep prompt continuation marks ("..").
- bend URL and vault password now share a single config-bar row, split
via a 2-column grid (1fr 1fr). Vault still hidden when free-form
isn't selected; just collapses its column.
- Every flexbox removed. Every multi-child container uses CSS grid:
.controls, .config-bar, .bend-bar, .vault-bar, .panes, .pane,
.brand, .tabbar, .tabs, .tab, .transcript, .entry, .tier-output,
.prompt-bar, .lock-screen, .lock-card, .lock-row.
- Added `[hidden] { display: none !important; }` so the ephemeral
button on the REPL lock screen actually hides the modal. Without
this, .lock-screen's `display: grid` overrode the hidden attribute's
UA-default display: none.
bend!-call from the WAT tier
- New WAT import: (import "env" "bend_call"). The host loader supplies
a sync XMLHttpRequest that POSTs the payload to a configured URL
(workers only — sync XHR isn't allowed on main thread).
- New primitive (bend!-call "<payload>") returns the response as a
lumbda string. Works in playground and REPL once the bend URL is
saved in the new top bar.
- bendUrl persists in plain localStorage (not encrypted — it's a
server address, not a secret).
- Tests stub the import with a no-op so unit / integration / functional
suites keep instantiating cleanly.
Playground + REPL zoom 133% by default
- html { zoom: 1.33 } so the styleguide sizes read comfortably without
requiring browser-level zoom.
Free-form default = cross-tier assertion runner in Lisp
- The default editor content for "free form" is now a small assertion
framework matching tests/functional.lsp's PASS/FAIL convention. A
starter the user can extend, runs identically on the three tiers.
C tier + Python tier (bend) are wired through the runner stub; full
bend integration in those tiers comes next once their loaders learn
about setBendUrl.
Adds (write x) and (write-string s) primitives. write quotes strings
and #\-prefixes chars — what tests/functional.lsp's assert-equal
uses to print failures.
New target: make wasm-test-functional-cross (also rolled into wasm-test)
runs the 205-assertion tests/functional.lsp against each WASM tier and
reports pass counts:
c-wasm: 205 / 205 (full parity with native c)
asm-wasm: 93 / 111 reaches mid-suite before stack overflow on
a deeply recursive test; the 84% it reaches
passes. Documented progress toward full parity
with asm/lumbda.s.
The runner exit-soft on the asm tier — it's a measurement, not a gate.
Free-form radio adds a 5th demo slot. When selected, a vault bar appears
under the controls: enter a password, "unlock" derives a per-device
vault and decrypts (or creates fresh). Edits in the editor auto-save
350ms after typing stops. Reload + same password restores the code.
Same Web Crypto stack as /repl/ (PBKDF2 + AES-GCM, vault id =
SHA-256(password || device-salt)).
Layout: one shared vertical scroller — code pane and output pane both
grow with content, the body scrolls. No more independent in-pane
scrollers fighting the page.
Home page split into "Demo" and "REPL" sections with their own CTAs.
Interactive REPL at lumbda.com/repl with:
- multi-tab sessions (click + to add, × to close, double-click to rename)
- per-tab tier selector (python/c/asm/all-three race)
- persistent transcripts encrypted in localStorage via Web Crypto
(PBKDF2 + AES-GCM, vault id = SHA-256(password || device-salt) —
same pattern as unsandbox's vault-encryption-design.md, native
crypto.subtle API instead of CryptoJS)
- ephemeral mode (skip vault, transcripts vanish on reload)
- one worker per (tab × tier) — state persists across evals in a tab
- reboot tier button (terminate this tab's worker, fresh state next eval)
- cancel button (kills the running worker in active tab)
Home page now links to both /playground/ and /repl/.
Tier state itself does NOT persist across reloads — the transcript does,
but defines/set!/hash-tables vanish with the worker. Portal save/resume
in WAT (deferred) will let a tier session survive close+reopen.
WAT prelude (evaluated after primitive binding at init) adds:
map, filter, fold-left, fold-right, for-each, any, every,
count, find, sort (quicksort), vector-map, vector-for-each,
vector-fill!, string-split, string-trim, string->list,
random-state, assert-equal/true/false.
Higher-order ops are now Lisp-defined, not primitive bloat. Eval-time
parse + bind happens once per WASM instance startup.
Playground output: per fox, single append-only column instead of
3-up grid. Tiers still race in parallel workers; whichever finishes
first appears first in the output. Live ms counters move to the status
bar (python 312ms · c 47ms · asm 89ms).
Two new heap tags:
7 = vector [tag, len, elem_0, elem_1, ...] 8 + 4*len bytes
8 = hashtable [tag, count, alist_ptr] 12 bytes
Vector primitives (88-95):
vector, vector?, make-vector, vector-length,
vector-ref, vector-set!, vector->list, list->vector
Hash table primitives (96-106):
make-hash-table, hash-table?, hash-table-set!,
hash-table-ref, hash-table-ref/default,
hash-table-delete!, hash-table-exists?, hash-table-size,
hash-table-keys, hash-table-values, hash-table->alist
Hash table lookup is linear (equal? on each key) — fine for browser-scale
demos. Same linear-scan caveat as the symbol intern; would warrant a real
hash function at scale.
print_value now renders vectors as #(a b c) and hashtables as #<hashtable>.
One Worker per tier (python/c/asm). "All three" mode dispatches
Promise.all so the tiers race on independent threads — a slow Pyodide
no longer blocks C and asm. Each tier-block ticks its own ms counter
until its worker resolves.
Output grid: 3 columns when 3 tier-blocks render, else stacked.
Status bar announces the winner: "ok — c won in 47 ms".
Cancel terminates every active worker.
User-visible changes
- Cancel button — terminates the running worker. Pyodide's slow mandelbrot
no longer freezes the UI; click cancel and the elapsed counter freezes
at "(cancelled @ NNNN ms)".
- Live ms counter ticks per animation frame while a tier is busy, so the
Pyodide tier's ~5-15 s wait is visible instead of looking hung.
- Restyled to match lumbda.com: chunkfive wordmark, --green: #227842
(light) / #5ec07a (dark), lumbda-logo-green.png, lowercase "lumbda"
everywhere. Pulls fonts/chunkfive locally so the playground stays
self-contained.
Architecture
- All tier evaluations now run inside a Web Worker (wasm/app/worker.mjs)
so the main thread stays responsive. Cancel = worker.terminate(); next
eval respawns a fresh worker.
- Loaders use new URL("./...", import.meta.url) so paths resolve against
the loader file's own location — works identically in window and
worker contexts, no baseURL argument needed.
- C tier Emscripten build flipped to EXPORT_ES6=1; loader uses dynamic
`import()` of the factory module. Integration test updated accordingly.
- Python loader uses `import("pyodide.mjs")` (ES module) instead of
document.createElement, which doesn't exist in workers.
Bug fixes
- Asm tier state leak: running the same demo twice on a cached WASM
instance produced corrupted output (every other cell on row 2+ rendered
as " " instead of the expected shade char). Root cause: top-level eval
passed `global_env` as the env, so closures captured stale globals;
fixed by passing NIL — env_lookup falls back to the CURRENT global_env
via its existing two-pass walk. Multi-run regression added to the
functional test suite.
- fib-ack demo: (ack 3 4) was too heavy for Pyodide (minutes). Cut to
(ack 3 3) + (fib 20) max so every tier finishes in seconds.
Test discipline
- Root `make test-all` now includes `wasm-test`. Adding a language
feature without exercising it on all six implementations is no longer
possible by accident.
- Functional suite: 11 assertions (was 10) — adds asm multi-run stability.
- Integration + unit: still 20 + 8.
Adds a "Try it in your browser" section at the bottom of www/index.html
pointing to /playground/, plus a footer link for redundancy. Lands the
three-tier WASM SPA committed in 346b873 as a discoverable surface on
lumbda.com.
shopt -s nullglob (set so outer for-loop tolerates empty *.bin) made
an unmatched .inflight-* glob expand to nothing. Bare "ls >/dev/null"
then succeeded by listing CWD, and our if-branch incorrectly skipped
every orphan whose .inflight-* did not match. Net effect: medium-sized
orphan bins (100 MiB+, valid QECCOPS1 magic, no markers) accumulated
in our queue indefinitely across pool restarts.
Replace with compgen -G which returns success only when our pattern
matches, immune to nullglob.
Adds tests/integration/test-heal-orphan-bins.sh as TCRAUDT reducer
covering our contract: medium-size orphan promotes to .ready,
sub-threshold truncated to .done, bad-magic DLQs to dlq/.
Incident 2026-06-14: 8 vecC-tri-*-w8c.bin orphans (493 MiB each, Jun 12
emit) survived multiple foxhop pool restarts. Live factory queue
visible to operator as a persistent ~8-bin floor that never drained.
Adds a parallel build of all three Lumbda implementations to WASM, a
single-page playground at www/playground/, and a verified test suite.
Tiers
- Python: Pyodide (CPython-in-WASM) hosting lumbda.py
- C: Emscripten build of c/ (tree-walker + bytecode VM; jit.c
stubbed, gc.c uses its existing no-Boehm fallback)
- Asm: hand-written asm/lumbda.wat — parallel impl to asm/lumbda.s.
Reader, eval (lambda/define/if/cond/let/and/or/quote/set!),
recursion across mutated top-level env, bump allocator with
memory.grow, 24 primitives. ~1200 lines of raw WAT.
SPA (wasm/app/, deployed to www/playground/)
- CodeMirror 6 editor (Scheme highlighting) on left, output on right
- Radios: 4 demos (Mandelbrot, Fib+Ack, Sieve, self-interp meta-eval)
x 4 tiers (Python | C | Asm | All three)
- All-three mode renders the three tier outputs side by side with
per-tier elapsed timing
Tests (38 verified assertions)
- 20 unit (Node): per-tier module loads, eval smoke
- 8 integration (Node): each demo on c+asm WASM byte-matches the
canonical native Python run
- 10 functional (Playwright headless Chromium): page mounts, every
demo runs on every tier, all-three renders
Makefile
- Root targets: wasm-build, wasm-test, wasm-test-fn, wasm-serve,
wasm-deploy, wasm-clean
- wasm/Makefile orchestrates the three tier builds; deploy copies
dist/ into www/playground/
Asm tier notes
- WAT linear symbol intern + linear env lookup is MOAD-0001 at scale;
documented in the asm/lumbda.wat header and in the SPA footer. The
demos hit ~30 globals so the linear walks are cheap enough.
- Bump allocator never frees (matches asm/lumbda.s heap discipline);
memory.grow expands by 1 MB chunks. Browser tab tears down at unload.
Toolchain (developer prerequisites)
- Emscripten 6.0.0 via emsdk at ~/git/emsdk
- wabt 1.0.36 at ~/git/wabt
- Playwright for functional tests (symlinked from ~/git/agnt)
29 new files publish factory infra (V2 autoscaler with live VRAM
sampling + EWMA peak tracking, HUGE solo-dispatch, two-tier DLQ/rDLQ
classifier + retry), general quantum circuit primitives (Cuccaro
ripple-carry adder, Clifford gate library, Clifford tableau simulator,
mod-arith family, dialog GCD reversible inverse, Karatsuba multiplier,
Solinas fast reduction), and a TCRAUDT reducer harness. Originally
developed in ~/git/www.foxhop.net/ecdsa/ for secp256k1 attack-surface
research; published upstream as obligated by AGPLv3.
Parametrization contract at factory/CONTRACT.md. Consumers export
LUMBDA_REPO_DIR + LUMBDA_QUEUE_DIR + LUMBDA_BACKEND_CMD + LUMBDA_EMITTER_CMD
then exec factory scripts. No fork-and-modify; single source of truth
upstream.
Integration tests gate 7 V2 defect classes that wedged a live factory
on 2026-06-12 (skewed-demand starve, zero-floor reservation,
multi-tier greedy, +-25%% damping, cold-start ramp, DLQ surge halve,
post-damp CPU ceiling) + 28 DLQ classifier cases (auto-retry vs
escalate partition) + bash -n syntax lint across every script.
GPU backend stays in consumer trees; rationale in
factory/GPU-BACKEND-NOTE.md. Bend wire protocol + gpu-worker.lsp
already upstream at examples/cuda-fanout/.
make factory-lint bash -n on every factory/*.sh
make test-integration V2 reducer + DLQ classifier + syntax gate
make sweep-doctrine TCRAUDT reducer gate (serial)
make sweep-doctrine-parallel xargs -P fan-out
Verified on neoblanka: factory-lint 12 scripts PASS; test-integration
14 V2 cases + 28 DLQ classifier cases + 12 syntax cases all PASS.
Drop the static *vram-budget-mib*=22000 cap that fox flagged as wrong:
'we shouldn't limit with a max — the algo should determine how many
children based on the bend forms usage in vram.'
New algorithm:
- *gpu-total-mib* (24576 default, RTX 3090) + *gpu-headroom-mib* (1024 pad)
- *vram-per-cell-max-mib* (4096 seed) tracks largest cell observed.
- admit-fork? returns true iff
(current_vram + projected_cell + headroom) < gpu_total
- wait-admit blocks at run-loop top using projected = current per-cell
max. Self-tunes: tiny cells → many concurrent, huge cells → few.
Helper file-size-mib (stat -c %s) reads bin file size as cheap proxy
for per-cell VRAM (bin file on disk ≈ peak VRAM bend-cuda loads).
Open: cross-fork learning. record-cell-vram! runs IN THE CHILD so
parent's *vram-per-cell-max-mib* doesn't see updates without a fork-
shared signal (TODO: parent peek bin path before forking, or child
writes per-cell-size to small file the parent reads). For now the
seed value + max-tracking-in-future-runs handle the common case
where all cells are similar size.