Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.

Commit 7b03674

Browse files
committed
0026: the dev loop assembles next to the engine, surveyed by running it
wabt.js is out — its parser rejects (rec ...) at the first token, and its README's proposal table has no GC row (both checked 2026-07-30). binaryen.js 131 assembles every emitted corpus module to the same outcome as wasm-tools, 113/113 values and traps, at 1.28 ms median per form in-process against the 23 ms spawn 0022 A measured — so the compiler emits WAT text in both modes, one emitter core, and the dev loop's assembler lives where the engine lives. The differential earned its keep before the decision landed: with no setFeatures binaryen silently degrades eqref to anyref, with Features.All it emits exact heap types stable V8 rejects, and stacky local.sets materialize anyref scratch locals — so feature flags are explicit forever, and emit-recur now rebinds through named temporaries instead of the value stack. The adversarial review demonstrated the load-bearing assumption instead of refuting it: rec groups survive binaryen byte-for-byte, and a binaryen-assembled module links against a wasm-tools-assembled one through (ref $Fn) — 0009's heap sharing holds across assemblers. corpus/devloop_differential.mjs is committed, and running it is part of "done" for emitted-grammar changes until the dev lane enters the gate; package.json pins binaryen 131 as the interim manifest. The throw representation is the one S3 decision still open. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 4f0b170 commit 7b03674

8 files changed

Lines changed: 274 additions & 15 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ dev/spike/rust_call_cost/target/
1919
# That has happened twice: a null export index passed to get_func, and a
2020
# recursive walk sharing its out-parameters.
2121
hs_err_pid*.log
22+
23+
# npm (dev-loop assembler, doc/design/0026)
24+
node_modules/

corpus/devloop_differential.mjs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// The dev-loop assembler differential (doc/design/0026): every emitted
2+
// corpus module, assembled by binaryen.js — the dev lane's assembler —
3+
// must agree with wasm-tools' binary (on disk, the oracle's reference
4+
// lane) on every outcome, value and trap.
5+
//
6+
// Until the dev lane itself is in the gate, running this is part of
7+
// "done" for any change to the emitted grammar (0026's interim rule).
8+
//
9+
// clojure -X:test :nses '[cljwit.corpus-test]' # regenerate target/corpus
10+
// npm install binaryen # once, anywhere on NODE_PATH
11+
// node corpus/devloop_differential.mjs
12+
//
13+
// Feature flags are explicit and must stay so: Features.All emits exact
14+
// heap types stable V8 rejects, and no features at all silently degrades
15+
// eqref to anyref — both are wrong-binary failures, not errors (0026).
16+
17+
import { readFileSync, readdirSync, existsSync } from "node:fs";
18+
import { join } from "node:path";
19+
20+
const dir = "target/corpus";
21+
if (!existsSync(dir)) {
22+
console.error("no target/corpus — run the corpus test first (it emits the modules)");
23+
process.exit(1);
24+
}
25+
26+
let binaryen;
27+
try {
28+
binaryen = (await import("binaryen")).default;
29+
} catch {
30+
console.error("binaryen.js not resolvable — npm install binaryen (see header)");
31+
process.exit(1);
32+
}
33+
34+
const F = binaryen.Features;
35+
const FEATS = F.GC | F.ReferenceTypes | F.TailCall | F.ExceptionHandling |
36+
F.BulkMemory | F.Multivalue | F.SignExt | F.NontrappingFPToInt |
37+
F.MutableGlobals;
38+
39+
const run = (bin) => {
40+
try {
41+
const i = new WebAssembly.Instance(new WebAssembly.Module(bin), {});
42+
return "result " + i.exports.entry();
43+
} catch (e) {
44+
if (e instanceof WebAssembly.RuntimeError || e instanceof RangeError)
45+
return "trap " + e.message;
46+
return "ERROR " + e.message;
47+
}
48+
};
49+
50+
let n = 0, agree = 0;
51+
for (const f of readdirSync(dir).filter((f) => f.endsWith(".wat")).sort()) {
52+
const wat = readFileSync(join(dir, f), "utf8");
53+
const reference = new Uint8Array(readFileSync(join(dir, f.replace(/\.wat$/, ".wasm"))));
54+
const mod = binaryen.parseText(wat);
55+
mod.setFeatures(FEATS);
56+
const bin = mod.emitBinary();
57+
mod.dispose();
58+
const a = run(reference);
59+
const b = run(bin);
60+
n++;
61+
if (a === b) agree++;
62+
else console.log(`DISAGREE ${f}\n wasm-tools: ${a}\n binaryen: ${b}`);
63+
}
64+
65+
console.log(`${agree}/${n} modules agree (binaryen.js ${binaryen.version ?? "?"})`);
66+
process.exit(agree === n ? 0 : 1);

doc/design/0022-s3-compiler-shape.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ cannot instantiate text anyway, so something assembles there regardless), a
6363
WasmGC binary writer** (JVM-hosted, Apache-2.0, named in E — the first
6464
draft's "no off-the-shelf JVM WasmGC-emission library" overclaimed against
6565
its own section E). The dev-loop format is decided by that benchmark,
66-
before the nREPL unit, not here.
66+
before the nREPL unit, not here. *(Decided 2026-07-30: `0026` — WAT text
67+
in both modes, assembled next to the engine by binaryen.js at 1.28 ms per
68+
form; wabt.js measured unable to parse rec groups.)*
6769

6870
### B. The differential oracle is CI-mandatory from the first special form
6971

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# 0026 — The dev loop assembles next to the engine
2+
3+
**Status:** proposed · 2026-07-30 · adversarially reviewed the same day.
4+
The review demonstrated the decision's load-bearing assumption rather
5+
than refuting it — cross-assembler, cross-module rec-group identity
6+
(§3b) — and changed four things, marked *(review)*: the cold-start and
7+
size-scaling costs stated next to the headline median, the committed
8+
differential script with the interim "done" rule, the skew watcher named
9+
as that same rule, and the preamble-growth falsifier with its
10+
import-shaped exit.
11+
12+
## The question
13+
14+
`0009` made the dev-mode output format a decision that cannot be
15+
retrofitted, and its own precondition was a cost measurement before S3
16+
commits to one. `0022` A ran the first half — `wasm-tools parse` spawns
17+
at ~23 ms median per form, so a 300-form namespace `require` through a
18+
per-form dev loop is ~7 s of assembler spawns — and named four
19+
candidates, none examined: assembling near the engine (binaryen.js /
20+
wabt.js), a persistent assembler process, batching forms per flush, and
21+
TeaVM's WasmGC binary writer. This note examines them and decides.
22+
23+
## The decision
24+
25+
1. **The compiler emits WAT text in both modes — one emitter core.**
26+
`0022` A's stated preference ("the two should share an emitter
27+
core"), now affordable because:
28+
2. **The dev loop assembles in-process, next to the engine, with
29+
binaryen.js.** Measured 2026-07-30 (binaryen.js 131.0.0 from npm,
30+
node v26.3.0, Apple M4 Pro; probe in this survey's session):
31+
parse+emit of a real emitted module (`fn-defn-fib.dev`, 538 B binary)
32+
is **1.28 ms median** (min 1.19, max 2.52 over 50 in-process reps)
33+
against the measured 23 ms spawn — **18×** — and V8
34+
compile+instantiate of the result adds 0.005 ms. The 7-second
35+
300-form `require` becomes ~0.4 s. Two honest costs the median hides
36+
*(review)*: a session pays **~170 ms once** (importing binaryen.js
37+
~134 ms plus a ~33 ms first assemble), and the per-form cost is
38+
**linear in module text** (4.0 KB → 0.92 ms, 8.7 KB → 1.94 ms) —
39+
consequences under "what would falsify this". V8 cannot instantiate
40+
text, so *something* must assemble on the engine side regardless
41+
(`0022` A); this makes that something the whole answer.
42+
3. **The evidence is the whole corpus, not one module.** All 113 emitted
43+
corpus modules (both modes) assembled through binaryen.js agree with
44+
`wasm-tools`' binaries on every outcome — every value, every trap.
45+
The probe is committed as `corpus/devloop_differential.mjs`, and
46+
**re-running it is part of "done" for any change to the emitted
47+
grammar** until the dev lane itself is in the gate *(review — the
48+
interim rule; weak enforcement, honestly labeled, and the gate lane
49+
is the recorded stronger fix the first time drift actually happens)*.
50+
That differential found two silent-wrong-binary failures on the way,
51+
which are now constraints:
52+
3b. **Cross-assembler, cross-module type identity holds — demonstrated,
53+
not assumed** *(review — it is the fact `0009`'s dev-mode heap
54+
sharing rests on)*: a module exporting `make : [] → (ref $Fn)` links
55+
into a separately assembled importer through `call_ref`, in both
56+
pairings (binaryen→binaryen and wasm-tools→binaryen), and
57+
`wasm-tools print` shows binaryen preserves the rec group verbatim.
58+
One caveat came with the proof: binaryen prunes unreachable types,
59+
so an emitted group must stay reachable or stay absent — never
60+
half-referenced.
61+
4. **Feature flags are explicit — never `Features.All`, never none.**
62+
With no `setFeatures` call, binaryen's writer silently degrades
63+
`(ref null eq)` to `anyref` and the binary fails validation on the
64+
engine; with `Features.All`, the writer emits *exact* heap types
65+
(custom-descriptors, off by default in stable V8) and fails the same
66+
way. The dev lane sets exactly the features the emitter uses — the
67+
same discipline the `wat` skill already records for `wasm-opt`, now
68+
with its "All is as wrong as none" half.
69+
5. **The emitter stays in the folded, non-stacky subset of WAT.**
70+
Binaryen's parser is an AST, not a stack machine: bare stacky
71+
`local.set`s (the first `recur` emission) materialize as `anyref`
72+
scratch locals, lose the eq refinement, and fail validation.
73+
`emit-recur` now rebinds through explicit temporaries. The
74+
constraint's mechanical pin — a binaryen.js lane in the harness —
75+
lands with the nREPL unit that actually wires the dev loop; until
76+
then this survey's 113/113, dated today, is the evidence.
77+
6. **`wasm-tools parse` stays the prod/batch assembler and the oracle's
78+
reference lane.** Two independent assemblers agreeing on the corpus
79+
is coverage, not redundancy — the same argument the gate already
80+
makes for two clj-kondos.
81+
82+
## Why
83+
84+
- **wabt.js is out, checked by running it** (1.0.39, 2026-07-30): its
85+
parser rejects `(rec …)` at the first token — the corpus's shared
86+
fn substrate cannot be expressed. Second instrument, per the survey
87+
rule on negative claims: the Supported Proposals table in wabt's own
88+
README omits GC entirely
89+
(<https://github.com/WebAssembly/wabt>, checked 2026-07-30).
90+
- **binaryen.js is the same project as the pinned `wasm-opt`** — one
91+
toolchain relationship, not a new one; the skew (npm 131 vs flake's
92+
129) is real and recorded below.
93+
- The measured per-form cost sits well below any editor-latency
94+
convention, with the margin available for the compiler's own
95+
analyze/emit time.
96+
97+
## Alternatives rejected
98+
99+
- **wabt.js** — cannot parse the output (measured; above).
100+
- **A persistent assembler process.** It amortizes the spawn cost that
101+
in-process assembly removes entirely, and pays for it with process
102+
lifecycle management in every editor session. Nothing is left for it
103+
to be better at.
104+
- **TeaVM's WasmGC binary writer.** A second, JVM-side emitter core —
105+
exactly what `0022` A's shared-core preference exists to avoid — and
106+
on the wrong side of the wire: `0009`'s dev loop instantiates in the
107+
engine's world, so JVM-side binaries still cross to the engine, while
108+
WAT text crossing the wire keeps the payload readable in every
109+
debugging session. Unneeded at 1.3 ms.
110+
- **An own binary writer** — already rejected with evidence in `0022` A
111+
(clj.wasm's recorded stall); nothing here reopens it.
112+
- **Batching as the primary mechanism.** Still trivially available on
113+
top (one flush, one module), but at 1.3 ms/form nothing forces it,
114+
and per-form modules are what `0009`'s open world wants.
115+
116+
## What would falsify this
117+
118+
- **A text shape the emitter later needs that binaryen's parser lacks**
119+
— it already dictated non-stacky emission once. Surfaces mechanically
120+
once the dev lane is in the gate; until then,
121+
`corpus/devloop_differential.mjs` is the check, and running it is
122+
part of "done" for emitted-grammar changes.
123+
- **The version skew biting**: npm binaryen.js and the flake's binaryen
124+
are different builds of one project (131 vs 129 today). `tools.json`
125+
gains the dev assembler's version when the nREPL unit lands; until
126+
then the committed differential is also the skew watcher — the same
127+
interim rule, named once *(review)*.
128+
- **The margin is a function of the runtime preamble** *(review)*:
129+
every per-form module re-parses the whole preamble, and assembly cost
130+
is linear in it — a 100 KB core-library preamble puts the per-form
131+
cost near 20 ms and the win is gone. The exit is already the shape
132+
`0009`'s shared heap wants: dev-mode forms *import* the shared
133+
runtime instead of re-declaring it. If that lands, this note's
134+
numbers should be re-taken for the import-shaped module.
135+
- **A browser measurement disagreeing with node's.** This survey
136+
measured node; one run characterises one path. The browser tab is the
137+
nREPL/browser unit's first measurement, and 1.28 ms has enough margin
138+
that only an order-of-magnitude surprise reopens the decision.

doc/status.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ _Short by design, and printed at every session start — so findings live in
4646
wraps at MIN∕−1 like the JVM, and the corpus runs the stop-condition
4747
domain iteratively — fib(46) and fib(91); the 92 entry died at the
4848
oracle's hands because that loop shape computes one step ahead, and
49-
the corpus comment records it. Next unit: **the dev-loop output
50-
format** (`0009`'s precondition, `0022` A's candidate list —
51-
assembling near the engine, a persistent assembler, batching,
52-
TeaVM's writer), because it gates S2's nREPL unit and is the larger
53-
of the two open decisions; the throw representation follows it.
49+
the corpus comment records it. **The dev-loop format is decided**
50+
(`0026`, surveyed by running): WAT text in both modes, one emitter
51+
core; the dev loop assembles next to the engine with binaryen.js at
52+
**1.28 ms/form** (18× the spawn path; ~170 ms once per session;
53+
cost linear in the preamble, exit named). wabt.js measured unable
54+
to parse rec groups. The review demonstrated cross-assembler
55+
rec-group identity — the fact `0009`'s heap sharing rests on — and
56+
two silent-wrong-binary traps are now constraints: explicit feature
57+
flags (never `Features.All`), non-stacky emission.
58+
`corpus/devloop_differential.mjs` (113/113) is part of "done" for
59+
emitted-grammar changes until the dev lane is in the gate.
60+
Remaining open: **the throw representation** — the next unit —
61+
after which S2's nREPL unit is unblocked on both sides.
5462

5563
Done since the last update: `0016` `own<T>` handles, `0017` host imports
5664
(A–F), `0018` host-defined resources, `0012`'s `ex-data` contract shrunk to

package-lock.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"private": true,
3+
"description": "npm manifest for the dev-loop assembler only (doc/design/0026). `npm install`, then `node corpus/devloop_differential.mjs`. The version pin moves into tools.json when the dev lane enters the gate.",
4+
"devDependencies": {
5+
"binaryen": "131.0.0"
6+
}
7+
}

src/cljwit/emit.clj

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,19 +94,24 @@
9494
(str/join " " sets) label (emit-expr ctx'' body))))
9595

9696
(defn- emit-recur
97-
"Rebinding is simultaneous: every argument is evaluated onto the value
98-
stack under the *current* bindings, then popped into the loop locals in
99-
reverse — a sequential `local.set` would let later arguments observe
100-
earlier rebinds. A fn method's params register under its `:loop-id`,
101-
so `recur` to the method head comes through here too (`0024`)."
97+
"Rebinding is simultaneous: every argument is evaluated into a fresh
98+
temporary under the *current* bindings, then the temporaries copy into
99+
the loop locals — a sequential `local.set` would let later arguments
100+
observe earlier rebinds. Explicit temporaries rather than the value
101+
stack: bare stacky `local.set`s make binaryen's parser materialize
102+
anyref scratch locals and lose the eq refinement (`0026`, found by
103+
the all-corpus binaryen.js differential). A fn method's params
104+
register under its `:loop-id`, so `recur` to the method head comes
105+
through here too (`0024`)."
102106
[ctx {:keys [exprs loop-id] :as ast}]
103107
(let [{:keys [label locals]} (get-in ctx [:loops loop-id])]
104108
(when-not label
105109
(out-of-slice! "recur outside an enclosing loop or fn method" ast))
106-
(format "(block (result (ref null eq)) %s %s (br %s))"
107-
(str/join " " (map #(emit-expr ctx %) exprs))
108-
(str/join " " (map #(format "(local.set %s)" %) (reverse locals)))
109-
label)))
110+
(let [temps (mapv (fn [_] (fresh-local! ctx)) exprs)]
111+
(format "(block (result (ref null eq)) %s %s (br %s))"
112+
(str/join " " (map #(format "(local.set %s %s)" %1 (emit-expr ctx %2)) temps exprs))
113+
(str/join " " (map #(format "(local.set %s (local.get %s))" %1 %2) locals temps))
114+
label))))
110115

111116
(defn- emit-let [ctx {:keys [bindings body]}]
112117
(let [[ctx' sets]

0 commit comments

Comments
 (0)