Skip to content

Commit 3989a7a

Browse files
committed
feat(bytecode): ADR-0173 C5'-b — flate-compressed .clj sources (-312KB) + binary-size rules/governance hardening
Smell-audited: 2: the sources dual-mode (cache_gen raw / cljw compressed) required a second options module + a stub import on its own WriteFiles — the first wiring created a build dependency loop (cache_gen -> cache_wf -> run cache_gen), probed and documented in the ADR revision. cache_gen emits a second artifact (bootstrap_sources.bin: label + uncompressed_len + flate bytes per file); bootstrap.sourceText decompresses on demand into rt.load_arena; rt.source_resolver (new runtime hook, lang-installed — zone-legal vtable direction) serves error-render registry misses and memoizes. The eager ~450KB registerSource gpa copy at startup is gone. Measured: 7,384,984 -> 7,073,080 B. Campaign total: 9,469,816 -> 7,073,080 (-25.3%). Per the user directive, size awareness is now mechanized: .claude/rules/binary_size.md (auto-loads on src/build edits; carries the campaign's measured lessons — instantiation-count metric, comptime cross-products, embed-compression pattern, safety-inviolable, median-of-20 protocol) and binary_size_report.sh --check now fails the gate when the binary exceeds the ADR-0172 derived ceiling (BUDGET_CEILING_BYTES=8800000, re-derived post-L2). README 7.1MB; ADR-0172 budget table refreshed (embedded data 0.76/1.0MB).
1 parent f85cb81 commit 3989a7a

15 files changed

Lines changed: 323 additions & 65 deletions

.claude/rules/binary_size.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
paths:
3+
- "src/**/*.zig"
4+
- "build.zig"
5+
- "build.zig.zon"
6+
---
7+
8+
# Binary-size awareness (ADR-0172 is the SSOT)
9+
10+
Auto-loaded when editing Zig source / the build. cljw's headline claim is
11+
"one small static binary"; size is budgeted per component and gate-checked.
12+
This rule carries the operational discipline + the measured lessons from the
13+
2026-07-16 campaign (9.47 MB → 7.38 MB, −22%) so they are not re-derived.
14+
15+
## The mechanical guards (already wired — do not duplicate)
16+
17+
- **`size_claims` step** (full gate, `test/run_all.sh`): README's headline
18+
"<N> MB" must stay within 10% of the freshly built binary — a prose size
19+
claim can no longer rot (the 3.8-vs-9.5 MB incident class is closed).
20+
- **Budget ceiling check** (same step, `scripts/binary_size_report.sh
21+
--check`): the built binary must stay under the ADR-0172 **derived
22+
ceiling** (`BUDGET_CEILING_BYTES` in the script, kept in sync with the
23+
ADR's budget table). A breach fails the gate: attribute with the report
24+
tool, then either land a lever or consciously amend the budget **in
25+
ADR-0172** (Revision history entry) — never silently.
26+
- **Measurement tool**: `bash scripts/binary_size_report.sh [BIN]` — total +
27+
segment breakdown; on a `-Dprofile=true` build it prints top symbols for
28+
attribution.
29+
30+
## When your change plausibly adds size, check it
31+
32+
A change is size-relevant when it adds: a new `@embedFile`/generated blob, a
33+
new comptime-instantiated generic family, a new std subsystem (crypto, http,
34+
regex-class tables), a large `switch`/table, or a new dependency. For those:
35+
measure before/after (`binary_size_report.sh`), and note the delta in the
36+
commit message when it exceeds ~50 KB. Feature growth is legitimate
37+
(F-013/F-014) — the budget exists to make it *conscious*, not to forbid it.
38+
39+
## Measured lessons (do not re-litigate; citations in ADR-0172/0173)
40+
41+
1. **Instantiation count predicts recoverable size — symbol size does NOT.**
42+
A huge single symbol that aggregates once-called inlined handlers is
43+
size-NEUTRAL to out-line (zwasm's 707 KB emitter: table-driven rewrite
44+
changed nothing). The real wins are ×N-duplicated bodies (zwasm's
45+
host-callback thunks: ~300 B × 3,840 → −1.08 MB collapsed to shared
46+
bridges + 23 B forwarders). Before proposing a size refactor, ask "how
47+
many call sites / comptime instantiations share this code?".
48+
2. **Watch comptime cross-products.** `fn(T) × arg-kinds × arity × slot`
49+
families explode silently. Prefer a type-erased shared body + thin
50+
per-instantiation forwarders (`noinline` on the shared body) when the
51+
family exceeds a handful of instances. Known open case: std.sort
52+
comparator monomorphization ≈ 224 KB / 93 instances (ADR-0172 L6).
53+
3. **Embedded assets compress ~4-5×** (bytecode blob, `.clj` text): flate
54+
`.raw` + exact `uncompressed_len` + decompress-on-demand into
55+
`rt.load_arena` is the established pattern (serialize.zig
56+
`flateCompress`/`flateDecompress`; two std pitfalls are documented on
57+
those fns — non-empty output buffer for Compress, REAL 32 KB window for
58+
Decompress).
59+
4. **Safety is never traded for size.** ReleaseSafe stays the shipped config
60+
(ADR-0132); ReleaseSmall/hybrid rejections are recorded as ADR-0172
61+
L3/L4 — don't re-propose them without new facts.
62+
5. **Startup claims need median-of-20 on a quiet machine.** Single-shot
63+
`CLJW_PROFILE_STARTUP` probes under load produced a phantom −32% once
64+
(corrected in ADR-0173). Size numbers are stable; time numbers are not.
65+
66+
## Cross-references
67+
68+
- `.dev/decisions/0172_binary_size_budget_and_ledger.md` — budget table +
69+
lever ledger + governance (the SSOT).
70+
- `.dev/decisions/0173_envelope_v7_zero_copy_pool_flate.md` — the envelope
71+
v7 arc (pool / zero-copy / flate) with its measurement corrections.
72+
- `.dev/debt.yaml` D-515 — the standing drain anchor.
73+
- `docs/works/binary_size.md` — the public measured comparison.

.dev/debt.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ active:
276276
findings fold into the D-175 floor.)
277277
last_reviewed: "2026-06-24"
278278
- id: "D-517"
279-
status: "IN PROGRESS 2026-07-16 (ADR-0173): C1+C2'+C3'+C4'+C5'-a LANDED. Measured: blob 698->425KB; shipped binary at this point 7,384,984 B (campaign total from 9,469,816 = -22.0%); startup neutral-within-noise except C4' memoize -165us median. Remaining: C5'-b compressed .clj sources (registerSource lazy-handle surface + cache_gen second blob; est -326KB), C6' has_handlers consumer flip (own bench), then the user-directed FULL cross-language bench + record refresh (task #5). ORIGINAL: open (ADR-0162 step 3)."
279+
status: "IN PROGRESS 2026-07-16 (ADR-0173): C1-C5' ALL LANDED (C5'-b: compressed .clj sources, binary 7,073,080 B campaign -25.3% from 9,469,816; startup 450KB gpa source-copy also gone via rt.source_resolver lazy decompress). Remaining: C6' has_handlers consumer flip (own bench), then L6 sort-dedup, then the user-directed FULL cross-language bench + record refresh (task list). ORIGINAL: open (ADR-0162 step 3)."
280280
category: "perf"
281281
barrier: |-
282282
ADR-0162 step 3. Sequenced AFTER D-516 (so it applies to both eager core + lazy

.dev/decisions/0172_binary_size_budget_and_ledger.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ components, and a breach localizes itself via the report tool:
104104
| zwasm (engine + api) | 1.94 MB | 2.5 MB | thunk collapse landed (v2.2.1); x86_64 emitter is comptime-gated (0 B on arm64) |
105105
| cljw text | 2.57 MB | 3.5 MB | F-013/F-014 comprehensiveness growth |
106106
| Zig std text | 1.17 MB | 1.5 MB | |
107-
| embedded data | 1.59 MB | 1.75 MB | re-set to **1.0 MB** when L2 lands |
107+
| embedded data | 0.76 MB | 1.0 MB | L2 LANDED 2026-07-16 (ADR-0173 C2'-C5': pool + flate regions + flate .clj) |
108108
| unwind + linkedit etc. | 0.23 MB | 0.3 MB | L1 LANDED 2026-07-16 (O-052): tables dropped, budget re-set |
109109
| **Derived ceiling** | **8.73 MB** | **≈ 11 MB → ≈ 10.3 MB post-L2** | |
110110

@@ -406,6 +406,20 @@ dispositions grounded, platform binding declared, peer number measured).
406406

407407
## Revision history
408408

409+
- **2026-07-16 (L2 complete + governance hardening)**: ADR-0173 C5′-b landed
410+
compressed `.clj` sources (7,384,984 → **7,073,080 B**; the ~450 KB
411+
startup gpa copy of raw text is also gone — `rt.source_resolver`
412+
decompresses on demand and memoizes). The embedded-data budget line
413+
re-sets to 1.0 MB per this ADR's own clause; derived ceiling ≈ 8.8 MB
414+
(measured 7.07). **Campaign total: 9,469,816 → 7,073,080 (−25.3%).**
415+
Governance mechanized further (user directive): `binary_size_report.sh
416+
--check` now also FAILS when the built binary exceeds the derived ceiling
417+
(`BUDGET_CEILING_BYTES`, kept in sync with this table), and the new
418+
auto-loaded `.claude/rules/binary_size.md` carries the campaign's
419+
operational lessons (instantiation-count metric, comptime cross-products,
420+
embed-compression pattern, median-of-20 protocol) for every future
421+
src/build edit.
422+
409423
- **2026-07-16 (L5 outcome — same day)**: zwasm accepted the CODEV request
410424
(their ADR-0204) and shipped **v2.2.1** same-day: the JIT host-callback
411425
thunk collapse (`api.jit_host_bridge` 1,311 → 232 KB, −82%) — measured

.dev/decisions/0173_envelope_v7_zero_copy_pool_flate.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,17 @@ measured per-commit (protocol below), not asserted.
200200
REAL 32 KB history window is required (the survey's §4 recipe is hereby
201201
corrected). C5′-b (compressed `.clj` sources, est −326 KB) remains.
202202

203+
- **2026-07-16 (C5′-b landing — flate `.clj` sources)**: the raw sources
204+
left the binary (cache_gen emits a second artifact, the compressed
205+
sources blob; `bootstrap.sourceText` decompresses on demand into
206+
`rt.load_arena`; `rt.source_resolver` serves error renders registry-miss
207+
→ memoized; cache_gen keeps raw @embedFile via the dual
208+
`embed_raw_clj_sources` options module — its own stub `bootstrap_sources`
209+
import avoids the WriteFiles dependency loop probed during landing).
210+
**Measured**: shipped binary 7,384,984 → **7,073,080 B**. The eager
211+
~450 KB registerSource gpa copy at startup is gone (resolver is lazy).
212+
Remaining in the arc: C6′ (has_handlers consumer flip).
213+
203214
## Measurement protocol (mandatory, per commit)
204215

205216
- VM-hot A/B for C1 (representation change, isolated):

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ still use what other languages have already built.
3333

3434
## Features
3535

36-
- **Small and quick to start** — one static binary of about 7.5 MB with the
36+
- **Small and quick to start** — one static binary of about 7.1 MB with the
3737
Wasm JIT engine included (for scale: babashka's native binary is ~71 MB;
3838
[measured comparison](docs/works/binary_size.md)), starting in ~5 ms, which
3939
suits short-lived, start-and-stop workloads (CLI tools, serverless, scripts).

build.zig

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ pub fn build(b: *std.Build) void {
5656
// a version literal. The user owns the value via the release tag.
5757
const build_zon = @import("build.zig.zon");
5858
build_options.addOption([]const u8, "version", build_zon.version);
59+
build_options.addOption(bool, "embed_raw_clj_sources", false);
5960

6061
// ROADMAP §9.6 / 4.8 / §349 — backend gate (ADR-0005 / ADR-0070 / F-012).
6162
// `vm` is the PRODUCTION DEFAULT (flipped 2026-06-02 once every D-196
@@ -105,13 +106,23 @@ pub fn build(b: *std.Build) void {
105106
.target = b.graph.host,
106107
.optimize = .ReleaseSafe,
107108
});
108-
cache_gen_mod.addOptions("build_options", build_options);
109+
// C5'-b (ADR-0173): cache_gen embeds the RAW .clj sources (host tool —
110+
// its size is irrelevant) and is the producer of the compressed sources
111+
// blob; the shipped cljw embeds only the blob. The flag picks the mode.
112+
const cache_gen_options = b.addOptions();
113+
cache_gen_options.addOption([]const u8, "version", build_zon.version);
114+
cache_gen_options.addOption(Backend, "backend", backend);
115+
cache_gen_options.addOption(bool, "wasm", wasm_enabled);
116+
cache_gen_options.addOption(bool, "embed_raw_clj_sources", true);
117+
cache_gen_mod.addOptions("build_options", cache_gen_options);
109118
if (zwasm_mod) |zm| cache_gen_mod.addImport("zwasm", zm);
110119
const cache_gen = b.addExecutable(.{ .name = "cache_gen", .root_module = cache_gen_mod });
111120
const run_cache_gen = b.addRunArtifact(cache_gen);
112121
const bootstrap_cache_blob = run_cache_gen.addOutputFileArg("bootstrap_core.cljc");
122+
const bootstrap_sources_blob = run_cache_gen.addOutputFileArg("bootstrap_sources.bin");
113123
const cache_wf = b.addWriteFiles();
114124
_ = cache_wf.addCopyFile(bootstrap_cache_blob, "bootstrap_core.cljc");
125+
_ = cache_wf.addCopyFile(bootstrap_sources_blob, "bootstrap_sources.bin");
115126
const cache_wrapper = cache_wf.add("bootstrap_cache.zig",
116127
\\// Generated by build.zig (ADR-0056 Cycle 2). Do not edit.
117128
\\// ADR-0173 C3': comptime-copy into align(8) rodata so the v7
@@ -123,6 +134,28 @@ pub fn build(b: *std.Build) void {
123134
\\
124135
);
125136
exe_mod.addAnonymousImport("bootstrap_cache", .{ .root_source_file = cache_wrapper });
137+
// C5'-b: the flate-compressed .clj sources blob (label -> compressed
138+
// text index; produced by cache_gen). The shipped cljw decompresses a
139+
// file's text on demand (error-context render / source-replay fallback)
140+
// instead of carrying 451KB of raw .clj in rodata.
141+
const sources_wrapper = cache_wf.add("bootstrap_sources.zig",
142+
\\// Generated by build.zig (ADR-0173 C5'-b). Do not edit.
143+
\\pub const data: []const u8 = @embedFile("bootstrap_sources.bin");
144+
\\
145+
);
146+
exe_mod.addAnonymousImport("bootstrap_sources", .{ .root_source_file = sources_wrapper });
147+
// cache_gen runs in raw mode and never reads the blob — a stub keeps the
148+
// @import("bootstrap_sources") in shared bootstrap code resolvable. The
149+
// stub MUST come from its own WriteFiles: cache_wf depends on cache_gen's
150+
// output, so wiring cache_gen's import through cache_wf is a dependency
151+
// loop (cache_gen -> cache_wf -> run cache_gen).
152+
const stub_wf = b.addWriteFiles();
153+
const sources_stub = stub_wf.add("bootstrap_sources_stub.zig",
154+
\\// Generated by build.zig (ADR-0173 C5'-b). cache_gen stub.
155+
\\pub const data: []const u8 = "";
156+
\\
157+
);
158+
cache_gen_mod.addAnonymousImport("bootstrap_sources", .{ .root_source_file = sources_stub });
126159

127160
const exe = b.addExecutable(.{
128161
.name = "cljw",

docs/works/binary_size.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ Sizes cluster into three capability layers:
7878
honest claims are the two above: smallest Clojure-family binary, and a
7979
full-runtime-plus-Wasm-engine inside the 5-20 MB single-binary layer.
8080
- cljw's number is moving fast: the v1.3.1 row above is the SHIPPED release;
81-
the working tree already measures **7.50 MB** (unwind-table strip, envelope
82-
v7 constant pool, and zwasm v2.2.1's JIT-thunk collapse — a same-day
83-
cross-repo campaign result), with embedded-data compression still queued
84-
(**~7.1-7.2 MB** projected). The README figure is gate-checked against
81+
the working tree already measures **7.07 MB** (−25% in one campaign:
82+
unwind-table strip, envelope-v7 constant pool + flate-compressed lazy
83+
regions and `.clj` sources, and zwasm v2.2.1's JIT-thunk collapse — a
84+
same-day cross-repo result). The README figure is gate-checked against
8585
every release build, so it cannot silently rot.

scripts/binary_size_report.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ if [[ "${1:-}" == "--check" ]]; then
3131
fi
3232
BIN="${1:-zig-out/bin/cljw}"
3333

34+
# ADR-0172 §2 derived ceiling (per-component budgets sum). Keep in sync with
35+
# the ADR's budget table — a change here REQUIRES an ADR-0172 Revision
36+
# history entry (the budget moves consciously, never to silence this check).
37+
BUDGET_CEILING_BYTES=8800000
38+
3439
if [[ ! -f "$BIN" ]]; then
3540
echo "binary_size_report: binary not found: $BIN (build first: zig build -Dwasm -Doptimize=ReleaseSafe)" >&2
3641
exit 1
@@ -49,6 +54,12 @@ if [[ "$MODE" == "check" ]]; then
4954
echo "size_claims: no '<N> MB' size claim found in README.md — add one (ADR-0172)" >&2
5055
exit 1
5156
fi
57+
if [[ "$ACTUAL" -gt "$BUDGET_CEILING_BYTES" ]]; then
58+
echo "size_claims: built binary ${ACTUAL_MB} MB (${ACTUAL} B) exceeds the ADR-0172 derived ceiling ($BUDGET_CEILING_BYTES B)." >&2
59+
echo " Attribute with 'bash scripts/binary_size_report.sh' (-Dprofile build for symbols), then land a lever" >&2
60+
echo " or consciously amend the budget table in .dev/decisions/0172_binary_size_budget_and_ledger.md." >&2
61+
exit 1
62+
fi
5263
DRIFT=$(awk "BEGIN{c=$CLAIM*1000000; d=($ACTUAL-c)/c; if(d<0)d=-d; printf \"%.3f\", d}")
5364
OK=$(awk "BEGIN{print ($DRIFT <= 0.10) ? 1 : 0}")
5465
if [[ "$OK" == "1" ]]; then

src/app/builder.zig

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,13 @@ pub fn buildBootstrapEnvelope(
230230
for (files) |file| {
231231
// Register the file's bytes so a build-time error frame keeps its
232232
// per-file SourceContext (mirror of loadCoreFiles; idempotent).
233-
try rt.registerSource(file.label, file.source);
233+
// cache_gen (raw mode) takes the embedded slice; the in-test
234+
// compressed-mode round-trip decompresses on demand (C5'-b).
235+
const file_source = file.source orelse (bootstrap.sourceText(rt, file.label) orelse return error.MissingBootstrapSource);
236+
try rt.registerSource(file.label, file_source);
234237
var chunks: std.ArrayList(BytecodeChunk) = .empty;
235238
defer chunks.deinit(allocator); // chunk slices are arena-owned; only the list is ours
236-
var reader = Reader.init(arena, file.source);
239+
var reader = Reader.init(arena, file_source);
237240
var form_idx: usize = 0;
238241
while (true) {
239242
const form = (try reader.read()) orelse break;
@@ -583,7 +586,8 @@ test "aot: core.clj round-trips — build envelope, restore into a fresh env, ru
583586
var table = macro_dispatch.Table.init(A);
584587
defer table.deinit();
585588
try bootstrap.setupCorePrefix(&rt, &env, &table);
586-
core_bytes = try buildEnvelope(A, &rt, &env, &table, arena, bootstrap.CORE_SOURCE, "<core>");
589+
const core_src = bootstrap.sourceText(&rt, bootstrap.SOURCE_LABEL) orelse return error.MissingBootstrapSource;
590+
core_bytes = try buildEnvelope(A, &rt, &env, &table, arena, core_src, "<core>");
587591
}
588592
defer A.free(core_bytes);
589593

src/app/repl.zig

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ pub fn run(
110110
// setupCorePrefix and leaves *ns* unresolved when test.clj loads (ADR-0083).
111111
try bootstrap.setupCorePrefix(&rt, &env, &macro_table);
112112

113-
const bootstrap_ctx = error_print.SourceContext{ .file = bootstrap.SOURCE_LABEL, .text = bootstrap.CORE_SOURCE };
113+
// C5'-b (ADR-0173): bootstrap sources ship flate-compressed; the REAL
114+
// text resolves through rt.source_resolver (registry fallback) at render
115+
// time, so this eager fallback ctx carries no source bytes (empty text →
116+
// the renderer's extractLine misses → registry path serves the line).
117+
const bootstrap_ctx = error_print.SourceContext{ .file = bootstrap.SOURCE_LABEL, .text = "" };
114118
// ADR-0056 Cycle 2c + Cycle 3 (D-452 Part B): restore the WHOLE eager
115119
// bootstrap (core + the 23 non-core libs) from the embedded AOT envelope
116120
// (prefix already done above) — no .clj re-parse on startup.

0 commit comments

Comments
 (0)