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

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