parity probe: cross-tier corpus + fix python remainder + asm modulo

New: wasm/tests/parity-cross-tier.mjs runs the parity-corpus.mjs (216
test cases tagged by whitepaper section / R7RS concept) against three
tiers — native python (reference), c-wasm, asm-wasm — and fails on any
unknown divergence. Known gaps live in KNOWN_DIVERGE so the table stays
green while the bignum / call/cc / etc. work proceeds.

Wired into `make wasm-test` so a regression against any spec claim gets
caught before merge.

Bugs caught and fixed:
  - python remainder: was `signed_a % signed_b * sign(a)`, which double-
    applied the sign of a (python's % floors) — gave -3 for (-17, 5)
    instead of the R7RS-correct -2. Now uses abs() on both sides.
  - asm-wasm modulo: was i32.rem_s (truncated, remainder semantics)
    where R7RS modulo wants sign of divisor. Added the "if rem and
    divisor disagree on sign, add divisor" branch.

Cross-tier numbers after fix:
  216 passing
    3 known diverge: expt-2-100, expt-3-50, big-arith — all asm-wasm
      (no bignums on the asm tier yet; whitepaper §2.1 claim still open)
    0 fail

REPL layout: body is now the scroll container, prompt-bar is
position:fixed at the viewport bottom so it doesn't get pushed off
screen by a long transcript. Empty space above the prompt on a fresh
session reads like a terminal.

All other tests still pass: 20 unit, 8 integration, 11 functional.
This commit is contained in:
russell@unturf.com 2026-06-14 15:21:19 -04:00
parent c50a9da7e8
commit 7ea65dba21
No known key found for this signature in database
17 changed files with 357 additions and 38 deletions

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"],
};

View file

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

Binary file not shown.

View file

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

Binary file not shown.

View file

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

View file

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

View file

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