diff --git a/wasm/app/demos/mandelbrot.lsp b/wasm/app/demos/mandelbrot.lsp index 8265ac2..9385341 100644 --- a/wasm/app/demos/mandelbrot.lsp +++ b/wasm/app/demos/mandelbrot.lsp @@ -1,6 +1,8 @@ ; Mandelbrot — fixed-point ASCII render. ; Runs identically on Python, C, and asm WASM tiers. -; The asm tier has no float support so we scale all coords by 1024. +; The asm tier has no float support so we scale all coords by 1024 +; and use quotient (integer truncation) so we don't accidentally +; surface rationals via /. (define SCALE 1024) (define SCALE4 4096) ; 4 * SCALE (escape threshold |z|^2) @@ -10,13 +12,13 @@ (define (escape-count cx cy) (define (loop zr zi n) - (let ((zr2 (/ (* zr zr) SCALE)) - (zi2 (/ (* zi zi) SCALE))) + (let ((zr2 (quotient (* zr zr) SCALE)) + (zi2 (quotient (* zi zi) SCALE))) (cond ((>= n MAXITER) MAXITER) ((> (+ zr2 zi2) SCALE4) n) (else (loop (+ (- zr2 zi2) cx) - (+ (/ (* 2 (/ (* zr zi) SCALE)) 1) cy) + (+ (* 2 (quotient (* zr zi) SCALE)) cy) (+ n 1)))))) (loop 0 0 0)) @@ -29,11 +31,11 @@ (else (display " ")))) (define (row py) - (define cy (- (/ (* py 2048) HEIGHT) 1024)) + (define cy (- (quotient (* py 2048) HEIGHT) 1024)) (define (col px) (if (< px WIDTH) (begin - (shade (escape-count (- (/ (* px 3072) WIDTH) 2048) cy)) + (shade (escape-count (- (quotient (* px 3072) WIDTH) 2048) cy)) (col (+ px 1))) (newline))) (col 0)) diff --git a/wasm/asm/lumbda.wat b/wasm/asm/lumbda.wat index aa4d092..e59093a 100644 --- a/wasm/asm/lumbda.wat +++ b/wasm/asm/lumbda.wat @@ -57,6 +57,12 @@ (global $global_env (mut i32) (i32.const 4)) ;; NIL initially (global $initialized (mut i32) (i32.const 0)) + ;; Scratch space for to_rat: the WAT one-result calling convention is + ;; awkward for "return two values", so to_rat sets these globals and + ;; rat_*_ops read them. Single-threaded execution makes this safe. + (global $rat_tmp_n (mut i32) (i32.const 0)) + (global $rat_tmp_d (mut i32) (i32.const 1)) + ;; Pre-allocated symbol pointers for special forms — filled at init. (global $sym_quote (mut i32) (i32.const 0)) (global $sym_if (mut i32) (i32.const 0)) @@ -179,6 +185,156 @@ (i32.store offset=4 (local.get $p) (local.get $code)) (local.get $p)) + ;; ─── Rationals (tag=9) ───────────────────────────────────────── + ;; Layout: [tag=9, num: i32, den: i32]. num/den are 31-bit signed. + ;; Real bignums would lift the overflow ceiling — TODO when the WAT + ;; tier grows arbitrary-precision integers. + (func $is_rational (param $v i32) (result i32) + (if (result i32) (call $is_fixnum (local.get $v)) + (then (i32.const 0)) + (else + (if (result i32) (call $is_immediate (local.get $v)) + (then (i32.const 0)) + (else (i32.eq (call $obj_tag (local.get $v)) (i32.const 9))))))) + + (func $is_number (param $v i32) (result i32) + (if (result i32) (call $is_fixnum (local.get $v)) + (then (i32.const 1)) + (else (call $is_rational (local.get $v))))) + + (func $rat_num (param $v i32) (result i32) + (i32.load offset=4 (local.get $v))) + + (func $rat_den (param $v i32) (result i32) + (i32.load offset=8 (local.get $v))) + + ;; Euclidean gcd on signed i32. Returns a positive result. + (func $gcd_i32 (param $a i32) (param $b i32) (result i32) + (local $t i32) + (if (i32.lt_s (local.get $a) (i32.const 0)) + (then (local.set $a (i32.sub (i32.const 0) (local.get $a))))) + (if (i32.lt_s (local.get $b) (i32.const 0)) + (then (local.set $b (i32.sub (i32.const 0) (local.get $b))))) + (block $done + (loop $l + (br_if $done (i32.eqz (local.get $b))) + (local.set $t (i32.rem_s (local.get $a) (local.get $b))) + (local.set $a (local.get $b)) + (local.set $b (local.get $t)) + (br $l))) + (local.get $a)) + + ;; Build a rational from i32 num/den. Normalizes via gcd, returns a + ;; fixnum if the denominator reduces to 1. + (func $make_rational (param $num i32) (param $den i32) (result i32) + (local $g i32) + (local $p i32) + (if (i32.eqz (local.get $den)) + (then + ;; div-by-zero: leave as raw num/0 so caller can handle as error + (local.set $p (call $alloc (i32.const 12))) + (i32.store (local.get $p) (i32.const 9)) + (i32.store offset=4 (local.get $p) (local.get $num)) + (i32.store offset=8 (local.get $p) (i32.const 0)) + (return (local.get $p)))) + ;; Move sign to numerator: keep den positive. + (if (i32.lt_s (local.get $den) (i32.const 0)) + (then + (local.set $num (i32.sub (i32.const 0) (local.get $num))) + (local.set $den (i32.sub (i32.const 0) (local.get $den))))) + (local.set $g (call $gcd_i32 (local.get $num) (local.get $den))) + (if (i32.gt_s (local.get $g) (i32.const 1)) + (then + (local.set $num (i32.div_s (local.get $num) (local.get $g))) + (local.set $den (i32.div_s (local.get $den) (local.get $g))))) + ;; den == 1 collapses to a fixnum. + (if (i32.eq (local.get $den) (i32.const 1)) + (then (return (call $make_fixnum (local.get $num))))) + (local.set $p (call $alloc (i32.const 12))) + (i32.store (local.get $p) (i32.const 9)) + (i32.store offset=4 (local.get $p) (local.get $num)) + (i32.store offset=8 (local.get $p) (local.get $den)) + (local.get $p)) + + ;; Convert a number Value to (num,den) returned via globals (numerator + ;; in $rat_tmp_n, denominator in $rat_tmp_d). Fixnum → (n, 1). + (func $to_rat (param $v i32) + (if (call $is_fixnum (local.get $v)) + (then + (global.set $rat_tmp_n (call $fixnum_val (local.get $v))) + (global.set $rat_tmp_d (i32.const 1)) + (return))) + (if (call $is_rational (local.get $v)) + (then + (global.set $rat_tmp_n (call $rat_num (local.get $v))) + (global.set $rat_tmp_d (call $rat_den (local.get $v))) + (return))) + ;; Anything else falls back to 0/1. + (global.set $rat_tmp_n (i32.const 0)) + (global.set $rat_tmp_d (i32.const 1))) + + (func $rat_add (param $a i32) (param $b i32) (result i32) + (local $an i32) (local $ad i32) (local $bn i32) (local $bd i32) + (call $to_rat (local.get $a)) + (local.set $an (global.get $rat_tmp_n)) (local.set $ad (global.get $rat_tmp_d)) + (call $to_rat (local.get $b)) + (local.set $bn (global.get $rat_tmp_n)) (local.set $bd (global.get $rat_tmp_d)) + (call $make_rational + (i32.add (i32.mul (local.get $an) (local.get $bd)) + (i32.mul (local.get $bn) (local.get $ad))) + (i32.mul (local.get $ad) (local.get $bd)))) + + (func $rat_sub (param $a i32) (param $b i32) (result i32) + (local $an i32) (local $ad i32) (local $bn i32) (local $bd i32) + (call $to_rat (local.get $a)) + (local.set $an (global.get $rat_tmp_n)) (local.set $ad (global.get $rat_tmp_d)) + (call $to_rat (local.get $b)) + (local.set $bn (global.get $rat_tmp_n)) (local.set $bd (global.get $rat_tmp_d)) + (call $make_rational + (i32.sub (i32.mul (local.get $an) (local.get $bd)) + (i32.mul (local.get $bn) (local.get $ad))) + (i32.mul (local.get $ad) (local.get $bd)))) + + (func $rat_mul (param $a i32) (param $b i32) (result i32) + (local $an i32) (local $ad i32) (local $bn i32) (local $bd i32) + (call $to_rat (local.get $a)) + (local.set $an (global.get $rat_tmp_n)) (local.set $ad (global.get $rat_tmp_d)) + (call $to_rat (local.get $b)) + (local.set $bn (global.get $rat_tmp_n)) (local.set $bd (global.get $rat_tmp_d)) + (call $make_rational + (i32.mul (local.get $an) (local.get $bn)) + (i32.mul (local.get $ad) (local.get $bd)))) + + (func $rat_div (param $a i32) (param $b i32) (result i32) + (local $an i32) (local $ad i32) (local $bn i32) (local $bd i32) + (call $to_rat (local.get $a)) + (local.set $an (global.get $rat_tmp_n)) (local.set $ad (global.get $rat_tmp_d)) + (call $to_rat (local.get $b)) + (local.set $bn (global.get $rat_tmp_n)) (local.set $bd (global.get $rat_tmp_d)) + (call $make_rational + (i32.mul (local.get $an) (local.get $bd)) + (i32.mul (local.get $ad) (local.get $bn)))) + + ;; Returns 1 if a == b as rationals, else 0. + (func $rat_eq (param $a i32) (param $b i32) (result i32) + (local $an i32) (local $ad i32) (local $bn i32) (local $bd i32) + (call $to_rat (local.get $a)) + (local.set $an (global.get $rat_tmp_n)) (local.set $ad (global.get $rat_tmp_d)) + (call $to_rat (local.get $b)) + (local.set $bn (global.get $rat_tmp_n)) (local.set $bd (global.get $rat_tmp_d)) + (i32.eq (i32.mul (local.get $an) (local.get $bd)) + (i32.mul (local.get $bn) (local.get $ad)))) + + ;; Returns 1 if a < b as rationals, else 0. Denominators always positive. + (func $rat_lt (param $a i32) (param $b i32) (result i32) + (local $an i32) (local $ad i32) (local $bn i32) (local $bd i32) + (call $to_rat (local.get $a)) + (local.set $an (global.get $rat_tmp_n)) (local.set $ad (global.get $rat_tmp_d)) + (call $to_rat (local.get $b)) + (local.set $bn (global.get $rat_tmp_n)) (local.set $bd (global.get $rat_tmp_d)) + (i32.lt_s (i32.mul (local.get $an) (local.get $bd)) + (i32.mul (local.get $bn) (local.get $ad)))) + ;; ─── Vectors (tag=7) ─────────────────────────────────────────── ;; Layout: [tag=7, len, elem_0, elem_1, ...] — 8 + 4*len bytes. (func $is_vector (param $v i32) (result i32) @@ -524,6 +680,12 @@ (local $len i32) (if (call $is_fixnum (local.get $v)) (then (call $out_int (call $fixnum_val (local.get $v))) (return))) + (if (call $is_rational (local.get $v)) + (then + (call $out_int (call $rat_num (local.get $v))) + (call $out_char (i32.const 47)) ;; / + (call $out_int (call $rat_den (local.get $v))) + (return))) (if (i32.eq (local.get $v) (global.get $NIL)) (then (call $out_str (i32.const 0xF000) (i32.const 2)) (return))) ;; "()" (if (i32.eq (local.get $v) (global.get $TRUE)) @@ -696,6 +858,8 @@ (local $byte i32) (local $sym i32) (local $end_str i32) + (local $den i32) + (local $den_start i32) (call $skip_ws) (if (i32.ge_u (global.get $source_ptr) (global.get $source_end)) (then (return (global.get $VOID)))) @@ -791,7 +955,32 @@ (then (if (local.get $neg) (then (local.set $n (i32.sub (i32.const 0) (local.get $n))))) - (return (call $make_fixnum (local.get $n))))))) + (return (call $make_fixnum (local.get $n))))) + ;; Try rational form: numerator '/' denominator (e.g. 67/7). + (if (i32.and + (i32.lt_u (local.get $i) (local.get $len)) + (i32.eq (i32.load8_u (i32.add (local.get $start) (local.get $i))) (i32.const 47))) + (then + (local.set $den (i32.const 0)) + (local.set $i (i32.add (local.get $i) (i32.const 1))) ;; skip / + (local.set $den_start (local.get $i)) + (block $den_done + (loop $den_loop + (br_if $den_done (i32.ge_u (local.get $i) (local.get $len))) + (local.set $byte (i32.load8_u (i32.add (local.get $start) (local.get $i)))) + (br_if $den_done (i32.eqz (call $is_digit (local.get $byte)))) + (local.set $den (i32.add (i32.mul (local.get $den) (i32.const 10)) + (i32.sub (local.get $byte) (i32.const 48)))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $den_loop))) + ;; Require: consumed all bytes AND denominator had at least 1 digit. + (if (i32.and + (i32.eq (local.get $i) (local.get $len)) + (i32.gt_s (local.get $i) (local.get $den_start))) + (then + (if (local.get $neg) + (then (local.set $n (i32.sub (i32.const 0) (local.get $n))))) + (return (call $make_rational (local.get $n) (local.get $den))))))))) ;; Symbol (return (call $intern (local.get $start) (local.get $len)))) @@ -961,9 +1150,11 @@ (local $params i32) (local $body i32) - ;; Self-evaluating: fixnum, immediate, string, char, closure, primitive + ;; Self-evaluating: fixnum, rational, immediate, string, char, closure, primitive (if (call $is_fixnum (local.get $expr)) (then (return (local.get $expr)))) + (if (call $is_rational (local.get $expr)) + (then (return (local.get $expr)))) (if (call $is_immediate (local.get $expr)) (then (return (local.get $expr)))) (if (call $is_string (local.get $expr)) @@ -1414,6 +1605,12 @@ (local $i i32) (if (i32.eq (local.get $a) (local.get $b)) (then (return (global.get $TRUE)))) + ;; Numeric values compare by value (1/2 == 1/2, fixnum 3 == 6/2). + (if (i32.and (call $is_number (local.get $a)) (call $is_number (local.get $b))) + (then + (if (call $rat_eq (local.get $a) (local.get $b)) + (then (return (global.get $TRUE)))) + (return (global.get $FALSE)))) (if (call $is_pair (local.get $a)) (then (if (i32.eqz (call $is_pair (local.get $b))) @@ -2046,6 +2243,9 @@ (local $rev i32) (local $idx i32) (local $idxt i32) + (local $any_rat i32) + (local $any_rat2 i32) + (local $any_rat3 i32) ;; Fetch first 2 args (most prims use 1 or 2). Defaults to fixnum 0. (local.set $a (call $make_fixnum (i32.const 0))) @@ -2056,25 +2256,62 @@ (if (i32.ne (call $cdr (local.get $args)) (global.get $NIL)) (then (local.set $b (call $car (call $cdr (local.get $args)))))))) - ;; + + ;; + — variadic; promotes to rational if any arg is rational. (if (i32.eq (local.get $id) (i32.const 1)) (then (local.set $sum (i32.const 0)) + (local.set $a (call $make_fixnum (i32.const 0))) (local.set $cur (local.get $args)) + (local.set $any_rat (i32.const 0)) (block $done (loop $loop (br_if $done (i32.eq (local.get $cur) (global.get $NIL))) - (local.set $sum (i32.add (local.get $sum) - (call $fixnum_val (call $car (local.get $cur))))) + (local.set $b (call $car (local.get $cur))) + (if (i32.and (call $is_rational (local.get $b)) (i32.eqz (local.get $any_rat))) + (then + ;; First rational encountered: lift the fixnum sum into a. + (local.set $a (call $make_fixnum (local.get $sum))) + (local.set $any_rat (i32.const 1)))) + (if (local.get $any_rat) + (then (local.set $a (call $rat_add (local.get $a) (local.get $b)))) + (else (local.set $sum (i32.add (local.get $sum) (call $fixnum_val (local.get $b)))))) (local.set $cur (call $cdr (local.get $cur))) (br $loop))) + (if (local.get $any_rat) + (then (return (local.get $a)))) (return (call $make_fixnum (local.get $sum))))) ;; - (if (i32.eq (local.get $id) (i32.const 2)) (then + ;; Unary case: negate. (if (i32.eq (call $cdr (local.get $args)) (global.get $NIL)) - (then (return (call $make_fixnum (i32.sub (i32.const 0) (call $fixnum_val (local.get $a))))))) + (then + (if (call $is_rational (local.get $a)) + (then (return (call $make_rational + (i32.sub (i32.const 0) (call $rat_num (local.get $a))) + (call $rat_den (local.get $a)))))) + (return (call $make_fixnum (i32.sub (i32.const 0) (call $fixnum_val (local.get $a))))))) + ;; Variadic difference. Scan rest looking for any rational. + (local.set $any_rat2 (call $is_rational (local.get $a))) + (local.set $cur (call $cdr (local.get $args))) + (block $rscan + (loop $rl + (br_if $rscan (i32.eq (local.get $cur) (global.get $NIL))) + (if (call $is_rational (call $car (local.get $cur))) + (then (local.set $any_rat2 (i32.const 1)))) + (local.set $cur (call $cdr (local.get $cur))) + (br $rl))) + (if (local.get $any_rat2) + (then + (local.set $cur (call $cdr (local.get $args))) + (block $rdone + (loop $rsub + (br_if $rdone (i32.eq (local.get $cur) (global.get $NIL))) + (local.set $a (call $rat_sub (local.get $a) (call $car (local.get $cur)))) + (local.set $cur (call $cdr (local.get $cur))) + (br $rsub))) + (return (local.get $a)))) (local.set $sum (call $fixnum_val (local.get $a))) (local.set $cur (call $cdr (local.get $args))) (block $done @@ -2090,25 +2327,43 @@ (if (i32.eq (local.get $id) (i32.const 3)) (then (local.set $sum (i32.const 1)) + (local.set $a (call $make_fixnum (i32.const 1))) (local.set $cur (local.get $args)) + (local.set $any_rat3 (i32.const 0)) (block $done (loop $loop (br_if $done (i32.eq (local.get $cur) (global.get $NIL))) - (local.set $sum (i32.mul (local.get $sum) - (call $fixnum_val (call $car (local.get $cur))))) + (local.set $b (call $car (local.get $cur))) + (if (i32.and (call $is_rational (local.get $b)) (i32.eqz (local.get $any_rat3))) + (then + (local.set $a (call $make_fixnum (local.get $sum))) + (local.set $any_rat3 (i32.const 1)))) + (if (local.get $any_rat3) + (then (local.set $a (call $rat_mul (local.get $a) (local.get $b)))) + (else (local.set $sum (i32.mul (local.get $sum) (call $fixnum_val (local.get $b)))))) (local.set $cur (call $cdr (local.get $cur))) (br $loop))) + (if (local.get $any_rat3) + (then (return (local.get $a)))) (return (call $make_fixnum (local.get $sum))))) - ;; / + ;; / — promotes int/int to rational when the result isn't integral + ;; (matches python lumbda and the C tier's recent fix). (if (i32.eq (local.get $id) (i32.const 4)) (then - (return (call $make_fixnum (i32.div_s (call $fixnum_val (local.get $a)) - (call $fixnum_val (local.get $b))))))) + (if (i32.or (call $is_rational (local.get $a)) (call $is_rational (local.get $b))) + (then (return (call $rat_div (local.get $a) (local.get $b))))) + (return (call $make_rational (call $fixnum_val (local.get $a)) + (call $fixnum_val (local.get $b)))))) ;; = (if (i32.eq (local.get $id) (i32.const 5)) (then + (if (i32.or (call $is_rational (local.get $a)) (call $is_rational (local.get $b))) + (then + (if (call $rat_eq (local.get $a) (local.get $b)) + (then (return (global.get $TRUE))) + (else (return (global.get $FALSE)))))) (if (i32.eq (call $fixnum_val (local.get $a)) (call $fixnum_val (local.get $b))) (then (return (global.get $TRUE))) (else (return (global.get $FALSE)))))) @@ -2116,6 +2371,11 @@ ;; < (if (i32.eq (local.get $id) (i32.const 6)) (then + (if (i32.or (call $is_rational (local.get $a)) (call $is_rational (local.get $b))) + (then + (if (call $rat_lt (local.get $a) (local.get $b)) + (then (return (global.get $TRUE))) + (else (return (global.get $FALSE)))))) (if (i32.lt_s (call $fixnum_val (local.get $a)) (call $fixnum_val (local.get $b))) (then (return (global.get $TRUE))) (else (return (global.get $FALSE)))))) @@ -2123,6 +2383,11 @@ ;; > (if (i32.eq (local.get $id) (i32.const 7)) (then + (if (i32.or (call $is_rational (local.get $a)) (call $is_rational (local.get $b))) + (then + (if (call $rat_lt (local.get $b) (local.get $a)) + (then (return (global.get $TRUE))) + (else (return (global.get $FALSE)))))) (if (i32.gt_s (call $fixnum_val (local.get $a)) (call $fixnum_val (local.get $b))) (then (return (global.get $TRUE))) (else (return (global.get $FALSE)))))) @@ -2130,6 +2395,12 @@ ;; <= (if (i32.eq (local.get $id) (i32.const 8)) (then + (if (i32.or (call $is_rational (local.get $a)) (call $is_rational (local.get $b))) + (then + (if (i32.or (call $rat_lt (local.get $a) (local.get $b)) + (call $rat_eq (local.get $a) (local.get $b))) + (then (return (global.get $TRUE))) + (else (return (global.get $FALSE)))))) (if (i32.le_s (call $fixnum_val (local.get $a)) (call $fixnum_val (local.get $b))) (then (return (global.get $TRUE))) (else (return (global.get $FALSE)))))) @@ -2137,6 +2408,12 @@ ;; >= (if (i32.eq (local.get $id) (i32.const 9)) (then + (if (i32.or (call $is_rational (local.get $a)) (call $is_rational (local.get $b))) + (then + (if (i32.or (call $rat_lt (local.get $b) (local.get $a)) + (call $rat_eq (local.get $a) (local.get $b))) + (then (return (global.get $TRUE))) + (else (return (global.get $FALSE)))))) (if (i32.ge_s (call $fixnum_val (local.get $a)) (call $fixnum_val (local.get $b))) (then (return (global.get $TRUE))) (else (return (global.get $FALSE)))))) @@ -2341,14 +2618,15 @@ (then (return (global.get $TRUE))) (else (return (global.get $FALSE)))))) - ;; number? (38) — fixnums only in this tier + ;; number? (38) — fixnum or rational (if (i32.eq (local.get $id) (i32.const 38)) (then - (if (call $is_fixnum (local.get $a)) + (if (call $is_number (local.get $a)) (then (return (global.get $TRUE))) (else (return (global.get $FALSE)))))) - ;; integer? (39) — same as number? here + ;; integer? (39) — fixnum only (a normalized rational with den=1 + ;; collapses to fixnum, so this implicitly handles 14/2 → 7). (if (i32.eq (local.get $id) (i32.const 39)) (then (if (call $is_fixnum (local.get $a)) diff --git a/wasm/dist-repl/asm/lumbda-asm.wasm b/wasm/dist-repl/asm/lumbda-asm.wasm index 8779c3f..33def25 100644 Binary files a/wasm/dist-repl/asm/lumbda-asm.wasm and b/wasm/dist-repl/asm/lumbda-asm.wasm differ diff --git a/wasm/dist-repl/c/lumbda-c.wasm b/wasm/dist-repl/c/lumbda-c.wasm index e3aa277..4f4ad55 100755 Binary files a/wasm/dist-repl/c/lumbda-c.wasm and b/wasm/dist-repl/c/lumbda-c.wasm differ diff --git a/wasm/dist-repl/python/lumbda.py b/wasm/dist-repl/python/lumbda.py index 3863d12..8ed1fa0 100644 --- a/wasm/dist-repl/python/lumbda.py +++ b/wasm/dist-repl/python/lumbda.py @@ -1203,13 +1203,28 @@ _BC_FOLDABLE = { class CodeObj: """Compiled bytecode chunk.""" - __slots__ = ('instrs', 'name', 'source_map', 'ic', '_cur_line', '_self_name', '_self_params') + __slots__ = ('instrs', 'name', 'source_map', 'ic', '_cur_line', + '_self_name', '_self_params', '_scope_depth', '_self_base') def __init__(self, name=None): self.instrs = []; self.name = name self.source_map = [] # parallel to instrs: line number or None self.ic = None # inline cache (populated at runtime) self._cur_line = None # current source line during compilation + # 2026-06-14 self-tail-call frame-unwind: tracks env-frame depth at + # compile time so OP_SELF_TAIL_CALL can pop accumulated let/let*/ + # letrec/do frames before reusing our lambda body env. Without this + # a (let* (...) (loop ...)) inside (let loop ...) bloated env per + # iter — 156k-element walk hung > 5 min instead of completing in + # 1.4s. _self_base records depth at lambda body entry; _scope_depth + # is current depth; pops_needed = depth - base at tail call site. + self._scope_depth = 0 + self._self_base = 0 def emit(self, op, arg=None): + # 2026-06-14 self-tail-call frame-unwind: track env-frame depth so + # OP_SELF_TAIL_CALL knows how many let/let*/letrec/do frames sit + # between us & our lambda body env. + if op == OP_PUSH_ENV: self._scope_depth += 1 + elif op == OP_POP_ENV: self._scope_depth -= 1 idx = len(self.instrs); self.instrs.append((op, arg)) self.source_map.append(self._cur_line) return idx @@ -1529,7 +1544,12 @@ def _bc(expr, code, env, tail=False): if tail and isinstance(head, Symbol) and hasattr(code, '_self_name') and str(head) == code._self_name: params = code._self_params for arg in call_args: _bc(arg, code, env) - code.emit(OP_SELF_TAIL_CALL, (len(call_args), tuple(params))); return + # 2026-06-14 frame-unwind: pop accumulated let/let*/letrec/do frames + # before we reuse our lambda body env. Without this each iter's + # let* frame stays on the env chain — env grows linearly with iters + # & every var lookup walks an O(n) chain → effective O(n^2). + pops_needed = code._scope_depth - code._self_base + code.emit(OP_SELF_TAIL_CALL, (len(call_args), tuple(params), pops_needed)); return # --- Function call --- _bc(head, code, env) @@ -1569,6 +1589,9 @@ def _bc_lambda(body, params, rest, env, name=None, self_name=None, self_params=N if def_names: inner.emit(OP_PUSH_ENV) for nm in def_names: inner.emit(OP_VOID); inner.emit(OP_BIND, nm) + # 2026-06-14: record baseline depth after any internal-defines frame. + # Our self-tail-call unwind pops back to here, not all the way to 0. + inner._self_base = inner._scope_depth _bc_body(body_list, inner, env, tail=True) inner.emit(OP_RETURN) _peephole(inner) @@ -1832,9 +1855,14 @@ def _vm_loop(instrs, ip, stack, env, frames, vm_id): c, addr = arg if _po() != c: ip = addr elif op == OP_SELF_TAIL_CALL: - n, params = arg + # 2026-06-14: arg now (n_args, params, pops_needed). pops_needed + # unwinds accumulated let/let*/letrec/do frames before we reuse + # our lambda body env — otherwise self-tail-call from inside a + # let* bloats env per iter & every var lookup walks O(n) chain. + n, params, pops = arg if n: args_ = stack[-n:]; del stack[-n:] else: args_ = [] + for _ in range(pops): env = env.p b = env.b for p, a in zip(params, args_): b[p] = a ip = 0; stack.clear(); continue @@ -1888,9 +1916,12 @@ def _serialize_operand(val): return {'t': 'closure', 'code': _serialize_code(code), 'params': [str(p) for p in params], 'rest': str(rest) if rest else None} - # OP_SELF_TAIL_CALL: (n_args, params_tuple) + # OP_SELF_TAIL_CALL: (n_args, params_tuple, pops_needed) + if len(val) == 3 and isinstance(val[0], int) and isinstance(val[1], tuple) and isinstance(val[2], int): + return {'t': 'stc', 'n': val[0], 'p': [str(p) for p in val[1]], 'pops': val[2]} + # Backwards-compat: pre-2026-06-14 portals have 2-tuple form. if len(val) == 2 and isinstance(val[0], int) and isinstance(val[1], tuple): - return {'t': 'stc', 'n': val[0], 'p': [str(p) for p in val[1]]} + return {'t': 'stc', 'n': val[0], 'p': [str(p) for p in val[1]], 'pops': 0} return {'t': 'repr', 'v': repr(val)} def _deserialize_operand(data): @@ -1921,7 +1952,8 @@ def _deserialize_operand(data): rest = S(data['rest']) if data['rest'] else None return (code, params, rest) if t == 'stc': - return (data['n'], tuple(S(p) for p in data['p'])) + # 2026-06-14: stc now carries pops field; older portals (no pops) → 0. + return (data['n'], tuple(S(p) for p in data['p']), data.get('pops', 0)) return data def _serialize_code(code): @@ -2632,7 +2664,8 @@ def _jit_transpile(instrs, params, name, has_self_tc): val = stack.pop() if stack else 'VOID' stmts.append(('return', val)); ip+=1 elif op == OP_SELF_TAIL_CALL: - tc_n, tc_params = arg + # arg is (n_args, params, pops_needed) since 2026-06-14 + tc_n = arg[0]; tc_params = arg[1] pnames = [str(p) for p in tc_params] args = []; for _ in range(tc_n): args.insert(0, stack.pop()) diff --git a/wasm/tests/functional.mjs b/wasm/tests/functional.mjs index 663b984..cbcb0a2 100644 --- a/wasm/tests/functional.mjs +++ b/wasm/tests/functional.mjs @@ -79,8 +79,8 @@ function nativePython(demoPath) { check("page loads", await page.title() !== ""); check("editor mounted", (await page.locator(".cm-editor").count()) > 0); - check("5 program radios (4 demos + free-form)", - (await page.locator('input[name="program"]').count()) === 5); + check("6 program radios (4 demos + bend-gpu + free-form)", + (await page.locator('input[name="program"]').count()) === 6); check("4 tier radios", (await page.locator('input[name="tier"]').count()) === 4); // Test each demo on C tier (fastest, deterministic). diff --git a/www/playground/asm/lumbda-asm.wasm b/www/playground/asm/lumbda-asm.wasm index 8779c3f..33def25 100644 Binary files a/www/playground/asm/lumbda-asm.wasm and b/www/playground/asm/lumbda-asm.wasm differ diff --git a/www/playground/c/lumbda-c.wasm b/www/playground/c/lumbda-c.wasm index c0835e8..4f4ad55 100755 Binary files a/www/playground/c/lumbda-c.wasm and b/www/playground/c/lumbda-c.wasm differ diff --git a/www/playground/demos/mandelbrot.lsp b/www/playground/demos/mandelbrot.lsp index 8265ac2..9385341 100644 --- a/www/playground/demos/mandelbrot.lsp +++ b/www/playground/demos/mandelbrot.lsp @@ -1,6 +1,8 @@ ; Mandelbrot — fixed-point ASCII render. ; Runs identically on Python, C, and asm WASM tiers. -; The asm tier has no float support so we scale all coords by 1024. +; The asm tier has no float support so we scale all coords by 1024 +; and use quotient (integer truncation) so we don't accidentally +; surface rationals via /. (define SCALE 1024) (define SCALE4 4096) ; 4 * SCALE (escape threshold |z|^2) @@ -10,13 +12,13 @@ (define (escape-count cx cy) (define (loop zr zi n) - (let ((zr2 (/ (* zr zr) SCALE)) - (zi2 (/ (* zi zi) SCALE))) + (let ((zr2 (quotient (* zr zr) SCALE)) + (zi2 (quotient (* zi zi) SCALE))) (cond ((>= n MAXITER) MAXITER) ((> (+ zr2 zi2) SCALE4) n) (else (loop (+ (- zr2 zi2) cx) - (+ (/ (* 2 (/ (* zr zi) SCALE)) 1) cy) + (+ (* 2 (quotient (* zr zi) SCALE)) cy) (+ n 1)))))) (loop 0 0 0)) @@ -29,11 +31,11 @@ (else (display " ")))) (define (row py) - (define cy (- (/ (* py 2048) HEIGHT) 1024)) + (define cy (- (quotient (* py 2048) HEIGHT) 1024)) (define (col px) (if (< px WIDTH) (begin - (shade (escape-count (- (/ (* px 3072) WIDTH) 2048) cy)) + (shade (escape-count (- (quotient (* px 3072) WIDTH) 2048) cy)) (col (+ px 1))) (newline))) (col 0)) diff --git a/www/playground/python/lumbda.py b/www/playground/python/lumbda.py index 3863d12..8ed1fa0 100644 --- a/www/playground/python/lumbda.py +++ b/www/playground/python/lumbda.py @@ -1203,13 +1203,28 @@ _BC_FOLDABLE = { class CodeObj: """Compiled bytecode chunk.""" - __slots__ = ('instrs', 'name', 'source_map', 'ic', '_cur_line', '_self_name', '_self_params') + __slots__ = ('instrs', 'name', 'source_map', 'ic', '_cur_line', + '_self_name', '_self_params', '_scope_depth', '_self_base') def __init__(self, name=None): self.instrs = []; self.name = name self.source_map = [] # parallel to instrs: line number or None self.ic = None # inline cache (populated at runtime) self._cur_line = None # current source line during compilation + # 2026-06-14 self-tail-call frame-unwind: tracks env-frame depth at + # compile time so OP_SELF_TAIL_CALL can pop accumulated let/let*/ + # letrec/do frames before reusing our lambda body env. Without this + # a (let* (...) (loop ...)) inside (let loop ...) bloated env per + # iter — 156k-element walk hung > 5 min instead of completing in + # 1.4s. _self_base records depth at lambda body entry; _scope_depth + # is current depth; pops_needed = depth - base at tail call site. + self._scope_depth = 0 + self._self_base = 0 def emit(self, op, arg=None): + # 2026-06-14 self-tail-call frame-unwind: track env-frame depth so + # OP_SELF_TAIL_CALL knows how many let/let*/letrec/do frames sit + # between us & our lambda body env. + if op == OP_PUSH_ENV: self._scope_depth += 1 + elif op == OP_POP_ENV: self._scope_depth -= 1 idx = len(self.instrs); self.instrs.append((op, arg)) self.source_map.append(self._cur_line) return idx @@ -1529,7 +1544,12 @@ def _bc(expr, code, env, tail=False): if tail and isinstance(head, Symbol) and hasattr(code, '_self_name') and str(head) == code._self_name: params = code._self_params for arg in call_args: _bc(arg, code, env) - code.emit(OP_SELF_TAIL_CALL, (len(call_args), tuple(params))); return + # 2026-06-14 frame-unwind: pop accumulated let/let*/letrec/do frames + # before we reuse our lambda body env. Without this each iter's + # let* frame stays on the env chain — env grows linearly with iters + # & every var lookup walks an O(n) chain → effective O(n^2). + pops_needed = code._scope_depth - code._self_base + code.emit(OP_SELF_TAIL_CALL, (len(call_args), tuple(params), pops_needed)); return # --- Function call --- _bc(head, code, env) @@ -1569,6 +1589,9 @@ def _bc_lambda(body, params, rest, env, name=None, self_name=None, self_params=N if def_names: inner.emit(OP_PUSH_ENV) for nm in def_names: inner.emit(OP_VOID); inner.emit(OP_BIND, nm) + # 2026-06-14: record baseline depth after any internal-defines frame. + # Our self-tail-call unwind pops back to here, not all the way to 0. + inner._self_base = inner._scope_depth _bc_body(body_list, inner, env, tail=True) inner.emit(OP_RETURN) _peephole(inner) @@ -1832,9 +1855,14 @@ def _vm_loop(instrs, ip, stack, env, frames, vm_id): c, addr = arg if _po() != c: ip = addr elif op == OP_SELF_TAIL_CALL: - n, params = arg + # 2026-06-14: arg now (n_args, params, pops_needed). pops_needed + # unwinds accumulated let/let*/letrec/do frames before we reuse + # our lambda body env — otherwise self-tail-call from inside a + # let* bloats env per iter & every var lookup walks O(n) chain. + n, params, pops = arg if n: args_ = stack[-n:]; del stack[-n:] else: args_ = [] + for _ in range(pops): env = env.p b = env.b for p, a in zip(params, args_): b[p] = a ip = 0; stack.clear(); continue @@ -1888,9 +1916,12 @@ def _serialize_operand(val): return {'t': 'closure', 'code': _serialize_code(code), 'params': [str(p) for p in params], 'rest': str(rest) if rest else None} - # OP_SELF_TAIL_CALL: (n_args, params_tuple) + # OP_SELF_TAIL_CALL: (n_args, params_tuple, pops_needed) + if len(val) == 3 and isinstance(val[0], int) and isinstance(val[1], tuple) and isinstance(val[2], int): + return {'t': 'stc', 'n': val[0], 'p': [str(p) for p in val[1]], 'pops': val[2]} + # Backwards-compat: pre-2026-06-14 portals have 2-tuple form. if len(val) == 2 and isinstance(val[0], int) and isinstance(val[1], tuple): - return {'t': 'stc', 'n': val[0], 'p': [str(p) for p in val[1]]} + return {'t': 'stc', 'n': val[0], 'p': [str(p) for p in val[1]], 'pops': 0} return {'t': 'repr', 'v': repr(val)} def _deserialize_operand(data): @@ -1921,7 +1952,8 @@ def _deserialize_operand(data): rest = S(data['rest']) if data['rest'] else None return (code, params, rest) if t == 'stc': - return (data['n'], tuple(S(p) for p in data['p'])) + # 2026-06-14: stc now carries pops field; older portals (no pops) → 0. + return (data['n'], tuple(S(p) for p in data['p']), data.get('pops', 0)) return data def _serialize_code(code): @@ -2632,7 +2664,8 @@ def _jit_transpile(instrs, params, name, has_self_tc): val = stack.pop() if stack else 'VOID' stmts.append(('return', val)); ip+=1 elif op == OP_SELF_TAIL_CALL: - tc_n, tc_params = arg + # arg is (n_args, params, pops_needed) since 2026-06-14 + tc_n = arg[0]; tc_params = arg[1] pnames = [str(p) for p in tc_params] args = []; for _ in range(tc_n): args.insert(0, stack.pop()) diff --git a/www/repl/asm/lumbda-asm.wasm b/www/repl/asm/lumbda-asm.wasm index 8779c3f..33def25 100644 Binary files a/www/repl/asm/lumbda-asm.wasm and b/www/repl/asm/lumbda-asm.wasm differ diff --git a/www/repl/c/lumbda-c.wasm b/www/repl/c/lumbda-c.wasm index c0835e8..4f4ad55 100755 Binary files a/www/repl/c/lumbda-c.wasm and b/www/repl/c/lumbda-c.wasm differ diff --git a/www/repl/python/lumbda.py b/www/repl/python/lumbda.py index 3863d12..8ed1fa0 100644 --- a/www/repl/python/lumbda.py +++ b/www/repl/python/lumbda.py @@ -1203,13 +1203,28 @@ _BC_FOLDABLE = { class CodeObj: """Compiled bytecode chunk.""" - __slots__ = ('instrs', 'name', 'source_map', 'ic', '_cur_line', '_self_name', '_self_params') + __slots__ = ('instrs', 'name', 'source_map', 'ic', '_cur_line', + '_self_name', '_self_params', '_scope_depth', '_self_base') def __init__(self, name=None): self.instrs = []; self.name = name self.source_map = [] # parallel to instrs: line number or None self.ic = None # inline cache (populated at runtime) self._cur_line = None # current source line during compilation + # 2026-06-14 self-tail-call frame-unwind: tracks env-frame depth at + # compile time so OP_SELF_TAIL_CALL can pop accumulated let/let*/ + # letrec/do frames before reusing our lambda body env. Without this + # a (let* (...) (loop ...)) inside (let loop ...) bloated env per + # iter — 156k-element walk hung > 5 min instead of completing in + # 1.4s. _self_base records depth at lambda body entry; _scope_depth + # is current depth; pops_needed = depth - base at tail call site. + self._scope_depth = 0 + self._self_base = 0 def emit(self, op, arg=None): + # 2026-06-14 self-tail-call frame-unwind: track env-frame depth so + # OP_SELF_TAIL_CALL knows how many let/let*/letrec/do frames sit + # between us & our lambda body env. + if op == OP_PUSH_ENV: self._scope_depth += 1 + elif op == OP_POP_ENV: self._scope_depth -= 1 idx = len(self.instrs); self.instrs.append((op, arg)) self.source_map.append(self._cur_line) return idx @@ -1529,7 +1544,12 @@ def _bc(expr, code, env, tail=False): if tail and isinstance(head, Symbol) and hasattr(code, '_self_name') and str(head) == code._self_name: params = code._self_params for arg in call_args: _bc(arg, code, env) - code.emit(OP_SELF_TAIL_CALL, (len(call_args), tuple(params))); return + # 2026-06-14 frame-unwind: pop accumulated let/let*/letrec/do frames + # before we reuse our lambda body env. Without this each iter's + # let* frame stays on the env chain — env grows linearly with iters + # & every var lookup walks an O(n) chain → effective O(n^2). + pops_needed = code._scope_depth - code._self_base + code.emit(OP_SELF_TAIL_CALL, (len(call_args), tuple(params), pops_needed)); return # --- Function call --- _bc(head, code, env) @@ -1569,6 +1589,9 @@ def _bc_lambda(body, params, rest, env, name=None, self_name=None, self_params=N if def_names: inner.emit(OP_PUSH_ENV) for nm in def_names: inner.emit(OP_VOID); inner.emit(OP_BIND, nm) + # 2026-06-14: record baseline depth after any internal-defines frame. + # Our self-tail-call unwind pops back to here, not all the way to 0. + inner._self_base = inner._scope_depth _bc_body(body_list, inner, env, tail=True) inner.emit(OP_RETURN) _peephole(inner) @@ -1832,9 +1855,14 @@ def _vm_loop(instrs, ip, stack, env, frames, vm_id): c, addr = arg if _po() != c: ip = addr elif op == OP_SELF_TAIL_CALL: - n, params = arg + # 2026-06-14: arg now (n_args, params, pops_needed). pops_needed + # unwinds accumulated let/let*/letrec/do frames before we reuse + # our lambda body env — otherwise self-tail-call from inside a + # let* bloats env per iter & every var lookup walks O(n) chain. + n, params, pops = arg if n: args_ = stack[-n:]; del stack[-n:] else: args_ = [] + for _ in range(pops): env = env.p b = env.b for p, a in zip(params, args_): b[p] = a ip = 0; stack.clear(); continue @@ -1888,9 +1916,12 @@ def _serialize_operand(val): return {'t': 'closure', 'code': _serialize_code(code), 'params': [str(p) for p in params], 'rest': str(rest) if rest else None} - # OP_SELF_TAIL_CALL: (n_args, params_tuple) + # OP_SELF_TAIL_CALL: (n_args, params_tuple, pops_needed) + if len(val) == 3 and isinstance(val[0], int) and isinstance(val[1], tuple) and isinstance(val[2], int): + return {'t': 'stc', 'n': val[0], 'p': [str(p) for p in val[1]], 'pops': val[2]} + # Backwards-compat: pre-2026-06-14 portals have 2-tuple form. if len(val) == 2 and isinstance(val[0], int) and isinstance(val[1], tuple): - return {'t': 'stc', 'n': val[0], 'p': [str(p) for p in val[1]]} + return {'t': 'stc', 'n': val[0], 'p': [str(p) for p in val[1]], 'pops': 0} return {'t': 'repr', 'v': repr(val)} def _deserialize_operand(data): @@ -1921,7 +1952,8 @@ def _deserialize_operand(data): rest = S(data['rest']) if data['rest'] else None return (code, params, rest) if t == 'stc': - return (data['n'], tuple(S(p) for p in data['p'])) + # 2026-06-14: stc now carries pops field; older portals (no pops) → 0. + return (data['n'], tuple(S(p) for p in data['p']), data.get('pops', 0)) return data def _serialize_code(code): @@ -2632,7 +2664,8 @@ def _jit_transpile(instrs, params, name, has_self_tc): val = stack.pop() if stack else 'VOID' stmts.append(('return', val)); ip+=1 elif op == OP_SELF_TAIL_CALL: - tc_n, tc_params = arg + # arg is (n_args, params, pops_needed) since 2026-06-14 + tc_n = arg[0]; tc_params = arg[1] pnames = [str(p) for p in tc_params] args = []; for _ in range(tc_n): args.insert(0, stack.pop())