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

Commit 9b3ddd8

Browse files
committed
0024: fn lands and the modes finally disagree — defn fib runs on both engines
Closures are subtypes of a per-arity-slot $Fn base, each capture signature in its own rec group (0009); method funcs declare (type $sigN) explicitly because a bare func type canonicalizes as its own singleton group and loses identity with the slot field — 0009's rule, met again. Free variables are computed on uniquified names (the pass is now scheduled in analyze-forms); the capture-shadow entry exists because name-based free vars are wrong without it. recur in a method rebinds the argument params through the same stack-swap as loop. The first real mode divergence: :prod direct-links a fn def into an immutable constant struct.new global and plain calls (^:dynamic/^:redef excluded, re-def refused), :dev keeps the mutable global and the cast+slot+call_ref path. The dev-only redef entry introduces :modes, because a shared corpus could never express what the open world exists to preserve (0024 review, finding 4). The same review re-measured the depth bands for this frame shape -- wasmtime ~8-10k, V8 ~10.3-10.7k, oracle JVM ~8.5-9.2k, none of them 0022 D's minimal-function numbers -- so depth entries pin 3000; the exhaustion entry adds trap row 2 (SOE matches on class alone, arrives unwrapped, V8 reports RangeError which run.mjs now classifies). It also verified constant struct.new globals, cross-rec-group subtyping, and that null-call/cast traps stay distinguishable from every table row. One regression caught mid-unit: the (def y y) ordering fix had broken recursive fn defs in dev; a fn init now registers its global before the method bodies emit, because a body's var reads happen at call time. 45 corpus entries, 220 assertions inside the flake, gate green. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 48f5d83 commit 9b3ddd8

8 files changed

Lines changed: 529 additions & 91 deletions

File tree

‎corpus/run.mjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const instance = new WebAssembly.Instance(
1414
try {
1515
console.log(`result ${instance.exports.entry()}`);
1616
} catch (e) {
17-
if (e instanceof WebAssembly.RuntimeError) console.log(`trap ${e.message}`);
17+
// V8 reports wasm stack exhaustion as RangeError, not RuntimeError
18+
// (0024) — both are classified outcomes, not harness failures.
19+
if (e instanceof WebAssembly.RuntimeError || e instanceof RangeError)
20+
console.log(`trap ${e.message}`);
1821
else throw e;
1922
}

‎corpus/s3.edn‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,44 @@
5353
;; sibling's recorded bug) is unobservable until try/catch exists —
5454
;; claimed nowhere until it does.
5555
{:id "def-redef-throws" :forms [(def x 5) (def x (quot 1 0)) x]}
56-
{:id "trap-div-zero" :forms [(quot 1 0)]}]
56+
{:id "trap-div-zero" :forms [(quot 1 0)]}
57+
;; --- fn (0024). An entry carries :modes when only one mode can
58+
;; express it; default is both.
59+
{:id "fn-anon-call" :forms [((fn [x y] (+ x y)) 2 3)]}
60+
{:id "fn-zero-arity" :forms [((fn [] 7))]}
61+
{:id "fn-multi-arity" :forms [(let [f (fn ([x] x) ([x y] (* x y)))]
62+
(+ (f 5) (f 3 4)))]}
63+
{:id "fn-closure" :forms [(let [a 10] ((fn [x] (+ x a)) 5))]}
64+
{:id "fn-closure-shared" :forms [(let [a 2]
65+
(let [g (fn [x] (* a x))]
66+
(+ (g 3) (g 4))))]}
67+
;; Partial shadowing: the outer x is captured, the inner x is not —
68+
;; exactly what uniquified free-variable computation exists for.
69+
{:id "fn-capture-shadow" :forms [(let [x 1]
70+
((fn [y] (+ x (let [x 10] (+ x y)))) 5))]}
71+
{:id "fn-named-fact" :forms [((fn fact [n] (if (< n 2) 1 (* n (fact (- n 1))))) 10)]}
72+
{:id "fn-method-recur" :forms [((fn [n acc] (if (< 0 n) (recur (- n 1) (+ acc n)) acc)) 10 0)]}
73+
{:id "fn-higher-order" :forms [((fn [f x] (f (f x))) (fn [n] (+ n 3)) 10)]}
74+
{:id "fn-def-call" :forms [(def dbl (fn [x] (* 2 x))) (dbl 21)]}
75+
{:id "fn-as-value" :forms [(def dbl (fn [x] (* 2 x)))
76+
((fn [g] (+ (g 3) (g 4))) dbl)]}
77+
;; The roadmap's S3 sentence, verbatim shape: defn fib runs everywhere.
78+
{:id "fn-defn-fib" :forms [(defn fib [n] (if (< n 2) n (+ (fib (- n 1)) (fib (- n 2)))))
79+
(fib 20)]}
80+
;; Depth pin: 3000 frames, ~2.8x under the shallowest measured band for
81+
;; this frame shape — wasmtime ~8-10k, V8 ~10.3-10.7k, the oracle JVM
82+
;; ~8.5-9.2k (0024 §9).
83+
{:id "fn-depth-safe" :forms [(defn down [n] (if (< 0 n) (down (- n 1)) 42))
84+
(down 3000)]}
85+
;; Deliberate exhaustion on every lane, including the oracle: trap row 2.
86+
{:id "fn-stack-exhaust" :forms [(defn down [n] (if (< 0 n) (down (- n 1)) 42))
87+
(down 10000000)]}
88+
;; Redefinition taking effect is the open world's point (0009); prod
89+
;; direct-links and refuses, so this entry is dev-only (0024 §10).
90+
{:id "fn-redef-dev" :modes [:dev]
91+
:forms [(def f (fn [] 1)) (def f (fn [] 2)) (f)]}
92+
;; ^:redef opts out of direct linking in prod (0004's exclusion rule),
93+
;; so redefinition works in both modes.
94+
{:id "fn-redef-marked" :forms [(def ^:redef f (fn [] 1))
95+
(def ^:redef f (fn [] 2))
96+
(f)]}]

‎corpus/trap_table.edn‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@
88
;; a division trap. All strings are observed, not recalled: the JVM's is
99
;; what `(quot 1 0)` actually throws, the engine strings verified
1010
;; 2026-07-30 on the pinned toolchain (wasmtime 47.0.1, V8/node).
11+
;; Row 2 matches on class alone (:jvm-message "") — StackOverflowError's
12+
;; message is null, a recorded weakening of the jvm-message guard (0024).
1113
[{:row 1
1214
:class "java.lang.ArithmeticException"
1315
:jvm-message "/ by zero"
1416
:wasmtime "integer divide by zero"
15-
:v8 "divide by zero"}]
17+
:v8 "divide by zero"}
18+
{:row 2
19+
:class "java.lang.StackOverflowError"
20+
:jvm-message ""
21+
:wasmtime "call stack exhausted"
22+
:v8 "Maximum call stack size exceeded"}]
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# 0024 — fn in the slice: closures, arities, and the first real mode divergence
2+
3+
**Status:** proposed · 2026-07-30 · adversarially reviewed the same day,
4+
before implementation finished. The review verified the mechanics it
5+
attacked (constant `struct.new` globals, cross-rec-group subtyping,
6+
distinguishable null/cast traps, `RangeError` on V8 exhaustion, the three
7+
arity rules enforced by bare tools.analyzer, safe SOE recovery in the
8+
oracle) and changed four things, marked *(review)*: the depth pin's
9+
rationale re-measured for the real frame shape, row 2's class-only match
10+
made explicit, K's definition extended to call sites, and a per-mode
11+
corpus annotation for behaviour only one mode can express.
12+
13+
## The question
14+
15+
`0022` D contracts what `fn` must do (multi-arity ships with it, varargs
16+
carry an explicit rest-mode, no tail calls, the depth bands). It does not
17+
pin how a closure is laid out in WasmGC, how a call site reaches the code,
18+
or where `:dev` and `:prod` emission first produce different bytes. Those
19+
are this note's decisions — the first ones `0023`'s harness can check
20+
mode-divergently, since until now both modes emitted identical modules.
21+
22+
## The decision
23+
24+
1. **Function signatures.** `$sigN` is
25+
`(func (param (ref eq)) (param (ref null eq))×N (result (ref null eq)))`
26+
— parameter 0 is the closure itself, through which the body reads its
27+
captures. Uniform boxing per `0023`; unboxed signatures are a prod
28+
optimisation for later, not the baseline (`0022` C).
29+
2. **`$Fn` is a base struct with one nullable typed-function-ref field per
30+
arity**, 0..K where K is the module's maximum arity **over both method
31+
definitions and fn-value call sites** *(review)* — a higher-order site
32+
calling arity N > any defined method must find a null slot and take
33+
the classified out-of-contract trap, not emit a `struct.get` on a
34+
field that does not exist. Per-arity slots are `0004`'s measured
35+
shape — collapsing them to one funcref was measured there and
36+
rejected for 0.11 ns; the structure stands.
37+
3. **Each capture signature is its own subtype of `$Fn`, in its own rec
38+
group** (`0009`'s identity rule: unit-private types must not perturb
39+
shared groups). The callee's prologue casts parameter 0 to its leaf
40+
type once into a local (B4: casts are cheap by depth), and capture
41+
reads are `struct.get` on that local. Captures are by value, which is
42+
exact — Clojure has no `set!` on locals, so there is nothing mutable
43+
to alias.
44+
4. **Free variables are computed on uniquified names.**
45+
`clojure.tools.analyzer.passes.uniquify` is now scheduled inside
46+
`analyze-forms` — the pass-scheduler home `0022` E predicted for
47+
closure conversion. Partial shadowing
48+
(`(let [x 1] (fn [] (+ x (let [x 2] x))))`) makes name-based free-var
49+
computation wrong without it.
50+
5. **Multi-arity is slots in one struct; a missing arity is a null slot.**
51+
Calling it traps on the null `call_ref` — reported by the harness as
52+
out-of-contract until an `ArityException` representation exists (the
53+
throw representation is open, `0022` C). The three JVM arity rules are
54+
enforced at analysis where tools.analyzer sees the fn literal.
55+
6. **Varargs analyze but refuse to emit.** `:variadic?` methods are a
56+
loud out-of-slice error: the rest parameter is a seq, no seq
57+
representation exists, and `0022` D's rest-mode contract stays marked
58+
untested — its corpus entry enters the day printing does (`0022` B.3).
59+
7. **`recur` in a fn method rebinds the method's argument parameters**
60+
(never parameter 0, the closure) — the method's `:loop-id` registers
61+
them exactly as `loop` registers its bindings, same simultaneous
62+
stack-swap emission.
63+
8. **Calls.** The generic path is `0004`'s measured shape — reach the fn
64+
value (local, global, or fresh), `ref.cast (ref $Fn)`, `struct.get`
65+
the arity-N slot, `call_ref $sigN` — for every call whose target is a
66+
fn value. **The first mode divergence**: a top-level
67+
`(def f (fn …))` whose var is neither `^:dynamic` nor `^:redef`
68+
compiles in `:prod` to an **immutable global holding a constant
69+
`struct.new`**, and every call through the var to a **direct `call`**
70+
of the method — Clojure's own direct-linking rule and exclusion list
71+
(`0004`). In `:dev` the var stays a mutable `(ref null eq)` global
72+
assigned at eval, and calls go through the generic path — the var
73+
indirection `0009`'s open world requires. Re-`def` of a direct-linked
74+
fn in `:prod` is a loud refusal (`^:redef` is how you ask for
75+
redefinability, same as on the JVM).
76+
9. **Stack depth.** No tail calls, for parity (`0022` D). *(review — the
77+
bands re-measured for this note's actual frame shape, because `0022`
78+
D's 30–40k/10–20k numbers were for a minimal function and it predicted
79+
fatter frames itself)*: with the self param, prologue cast, slot load
80+
and `call_ref`, **wasmtime exhausts at ~8–10k, V8 at ~10.3–10.7k, and
81+
the oracle JVM itself at ~8.5–9.2k** for an eval'd recursive fn on the
82+
default test-JVM stack — the oracle is a band like any other lane.
83+
Depth-sensitive entries therefore pin **3,000 frames**, ~2.8× under
84+
the shallowest measured band. One entry exhausts the stack on purpose
85+
and adds **trap-table row 2**: `java.lang.StackOverflowError` — whose
86+
message is null, so **this row matches on class alone**, a recorded
87+
weakening of the table's jvm-message guard *(review)*; SOE also
88+
arrives *unwrapped* by `eval`, so the CompilerException unwrap is
89+
correctly a no-op here — ↔ wasmtime's "call stack exhausted" ↔ V8's
90+
`RangeError` ("Maximum call stack size exceeded"), which is *not* a
91+
`WebAssembly.RuntimeError`, so the V8 runner learns to classify
92+
`RangeError` as a trap.
93+
10. **Per-mode corpus entries** *(review)*. `:prod` refuses fn re-`def`
94+
(point 8), so the dev-mode behaviour it exists to preserve —
95+
redefinition taking effect — could never be exercised by entries
96+
that run in both modes. A corpus entry may carry `:modes [:dev]`
97+
(default: both); the fn-redef entry is dev-only by construction,
98+
and the `^:redef` entry covers both modes.
99+
100+
## Why
101+
102+
- Every load-bearing shape is inherited from a measured decision: the
103+
per-arity slots and the direct-linking exclusion rule from `0004`, the
104+
rec-group isolation from `0009`, cheap leaf casts from B4, boxed
105+
signatures from `0023`/`0022` C, the depth bands from `0022` D's
106+
measurements.
107+
- The divergence point (calls through vars, not `def` itself) is where
108+
Clojure's own direct linking draws it, so no new vocabulary or caveat
109+
list is invented — including its known behaviour that a direct-linked
110+
fn ignores redefinition, which here is a refusal instead of a silent
111+
staleness.
112+
113+
## Alternatives rejected
114+
115+
- **One funcref field plus arity dispatch in the callee.** `0004`
116+
measured the per-arity table against this and kept the table; an
117+
arity switch on every call buys one word per closure.
118+
- **Reusing `0004`'s full `$obj`/vtable-array layout now.** That layer
119+
exists for protocols and open user types (selector coloring over the
120+
whole program). A fn value's callee set is closed at creation; slots
121+
suffice. The two converge when protocols land, not before.
122+
- **A universal closure type with an `(ref null eq)` capture array.**
123+
Loses typed capture reads, adds a bounds-checked load per capture, and
124+
`0009` already decided per-signature types for sharing reasons.
125+
- **Implementing varargs against an ad-hoc pair/array type.** Every rest
126+
argument would be re-done when seqs land; `0022` D's rest-mode
127+
contract is about *marking*, which analysis already preserves.
128+
- **`return_call` for self-recursion now.** The toolchain supports it,
129+
but parity with the JVM's missing TCO is the deliberate baseline
130+
(`0022` D); tail-position marking lands with its consumer, post-S3.
131+
- **Direct-linking in `:dev` too.** Kills redefinition, which is the
132+
entire point of the dev mode's open world (`0009`).
133+
134+
## What would falsify this
135+
136+
- A measured prod-vs-dev call-cost gap too small to justify two paths —
137+
nothing here is a performance *claim*; the benchmark named by `0010`
138+
(coverage/guard precision) arrives with specialisation, and if direct
139+
linking buys nothing measurable the prod path simplifies to the
140+
generic one.
141+
- The null-slot arity trap colliding with a future `ArityException`
142+
row — expected; resolving it is part of the throw-representation
143+
decision (`0022` C), and the trap table gains the distinction then.
144+
- A capture set the uniquified-name computation gets wrong — one corpus
145+
entry exists specifically to catch partial shadowing.
146+
- `struct.new` with `ref.func` failing as a constant expression on
147+
either engine — checked at validation, would force prod fns back to
148+
start-time assignment (a smaller divergence, same call-site shape).

‎doc/status.md‎

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,25 @@ _Short by design, and printed at every session start — so findings live in
1818
reports tiered drift (`:stale`/`:modified`/`:missing`/`:orphans`), and
1919
`check` is the CI verb. What remains of S2 — the nREPL entry point and
2020
the watcher — waits for a compiler to serve, so:
21-
3. **S3's first vertical slice is landed** (`0022` decided the shape,
22-
`0023` the harness and representation): the differential oracle is in
23-
the gate — `corpus/s3.edn` (29 scalar entries) runs through real
24-
`clojure` (fresh namespace per entry) and through
21+
3. **Every S3 form now compiles and is oracle-checked** (`0023` the
22+
harness, `0024` fn): `corpus/s3.edn` — 45 entries: literals, `if`,
23+
`let`, `loop`/`recur`, `def`, `fn` with multi-arity, closures,
24+
method-`recur`, higher-order calls — runs on every `bb check` through
25+
real `clojure` (fresh namespace per entry) and through
2526
`cljwit.analyze` → `cljwit.emit` → `wasm-tools` → **wasmtime and V8,
26-
in both modes** (which emit identical bytes until calls to def'd fns
27-
land — stated, not hidden), with `corpus/trap_table.edn` row 1
28-
(`ArithmeticException` ↔ division trap) exercised. Representation
29-
pinned in `0023`: values are `(ref null eq)`, fixnums i31, nil = null,
30-
false/true = singleton structs, overflow arm `unreachable`. Still
31-
open, each with its benchmark named (`0022`): the dev-loop output
32-
format (before the nREPL unit), the boxed-i64 lane (before the
33-
emitter grows past i31), the throw representation. `loop`/`recur` and
34-
scalar `def` are in the corpus (29 entries); mode divergence has still
35-
not started — it begins with *calls to* def'd fns (direct linking,
36-
`0004`), so the next unit is **`fn`**: `0022` D's full contract —
37-
multi-arity ships with it, varargs carry an explicit rest-mode — and
38-
the first depth-sensitive entry brings the trap table its stack row.
27+
in both modes**. `(defn fib [n] …) (fib 20)` returns 6765 on both
28+
engines — the roadmap's S3 sentence, minus an actual browser page.
29+
**The modes now genuinely diverge**: `:prod` direct-links fn defs
30+
(immutable constant global + plain `call`, `^:dynamic`/`^:redef`
31+
excluded, re-`def` refused), `:dev` keeps var indirection; the
32+
dev-only redef entry carries `:modes [:dev]` (`0024` §10). The trap
33+
table has two exercised rows (division, stack exhaustion — depth
34+
bands re-measured for the real frame shape, pin at 3,000). Varargs
35+
analyze but refuse to emit until seqs exist. Still open, each with
36+
its benchmark named (`0022`): the dev-loop output format (before the
37+
nREPL unit), the **boxed-i64 lane** (before the emitter grows past
38+
i31 — fib's n = 46…92 domain), the throw representation. Next unit:
39+
**the boxed-i64 benchmark**, predictions first (`0002`'s rule).
3940

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

‎src/cljwit/analyze.clj‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@
1515
(:refer-clojure :exclude [macroexpand-1])
1616
(:require [clojure.tools.analyzer :as ana]
1717
[clojure.tools.analyzer.env :refer [with-env]]
18+
[clojure.tools.analyzer.passes :refer [schedule]]
19+
[clojure.tools.analyzer.passes.uniquify :refer [uniquify-locals]]
1820
[clojure.tools.analyzer.utils :refer [resolve-sym]]))
1921

2022
(set! *warn-on-reflection* true)
2123

24+
;; Uniquify is the one scheduled pass: emission computes free variables
25+
;; and lexical scope by name, which partial shadowing would corrupt —
26+
;; `(let [x 1] (fn [] (+ x (let [x 2] x))))` has two locals both named x.
27+
(def ^:private run-passes (schedule #{#'uniquify-locals}))
28+
2229
(defn- macroexpand-1
2330
"Only `:macro` vars expand; specials and locals shield; no `:inline`."
2431
[form env]
@@ -53,5 +60,5 @@
5360
ana/parse ana/-parse
5461
ana/var? var?]
5562
(with-env genv
56-
(mapv #(ana/analyze % env) forms))))
63+
(mapv #(run-passes (ana/analyze % env)) forms))))
5764
(finally (remove-ns ns-sym)))))

0 commit comments

Comments
 (0)