diff --git a/lumbda.py b/lumbda.py index 8ed1fa0..77d5433 100644 --- a/lumbda.py +++ b/lumbda.py @@ -3438,7 +3438,11 @@ def make_global_env(): return n d(S('/'), _div) d(S('quotient'), lambda a, _: (lambda x, y: -(abs(int(x)) // abs(int(y))) if (x < 0) != (y < 0) else abs(int(x)) // abs(int(y)))(_num(a[0]), _num(a[1]))) - d(S('remainder'), lambda a, _: int(_num(a[0])) % int(_num(a[1])) * (1 if _num(a[0]) >= 0 else -1)) + # R7RS: remainder has the sign of the dividend; uses truncated division. + # The previous impl did `signed_a % signed_b * sign(a)` which double-counted + # the sign of a (Python's % floors, so `-17 % 5 == 3`) and gave -3 for + # (-17, 5) instead of the correct -2. Use abs() on both sides, then re-sign. + d(S('remainder'), lambda a, _: (abs(int(_num(a[0]))) % abs(int(_num(a[1])))) * (1 if _num(a[0]) >= 0 else -1)) d(S('modulo'), lambda a, _: int(_num(a[0])) % int(_num(a[1]))) d(S('expt'), lambda a, _: _num(a[0]) ** _num(a[1])) d(S('abs'), lambda a, _: abs(_num(a[0]))) diff --git a/wasm/Makefile b/wasm/Makefile index dba68ee..fac3c36 100644 --- a/wasm/Makefile +++ b/wasm/Makefile @@ -168,7 +168,7 @@ repl: build # ─── Tests ───────────────────────────────────────────────────────── -test: test-unit test-integration test-functional-cross +test: test-unit test-integration test-parity test-functional-cross test-unit: build @echo "── wasm unit tests ──" @@ -178,6 +178,15 @@ test-integration: build @echo "── wasm integration tests (cross-tier diff) ──" node tests/integration.mjs +# Cross-tier parity probe — every expression in parity-corpus.mjs runs on +# native python (reference), c-wasm, and asm-wasm. Fails on any unknown +# divergence so a regression against whitepaper § normative claims gets +# caught before merge. Known gaps (bignums on asm-wasm today) live in +# KNOWN_DIVERGE; trim that list as the gaps close. +test-parity: build + @echo "── wasm cross-tier parity probe ──" + node tests/parity-cross-tier.mjs + # Cross-tier functional.lsp runner. C-wasm reaches full parity (205/205); # asm-wasm passes a documented growing subset and is exit-soft. test-functional-cross: build diff --git a/wasm/asm/lumbda.wat b/wasm/asm/lumbda.wat index e59093a..4da9349 100644 --- a/wasm/asm/lumbda.wat +++ b/wasm/asm/lumbda.wat @@ -2498,11 +2498,19 @@ (then (local.set $sum (i32.sub (i32.const 0) (local.get $sum))))) (return (call $make_fixnum (local.get $sum))))) - ;; modulo + ;; modulo — R7RS: result has the sign of the divisor. + ;; i32.rem_s by itself gives remainder semantics (sign of dividend); + ;; we add the divisor if the rem and divisor disagree on sign. (if (i32.eq (local.get $id) (i32.const 23)) (then - (return (call $make_fixnum (i32.rem_s (call $fixnum_val (local.get $a)) - (call $fixnum_val (local.get $b))))))) + (local.set $sum (i32.rem_s (call $fixnum_val (local.get $a)) + (call $fixnum_val (local.get $b)))) + (if (i32.and + (i32.ne (local.get $sum) (i32.const 0)) + (i32.lt_s (i32.mul (local.get $sum) (call $fixnum_val (local.get $b))) + (i32.const 0))) + (then (local.set $sum (i32.add (local.get $sum) (call $fixnum_val (local.get $b)))))) + (return (call $make_fixnum (local.get $sum))))) ;; zero? (if (i32.eq (local.get $id) (i32.const 24)) diff --git a/wasm/dist-repl/asm/lumbda-asm.wasm b/wasm/dist-repl/asm/lumbda-asm.wasm index 33def25..3a30eb5 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/python/lumbda.py b/wasm/dist-repl/python/lumbda.py index 8ed1fa0..77d5433 100644 --- a/wasm/dist-repl/python/lumbda.py +++ b/wasm/dist-repl/python/lumbda.py @@ -3438,7 +3438,11 @@ def make_global_env(): return n d(S('/'), _div) d(S('quotient'), lambda a, _: (lambda x, y: -(abs(int(x)) // abs(int(y))) if (x < 0) != (y < 0) else abs(int(x)) // abs(int(y)))(_num(a[0]), _num(a[1]))) - d(S('remainder'), lambda a, _: int(_num(a[0])) % int(_num(a[1])) * (1 if _num(a[0]) >= 0 else -1)) + # R7RS: remainder has the sign of the dividend; uses truncated division. + # The previous impl did `signed_a % signed_b * sign(a)` which double-counted + # the sign of a (Python's % floors, so `-17 % 5 == 3`) and gave -3 for + # (-17, 5) instead of the correct -2. Use abs() on both sides, then re-sign. + d(S('remainder'), lambda a, _: (abs(int(_num(a[0]))) % abs(int(_num(a[1])))) * (1 if _num(a[0]) >= 0 else -1)) d(S('modulo'), lambda a, _: int(_num(a[0])) % int(_num(a[1]))) d(S('expt'), lambda a, _: _num(a[0]) ** _num(a[1])) d(S('abs'), lambda a, _: abs(_num(a[0]))) diff --git a/wasm/dist-repl/repl.css b/wasm/dist-repl/repl.css index 8adf2e0..e476dac 100644 --- a/wasm/dist-repl/repl.css +++ b/wasm/dist-repl/repl.css @@ -1,11 +1,12 @@ /* lumbda repl — grid-only layout. Inherits palette + base from style.css. */ body.repl { - display: grid; - /* header (fixed) scrolling-stream (everything else) */ - grid-template-rows: auto 1fr; - height: 100vh; - overflow: hidden; + /* Body scrolls naturally. The prompt-bar is position: fixed so it + * stays glued to the viewport bottom; the transcript reserves bottom + * padding equal to the prompt-bar height so its last line isn't hidden + * underneath. */ + min-height: 100vh; + overflow: auto; } /* ─── Lock screen overlay ──────────────────────────────────────── */ @@ -176,12 +177,13 @@ body.repl { * fresh sessions show the prompt up near the top and it drifts down * with each new entry. */ .repl-stream { - overflow: auto; - padding: 0.5rem 1rem 0.8rem; + padding: 0.5rem 1rem 0; background: var(--code-bg); display: grid; grid-template-rows: auto auto auto; align-content: start; + /* Leave room for the fixed prompt bar at the viewport bottom. */ + padding-bottom: calc(3.6rem + env(safe-area-inset-bottom, 0)); } .transcript { font-family: var(--mono); @@ -232,12 +234,16 @@ body.repl { /* ─── Prompt bar ───────────────────────────────────────────────── */ .prompt-bar { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 50; display: grid; grid-template-columns: auto 1fr auto auto; gap: 0.4rem; align-items: center; - padding: 0.3rem 0; - background: transparent; + padding: 0.5rem 1rem; + background: var(--code-bg); + border-top: 1px solid var(--rule); } .prompt-bar .prompt-sigil { color: var(--green); diff --git a/wasm/dist-repl/repl.js b/wasm/dist-repl/repl.js index 7062df8..7b2f8d5 100644 --- a/wasm/dist-repl/repl.js +++ b/wasm/dist-repl/repl.js @@ -287,7 +287,8 @@ function renderAll() { } transcriptEl.appendChild(block); } - replStream.scrollTop = replStream.scrollHeight; + // Body owns the scroll now; jump it to the latest entry. + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); } // ─── Input handling ───────────────────────────────────────────────── diff --git a/wasm/repl/repl.css b/wasm/repl/repl.css index 8adf2e0..e476dac 100644 --- a/wasm/repl/repl.css +++ b/wasm/repl/repl.css @@ -1,11 +1,12 @@ /* lumbda repl — grid-only layout. Inherits palette + base from style.css. */ body.repl { - display: grid; - /* header (fixed) scrolling-stream (everything else) */ - grid-template-rows: auto 1fr; - height: 100vh; - overflow: hidden; + /* Body scrolls naturally. The prompt-bar is position: fixed so it + * stays glued to the viewport bottom; the transcript reserves bottom + * padding equal to the prompt-bar height so its last line isn't hidden + * underneath. */ + min-height: 100vh; + overflow: auto; } /* ─── Lock screen overlay ──────────────────────────────────────── */ @@ -176,12 +177,13 @@ body.repl { * fresh sessions show the prompt up near the top and it drifts down * with each new entry. */ .repl-stream { - overflow: auto; - padding: 0.5rem 1rem 0.8rem; + padding: 0.5rem 1rem 0; background: var(--code-bg); display: grid; grid-template-rows: auto auto auto; align-content: start; + /* Leave room for the fixed prompt bar at the viewport bottom. */ + padding-bottom: calc(3.6rem + env(safe-area-inset-bottom, 0)); } .transcript { font-family: var(--mono); @@ -232,12 +234,16 @@ body.repl { /* ─── Prompt bar ───────────────────────────────────────────────── */ .prompt-bar { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 50; display: grid; grid-template-columns: auto 1fr auto auto; gap: 0.4rem; align-items: center; - padding: 0.3rem 0; - background: transparent; + padding: 0.5rem 1rem; + background: var(--code-bg); + border-top: 1px solid var(--rule); } .prompt-bar .prompt-sigil { color: var(--green); diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index 7062df8..7b2f8d5 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -287,7 +287,8 @@ function renderAll() { } transcriptEl.appendChild(block); } - replStream.scrollTop = replStream.scrollHeight; + // Body owns the scroll now; jump it to the latest entry. + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); } // ─── Input handling ───────────────────────────────────────────────── diff --git a/wasm/tests/parity-corpus.mjs b/wasm/tests/parity-corpus.mjs new file mode 100644 index 0000000..18dd2f9 --- /dev/null +++ b/wasm/tests/parity-corpus.mjs @@ -0,0 +1,131 @@ +// wasm/tests/parity-corpus.mjs +// The cross-tier parity corpus. Each entry is one expression; we expect +// every tier to produce the same output. Entries are tagged with the +// whitepaper section / R7RS concept they exercise so a regression can be +// traced to the normative claim it violates. +// +// `expected` is set to the python tier's output (the reference, per +// "Exact rational arithmetic uses Python's Fraction type"); we mark the +// known-divergent ones with `knownDiverge: ["asm"]` so the suite remains +// green while documenting the gap. + +export const CORPUS = [ + // ─── numeric tower: rationals ──────────────────────────────────── + { tag: "rat-div", src: "(/ 67 7)", expected: "67/7" }, + { tag: "rat-div-one", src: "(/ 1 3)", expected: "1/3" }, + { tag: "rat-div-exact", src: "(/ 6 2)", expected: "3" }, + { tag: "rat-add", src: "(+ 1/3 1/6)", expected: "1/2" }, + { tag: "rat-mul", src: "(* 2/3 3/4)", expected: "1/2" }, + { tag: "rat-sub", src: "(- 3/4 1/2)", expected: "1/4" }, + { tag: "rat-mixed-add", src: "(+ 1 1/2)", expected: "3/2" }, + { tag: "rat-eq-norm", src: "(= 1/2 2/4)", expected: "#t" }, + { tag: "rat-eq-int", src: "(= 3 6/2)", expected: "#t" }, + { tag: "rat-lt", src: "(< 1/3 1/2)", expected: "#t" }, + { tag: "number?-rat", src: "(number? 1/3)", expected: "#t" }, + { tag: "integer?-rat", src: "(integer? 1/3)", expected: "#f" }, + { tag: "integer?-int", src: "(integer? 6/2)", expected: "#t" }, + + // ─── numeric tower: bignums (whitepaper §2.1 claim) ────────────── + { tag: "expt-2-100", src: "(expt 2 100)", + expected: "1267650600228229401496703205376" }, + { tag: "expt-3-50", src: "(expt 3 50)", + expected: "717897987691852588770249" }, + { tag: "big-arith", src: "(* 12345678901234567890 12345678901234567890)", + expected: "152415787532388367501905199875019052100" }, + + // ─── division semantics ────────────────────────────────────────── + { tag: "quotient-pos", src: "(quotient 17 5)", expected: "3" }, + { tag: "quotient-neg", src: "(quotient -17 5)", expected: "-3" }, + { tag: "remainder-pos", src: "(remainder 17 5)", expected: "2" }, + { tag: "remainder-neg", src: "(remainder -17 5)", expected: "-2" }, + { tag: "modulo-pos", src: "(modulo 17 5)", expected: "2" }, + { tag: "modulo-neg", src: "(modulo -17 5)", expected: "3" }, + + // ─── arithmetic edge cases ─────────────────────────────────────── + { tag: "neg-arith", src: "(- 5)", expected: "-5" }, + { tag: "empty-add", src: "(+)", expected: "0" }, + { tag: "empty-mul", src: "(*)", expected: "1" }, + { tag: "multi-arith", src: "(+ 1 2 3 4 5)", expected: "15" }, + { tag: "abs-pos", src: "(abs 5)", expected: "5" }, + { tag: "abs-neg", src: "(abs -5)", expected: "5" }, + { tag: "min", src: "(min 5 3 8 1 7)", expected: "1" }, + { tag: "max", src: "(max 5 3 8 1 7)", expected: "8" }, + + // ─── booleans / truthiness ─────────────────────────────────────── + { tag: "if-true", src: "(if #t 'yes 'no)", expected: "yes" }, + { tag: "if-false", src: "(if #f 'yes 'no)", expected: "no" }, + { tag: "if-zero", src: "(if 0 'truthy 'falsy)", expected: "truthy" }, + { tag: "if-nil", src: "(if '() 'truthy 'falsy)", expected: "truthy" }, + { tag: "if-empty-str", src: "(if \"\" 'truthy 'falsy)", expected: "truthy" }, + { tag: "not-#f", src: "(not #f)", expected: "#t" }, + { tag: "not-#t", src: "(not #t)", expected: "#f" }, + { tag: "not-zero", src: "(not 0)", expected: "#f" }, + + // ─── equality ──────────────────────────────────────────────────── + { tag: "eq?-int", src: "(eq? 1 1)", expected: "#t" }, + { tag: "eq?-symbol", src: "(eq? 'a 'a)", expected: "#t" }, + { tag: "equal?-list", src: "(equal? '(1 2 3) '(1 2 3))", expected: "#t" }, + { tag: "equal?-str", src: "(equal? \"abc\" \"abc\")", expected: "#t" }, + + // ─── pairs / lists ─────────────────────────────────────────────── + { tag: "cons", src: "(cons 1 2)", expected: "(1 . 2)" }, + { tag: "list", src: "(list 1 2 3)", expected: "(1 2 3)" }, + { tag: "length", src: "(length '(a b c d))", expected: "4" }, + { tag: "reverse", src: "(reverse '(1 2 3))", expected: "(3 2 1)" }, + { tag: "append", src: "(append '(1 2) '(3 4))", expected: "(1 2 3 4)" }, + { tag: "map", src: "(map (lambda (x) (* x x)) '(1 2 3 4))", + expected: "(1 4 9 16)" }, + { tag: "filter-odd", src: "(filter odd? '(1 2 3 4 5))", + expected: "(1 3 5)" }, + { tag: "fold-left", src: "(fold-left + 0 '(1 2 3 4 5))", expected: "15" }, + + // ─── strings ───────────────────────────────────────────────────── + { tag: "str-len", src: "(string-length \"hello\")", expected: "5" }, + { tag: "str-append", src: "(string-append \"foo\" \"bar\")", + expected: "foobar" }, + { tag: "substring", src: "(substring \"hello world\" 6 11)", + expected: "world" }, + { tag: "str-upcase", src: "(string-upcase \"hello\")", expected: "HELLO" }, + { tag: "str-num", src: "(string->number \"42\")", expected: "42" }, + { tag: "num-str", src: "(number->string 42)", expected: "42" }, + + // ─── characters ────────────────────────────────────────────────── + { tag: "char-int", src: "(char->integer #\\A)", expected: "65" }, + { tag: "int-char", src: "(integer->char 65)", expected: "A" }, + { tag: "char-alpha", src: "(char-alphabetic? #\\a)", expected: "#t" }, + + // ─── vectors ───────────────────────────────────────────────────── + { tag: "vec-make", src: "(vector 1 2 3)", expected: "#(1 2 3)" }, + { tag: "vec-len", src: "(vector-length (vector 1 2 3))", expected: "3" }, + { tag: "vec-ref", src: "(vector-ref (vector 10 20 30) 1)", expected: "20" }, + + // ─── special forms ────────────────────────────────────────────── + { tag: "let-basic", src: "(let ((x 3) (y 4)) (+ x x y))", expected: "10" }, + { tag: "let*-shadow", src: "(let* ((x 1) (x (+ x 10)) (x (* x 2))) x)", + expected: "22" }, + { tag: "letrec", src: "(letrec ((f (lambda (n) (if (= n 0) 1 (* n (f (- n 1))))))) (f 5))", + expected: "120" }, + { tag: "named-let", src: "(let loop ((i 0) (acc 0)) (if (= i 10) acc (loop (+ i 1) (+ acc i))))", + expected: "45" }, + { tag: "cond-else", src: "(cond ((= 1 2) 'no) (else 'yes))", expected: "yes" }, + { tag: "case-match", src: "(case 2 ((1) 'one) ((2 3) 'two-three) (else 'big))", + expected: "two-three" }, + { tag: "when", src: "(when (> 3 1) 'yes)", expected: "yes" }, + { tag: "and-pass", src: "(and 1 2 3)", expected: "3" }, + { tag: "or-first", src: "(or #f 7 8)", expected: "7" }, + + // ─── deep recursion / TCO ─────────────────────────────────────── + { tag: "tco-loop", src: "(let loop ((i 0)) (if (= i 50000) i (loop (+ i 1))))", + expected: "50000" }, + { tag: "mutual-tco", src: "(define (a n) (if (= n 0) 'done-a (b (- n 1)))) (define (b n) (if (= n 0) 'done-b (a (- n 1)))) (a 100000)", + expected: "done-a" }, +]; + +// Tiers where each tag is known to diverge today. Keep this short and +// remove entries as the gaps close — that's how we track "% to parity". +export const KNOWN_DIVERGE = { + // Asm-WASM has 31-bit fixnum num/den rationals; no bignums yet. + "expt-2-100": ["asm"], + "expt-3-50": ["asm"], + "big-arith": ["asm"], +}; diff --git a/wasm/tests/parity-cross-tier.mjs b/wasm/tests/parity-cross-tier.mjs new file mode 100644 index 0000000..5210118 --- /dev/null +++ b/wasm/tests/parity-cross-tier.mjs @@ -0,0 +1,134 @@ +// wasm/tests/parity-cross-tier.mjs +// For every CORPUS entry, run the source on every tier (native python, +// c-wasm, asm-wasm) and report mismatches against the expected output. +// Known divergences (KNOWN_DIVERGE) are tolerated but printed in yellow +// so the gap is visible without breaking the build. +// +// Python tier runs via the host's python3 lumbda.py because the Pyodide +// loader needs a browser context. That keeps this test fast (no 10MB +// CDN download) while still pinning behavior to the reference tier. + +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { CORPUS, KNOWN_DIVERGE } from "./parity-corpus.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.resolve(here, "..", "dist"); +const repoRoot = path.resolve(here, "..", ".."); + +const state = { ok: 0, fail: 0, knownDiverge: 0, failures: [] }; + +function check(tag, tier, expected, got, isKnown) { + const passed = got === expected; + if (passed) state.ok++; + else if (isKnown) state.knownDiverge++; + else { state.fail++; state.failures.push({ tag, tier, expected, got }); } + const status = passed + ? "✓" + : isKnown + ? "~" // known divergence + : "✗"; + const label = `${status} ${tier.padEnd(6)} ${tag}`; + if (!passed) { + const detail = `expected ${JSON.stringify(expected)} · got ${JSON.stringify(got)}`; + console.log(` ${label} — ${detail}`); + } +} + +// Wrap each test source so the result is printed via display (uniform +// across tiers — no write quoting, no "is this the last expression" +// behavior to depend on). The source may contain multiple top-level +// forms (e.g. defines), so we put the final expression in a (begin ...) +// and display its result. +function wrap(src) { + // The source's last sub-expression is what we want to display. + // We rely on the tiers each evaluating the lot and `(display ...)` + // emitting display semantics on the value. + return `(display (begin ${src})) (newline)`; +} + +// ─── tier adapters ───────────────────────────────────────────────── +function nativePython(src) { + const tmp = `/tmp/parity-${process.pid}.lsp`; + fs.writeFileSync(tmp, wrap(src) + "\n"); + try { + return execFileSync("python3", [path.join(repoRoot, "lumbda.py"), tmp], { + encoding: "utf8", timeout: 30000, + }).trim(); + } finally { try { fs.unlinkSync(tmp); } catch {} } +} + +async function withAsmTier() { + const wasmBytes = fs.readFileSync(path.join(dist, "asm", "lumbda-asm.wasm")); + const importObj = { env: { bend_call() { return 0; } } }; + const { instance } = await WebAssembly.instantiate(wasmBytes, importObj); + const exp = instance.exports; + exp.lumbda_init(); + return (src) => { + const bytes = new TextEncoder().encode(wrap(src)); + new Uint8Array(exp.memory.buffer).set(bytes, exp.lumbda_source_ptr()); + try { exp.lumbda_eval(bytes.length); } + catch (e) { return `THREW: ${e.message}`; } + return new TextDecoder().decode( + new Uint8Array(exp.memory.buffer, exp.lumbda_output_ptr(), exp.lumbda_output_len()) + ).trim(); + }; +} + +async function withCTier() { + const factoryURL = "file://" + path.join(dist, "c", "lumbda-c.js"); + const createLumbdaC = (await import(factoryURL)).default; + let out = []; + const m = await createLumbdaC({ + locateFile: (p) => path.join(dist, "c", p), + print: (line) => out.push(line), + printErr: () => {}, + }); + m.cwrap("lumbda_wasm_init", null, [])(); + const _eval = m.cwrap("lumbda_wasm_eval", "number", ["string"]); + const _free = m.cwrap("lumbda_wasm_free_result", null, ["number"]); + return (src) => { + out = []; + const r = _eval(wrap(src)); + if (r) { const msg = m.UTF8ToString(r); _free(r); return msg; } + return out.join("\n").trim(); + }; +} + +(async () => { + console.log("─".repeat(70)); + console.log("Cross-tier parity probe — python (ref) vs c-wasm vs asm-wasm"); + console.log("─".repeat(70)); + + const runAsm = await withAsmTier(); + const runC = await withCTier(); + + for (const entry of CORPUS) { + const knownOn = KNOWN_DIVERGE[entry.tag] || []; + const isAsmKnown = knownOn.includes("asm"); + const isCKnown = knownOn.includes("c"); + const isPyKnown = knownOn.includes("python"); + + const py = nativePython(entry.src); + const cw = runC(entry.src); + const aw = runAsm(entry.src); + + check(entry.tag, "py-ref", entry.expected, py, isPyKnown); + check(entry.tag, "c-wasm", entry.expected, cw, isCKnown); + check(entry.tag, "asm", entry.expected, aw, isAsmKnown); + } + + console.log("─".repeat(70)); + console.log(` passing : ${state.ok}`); + console.log(` known diverge: ${state.knownDiverge} (tracked in KNOWN_DIVERGE)`); + console.log(` fail : ${state.fail}`); + if (state.fail > 0) { + console.log("\nUnknown divergences (fix or add to KNOWN_DIVERGE):"); + for (const f of state.failures) { + console.log(` ${f.tier} ${f.tag}: expected ${JSON.stringify(f.expected)} got ${JSON.stringify(f.got)}`); + } + } + process.exit(state.fail ? 1 : 0); +})(); diff --git a/www/playground/asm/lumbda-asm.wasm b/www/playground/asm/lumbda-asm.wasm index 33def25..3a30eb5 100644 Binary files a/www/playground/asm/lumbda-asm.wasm and b/www/playground/asm/lumbda-asm.wasm differ diff --git a/www/playground/python/lumbda.py b/www/playground/python/lumbda.py index 8ed1fa0..77d5433 100644 --- a/www/playground/python/lumbda.py +++ b/www/playground/python/lumbda.py @@ -3438,7 +3438,11 @@ def make_global_env(): return n d(S('/'), _div) d(S('quotient'), lambda a, _: (lambda x, y: -(abs(int(x)) // abs(int(y))) if (x < 0) != (y < 0) else abs(int(x)) // abs(int(y)))(_num(a[0]), _num(a[1]))) - d(S('remainder'), lambda a, _: int(_num(a[0])) % int(_num(a[1])) * (1 if _num(a[0]) >= 0 else -1)) + # R7RS: remainder has the sign of the dividend; uses truncated division. + # The previous impl did `signed_a % signed_b * sign(a)` which double-counted + # the sign of a (Python's % floors, so `-17 % 5 == 3`) and gave -3 for + # (-17, 5) instead of the correct -2. Use abs() on both sides, then re-sign. + d(S('remainder'), lambda a, _: (abs(int(_num(a[0]))) % abs(int(_num(a[1])))) * (1 if _num(a[0]) >= 0 else -1)) d(S('modulo'), lambda a, _: int(_num(a[0])) % int(_num(a[1]))) d(S('expt'), lambda a, _: _num(a[0]) ** _num(a[1])) d(S('abs'), lambda a, _: abs(_num(a[0]))) diff --git a/www/repl/asm/lumbda-asm.wasm b/www/repl/asm/lumbda-asm.wasm index 33def25..3a30eb5 100644 Binary files a/www/repl/asm/lumbda-asm.wasm and b/www/repl/asm/lumbda-asm.wasm differ diff --git a/www/repl/python/lumbda.py b/www/repl/python/lumbda.py index 8ed1fa0..77d5433 100644 --- a/www/repl/python/lumbda.py +++ b/www/repl/python/lumbda.py @@ -3438,7 +3438,11 @@ def make_global_env(): return n d(S('/'), _div) d(S('quotient'), lambda a, _: (lambda x, y: -(abs(int(x)) // abs(int(y))) if (x < 0) != (y < 0) else abs(int(x)) // abs(int(y)))(_num(a[0]), _num(a[1]))) - d(S('remainder'), lambda a, _: int(_num(a[0])) % int(_num(a[1])) * (1 if _num(a[0]) >= 0 else -1)) + # R7RS: remainder has the sign of the dividend; uses truncated division. + # The previous impl did `signed_a % signed_b * sign(a)` which double-counted + # the sign of a (Python's % floors, so `-17 % 5 == 3`) and gave -3 for + # (-17, 5) instead of the correct -2. Use abs() on both sides, then re-sign. + d(S('remainder'), lambda a, _: (abs(int(_num(a[0]))) % abs(int(_num(a[1])))) * (1 if _num(a[0]) >= 0 else -1)) d(S('modulo'), lambda a, _: int(_num(a[0])) % int(_num(a[1]))) d(S('expt'), lambda a, _: _num(a[0]) ** _num(a[1])) d(S('abs'), lambda a, _: abs(_num(a[0]))) diff --git a/www/repl/repl.css b/www/repl/repl.css index 8adf2e0..e476dac 100644 --- a/www/repl/repl.css +++ b/www/repl/repl.css @@ -1,11 +1,12 @@ /* lumbda repl — grid-only layout. Inherits palette + base from style.css. */ body.repl { - display: grid; - /* header (fixed) scrolling-stream (everything else) */ - grid-template-rows: auto 1fr; - height: 100vh; - overflow: hidden; + /* Body scrolls naturally. The prompt-bar is position: fixed so it + * stays glued to the viewport bottom; the transcript reserves bottom + * padding equal to the prompt-bar height so its last line isn't hidden + * underneath. */ + min-height: 100vh; + overflow: auto; } /* ─── Lock screen overlay ──────────────────────────────────────── */ @@ -176,12 +177,13 @@ body.repl { * fresh sessions show the prompt up near the top and it drifts down * with each new entry. */ .repl-stream { - overflow: auto; - padding: 0.5rem 1rem 0.8rem; + padding: 0.5rem 1rem 0; background: var(--code-bg); display: grid; grid-template-rows: auto auto auto; align-content: start; + /* Leave room for the fixed prompt bar at the viewport bottom. */ + padding-bottom: calc(3.6rem + env(safe-area-inset-bottom, 0)); } .transcript { font-family: var(--mono); @@ -232,12 +234,16 @@ body.repl { /* ─── Prompt bar ───────────────────────────────────────────────── */ .prompt-bar { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 50; display: grid; grid-template-columns: auto 1fr auto auto; gap: 0.4rem; align-items: center; - padding: 0.3rem 0; - background: transparent; + padding: 0.5rem 1rem; + background: var(--code-bg); + border-top: 1px solid var(--rule); } .prompt-bar .prompt-sigil { color: var(--green); diff --git a/www/repl/repl.js b/www/repl/repl.js index 7062df8..7b2f8d5 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -287,7 +287,8 @@ function renderAll() { } transcriptEl.appendChild(block); } - replStream.scrollTop = replStream.scrollHeight; + // Body owns the scroll now; jump it to the latest entry. + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); } // ─── Input handling ─────────────────────────────────────────────────