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

Commit b73388c

Browse files
committed
0023: the first vertical slice — the differential oracle is in the gate
corpus/s3.edn (21 scalar entries) now runs on every bb check through real clojure (fresh namespace per entry) and through cljwit.analyze -> cljwit.emit -> wasm-tools -> wasmtime + V8 in both modes, compared structurally; corpus/trap_table.edn is 0022 B.5's numbered artifact with row 1 (ArithmeticException <-> division trap) exercised in this commit. Representation pinned by 0023, shaped by its adversarial review: values are (ref null eq), fixnums i31 (B3's substrate), nil = null, false/true = singleton structs, overflow arm unreachable until the boxed-lane benchmark. The review also forced analyzer-side namespace isolation, the jvm-message column (the table's first draft said "Divide by zero"; the oracle's first run said "/ by zero" — observed beats recalled), and the out-of-contract trap classification. The wasmtime lane skips with a printed line off-flake, like the FFM tests; CI runs where nothing skips. tools.analyzer moves from :dev to :deps — the front end is now src/, not a spike. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 9a6c4d4 commit b73388c

9 files changed

Lines changed: 603 additions & 19 deletions

File tree

‎corpus/run.mjs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// V8 corpus lane (doc/design/0023): instantiate one compiled entry module
2+
// and print `result <value>` or `trap <message>`. A trap is an expected,
3+
// classified outcome — exit 0 either way; the harness compares it against
4+
// corpus/trap_table.edn. Anything else (bad file, missing export) throws
5+
// and exits non-zero, which the harness treats as a broken lane.
6+
7+
import { readFileSync } from "node:fs";
8+
9+
const [, , path] = process.argv;
10+
const instance = new WebAssembly.Instance(
11+
new WebAssembly.Module(readFileSync(path)),
12+
{},
13+
);
14+
try {
15+
console.log(`result ${instance.exports.entry()}`);
16+
} catch (e) {
17+
if (e instanceof WebAssembly.RuntimeError) console.log(`trap ${e.message}`);
18+
else throw e;
19+
}

‎corpus/s3.edn‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
;; The S3 corpus — doc/design/0022 B's committed artifact; the harness is
2+
;; test/cljwit/corpus_test.clj (doc/design/0023). Every special form lands
3+
;; with its entries in the same commit, and any "X works" claim leaves its
4+
;; probing expression here.
5+
;;
6+
;; Authoring rules for day one (0023):
7+
;; - entries are scalar: the last form's value is a long (0022 B.3)
8+
;; - literals, intermediates AND results all stay inside i31
9+
;; (-2^30 .. 2^30-1) — the boxed-i64 lane is open (0022 C), and a
10+
;; violation traps `unreachable`, which the harness reports as an
11+
;; out-of-contract entry, not a compiler divergence
12+
;; - an entry that expects a throw needs its row in trap_table.edn
13+
[{:id "lit" :forms [42]}
14+
{:id "lit-neg" :forms [-7]}
15+
{:id "lit-i31-max" :forms [1073741823]}
16+
{:id "lit-i31-min" :forms [-1073741824]}
17+
{:id "add-nary" :forms [(+ 1 2 3 4)]}
18+
{:id "sub-chain" :forms [(- 100 1 2 3)]}
19+
{:id "neg-unary" :forms [(- 5)]}
20+
{:id "mul" :forms [(* 6 7)]}
21+
{:id "arith-nested" :forms [(+ (* 3 4) (- 10 (quot 9 2)))]}
22+
{:id "quot-pos" :forms [(quot 17 5)]}
23+
{:id "quot-neg" :forms [(quot -17 5)]}
24+
{:id "if-lt" :forms [(if (< 1 2) 10 20)]}
25+
{:id "if-lt-false" :forms [(if (< 2 1) 10 20)]}
26+
{:id "if-nil" :forms [(if nil 1 2)]}
27+
{:id "if-false" :forms [(if false 1 2)]}
28+
{:id "if-zero-truthy" :forms [(if 0 1 2)]}
29+
{:id "if-untaken-arm" :forms [(if true 3 (quot 1 0))]}
30+
{:id "let-shadow" :forms [(let [x 1] (let [x (+ x 1)] (* x 10)))]}
31+
{:id "let-sequential" :forms [(let [x 2 y (+ x 3)] (* x y))]}
32+
{:id "top-level-forms" :forms [(+ 1 1) (+ 2 2)]}
33+
{:id "trap-div-zero" :forms [(quot 1 0)]}]

‎corpus/trap_table.edn‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
;; The trap↔class table — doc/design/0022 B.5's numbered artifact. A wasm
2+
;; trap has no exception class, so an entry where the oracle threw and a
3+
;; compiled lane trapped compares equal only where a row says so; any
4+
;; other pairing is a bug or a new numbered divergence.
5+
;;
6+
;; :jvm-message narrows the class (0023, review): ArithmeticException
7+
;; also means long overflow ("long overflow"), which must never equate to
8+
;; a division trap. All strings are observed, not recalled: the JVM's is
9+
;; what `(quot 1 0)` actually throws, the engine strings verified
10+
;; 2026-07-30 on the pinned toolchain (wasmtime 47.0.1, V8/node).
11+
[{:row 1
12+
:class "java.lang.ArithmeticException"
13+
:jvm-message "/ by zero"
14+
:wasmtime "integer divide by zero"
15+
:v8 "divide by zero"}]

‎deps.edn‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{:paths ["src" "resources"]
2-
:deps {org.clojure/clojure {:mvn/version "1.12.4"}}
2+
:deps {org.clojure/clojure {:mvn/version "1.12.4"}
3+
;; The S3 front end: tools.analyzer core plus our own host layer
4+
;; (doc/design/0022 E decided it, 0023 grows it into src/).
5+
org.clojure/tools.analyzer {:mvn/version "1.2.2"}}
36

47
:aliases
58
{;; --- development -------------------------------------------------------
69
:dev {:extra-paths ["test" "dev"]
710
:extra-deps {io.github.cognitect-labs/test-runner
8-
{:git/tag "v0.5.1" :git/sha "dfb30dd"}
9-
;; bb spike-analyzer (0022 E): the S3 front-end survey's
10-
;; executable half.
11-
org.clojure/tools.analyzer {:mvn/version "1.2.2"}}}
11+
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}}
1212

1313
;; `clj -M:test` — the unit lane
1414
:test {:extra-paths ["test"]
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# 0023 — The first vertical slice: corpus harness and the representation it pins
2+
3+
**Status:** proposed · 2026-07-30 · adversarially reviewed the same day.
4+
The review verified the WasmGC mechanics it attacked (nullable `ref.eq`,
5+
`if` with a ref result, singleton identity surviving `wasm-opt -O3`) and
6+
changed four things, marked *(review)* below: analyzer-side namespace
7+
isolation, a JVM-message column in the trap table, the i31 rule extended
8+
from literals to intermediates and results, and an overclaimed truthiness
9+
example dropped.
10+
11+
## The question
12+
13+
`0022` decided S3's shape and named its first unit: a corpus harness plus
14+
scalar entries running analyze → emit → both engines, forcing none of the
15+
open decisions (dev-loop format, boxed-i64 lane, throw representation).
16+
Building it still requires pinning things `0022` deliberately left to "the
17+
first corpus entry": the concrete corpus artifacts, and the emitter's value
18+
representation for everything the slice touches.
19+
20+
## The decision
21+
22+
### The harness (implements `0022` B, adds nothing to it)
23+
24+
- **`corpus/s3.edn`** — the committed corpus. An entry is
25+
`{:id "…" :forms [f1 … fn]}`; forms evaluate in order, the last one's
26+
value is the entry's result. Day-one entries are scalar (`0022` B.3).
27+
- **`test/cljwit/corpus_test.clj`** — the differential oracle, an ordinary
28+
`clojure.test` so it is in `bb check` from the first entry
29+
(CI-mandatory, `0022` B). The oracle lane evaluates each entry in a
30+
fresh, discarded namespace in the test JVM — which *is* real `clojure`,
31+
satisfying the isolation rule without a second process. **Analysis is
32+
isolated the same way** *(review)*: each `analyze-forms` call gets its
33+
own gensym'd namespace and `create-var` interns only there, so a `def`
34+
in one entry can never resolve during another entry's analysis — the
35+
oracle-lane leak rule, one lane over. Compiled lanes: emit WAT →
36+
`wasm-tools parse` → `wasmtime run --invoke entry` and
37+
`node corpus/run.mjs`, in both `:dev` and `:prod` modes.
38+
`--invoke`'s printed result is a warned-experimental interface — the
39+
same one `bench/s0` already stands on; the pinned toolchain makes that
40+
acceptable, and a format change fails every entry loudly at once
41+
*(review)*. On a machine without the `wasmtime` CLI on PATH that one
42+
lane skips with a printed line — the exact trade the FFM tests already
43+
make with `CLJWIT_WASMTIME_LIB` — while the oracle, analysis, emission
44+
and the V8 lane always run; CI runs inside the flake, where nothing
45+
skips.
46+
- **`corpus/trap_table.edn`** — the numbered trap↔class artifact
47+
(`0022` B.5). Row 1: `java.lang.ArithmeticException` ↔ the engines'
48+
division traps, exercised by a corpus entry in the same commit.
49+
A row carries the **JVM message substring, not just the class**
50+
*(review)*: `ArithmeticException` also means long overflow, which is
51+
not a division trap, and class-only matching would equate them the day
52+
the boxed lane lands. Stack exhaustion's row lands with the first
53+
depth-sensitive entry, not before (same-commit coverage, `0022` B.1).
54+
- **Value comparison** is structural: the oracle's `pr-str` and the wasm
55+
lane's printed integer both go through `clojure.edn/read-string` and
56+
compare with `=`. An oracle value that `pr-str`→`read-string` cannot
57+
round-trip is a corpus lint error (`0022` B.4) — this falls out of the
58+
mechanism rather than being a separate linter.
59+
60+
### The representation (the new pins)
61+
62+
1. **Every Clojure value is `(ref null eq)`.** One uniform type; no
63+
unboxing pass in the slice. Refinement is a prod-mode optimisation
64+
later, per binaryen's lowering guidance already cited in `0022` A.
65+
2. **Fixnums are `i31`** — B3's measured substrate, verbatim. A literal
66+
outside i31's signed 31-bit range is a **compile error** in the slice:
67+
that value belongs to the boxed-i64 lane, which is open (`0022` C)
68+
precisely because it is unmeasured. Runtime arithmetic computes in
69+
i64 and re-boxes through one guard; a result outside i31 hits
70+
`(unreachable)` — the same shape B3 measured, and the same honesty:
71+
the slow path exists and no corpus entry may reach it yet. The
72+
corresponding **corpus authoring rule covers intermediates and
73+
results, not just literals** *(review)*: `(quot -1073741824 -1)` or an
74+
overflowing left-fold would compile, run, and trap where Clojure's
75+
longs succeed. The harness classifies `unreachable` and cast-failure
76+
traps as "entry out of contract — fix the entry", so a violated
77+
authoring rule cannot read as a compiler divergence.
78+
3. **nil is the null reference.** `(ref null eq)` locals default to it,
79+
`ref.is_null` is the nil test, and no allocation or global is spent
80+
on it.
81+
4. **false and true are two singleton empty-struct globals**, distinct by
82+
identity (`ref.eq`), not by type. Truthiness — the thing `0022` D said
83+
the first corpus entry pins — is
84+
`(not (or (ref.is_null v) (ref.eq v $false)))`, emitted as a call to a
85+
runtime `$truthy`. `(if 0 …)` is therefore truthy by construction,
86+
matching Clojure with no special cases — and the shape extends to any
87+
future non-nil, non-false value, though only fixnums can demonstrate
88+
it while no other representation exists *(review)*.
89+
5. **The entry protocol**: each entry compiles to one self-contained
90+
module exporting `entry : [] -> [i64]`, the last form's value
91+
unwrapped by `ref.cast (ref i31)` + `i31.get_s`. A non-fixnum result
92+
traps the cast — which is the mechanical form of `0022` B.3's rule
93+
that non-scalar entries wait for printing.
94+
6. **Intrinsics, not a core library**: `+ - * quot <` compile from
95+
`:invoke`-of-`#'clojure.core/…` (the `:inline`-off contract from
96+
`0022` E) to runtime functions; `+ - *` fold n-ary to binary with
97+
Clojure's identities, `<` is binary-only for now. Any other var is a
98+
loud compile error naming the var.
99+
7. **Both modes exist from day one and currently emit identical bytes.**
100+
`:dev`/`:prod` thread the whole pipeline and all four compiled lanes
101+
run, but nothing in the slice's forms diverges by mode — divergence
102+
starts with `def`/var emission (`0022` D). Stated here so nobody reads
103+
four green lanes as mode coverage that does not exist yet.
104+
105+
## Why
106+
107+
- Every mechanism above is inherited from something already measured or
108+
decided: the WAT→`wasm-tools`→both-engines pipeline is `bench/s0`'s,
109+
the i31 substrate and the `unreachable` slow path are B3's, the oracle
110+
contract is `0022` B, the analyzer front end is `0022` E's spike grown
111+
into `src/`.
112+
- The pins themselves are chosen for *reversibility*: null-nil, singleton
113+
false, and `$truthy` are each one emitter function; the boxed lane can
114+
replace the `unreachable` arm without touching entry, corpus, or
115+
comparison machinery.
116+
117+
## Alternatives rejected
118+
119+
- **false as a reserved i31 value.** Free identity test, but it steals a
120+
value from fixnum space and makes every arithmetic fast-path guard also
121+
a not-false guard. The sibling's lesson that dispatch guards dominate
122+
makes polluting the fixnum domain the wrong default.
123+
- **nil as a singleton struct, refs non-nullable.** Binaryen's tips favor
124+
non-nullable fields, but that is a prod-mode refinement; nullable
125+
locals need no explicit init, `ref.is_null` is one instruction, and
126+
nothing measured yet distinguishes them. Revisit when the optimiser
127+
lane exists.
128+
- **Running compiled lanes through `cljwit.host` instead of spawning
129+
engines.** Tempting reuse, but corpus modules are core modules, not
130+
components (`0007`: components are S4's boundary), `cljwit.host` speaks
131+
components only, and the V8 lane has no host at all. `bench/s0`'s spawn
132+
mechanism is validated and already fails on wrong answers.
133+
- **A standalone corpus runner (`bb corpus`) instead of a test in the
134+
gate.** `0022` B's own clause: mandatory-ness alone saved the sibling
135+
nothing, but an opt-in lane is strictly worse. The gate is where the
136+
oracle lives; a convenience runner can come later if iteration wants it.
137+
- **Waiting for `fn`/`def` so the slice "does more".** The harness is the
138+
deliverable; every form it carries today has its corpus lines in the
139+
same commit, and `fn`'s multi-arity contract (`0022` D) is its own
140+
unit.
141+
142+
## What would falsify this
143+
144+
- The boxed-lane benchmark (`0022` C) pricing the i31 split out — the
145+
fixnum pin and the `unreachable` arm both reopen.
146+
- A corpus entry whose truthiness the `$truthy` shape cannot express —
147+
none is known; finding one would mean the false-singleton choice leaks.
148+
- The trap table failing to distinguish two JVM exception classes that
149+
map to one wasm trap message — expected eventually (e.g. cast failure
150+
vs NPE); the table gains columns or the entry gains a numbered
151+
divergence when it happens.
152+
- Engine trap messages changing across pinned-toolchain upgrades — the
153+
table rows carry the exact strings, so this fails loudly in the gate.

‎doc/status.md‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,30 @@ _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 is entered** (`0022`, proposed, rewritten twice on day one).
22-
Decided: prod emission as WAT text through the pinned toolchain; the
23-
differential-oracle contract (isolation rule, trap↔class table,
24-
comparison holes named); the numeric *fast path* (i31) and semantic
25-
baseline (`+` throws, `+'` promotes; `identical?` divergence numbered);
26-
**tools.analyzer core plus our own host layer** as the front end
27-
(`bb spike-analyzer` is the executable half). Open, each with its
28-
benchmark named: the dev-loop output format (`0009`'s precondition —
29-
~23 ms/spawn measured, candidates listed), the boxed-i64 lane (fib's
30-
n = 46…92 runs entirely on it, never measured), the throw
31-
representation. Next unit: the first vertical slice — corpus harness +
32-
scalar entries through analyze → emit → both engines — which forces
33-
none of the open decisions.
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` (21 scalar entries) runs through real
24+
`clojure` (fresh namespace per entry) and through
25+
`cljwit.analyze` → `cljwit.emit` → `wasm-tools` → **wasmtime and V8,
26+
in both modes** (which emit identical bytes until `def` lands — stated,
27+
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. Next unit:
34+
**`loop`/`recur` entries and emission** (a block and a `br`, forces
35+
nothing open), then `def` — which starts the real dev/prod divergence.
3436

3537
Done since the last update: `0016` `own<T>` handles, `0017` host imports
3638
(A–F), `0018` host-defined resources, `0012`'s `ex-data` contract shrunk to
3739
what reflection can supply, `0019` libwasmtime resolution for library users,
3840
`0020` generated namespaces (`cljwit.host.gen` — one tagged var per export,
3941
instance first, `host/unwrap` as the opt-in result sugar), `host/describe`,
40-
and the reflection delete contracts the headers require. What is left of
42+
the reflection delete contracts the headers require, and `0023` — the
43+
compiler's first two namespaces exist and every claim they make is checked
44+
against `clojure` itself on every `bb check`. What is left of
4145
`0012` is `map`, `list<T,N>`, `stream`/`future` and `error-context`, none of
4246
which a shipped release can express.
4347

‎src/cljwit/analyze.clj‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
(ns cljwit.analyze
2+
"The S3 front end: tools.analyzer core plus this host layer
3+
(`doc/design/0022` E decided it; `0023` grew the spike into the
4+
pipeline).
5+
6+
The one semantic decision embedded here: `macroexpand-1` applies only
7+
`:macro` vars, never `:inline` — with inlining on, `(+ x 1)` becomes a
8+
JVM interop form and `+`-vs-`+'` semantics leave cljwit's hands
9+
(`0022` C). Without it, `+` stays an `:invoke` of `#'clojure.core/+`.
10+
11+
Analysis state is isolated per call: each `analyze-forms` run gets its
12+
own discarded namespace and `create-var` interns only there, so a
13+
`def` in one corpus entry can never resolve during another entry's
14+
analysis — the oracle lane's isolation rule (`0022` B), one lane over."
15+
(:refer-clojure :exclude [macroexpand-1])
16+
(:require [clojure.tools.analyzer :as ana]
17+
[clojure.tools.analyzer.env :refer [with-env]]
18+
[clojure.tools.analyzer.utils :refer [resolve-sym]]))
19+
20+
(set! *warn-on-reflection* true)
21+
22+
(defn- macroexpand-1
23+
"Only `:macro` vars expand; specials and locals shield; no `:inline`."
24+
[form env]
25+
(if (seq? form)
26+
(let [op (first form)]
27+
(if (ana/specials op)
28+
form
29+
(let [v (resolve-sym op env)]
30+
(if (and (not (-> env :locals (get op)))
31+
(:macro (meta v)))
32+
(apply v form env (rest form))
33+
form))))
34+
form))
35+
36+
(defn analyze-forms
37+
"Analyzes top-level `forms` in order as one program — later forms see
38+
earlier `def`s. Returns a vector of ASTs."
39+
[forms]
40+
(let [ns-sym (gensym "cljwit.analysis")]
41+
(create-ns ns-sym)
42+
(try
43+
(let [env {:context :ctx/expr :locals {} :ns ns-sym}
44+
genv (atom {:namespaces
45+
{ns-sym {:mappings (ns-map 'clojure.core)
46+
:aliases {} :ns ns-sym}
47+
'clojure.core {:mappings (ns-map 'clojure.core)
48+
:aliases {} :ns 'clojure.core}}})]
49+
(binding [ana/macroexpand-1 macroexpand-1
50+
ana/create-var (fn [sym var-env]
51+
(doto (intern (:ns var-env) sym)
52+
(reset-meta! (meta sym))))
53+
ana/parse ana/-parse
54+
ana/var? var?]
55+
(with-env genv
56+
(mapv #(ana/analyze % env) forms))))
57+
(finally (remove-ns ns-sym)))))

0 commit comments

Comments
 (0)