Skip to content

Commit 77f690a

Browse files
committed
docs(truth): gate every public size claim, not just README's first one
Smell-audited: depth 1: the rot found here is the same shape as zwasm's 2026-08-03 `ungated-negative-doc-claim-rotted-into-a-lie` lesson — every document the gate did not read had drifted, and every one it did read was correct. Fixing the three claims without widening the gate would have re-created the condition, so the gate moved with them. The 2026-08-04 cross-project audit measured the shipped binary at 7,368,808 bytes and found three public documents disagreeing with it: - `docs/landscape.md` claimed "~3 MB native binary" (2.4x under) on the very page that says it "lists only its own measured figures", and asserted that ClojureWasm "compiles to WebAssembly itself" — there is no `wasm32` target in `build.zig` and `cljw build` emits a native binary with a bytecode payload, not a `.wasm`. - `bench/RELEASE_METRICS.md` carried a locked table saying 6.97 MB with prose two paragraphs below still quoting 3.24 MB and 3.64 MB from a pre-campaign build, plus a third figure (~3.4 MB) in the honesty note. - `README.md` said 7.8 MB and ~5 ms; measured is 7.37 MB and ~6 ms. Cause: `size_claims` read README.md's FIRST "<N> MB" match and nothing else. `scripts/binary_size_report.sh --check` now checks every "<N> MB" figure in every file in SIZE_CLAIM_FILES (README.md, docs/landscape.md, bench/RELEASE_METRICS.md). A figure that deliberately reports something else — babashka's binary, the ReleaseSmall floor, a historical row — must carry an inline `<!--size:other-->` marker on the same line, so exemptions are explicit and greppable instead of silent. Verified in both directions: the gate caught a quoted historical figure in the rewritten honesty note on its first run, and re-breaking landscape.md back to "~3 MB" fails the check. landscape.md now states plainly that the runtime does not compile itself to WebAssembly, why (linear-memory value representation and GC, so no WasmGC interop), and points at ClojureWit for the compile-Clojure-to-a-component direction.
1 parent 7cc7cdc commit 77f690a

4 files changed

Lines changed: 147 additions & 70 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ still use what other languages have already built.
3333

3434
## Features
3535

36-
- **Small and quick to start** — one static binary of about 7.8 MB with the
37-
Wasm JIT engine included (for scale: babashka's native binary is ~71 MB;
38-
[measured comparison](docs/works/binary_size.md)), starting in ~5 ms, which
36+
- **Small and quick to start** — one static binary of about 7.4 MB with the
37+
Wasm JIT engine included (for scale: babashka's native binary is ~71 MB <!--size:other-->;
38+
[measured comparison](docs/works/binary_size.md)), starting in ~6 ms, which
3939
suits short-lived, start-and-stop workloads (CLI tools, serverless, scripts).
4040
- **A lot of everyday Clojure runs** — `clojure.core` plus a growing set of
4141
standard-library namespaces (`clojure.string` / `set` / `walk` / `zip` /

‎bench/RELEASE_METRICS.md‎

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,38 +13,56 @@ bash bench/release_metrics.sh
1313

1414
## Locked figure
1515

16-
| Build (`-Dwasm` — the SHIPPED config, embedded zwasm JIT engine included) | On-disk (build-stripped) | CLI-strip floor |
17-
|---------------------------------------------------------------------------|-------------------------------|------------------|
18-
| **ReleaseSafe** — the release build | **6.97 MB** (6,974,584 bytes) | 7.01 MB (CLI strip is a no-op here) |
19-
| ReleaseSmall — the size floor (safety checks off; NOT shipped) | 3.55 MB (3,547,336 bytes) | 3.21 MB (3,213,360 bytes) |
20-
21-
Measured with Zig 0.16.0 for `aarch64-macos` (re-measured **2026-07-16**, after
22-
the ADR-0172 binary-size campaign: 9,469,816 → 6,974,584 bytes, −26.3% in one
23-
campaign — unwind-table strip O-052, envelope-v7 constant pool + flate
24-
regions/sources ADR-0173, zwasm v2.2.1 thunk collapse, sort dedup O-053; the
25-
per-component budget + `size_claims` gate now govern growth). The pre-campaign
26-
2026-06-11 row (no `-Dwasm`, 3.24 MB) predates the always-embedded Wasm engine
27-
and is superseded — the `-Dwasm` build IS the artifact users download. **As of
28-
O-008, `build.zig` strips the symbol table from every non-Debug build**
16+
There is exactly **one** shipped configuration, and it is the one users
17+
download: `-Dwasm -Doptimize=ReleaseSafe`, with the zwasm JIT engine embedded.
18+
19+
| Build | On-disk (build-stripped) |
20+
|---------------------------------------------------------------------|-------------------------------|
21+
| **ReleaseSafe `-Dwasm`** — the release build, the shipped artifact | **7.37 MB** (7,368,808 bytes) |
22+
23+
Measured with Zig 0.16.0 for `aarch64-macos`, re-measured **2026-08-04**.
24+
25+
Every `<N> MB` figure in this file, in `README.md`, and in `docs/landscape.md`
26+
is checked against the freshly built binary by the `size_claims` gate
27+
(`scripts/binary_size_report.sh --check`, wired into `test/run_all.sh`). A
28+
figure that is deliberately *not* the shipped size carries an inline
29+
`<!--size:other-->` marker so the exemption is explicit rather than silent.
30+
This is the structural answer to the rot this file itself carried until
31+
2026-08-04, when the table said 6,974,584 bytes while the prose two paragraphs
32+
below still quoted 3,240,000 and 3,820,664 from a pre-campaign build.
33+
34+
**As of O-008, `build.zig` strips the symbol table from every non-Debug build**
2935
(`.strip = optimize != .Debug`) — so the *installed* `zig-out/bin/cljw` is the
30-
"shipped" column directly, with no separate packaging step (cljw renders error
36+
shipped artifact directly, with no separate packaging step. cljw renders error
3137
traces from its own runtime stack, not native symbols, so stripping costs no
32-
diagnostics; Debug stays unstripped for `lldb`). **ReleaseSafe is the
33-
recommended release build** (optimised *with* runtime safety checks), so 3.24 MB
34-
is the honest "what you download" number. For the absolute size floor, a
35-
post-build `strip zig-out/bin/cljw` shaves ReleaseSmall a further ~175 KB to
36-
1.63 MB (Zig's link-time strip is less aggressive than the CLI `strip` for the
37-
ReleaseSmall layout; for ReleaseSafe the link-strip is already minimal). Sizes
38-
are for the default `cljw`; with the optional WebAssembly FFI engine (`-Dwasm`)
39-
the ReleaseSafe build is about **3.64 MB** (3,820,664 bytes — still a single
40-
binary, under 4 MB).
38+
diagnostics; Debug stays unstripped for `lldb`.
39+
40+
**ReleaseSafe is the release build** — optimised *with* runtime safety checks
41+
retained. `ReleaseSmall` builds smaller but turns safety checks off and is not
42+
shipped; it is a size-floor reference only, and its figures are not gated
43+
because they are not what anyone downloads. <!--size:other-->
44+
45+
### Growth history
46+
47+
| Date | Bytes | Note |
48+
|------------|-----------|-------------------------------------------------------------|
49+
| 2026-07-16 | 9,469,816 | before the ADR-0172 binary-size campaign |
50+
| 2026-07-16 | 6,974,584 | after the campaign (−26.3% in one pass) |
51+
| 2026-08-04 | 7,368,808 | current — +5.6% since the campaign, well under the ceiling |
52+
53+
The ADR-0172 campaign levers were: unwind-table strip (O-052), envelope-v7
54+
constant pool + flate regions/sources (ADR-0173), zwasm v2.2.1 thunk collapse,
55+
and sort dedup (O-053). The per-component budget plus this gate now govern
56+
growth; the ADR-0172 derived ceiling is 8,800,000 bytes, and crossing it
57+
requires a conscious budget amendment, not a silenced check.
4158

4259
What sits inside that binary: a full Clojure numeric tower (Long→BigInt
4360
promotion, Ratio, BigDecimal), MVCC software transactional memory, agents,
4461
futures/promises/delays, lazy + chunked sequences, transducers,
45-
protocols/records/multimethods, namespaces, a CIDER-compatible nREPL, and ~24
46-
bundled `clojure.*` standard namespaces — plus both a tree-walking interpreter
47-
and a bytecode VM.
62+
protocols/records/multimethods, namespaces, a CIDER-compatible nREPL, ~24
63+
bundled `clojure.*` standard namespaces, both a tree-walking interpreter and a
64+
bytecode VM — and the embedded zwasm engine (interpreter + JIT), which is
65+
roughly 3 MB of that total on its own. <!--size:other-->
4866

4967
## Cold start (secondary, machine-dependent)
5068

@@ -62,9 +80,16 @@ cross-machine number — reproduce it on your own hardware with the script above
6280

6381
## Honesty note
6482

65-
These figures supersede earlier rougher estimates (~600 KB / ~2.5 ms). The
66-
binary grew as the numeric tower, STM, agents, nREPL, protocols, and the bundled
67-
`clojure.*` namespaces landed; ~3.4 MB (ReleaseSafe) is the honest current size
68-
for the full runtime, ~1.6 MB if built purely for size. The point is not a size
69-
record — it is that a from-scratch Clojure runtime with this much of the
70-
language ships as a single small binary that starts in a few milliseconds.
83+
These figures supersede earlier, rougher estimates. The binary grew as the
84+
numeric tower, STM, agents, nREPL, protocols, the bundled `clojure.*`
85+
namespaces, and finally the always-embedded Wasm engine landed. The point was
86+
never a size record — it is that a from-scratch Clojure runtime with this much
87+
of the language, plus a spec-complete WebAssembly engine, ships as a single
88+
binary that starts in a few milliseconds.
89+
90+
What the honesty note used to say, and why it is worth recording: it claimed
91+
"~3.4 MB (ReleaseSafe) is the honest current size" <!--size:other-->, a figure
92+
from before the Wasm engine was embedded by default. A number that no gate
93+
checks will drift from the truth and then be quoted as if it were measured.
94+
That is why the `size_claims` gate now reads this file too — and why it caught
95+
this very sentence on its first run.

‎docs/landscape.md‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ different job and is excellent at it. This page is a map of where they sit, not
55
a ranking — ClojureWasm is one young entry exploring the WebAssembly
66
corner, and the only numbers it claims are its own measured ones.
77

8-
| Runtime | Host / where it shines | Distribution |
9-
|--------------------|-----------------------------------------------------------------------------------------------------------------------|------------------------------------------|
10-
| **Clojure (JVM)** | The JVM. Mature, complete, an enormous ecosystem. | Runs on a JVM. |
11-
| **Babashka / SCI** | GraalVM native / JS. Fast-starting scripting, glue. | Self-contained binary. |
12-
| **ClojureScript** | JavaScript. Front-end and the Node ecosystem. | JS host. |
13-
| **jank** | LLVM / C++. Native code and seamless C++ interop. | Native. |
14-
| **ClojureDart** | Dart / Flutter. Mobile and cross-platform UI. | App bundles. |
15-
| **ClojureWasm** | Zig. Embeds a Wasm engine so Clojure calls modules from Rust/Go/C (polyglot FFI), and compiles to WebAssembly itself. | ~3 MB native binary, starts in a few ms. |
8+
| Runtime | Host / where it shines | Distribution |
9+
|--------------------|-----------------------------------------------------------------------------------|-----------------------------------------|
10+
| **Clojure (JVM)** | The JVM. Mature, complete, an enormous ecosystem. | Runs on a JVM. |
11+
| **Babashka / SCI** | GraalVM native / JS. Fast-starting scripting, glue. | Self-contained binary. |
12+
| **ClojureScript** | JavaScript. Front-end and the Node ecosystem. | JS host. |
13+
| **jank** | LLVM / C++. Native code and seamless C++ interop. | Native. |
14+
| **ClojureDart** | Dart / Flutter. Mobile and cross-platform UI. | App bundles. |
15+
| **ClojureWasm** | Zig. Embeds a Wasm engine so Clojure calls modules from Rust/Go/C (polyglot FFI). | ~7.4 MB native binary, starts in ~6 ms. |
1616

1717
The other rows are respectful summaries of what each runtime is known for, not
1818
evaluations. The ClojureWasm row lists only its own
@@ -21,19 +21,25 @@ anything.
2121

2222
## What "the WebAssembly corner" means here
2323

24-
Two things, which are easy to conflate:
24+
Two things, which are easy to conflate — and only the first one is shipped:
2525

26-
1. **ClojureWasm compiles to WebAssembly** — the runtime is plain Zig, so a
27-
WebAssembly build is a natural target (a browser-only playground, a small
28-
Wasm function).
29-
2. **ClojureWasm embeds a WebAssembly engine** — so a Clojure program can load a
26+
1. **ClojureWasm embeds a WebAssembly engine.** A Clojure program can load a
3027
sandboxed module compiled from Rust, Go, Zig, or C and call it like a
3128
namespace. WebAssembly becomes an FFI: other languages' libraries become
3229
callable from Clojure, in-process and sandboxed. (See
33-
[`docs/examples/wasm/`](../docs/examples/wasm/).)
34-
35-
That second point is the part that does not have an obvious equivalent in the
36-
other runtimes, and it is the angle ClojureWasm is exploring.
30+
[`docs/examples/wasm/`](../docs/examples/wasm/).) This is the angle
31+
ClojureWasm is exploring, and it has no obvious equivalent in the other
32+
runtimes.
33+
2. **ClojureWasm does not compile *itself* to WebAssembly.** There is no
34+
`wasm32` build of the runtime, and `cljw build` produces a native binary
35+
with an embedded bytecode payload — not a `.wasm`. The runtime is plain
36+
Zig, so the target is reachable in principle, but the value representation
37+
and garbage collector are designed for linear memory, so such a build would
38+
not interoperate with WasmGC-based languages. It is tracked as future
39+
research, not a feature.
40+
41+
Compiling *Clojure source* into a WebAssembly component is a separate project:
42+
[ClojureWit](https://github.com/clojurewasm/ClojureWit), in the same org.
3743

3844
## A note on respect
3945

‎scripts/binary_size_report.sh‎

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,23 @@
1010
#
1111
# Gate mode:
1212
# bash scripts/binary_size_report.sh --check [BIN]
13-
# Compares README.md's headline size claim (the FIRST "<N> MB" match
14-
# in the file — keep the claim the first MB figure in README) against
15-
# BIN's measured size. Fails (exit 1) when the drift exceeds 10%.
16-
# This is the structural fix for the 2026-07 rot incident where
17-
# README said "about 3.8 MB" while the shipped binary was 9.5 MB.
13+
# Checks EVERY "<N> MB" figure in every file listed in SIZE_CLAIM_FILES
14+
# against BIN's measured size. Fails (exit 1) when any of them drifts
15+
# more than 10%. A figure that is deliberately NOT the shipped size
16+
# (babashka's binary, the ReleaseSmall floor, a historical row) must
17+
# carry an inline `<!--size:other-->` marker on the same line — so the
18+
# exemption is explicit and greppable, never silent.
1819
# Wired into test/run_all.sh as the `size_claims` step (full gate;
1920
# runs right after build_cljw so the binary is fresh).
2021
#
22+
# History: the check originally read ONLY README.md's first MB figure.
23+
# The 2026-08-04 audit found that docs/landscape.md ("~3 MB", 2.4x
24+
# under) and bench/RELEASE_METRICS.md (a table saying 6.97 MB with
25+
# prose two paragraphs below still claiming 3.24 MB) had both rotted
26+
# precisely because nothing checked them — the same shape as zwasm's
27+
# 2026-08-03 `ungated-negative-doc-claim-rotted-into-a-lie` lesson.
28+
# Every public size claim is gated now.
29+
#
2130
# Reference platform for ADR-0172 numbers: macOS arm64, ReleaseSafe,
2231
# -Dwasm, stripped (= the brew artifact config). MB here is decimal
2332
# (10^6 bytes), matching what brew/GitHub display to users.
@@ -48,27 +57,64 @@ else
4857
fi
4958
ACTUAL_MB=$(awk "BEGIN{printf \"%.2f\", $ACTUAL/1000000}")
5059

60+
# Every public document that states the shipped binary's size. Overridable so a
61+
# new doc can be added without editing the script body.
62+
SIZE_CLAIM_FILES="${SIZE_CLAIM_FILES:-README.md docs/landscape.md bench/RELEASE_METRICS.md}"
63+
64+
# A line carrying this marker is exempt: the figure on it is deliberately not
65+
# the shipped size (another project's binary, the ReleaseSmall floor, a dated
66+
# historical row). Exemptions are visible in the source and greppable.
67+
SIZE_EXEMPT_MARKER='<!--size:other-->'
68+
5169
if [[ "$MODE" == "check" ]]; then
52-
CLAIM=$(grep -m1 -oE '[0-9]+(\.[0-9]+)? MB' README.md | head -1 | grep -oE '[0-9]+(\.[0-9]+)?' || true)
53-
if [[ -z "$CLAIM" ]]; then
54-
echo "size_claims: no '<N> MB' size claim found in README.md — add one (ADR-0172)" >&2
55-
exit 1
56-
fi
5770
if [[ "$ACTUAL" -gt "$BUDGET_CEILING_BYTES" ]]; then
5871
echo "size_claims: built binary ${ACTUAL_MB} MB (${ACTUAL} B) exceeds the ADR-0172 derived ceiling ($BUDGET_CEILING_BYTES B)." >&2
5972
echo " Attribute with 'bash scripts/binary_size_report.sh' (-Dprofile build for symbols), then land a lever" >&2
6073
echo " or consciously amend the budget table in .dev/decisions/0172_binary_size_budget_and_ledger.md." >&2
6174
exit 1
6275
fi
63-
DRIFT=$(awk "BEGIN{c=$CLAIM*1000000; d=($ACTUAL-c)/c; if(d<0)d=-d; printf \"%.3f\", d}")
64-
OK=$(awk "BEGIN{print ($DRIFT <= 0.10) ? 1 : 0}")
65-
if [[ "$OK" == "1" ]]; then
66-
echo " size_claims: README claims ${CLAIM} MB, measured ${ACTUAL_MB} MB (${ACTUAL} B) — within 10%"
67-
exit 0
76+
77+
checked=0
78+
exempt=0
79+
failed=0
80+
for f in $SIZE_CLAIM_FILES; do
81+
if [[ ! -f "$f" ]]; then
82+
echo "size_claims: listed file not found: $f (fix SIZE_CLAIM_FILES in scripts/binary_size_report.sh)" >&2
83+
exit 1
84+
fi
85+
while IFS=: read -r lineno line; do
86+
[[ -z "$lineno" ]] && continue
87+
if [[ "$line" == *"$SIZE_EXEMPT_MARKER"* ]]; then
88+
exempt=$((exempt + 1))
89+
continue
90+
fi
91+
# A line may carry more than one figure; check each.
92+
while read -r claim; do
93+
[[ -z "$claim" ]] && continue
94+
checked=$((checked + 1))
95+
DRIFT=$(awk "BEGIN{c=$claim*1000000; d=($ACTUAL-c)/c; if(d<0)d=-d; printf \"%.3f\", d}")
96+
OK=$(awk "BEGIN{print ($DRIFT <= 0.10) ? 1 : 0}")
97+
if [[ "$OK" != "1" ]]; then
98+
failed=$((failed + 1))
99+
echo "size_claims: $f:$lineno claims ${claim} MB but the built binary is ${ACTUAL_MB} MB (${ACTUAL} B) — drift >10%." >&2
100+
echo " → $(printf '%s' "$line" | sed 's/^[[:space:]]*//' | cut -c1-100)" >&2
101+
fi
102+
done < <(printf '%s\n' "$line" | grep -oE '[0-9]+(\.[0-9]+)? ?MB' | grep -oE '[0-9]+(\.[0-9]+)?')
103+
done < <(grep -nE '[0-9]+(\.[0-9]+)? ?MB' "$f" || true)
104+
done
105+
106+
if [[ "$checked" -eq 0 ]]; then
107+
echo "size_claims: no gated '<N> MB' size claim found in: $SIZE_CLAIM_FILES" >&2
108+
echo " At least README.md must state the shipped size (ADR-0172). Did every claim get an exemption marker?" >&2
109+
exit 1
68110
fi
69-
echo "size_claims: README claims ${CLAIM} MB but the built binary is ${ACTUAL_MB} MB (${ACTUAL} B) — drift >10%." >&2
70-
echo " Update README's size figure (and CHANGELOG on release) per ADR-0172; do not let the claim rot." >&2
71-
exit 1
111+
if [[ "$failed" -gt 0 ]]; then
112+
echo " Update the figure (and CHANGELOG on release) per ADR-0172, or mark the line $SIZE_EXEMPT_MARKER" >&2
113+
echo " if it deliberately reports something other than the shipped binary. Do not let a claim rot." >&2
114+
exit 1
115+
fi
116+
echo " size_claims: ${checked} claim(s) across $(echo "$SIZE_CLAIM_FILES" | wc -w | tr -d ' ') file(s) match ${ACTUAL_MB} MB (${ACTUAL} B) within 10%; ${exempt} line(s) exempt"
117+
exit 0
72118
fi
73119

74120
echo "== $BIN: ${ACTUAL} bytes (${ACTUAL_MB} MB decimal)"

0 commit comments

Comments
 (0)