wat tier: rationals — (/ 67 7) → 67/7 + arithmetic + reader + printer

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.
This commit is contained in:
russell@unturf.com 2026-06-14 15:11:36 -04:00
parent 78fbd906f0
commit c50a9da7e8
No known key found for this signature in database
13 changed files with 430 additions and 49 deletions

View file

@ -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))

View file

@ -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))

Binary file not shown.

Binary file not shown.

View file

@ -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())

View file

@ -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).

Binary file not shown.

Binary file not shown.

View file

@ -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))

View file

@ -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())

Binary file not shown.

Binary file not shown.

View file

@ -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())