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

Commit d2e572a

Browse files
committed
0028: dev-mode linking — one runtime, one module per form, a real session
A REPL is not a REPL until form 2 reads form 1's def, and now it does: the linked dev lane compiles each form to a module importing the shared runtime under "rt" and session vars under "vars" by qualified name; the first definer exports the canonical global forever, a re-def imports and sets it, and the ledger throws on a duplicate var export because the silent version is later forms reading nil. Every corpus entry's :forms vector is now also run as a real session on V8, and the 0026 differential grew a session half: 179/179 modules+sessions agree between wasm-tools and binaryen.js assembly. The adversarial review found the blocker before implementation did: 0024's per-module K makes cross-form fn values trap — different max arity, different rec group, different type, 0009's own rule — probed as "illegal cast". Linked K is therefore a session constant, 20, Clojure's own positional ceiling, so the ceiling is JVM parity rather than a new restriction; 0024 carries the amendment. The review also verified every load-bearing mechanic (mutable eqref globals cross modules and the importer can set them; an imported tag catches a cross-module throw) and corrected the note's claim that wasmtime's CLI cannot link — --preload plus a start section can, so that lane is deferred on cost and recorded as feasible. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 390ca95 commit d2e572a

7 files changed

Lines changed: 406 additions & 25 deletions

File tree

‎corpus/devloop_differential.mjs‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,70 @@ const run = (bin) => {
4747
}
4848
};
4949

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"))));
50+
const assemble = (wat) => {
5451
const mod = binaryen.parseText(wat);
5552
mod.setFeatures(FEATS);
5653
const bin = mod.emitBinary();
5754
mod.dispose();
55+
return bin;
56+
};
57+
58+
let n = 0, agree = 0;
59+
for (const f of readdirSync(dir).filter((f) => f.endsWith(".wat")).sort()) {
60+
const wat = readFileSync(join(dir, f), "utf8");
61+
const reference = new Uint8Array(readFileSync(join(dir, f.replace(/\.wat$/, ".wasm"))));
5862
const a = run(reference);
59-
const b = run(bin);
63+
const b = run(assemble(wat));
6064
n++;
6165
if (a === b) agree++;
6266
else console.log(`DISAGREE ${f}\n wasm-tools: ${a}\n binaryen: ${b}`);
6367
}
6468

65-
console.log(`${agree}/${n} modules agree (binaryen.js ${binaryen.version ?? "?"})`);
69+
// The linked sessions (0028): each target/corpus/linked/<id>/ is one
70+
// session — rt + formN in order — run once from wasm-tools' binaries
71+
// and once re-assembled by binaryen, same vocabulary as session.mjs.
72+
const runSession = (bins) => {
73+
try {
74+
const rt = new WebAssembly.Instance(new WebAssembly.Module(bins[0]), {});
75+
const tag = rt.exports["clj-exn"];
76+
const vars = {};
77+
let last;
78+
for (const bin of bins.slice(1)) {
79+
const inst = new WebAssembly.Instance(new WebAssembly.Module(bin), { rt: rt.exports, vars });
80+
for (const [name, val] of Object.entries(inst.exports)) {
81+
if (name === "entry") continue;
82+
if (name in vars) return `ERROR duplicate var export ${name}`;
83+
vars[name] = val;
84+
}
85+
try {
86+
last = inst.exports.entry();
87+
} catch (e) {
88+
if (e instanceof WebAssembly.Exception && e.is(tag))
89+
return `exn ${rt.exports.exn_class(e.getArg(tag, 0))}`;
90+
if (e instanceof WebAssembly.RuntimeError || e instanceof RangeError)
91+
return `trap ${e.message}`;
92+
throw e;
93+
}
94+
}
95+
return `result ${last}`;
96+
} catch (e) {
97+
return `ERROR ${e.message}`;
98+
}
99+
};
100+
101+
const linkedRoot = join(dir, "linked");
102+
if (existsSync(linkedRoot)) {
103+
for (const session of readdirSync(linkedRoot).sort()) {
104+
const sdir = join(linkedRoot, session);
105+
const wats = readdirSync(sdir).filter((f) => f.endsWith(".wat"))
106+
.sort((x, y) => (x === "rt.wat" ? -1 : y === "rt.wat" ? 1 : x.localeCompare(y, "en", { numeric: true })));
107+
const a = runSession(wats.map((f) => new Uint8Array(readFileSync(join(sdir, f.replace(/\.wat$/, ".wasm"))))));
108+
const b = runSession(wats.map((f) => assemble(readFileSync(join(sdir, f), "utf8"))));
109+
n++;
110+
if (a === b) agree++;
111+
else console.log(`DISAGREE linked/${session}\n wasm-tools: ${a}\n binaryen: ${b}`);
112+
}
113+
}
114+
115+
console.log(`${agree}/${n} modules+sessions agree (binaryen.js)`);
66116
process.exit(agree === n ? 0 : 1);

‎corpus/session.mjs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// The linked dev-mode session runner (doc/design/0028): instantiate the
2+
// runtime once, then each form module in order against it, threading
3+
// the var ledger. Prints the last form's outcome in the corpus
4+
// vocabulary (result/exn/trap), exit 0 for classified outcomes.
5+
//
6+
// node corpus/session.mjs rt.wasm form1.wasm form2.wasm ...
7+
8+
import { readFileSync } from "node:fs";
9+
10+
const [, , rtPath, ...formPaths] = process.argv;
11+
12+
const rt = new WebAssembly.Instance(
13+
new WebAssembly.Module(readFileSync(rtPath)),
14+
{},
15+
);
16+
const tag = rt.exports["clj-exn"];
17+
18+
// The ledger (0028 §3): starts empty, gains each form's exported var
19+
// globals. A duplicate var export is a compiler bug made loud — the
20+
// silent version is later forms reading a fresh null global as nil.
21+
const vars = {};
22+
23+
const classify = (e) => {
24+
if (e instanceof WebAssembly.Exception && e.is(tag))
25+
return `exn ${rt.exports.exn_class(e.getArg(tag, 0))}`;
26+
if (e instanceof WebAssembly.RuntimeError || e instanceof RangeError)
27+
return `trap ${e.message}`;
28+
throw e;
29+
};
30+
31+
let last;
32+
for (const p of formPaths) {
33+
const inst = new WebAssembly.Instance(
34+
new WebAssembly.Module(readFileSync(p)),
35+
{ rt: rt.exports, vars },
36+
);
37+
for (const [name, val] of Object.entries(inst.exports)) {
38+
if (name === "entry") continue;
39+
if (name in vars) throw new Error(`duplicate var export: ${name} (from ${p})`);
40+
vars[name] = val;
41+
}
42+
try {
43+
last = inst.exports.entry();
44+
} catch (e) {
45+
console.log(classify(e));
46+
process.exit(0);
47+
}
48+
}
49+
console.log(`result ${last}`);

‎doc/design/0024-fn-representation.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ mode-divergently, since until now both modes emitted identical modules.
3333
the classified out-of-contract trap, not emit a `struct.get` on a
3434
field that does not exist. Per-arity slots are `0004`'s measured
3535
shape — collapsing them to one funcref was measured there and
36-
rejected for 0.11 ns; the structure stands.
36+
rejected for 0.11 ns; the structure stands. *(Amended 2026-07-30,
37+
same day: per-module K is a **self-contained-module** property.
38+
`0028`'s review probed the linked dev world and per-module K makes
39+
cross-form fn values trap — different K, different rec group,
40+
different type. Linked dev mode fixes K at 20, Clojure's own
41+
positional ceiling; `0028` §2a is the decision.)*
3742
3. **Each capture signature is its own subtype of `$Fn`, in its own rec
3843
group** (`0009`'s identity rule: unit-private types must not perturb
3944
shared groups). The callee's prologue casts parameter 0 to its leaf

‎doc/design/0028-dev-linking.md‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# 0028 — Dev-mode linking: one shared runtime, one module per form
2+
3+
**Status:** proposed · 2026-07-30 · adversarially reviewed the same day,
4+
before implementation — and the review found a real blocker. Every §1
5+
mechanic survived being run (mutable `(ref null eq)` globals cross
6+
modules and the importer can set them; an imported tag catches a
7+
cross-module throw; eqref signatures type-check structurally at
8+
instantiation — two-module probes, both engines). What did not survive:
9+
per-module K. §2a below is the fix, and *(review)* marks the other
10+
three corrections: the ledger's ownership invariant made loud, the
11+
wasmtime-CLI claim corrected from impossible to deferred-on-cost, and
12+
the probe evidence cited instead of assumed.
13+
14+
## The question
15+
16+
Every decided piece of the dev loop assumes a shape nobody has emitted
17+
yet: `0009`'s open world wants per-form modules over one heap, `0026`
18+
measured that per-form assembly cost is linear in the re-declared
19+
runtime preamble and named "forms *import* the shared runtime" as the
20+
exit, and `0027` made cross-module throw identity explicitly a
21+
tag-import question. And a REPL is not a REPL until form 2 can read
22+
form 1's `def`. What exactly does dev mode emit, what does the runtime
23+
module export, and who remembers which var lives where?
24+
25+
## The decision
26+
27+
1. **The runtime becomes its own module, instantiated once per
28+
session.** It exports, by name: the arithmetic and comparison ops
29+
(`add sub mul quot lt`), `truthy`, `box`/`unbox`, the `true` and
30+
`false` singletons, and the `clj-exn` tag with its `exn_class`
31+
scaffold (`0027` §3 lands here: one tag instance, every form imports
32+
it, so a throw in form N is catchable — later — in form M).
33+
**Types cross by structure, not by name**: the value types and the
34+
`$Fn` substrate are identical rec groups in every module, which is
35+
exactly the wire-format property `0009` measured and
36+
`test/cljwit/rec_group_identity_test.clj` pins — nothing to export.
37+
2. **A dev-mode form compiles to a module that imports what it uses**
38+
— runtime pieces under `"rt"`, and session vars under `"vars"` by
39+
their fully qualified name. A form that `def`s a var **exports** the
40+
backing mutable global from its own module; every later form that
41+
mentions the var **imports** it. Re-`def` of an existing var
42+
imports and `global.set`s it — the open world's whole point, now
43+
across modules — and **never exports it again** *(review)*: the
44+
first definer owns the canonical global forever, and the session
45+
ledger *rejects* a duplicate var export, because the silent version
46+
of that bug is later forms reading a fresh null global as nil.
47+
`:prod` is untouched: one self-contained module, direct-linked,
48+
exactly as `0024` left it.
49+
50+
2a. **K is a session constant in linked mode — 20 — because per-module
51+
K breaks cross-form fn values** *(review — the blocker)*. `0024`
52+
sized the `$Fn` slot table per module; two forms with different max
53+
arity therefore emit *structurally different* rec groups, which by
54+
`0009`'s own identity rule are different types — probed: a closure
55+
built under a K=1 group, passed through a var global, **traps
56+
"illegal cast"** at a K=2 importer's `ref.cast (ref $Fn)`. So every
57+
linked-mode module — the runtime included, when it ever holds fn
58+
values — emits the same `$sig0..$sig20`/`$Fn` group. Twenty is not
59+
arbitrary: **Clojure itself refuses more than 20 positional
60+
params**, so the ceiling is JVM parity, not a new restriction, and
61+
arities beyond it arrive with `apply`/varargs, not with wider
62+
structs. The cost is ~21 nullable slots per closure struct in dev
63+
mode only; `:prod` keeps `0024`'s per-module K, where the module is
64+
whole-program and prunable. `0024` §2 carries the amendment.
65+
3. **The session environment is the instantiator's ledger.** Whoever
66+
drives the session — the corpus lane today, the nREPL server next —
67+
holds one import object: the runtime instance's exports plus every
68+
var-global observed so far; after each instantiation it merges the
69+
new module's exports. The compiler's half of the same ledger is the
70+
analysis environment, which already persists vars across forms
71+
inside one `analyze-forms` call; the session makes that persistence
72+
explicit across calls.
73+
4. **The corpus gains a third compiled lane, not new entries**: every
74+
existing entry's `:forms` vector *is* a session, so the dev-linked
75+
lane runs each form as its own module against one shared runtime
76+
instance and compares the last form's value — the same oracle, the
77+
same table. Entries with cross-form `def`s (`def-uses-def`,
78+
`fn-redef-dev`) stop being single-module simulations of a session
79+
and become the real thing.
80+
5. **The linked lane lands on V8 first; the wasmtime half is deferred
81+
on cost, not capability** *(review — the first draft said the CLI
82+
could not link, which is false)*: `wasmtime run --preload` exists at
83+
47, preloads can import from earlier preloads, and a form module
84+
carrying its evaluation in a start section runs at instantiation —
85+
the review drove a three-module session through the CLI that way.
86+
The trade it costs: start-section evaluation means **a throwing form
87+
fails instantiation**, which any driver must classify as the form's
88+
outcome, not a broken lane. The node session runner is the shape
89+
the nREPL unit consumes next, so it lands now; the wasmtime linked
90+
lane (CLI `--preload` or `cljwit.host`'s FFM world) is recorded as
91+
feasible today and lands with the server unit — a scope choice,
92+
named to keep `0007`'s one-path lesson from being re-derived.
93+
94+
## Why
95+
96+
- Assembly cost: `0026` measured per-form cost linear in module text
97+
(0.92–1.94 ms over 4–8.7 KB); the runtime preamble is the bulk of
98+
every small form's text, and importing it caps the per-form module at
99+
roughly the form's own code.
100+
- Cross-module type identity is not a hope: demonstrated for the `$Fn`
101+
substrate across assemblers in `0026`'s review, and pinned in the
102+
gate since `0009` for rec groups generally.
103+
- Nominal things (functions, globals, tags) already have exactly one
104+
sharing mechanism in Wasm — import/export — so the only real design
105+
freedom was the namespace layout (`"rt"`/`"vars"`) and who keeps the
106+
ledger; both follow from the nREPL unit being the next consumer.
107+
108+
## Alternatives rejected
109+
110+
- **Keep duplicating the runtime per form.** Works today, and `0026`'s
111+
falsifier says exactly how it dies: the margin shrinks linearly as
112+
the runtime grows toward a core library. Import-shaped forms are the
113+
named exit, taken before the preamble grows rather than after.
114+
- **A single mutable "session module" re-instantiated per form.** Loses
115+
the heap (every form's data dies with the previous instance), which
116+
is the opposite of `0009`'s world.
117+
- **Registering var globals back into the runtime module's table**
118+
(a `vars` table indexed by number). An indirection layer with its own
119+
allocator, to avoid string-keyed import resolution the host already
120+
does per instantiation; and it erases the property that a form's
121+
module *says* which vars it touches.
122+
- **Exporting types by name.** Not a thing Wasm has for GC types, and
123+
not needed — rec-group identity is structural; chasing nominal type
124+
sharing would reinvent what `0009` already measured as free.
125+
126+
## What would falsify this
127+
128+
- **A var global imported before its defining module exports it** — the
129+
ledger's ordering assumption. Clojure's own forward-reference story
130+
(`declare`, unbound vars) is out of the slice today (`(def y y)`
131+
refuses); if `declare` lands, the ledger needs a declared-but-unbound
132+
representation, and this note gains an amendment.
133+
- **Import resolution cost growing with session length** — hundreds of
134+
var imports per form would show up in `0026`-style measurements; the
135+
per-form import list is the form's own free variables, which for
136+
human-written forms stays small. Re-measure if a generated-code
137+
session contradicts that.
138+
- **An engine cost cliff for many-module sessions** (thousands of live
139+
instances in one store). Unmeasured; the nREPL unit's soak test is
140+
where it would surface, and `0026`'s per-form numbers bound the
141+
entry cost.

‎doc/status.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,19 @@ _Short by design, and printed at every session start — so findings live in
7070
ran in a real Chrome tab — `fn-defn-fib` printed `result 6765` and
7171
`throw-add-overflow` printed `exn 1`, the same vocabulary as the
7272
node runner (2026-07-30; serve the repo root, the page header says
73-
how). Next unit: **S2's nREPL entry point**, unblocked on both
74-
sides — the shadow-cljs shape from `doc/roadmap.md`, with the
75-
dev-loop pieces decided (`0026` assembler, `0009` open world,
76-
`0027` tag import/export arriving with the shared runtime).
73+
how). **Dev-mode linking is built and oracle-checked** (`0028`):
74+
one runtime module per session, one module per form importing
75+
`"rt"` and the session vars it reads, exporting the ones it defs —
76+
the corpus's third compiled lane runs every entry as a real session
77+
(form 2 reads form 1's `def`, fn values cross modules, a
78+
cross-module throw classifies through the imported tag). The review
79+
caught a real blocker before it shipped: per-module K broke
80+
cross-form fn values, so linked K is a session constant, 20 —
81+
Clojure's own ceiling (`0028` §2a, `0024` amended). The wasmtime
82+
linked lane is deferred on cost, not capability (`--preload` works;
83+
recorded). Next unit: **S2's nREPL entry point** — the shadow-cljs
84+
shape from `doc/roadmap.md`, every prerequisite now decided and
85+
the session runner already the shape the server drives.
7786

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

0 commit comments

Comments
 (0)