Per user direction: the low-level abi(.compiler) primitive surface is the comptime 'compiler' library, so name the file compiler.sx (a peer of build.sx) instead of the interim std/build.sx — which also frees the 'build' name for the default build IMPLEMENTATION (default_build + on_build slot), which will live in modules/build.sx alongside the BuildOptions DSL. Updated the two example imports + the plan's Phase 5 file-split note. 704/0 both gates.
53 KiB
PLAN — Comptime Bytecode VM + flat memory (then re-home the compiler-API on it)
Direction change (2026-06-17). The comptime compiler-API stream pivots off the byte-weld. The weld (sx structs whose layout is validated to mirror the compiler's Zig types) + the serialization / marshaling bridge at the call boundary is the wrong direction — it bolts a parallel layout regime and hand-built byte-copies onto a comptime value model that fundamentally isn't bytes. We strip it and build the right foundation: a bytecode VM over flat, byte-addressable memory, where comptime values ARE native bytes (like runtime). On that base the compiler-API needs no weld, no validation, no marshaling — the compiler's own types are read/built directly as memory and its functions take/return real pointers.
Supersedes the build order in
design/comptime-compiler-api.md(kept for history). This is the active plan for the stream. Branch:reify.
Why
src/ir/interp.zig is a tree-walking interpreter over the SSA IR that represents
every value as a tagged Value union (int, float, aggregate: []const Value,
type_tag, heap_ptr, …). Two consequences:
- Slow. Per-value boxing in a tagged union; per-op
switchoverInst; an aggregate is a heap[]const Value, walked element-by-element. - Not native memory. A struct value is
[]const Value(tagged unions), NOT the struct's bytes. So a comptime@ptrCast(*StructInfo)reads theValueunion's memory, not aStructInfo— which forced the whole weld+marshal detour.
Make comptime values native bytes in a flat memory and both problems dissolve: structs/arrays/slices are their bytes at natural layout (no weld), the compiler's own records are directly addressable (no marshal), and a bytecode loop over flat memory is fast.
End state
- Comptime execution = a bytecode VM over a flat linear memory (real
host-allocated bytes; layout is target-aware via the type table's sizes). Values
are bytes at addresses plus a scalar register file. No tagged
Valueunion. - The comptime compiler-API: the compiler exposes its real types + functions to
comptime sx. sx reads/builds them as native memory and calls compiler functions by
pointer. No
abi(.zig)weld, novalidateStructLayout, noregister_structfield-by-field marshaling — gone. declare/define/type_infoand#compiler/BuildOptionsride this one mechanism; the bespoke interp arms are deleted.- ONE evaluator at the end — non-negotiable. The legacy tagged-
Valueinterpreter (interp.zig) is DELETED. We do NOT ship both permanently. "Dual-path" (a compiler-API fn with both a legacycompiler_libhandler AND a VM-native impl) and the emit-time legacy fallback are transitional only — scaffolding while the VM reaches parity at BOTH comptime sites (emit time AND lowering time). The flag-Dcomptime-flatis the swap mechanism; once the VM runs everywhere with parity, the flag, the fallback, andinterp.zigall go. Any "VM-only at emit, legacy at lowering" split is a waypoint, never the destination.
Principles (hold at every step)
- Green at every step.
zig build && zig build testpass after each sub-step. The existing tagged-Valueinterpreter stays the live evaluator until the VM reaches corpus parity; swap behind a build flag, then delete the old path. - Target-aware, not host-baked. Flat-memory layout uses the type table's target
sizes (
pointer_size,typeSizeBytes/offsets), NEVER host@sizeOf. This is what keeps cross-compilation correct (the JIT-comptime alternative could not). - Sandboxed. Flat-memory accesses are bounds-checked; step/call-depth budgets remain; an OOB / bad access traps to a build-gating diagnostic with a source span — never a compiler-process crash.
- No silent fallbacks (per CLAUDE.md): an unhandled op / shape bails loudly with a named reason, never a zero/default that looks like success.
Phases
Phase 0 — Strip the weld / serialize / marshal machinery
Delete the wrong-direction code so the VM builds on a clean base. Pure removal + corpus rebaseline; suite green.
src/ir/compiler_lib.zig: the reflection (weldStruct/bound_types/FieldLayout/BoundType), the layout validation (validateStructLayout/LayoutMismatch/SxField). Decide the fate of thebound_fnshost-call registry (intern/text_ofhandlers) — it is likely subsumed by the VM's compiler-call path in Phase 3, butintern/text_ofmay survive as the first such calls.src/ir/lower/nominal.zig:validateWeldedStruct+weldedFieldOrderStr+ thesd.abi == .zigvalidation call inregisterStructDecl.src/ir/interp.zig: thecompiler_weldeddispatch branch.src/backend/llvm/ops.zig: theemitCallcomptime-only gate keyed oncompiler_welded(re-derive the comptime-only guard from a non-weld signal if still needed).- Corpus: retire / convert the weld examples + diagnostics —
0625,0627(welded struct),1183,1186(weld-layout diagnostics),1184/1185(welded-fn). Keep0626(intern/text_ofround-trip) only if it survives the new call path. - Keep (re-evaluate in Phase 3), independent of the weld semantics: the
#library "compiler"decl, theabi(.x)annotation +extern <lib>syntax, and thecallconv → abiunification. These are surface syntax that may still serve the compiler-API; only the weld semantics are stripped here.
Verification: zig build test green with the weld machinery gone; the surviving
syntax still parses (parser unit tests).
Phase 1 — Flat-memory value model (still IR-walking, no bytecode yet)
Introduce flat memory and move comptime values onto it, decoupled from bytecode so
the value-model change is isolated. Each sub-step ports one op group and keeps the
corpus green; the OLD tagged path stays behind a build flag (-Dcomptime-flat) until
all groups land, then the shim is deleted.
- Machine + scalars. A flat memory region (host
[]u8) with a stack (frames) + bump-allocated heap, and a scalar register file. Portint/float/bool/undefand arithmetic/compare/branch. Aggregates still go through a compat shim to the old representation. - Aggregates. Structs/arrays/tuples laid out in flat memory at target layout;
port
struct_init/struct_get/array/index_gepto read/write bytes at computed offsets. - Slices / strings.
{ptr, len}fat pointers in flat memory. - Optionals / enums / tagged unions. Tag + payload bytes.
- Pointers.
alloca/store/load/ GEP unified onto flat addresses; retireslot_ptr/heap_ptr/byte_ptrin favor of flat-memory addresses. - Closures. Fn id + captured env materialized in flat memory.
- Extern / host calls. A struct arg is already bytes → pass its address; this
removes most of
marshalExternArg. - Reflection / minting.
declare/define/type_inforead flat-memory values; type-table mutation copies escaping data into compiler-owned memory at the boundary (lifetime), as today.
Verification: with -Dcomptime-flat the full corpus (currently 692) is byte-for-
byte identical to the tagged path; then make flat the default and delete the shim.
Phase 2 — Bytecode
Compile a comptime function's IR → a compact bytecode and execute the bytecode instead
of walking Inst. Pure encoding/speed; semantics identical to Phase 1. Land at least a
minimal register-bytecode loop (the stream's stated goal is a bytecode VM); a
fragment cache is optional follow-up.
Verification: corpus identical to Phase 1; comptime throughput measurably improved on a heavy-comptime micro-benchmark.
Phase 1.final — host wiring (the remaining integration)
The wiring ENTRY POINT exists: comptime_vm.tryEval(gpa, module, func_id) ?Value runs a
comptime function entirely on the VM and returns a legacy Value, or null to fall
back. Unit-tested (pure 6*7 → 42; unsupported → null). Remaining to actually route the
host through it:
- Panic→error hardening (prerequisite).
Machine.readWord/writeWord/bytescurrentlyassert(debug panic) on null/OOB. For arbitrary host functions to be safe, make them returnerror.OutOfBoundsso a malformed run BAILS (→ null → legacy) instead of crashing the compiler. Ripples throughreadField/writeField/slice helpers (addtry). - Implicit context. Host comptime functions may have
has_implicit_ctx(param 0 =*Context); the legacyrunmaterializes a default ctx. The VMrundoes not — so either materialize it too, or only routetryEvalat funcs without implicit ctx. - Wire one site behind a flag/env (
SX_COMPTIME_FLAT, →-Dcomptime-flatlater): the const-init fold inemit_llvm.zigemitGlobals(result = tryEval(...) orelse interp.call(...)). Default off → corpus unaffected. - Parity + coverage. Run the corpus with the flag ON; results must be byte-identical
to legacy. Measure how many comptime evals the VM already handles; the bail
details name what to port next (tagged-union payload / any / closures / builtins). - Grow coverage (port the deferred ops +
call_builtin/compiler_callvia the bridge) until the VM is the default and the legacy path is deleted.
Status (2026-06-17): steps 1–4 DONE; step 5 = the next session.
- (1) Hardening — DONE.
Machine.readWord/writeWord/bytesreturnerror.OutOfBounds(null / out-of-range / oversized / overflow-safe) instead of asserting.OutOfBoundsadded toVm.Error;trythreaded throughreadField/writeField/optHas/makeSlice/sliceLen/sliceData/elemAddrand every exec arm + the bridge. New unit tests: hardened-accessor OOB returns, and a null-deref function →tryEvalreturnsnull(legacy fallback), not a panic. - (2) Implicit context — DONE (materialized, 2026-06-17 step 5). Initially a
conservative skip; now
tryEvalMATERIALIZES the implicit ctx: a comptime entry withhas_implicit_ctx(whose sole param is the*Context) gets a zeroedContextof the right size/align allocated in flat memory, its address passed as arg 0. The common const body never reads the ctx; a body that USES the allocator loads a fn from it andcall_indirects (unported) → bails → legacy. No func-ref materialization was needed: handled bodies don't read the ctx contents, and gate-ON corpus parity (688, 0 failed) empirically confirms no divergence. (A body that read+branched on a null allocator fn could in principle diverge; none does — parity is the guard.) - (3) Wire one site — DONE. Const-init fold in
emitGlobalsis(if comptime_flat) tryEval(...) else null) orelse interp.call(...). Gated by envSX_COMPTIME_FLAT(aLLVMEmitter.comptime_flatfield read once fromstd.c.getenvininit). Default OFF → corpus unaffected (688 green). - (4) Parity + coverage — DONE. Gate ON: full corpus byte-identical (688, 0 failed);
manual
sx runof 0605/0606/0607/0608 byte-identical to gate-OFF. Coverage-trace facility in place (comptime_vm.last_bail_reason+ envSX_COMPTIME_FLAT_TRACE, printing HANDLED / fallback+reason per init). - (5) Implicit-context materialization + memory builtins + f32 — DONE; op-porting CONTINUES.
Coverage climbed 0 → 16 → 27 handled corpus const-inits (fallbacks 22 → 11); parity
stays 688/688 (gate ON and OFF) at every step. Landed, in order: implicit ctx
materialized (→16);
writeFieldnull-aggregate fix (storing anullnon-pointer optionalnull_addrsentinel into an aggregate slot OOB-bailed → now ZEROES the destination = none/empty; unit-test regression); curated libc MEMORY builtins on flat memory (Vm.callMemBuiltin:malloc/calloc→allocBytes16-aligned & 256-MiB-capped,free→ no-op,memcpy/memmove/memseton flat bytes — sandboxed, target-aware, result byte-identical to legacy; unlocked0604's 11 comptime mallocs); and an f32 storage fix (float registers hold f64 bits, but f32 memory is the 4-byte single —readField/writeFieldnow@floatCastinstead of truncating the f64 bits, which had written zeros for1.0; a real latent bug0604surfaced; unit tests added). - (6) Real default context + call_indirect + func_ref + global_get — DONE. Coverage
27 → 31 handled (fallbacks 11 → 7); parity stays 688/688 both gate ON and OFF.
Per the user's direction ("the VM can set up a default context"),
runEntrynow materializes the REAL default context (not a zeroed one): the implicit-ctx param is an opaque*void, somaterializeDefaultContextfinds the__sx_default_contextglobal and lays its initializer constant ({ {null, alloc_fn, dealloc_fn}, null }, carrying the CAllocator thunk func-refs) into flat memory via a new recursivelayoutConst. Withfunc_ref(a function value encoded asFuncId.index() + 1so word 0 stays reserved for the NULL function pointer —funcRefWord/funcRefToId) andcall_indirect(decode the callee word →FuncId→ dispatch; 0 → bail) ported, a comptime body that allocates viacontext.allocatornow runs ENTIRELY on the VM:alloc_string→context.allocator.alloc_bytes→call_indirect→ thunk →CAllocator.alloc_bytes→libc_malloc→ the VM's native flat-memorymalloc. Unlocked0606(string global via the allocator). Also:global_getlazily evaluates a comptime global'scomptime_func(memoized inglobal_cache) — unlockedCT_CHAIN; struct field access (fieldOffset/struct_get) now handles string/slice{ptr@0,len@8}fat pointers (needed byalloc_string'ss.ptr/s.len); andregToValuemaps a function-typed word back to.func_refso a func-ref result serializes identically to legacy (kept1128's rejection diagnostic byte-identical). Unit tests added (global_get, func_ref + call_indirect). Note: nativemallocis still REQUIRED — the CAllocator thunk bottoms out at libcmalloc, and the VM can't use a host pointer with flat-memory load/store, so comptimemallocmust allocate from flat memory. The default context lets the allocator PROTOCOL run; nativemallocis its final step. - (7)
is_comptime+ failable/error cluster + the signed-load fix — DONE. Coverage 31 → 36 handled (fallbacks 7 → 2); parity stays 688/688 both gate ON and OFF.is_comptime→ always 1 on the VM (folds to false in compiled code). Unlocked1030.- Failable / error-channel cluster (
1037escape,1038handled):kindOf(error_set) → word(a u32 tag id);regToValuenow bridges TUPLES (the failable(value…, tag)shape the host'scheckComptimeFailablereads);trace_framepacks(func_id<<32 | span.start)from a newcall_stack(pushed byinvoke/runEntry); andsx_trace_push/sx_trace_clearare serviced NATIVELY (the VM calls the real sx_trace.c functions — linked into the compiler — so the return-trace buffer the host reads is populated identically to the legacy dlsym path).raise/catch/orall run on the VM now. - Signed sub-64-bit load fix (a real GENERAL bug the failable case surfaced):
readFieldnow SIGN-extendsi8/i16/i32/isizeloads (was zero-extending, so a storedi32 -1reloaded as0xFFFFFFFF= +4.29e9 and< 0was false — which silently hidraise error.Bad). Affects any negative signed sub-64-bit value stored & reloaded; gate-ON corpus parity confirms it's a strict fix. Unit test added (+ failable tests pass via 1037/1038 in the corpus). - Remaining fallbacks (2, both principled — the VM correctly stays on legacy):
intern(0626, the welded compiler-API fn — Phase 3 re-homes it) and the inline-asm global call (1654, never comptime-evaluable). Every other measured corpus const-init is handled on the VM. At this point the flat-memory VM handles essentially the entire real comptime corpus (scalars, control flow, structs/tuples/arrays/slices/strings/optionals/enums, calls + recursion, the implicit context + allocator protocol, globals, failables + return traces). Phase 2 (bytecode) and Phase 3 (compiler-API on flat memory) are the forward work; flipping the VM to default + deleting the legacy path awaits those.
- (8) Wire the
#runside-effect path; trace-clear-on-fallback — DONE. The second comptime call site (emit_llvm.runComptimeSideEffects, top-level#run <expr>;) now routes throughtryEvalwith legacy fallback, like the const-init fold;tryEvalyields.void_valfor a void/noreturn entry. Fixed a trace-corruption the new site exposed (1035): a side-effect that pushes trace frames then bails (onprint) had the legacy re-run double-push them — both sites nowsx_trace_clear()right before the legacy fallback to discard the VM's partial pushes. Parity 688/688 both gate ON and OFF. All comptime evaluation now routes through the VM-with-fallback (uniform). - (9)
-Dcomptime-flatbuild flag — DONE (the "swap behind a build flag" step). The VM gate is now a build option (build.zig→ abuild_optsmodule onmod;emit_llvm.initreadsbuild_opts.comptime_flat or SX_COMPTIME_FLAT env), default OFF.zig build test -Dcomptime-flatruns the FULL corpus on the VM (688/0) — the build-integrated parity gate. Verified the flag toggles the binary (flag-builtsxuses the VM with no env var; default-built does not). This is the prerequisite to eventually making the VM default + deleting the legacy path (which still awaits Phase 2/3 + broader confidence). - (10) Compiler-call path on the VM —
intern/text_ofnative (Phase 3 SEED) — DONE.invokenow services a weldedcompiler-library function (thecompiler_weldedflag is the safety boundary) viaVm.callCompilerFn— natively on flat memory, NO legacyInterpreter:intern(s: string) -> StringIdreads the string bytes from flat memory andinternStrings into the (const-cast) table (pool-only, never touches type layout, so the VM's cached sizes stay valid);text_of(id) -> stringmaterializes the pooled text back into flat memory as a fat pointer. Unlocked0626— the ONLY remaining const-init fallback is now the inline-asm global (1654, genuinely not comptime-evaluable). Parity 688/688 both gate ON and OFF; unit test added. This is the mechanism Phase 3 grows: the next compiler functions (find_type,register_struct, the reflection readers) are added the same way — flat-memory pointer in, handle/pointer out, no marshaling.
Phase 3 progress (2026-06-18):
- (P3.1) First read-only reflection readers —
find_type+type_field_count(DONE). Two morecompiler-library fns bound the same way as theintern/text_ofseed (added tocompiler_lib.bound_fnsANDVm.callCompilerFn, native on flat memory, no marshaling). A type handle is a plainu32TypeId(exactly likeStringId), so both calls keep the seed's clean scalar shape — handle in, scalar out:find_type(name: StringId) -> TypeId(TypeTable.findByName) andtype_field_count(t: TypeId) -> i64(a newTypeTable.memberCountquery — struct/union/ tagged-union fields, enum variants, array/vector length — that BOTH the legacy handler and the VM call, so the two paths can't drift). Example0628chainsintern → find_type → type_field_countand a not-found lookup, both folded at#run, both VM-HANDLED natively (no fallback). Parity 689/689 (gate ON and OFF); VM unit test added.- Decision (resolves the plan's
find_type → ?Typesketch):find_typereturns a NON-optionalTypeId, using the codebase's dedicatedunresolved(0) sentinel for not-found — NOT an?Type. Rationale: aTypevalue resolves to.any(type_resolver.zig), which the flat-memory VM does not represent; and an optional return can't cross the legacy↔VM eval boundary (regToValuebridges only word/string/struct/tuple).unresolvedis the project-blessed unmistakable "no type" marker (see CLAUDE.md REJECTED PATTERNS — a dedicated sentinel is the required shape), so the caller checks the handle against 0. This keeps the reader a clean scalar mirror ofintern/text_ofand defers.any/optional plumbing to when it's actually needed.
- Decision (resolves the plan's
- (P3.2) Field-level reflection readers —
type_nominal_name+type_field_name+type_field_type(DONE). Three more readers on the sameTypeId-handle shape (each backed by a newTypeTablequery that BOTH the legacy handler and the VM call, so no drift):type_nominal_name(t: TypeId) -> StringId(nominalName— a named type's own name; loud-bail for unnamed types),type_field_name(t: TypeId, idx: i64) -> StringId(memberName— struct/union/tagged-union field, enum variant, named-tuple element), andtype_field_type(t: TypeId, idx: i64) -> TypeId(memberType— struct/tuple/array/vector member type). All loud-bail on out-of-range idx / no-member (no silent default). These are the first MULTI-ARG compiler fns (the VM'scallCompilerFnnow reads arg 1 = idx); addedVm.argHandle/argTypeIdhelpers (range-checked u32/TypeId arg reads). Naming uses thetype_*family so nothing collides with the std metatype builtins (field_name/type_nameexist incore.sx). Example0629reflectsPair { lo: Point; hi: Point }— reads each field name and the nominal name of a field's type, all folded at#run, all VM-HANDLED natively. Parity 690/690 (gate ON and OFF); VM unit test added. - (P3.2b) Kind + enum-value readers —
type_kind+type_field_value(DONE). The last two read-only readers the metatype'stype_info(T)needs, completing the READ side: a comptime sx fn can now fully reflect a struct/enum/tagged-union/tuple into data with no#builtin.type_kind(t: TypeId) -> i64(TypeTable.kindCode— a stable, compiler-owned discriminant: 0 other · 1 struct · 2 enum · 3 tagged_union · 4 tuple · 5 union · 6 array · 7 vector · 8 error_set; TOTAL — never bails, an unnamed/non-aggregate type readsother) andtype_field_value(t: TypeId, idx: i64) -> i64(TypeTable.memberValue— an enum variant's explicit value or ordinal; mirrors thefield_value_intbuiltin; loud-bail for a non-enum / out-of-range idx). Example0630reflectsColor/WindowFlags(flags)/Point. Parity 691/691 (gate ON and OFF); VM unit test added.- READ side now complete:
find_type+type_kind+type_field_count+type_field_name+type_field_type+type_nominal_name+type_field_valuecover everythingreflectTypeInforeads.
- READ side now complete:
- (P3.3) WRITE side —
declare_type+pointer_to+ ONE kind-branchingregister_type(DONE). The mutating side is a SINGLEregister_type(handle, kind, members)that branches onkindIN THE COMPILER (subsumingdefine'sdefineStruct/defineEnum/defineTuple), plusdeclare_type(name) -> Type(forward handle) andpointer_to(t) -> Type(build*Treferences). They take/return realTypevalues (matching meta.sx's declare/define).- Timing decision (per the user): mint LAZILY at LOWERING time (single pass, NOT a
pre-emit phase, NOT two-pass) — the existing
runComptimeTypeFuncpath. So the write side is legacy-only (compiler_libhandlers); the VM isn't wired at lowering time, so no VM mirror is needed (the read-side readers stay dual-path for emit-time reflection). A non-generic-> Typebuilder is now flaggedis_comptime(decl.zig) so its dead body permits the welded calls (the comptime-only gate). - Graph support: forward
declare_typehandles +pointer_toexpress a mutually-recursive A↔B graph (*A,*B, B-by-value) before bodies are filled.register_typeis idempotent — re-filling a nominal slot (same module reached via two import edges) re-mints identically instead of erroring (nominalIdentreads identity from any nominal kind).kindcodes matchtype_kind: 1 struct · 2 enum (actual.@"enum") · 3 tagged_union · 4 tuple. - Two bugs fixed en route (issue 0142): (a) a fully payloadless comptime-minted enum
was minted as an all-void
tagged_union→verifySizespanic; now mints a real.@"enum"(bothregister_typekind 2 AND the metatypedefineEnum). (b) bareEnumType.variantqualified construction of a payloadless variant wasn't supported (failed for hand-written enums too) — added inlowerFieldAccess(isPayloadlessVariant). - Examples:
0631(graph + actual-enum + reflection),0632(make_enum all-void),0633/0634/0635(namespaced / bare / multi-edge import of a minted type),0187(qualified variant construction). Parity 697/697 (gate ON and OFF); unit tests added.
- Timing decision (per the user): mint LAZILY at LOWERING time (single pass, NOT a
pre-emit phase, NOT two-pass) — the existing
- Next (P3.4): re-express
declare/define/type_infoas sx over the read+write compiler-API and DELETE the bespoke interp arms — needs the VM hardened against malformed lowering-time IR first (the metatype runs at lowering time), so either harden + wire the VM there, or migrate the metatype onto the legacy compiler-API calls first. Decide when reached. Phase 2 (bytecode) is the orthogonal speed work.
Phase 3 — Compiler-API on flat memory (resume the stream — no weld)
With native-byte comptime values, re-home the compiler-API:
- Expose the compiler's real types. Register the actual
types.zigrecords (StructInfo,EnumInfo,Field, …) into the comptime type table under sx-visible names, with their real (host) layout — the type IS the compiler's, so there is nothing to validate or keep in sync. (This is the projection that replaces the weld's reflection — owned by the compiler, not declared in sx.) - Expose the compiler's functions.
register_struct,find_type,intern,text_of, and the reflection readers operate on flat-memory pointers / handles directly (no marshaling — the bytes already ARE the record). - Re-express
declare/define/type_infoas sx over these; delete the bespoke interp arms (defineStruct/defineEnum/defineTuple/reflectTypeInfo); migrateexamples/0622(struct),0619/0620/0623(enum/tuple). - Migrate
BuildOptionsoff#compileronto this mechanism; delete#compiler.
Verification: the metatype + #compiler surfaces are gone, re-expressed as sx over
the exposed compiler-API; full corpus green.
Phase 4 — Retire the legacy interp (the ONE-evaluator end state)
The metatype CONSTRUCTION + REFLECTION surface is VM-native (steps 7/8 — 0614–0624,
0632 all HANDLED). This phase moves EVERYTHING ELSE off interp.zig and deletes it.
What the legacy interp is still used for (audited 2026-06-18) — five roles:
| Role | Wired to VM? | Site |
|---|---|---|
A. Comptime folds (type-fn / :: const-init / #run) |
✅ VM + legacy fallback | comptime.zig:530, emit_llvm.zig:871/971 |
B. #insert string eval |
❌ legacy-only (VM wiring reverted — 0737 malformed-IR crash) | comptime.zig:634 |
C. Post-link bundler (platform.bundle — Info.plist/codesign/process/fs) |
❌ legacy-only | core.zig:invokeByFuncId ← main.zig:769 |
D. #compiler hooks (compiler_call — BuildOptions/bundling) |
❌ legacy-only; Value-based ABI |
compiler_hooks.zig, interp.zig:1130 |
E. Bail diagnostics (Interpreter.last_bail_* statics) |
n/a | main.zig:464 |
Shared substrate everything traffics in: the Value tagged union (the
regToValue/valueToReg bridge + the hooks + core.zig) and the host-FFI bridge
(host_ffi.zig + interp.callExtern — dlsym + cdecl trampolines for real libc).
DECISION (2026-06-18, user): UNIFY. The VM gains a host-FFI escape + real-pointer
translation and runs BOTH sandboxed comptime folds AND the unsandboxed post-link bundler.
interp.zig is fully deleted — true ONE evaluator, two modes (sandboxed / host-effects).
Remaining comptime-fold gaps (full corpus fallback inventory — 15 examples; 1179/1180
are legitimate negative-test bails that BECOME VM diagnostics, 1145 is a scan artifact):
box_any/unbox_any (6), out/print (2), global_addr (1), trace frames (1),
compiler_call (2 — role D).
Sub-phases (dependency order; each its own session, both gates 697/0 after each):
- 4A — finish comptime ops (small, parity-guarded). Drive the fold fallback list to
empty except
compiler_call:- 4A.1
box_any/unbox_any. Word case = alloc 16B{tag@0, value@8}, tag =source_type.index()(matches legacy comptime; note runtimeanyTagnormalizes arbitrary-width ints), value viawriteField(source_type)(so f32 etc. round-trip); unbox =readField(addr+8, target). Aggregate-Any payload needs the runtime pointer-in-value-slot shape (coerceToI64alloca+ptrtoint) — implement or bail loudly. - 4A.2
out/print → add a VM output buffer; flush through the same path ascore.flushInterpOutput. - 4A.3
global_addr(address-of a global in flat memory). - 4A.4 trace frames (
sx_trace_*/interp_print_frames).
- 4A.1
- 4B — VM-native diagnostics (role E). MUST land before deleting legacy. Today a VM
bail silently falls back; with legacy gone the VM bail IS the user-facing build-gating
diagnostic. Surface the VM's
detail/span/file into whatmain.zigrenders; turn 1179/1180-style bails into proper diagnostics. No diagnostic may regress. - 4C —
#inserton the VM (role B). Re-wireevalComptimeStringthroughtryEval; the lowering-time-IR hardening that forced the 0737 revert is already in place. Verify the#insertcorpus parity. - 4D — host FFI on the VM (role D substrate). DONE. Solved by a better allocator, not a
pin/tag scheme: the comptime memory is now an arena of stable host allocations and
AddrIS a real host pointer (4D.0,625ba0f), so a flat-memory pointer and an FFI-returned host pointer are the same value — no translation, no realloc hazard.Vm.callHostExtern(4D.1,e7a8708) dispatches ANY extern viahost_ffidlsym + trampolines (args/returns pass untouched);4D.2(6a7f690) adds slice/string args (→ NUL-termchar*) + float guards. Examples 0636/0637. (Superseded sub-note: the earlier "pin the buffer / flat↔host translate" hazard is moot — the arena never moves an allocation.) #compiler/compiler_call— DELETED, replaced by theabi(.compiler)ABI (decision 2026-06-18, REVISED from the earlierabi(.zig) extern compilershape). A function is compiler-domain — it runs in the comptime evaluator (VM/interp), NEVER in the shipped binary — because its ABI says so:abi(.compiler). Noextern <lib>, no fake#library "compiler". One annotation covers BOTH roles: (a) the compiler-API surface (intern/find_type/build_options/set_post_link_callback/… — bodiless decls whose Zig/VM handler is the impl, oncompiler_lib's export list, dispatched byVm.callCompilerFn); (b) user compiler-domain functions like post-link callbacks (bundle_main— BODIEDabi(.compiler), lowered for VM eval but emit-skipped). The#compilerstruct attribute + thecompiler_callIR op + theValue-based hookRegistry(compiler_hooks.zig) all go away. Why this is cleaner than the welded-fn approach: the former runtime-call enforcement blocker (abuild_options()call inside an LLVM-emitted callback body) is MOOT — a compiler-domain function is never emitted, so its compiler-API calls never reachemitCall. Staged build (each its own step, both gates green):- S1+S2 — DONE (2026-06-18): introduced
abi(.compiler), REMOVED the.zigABI +abi(.zig) extern compiler+#library "compiler"(clean cutover, no legacy); migrated all compiler-API examples. The binding now keys offfd.abi == .compiler(decl.zigweldedCompilerFn); a bodilessabi(.compiler)decl lowers extern-like (declared-not-defined) with no implicit ctx. 700/0 both gates. - S3 — DONE (2026-06-18): emit_llvm skips BODIED
abi(.compiler)function bodies. Added anis_compiler_domainflag to the IRFunction; a bodiedabi(.compiler)function LOWERS its body (for VM eval) + is flaggedis_comptimebut is NOT emitted (Pass 2 skip; declared external-linkage so the empty decl verifies). KEY fix: a call to a comptime-only callee (compiler-APIcompiler_weldedORis_compiler_domain) inside a dead comptime body now emitsundefinstead of a realcall(ops.zigemitCall) — the oldcompiler_calldid this; without it an AOT link leaves an undefined_double/_internreference (this also fixed a pre-existing untested AOT breakage of the bodiless compiler-API examples).fnIsBodilessCompilerdistinguishes the API surface (declare-only) from a compiler-domain callback (lowered, emit-skipped). Regression:examples/0638-comptime-domain-fn-not-emitted(doublefolds a#runconst, absent from the binary, JIT+AOT). 701/0 both gates. - S4 — callback-param propagation: OPTIONAL / DEFERRED (ergonomics only). Verified 2026-06-18: an
abi(.compiler)function is TYPE-compatible with a plain() -> Rparam (the ABI marks the function —is_compiler_domain— not its type, which stays() -> RCC-default). So a callback that needs to be compiler-domain just declares itselfabi(.compiler)(S3) and passes to a plain param fine; auto-propagation from anabi(.compiler)PARAM type is a nicety, not a prerequisite for S5. Skipped for now. - S5a — DONE (2026-06-18): the corpus-covered slice.
build_options+set_post_link_callback→ freeabi(.compiler)functions (VMcallCompilerFnarms + legacycompiler_libhandlers);BuildConfigthreaded into the VM via atryEvalparam (the same onemain.zigforwards — shared with 4E).build.sxextractsset_post_link_callbackfrom thestruct #compileras a freeufcsfn;bundle_main+ the platform registrars (configure) areabi(.compiler). 37 examples'.irsnapshots regen'd (benign: declaration renumber +@strsuffix shift — every example imports build.sx via the prelude). Strictcompiler_callbails 6→2; 0602/0603/1604/1611 HANDLED. 701/0 both gates. - S5b/S5c (port the ~37 hooks) — SUPERSEDED 2026-06-18 by the sx-driven build pipeline (below).
Porting each
BuildOptionsaccessor to anabi(.compiler)function that delegates to acompiler_hookshook just re-encodes sx-level logic (string setters/getters,is_macostriple-matching, list appends) as compiler hooks. The hooks need NOTHING from the compiler except theBuildConfigstate. So instead of 37 hooks, drive the whole build pipeline from sx (the logical end of "bundling lives in sx"). S5a stays as a green intermediate; the sx-build-pipeline replacesbuild_options/set_post_link_callback/the whole#compilersurface wholesale.
- S1+S2 — DONE (2026-06-18): introduced
Phase 5 — sx-driven build pipeline (replaces the BuildOptions hooks; decision 2026-06-18, user)
The build pipeline becomes an sx program. BuildConfig is plain sx data (an ordinary struct, sx-owned
end-to-end — no #compiler, no hooks, no shared Zig state, no weld/offset access). The compiler shrinks to
a few abi(.compiler) PRIMITIVES that take explicit args (so nothing is shared by memory), and an sx
build() driver orchestrates configure → emit → link → bundle. Chosen boundary: Option B — the compiler
keeps the proven Zig linker as a primitive; sx owns config + orchestration + bundle. (Option A — sx shells
cc/ld itself — is a later refinement once the per-target link-line logic is ported to sx.)
File split (user decision 2026-06-19): the low-level compiler-API PRIMITIVES live in
library/modules/compiler.sx (the comptime compiler library — renamed from the interim std/build.sx); the
default build IMPLEMENTATION (default_build + the on_build slot + the sx BuildConfig) lives in
library/modules/build.sx alongside the existing BuildOptions DSL. So compiler.sx = primitives, build.sx =
orchestration/default impl. Build-callback fallibility was DROPPED (user 2026-06-19): the primitives + the
build callback are NOT -> ! — a failed action (e.g. link) BAILS on the VM (hard build error). So the shapes
below shed their -> !.
Shape (build-callback fallibility dropped 2026-06-19):
// library/modules/compiler.sx (the comptime `compiler` library — PRIMITIVES)
emit_object :: () -> string abi(.compiler); // emitted .o path (query)
link :: (objects: List(string), output: string, libraries: List(string),
frameworks: List(string), flags: List(string), target: string) abi(.compiler); // void; bails on failure
c_object_paths :: () -> List(string) abi(.compiler); // metadata queries
link_libraries :: () -> List(string) abi(.compiler);
// library/modules/build.sx (the build DSL — DEFAULT IMPLEMENTATION + slot)
BuildConfig :: struct { output: string; target: string; flags: List(string);
frameworks: List(string); bundle_path: string; bundle_id: string; ... }
default_build :: (config: BuildConfig) abi(.compiler) { // the default pipeline (void)
obj := emit_object(); objs := c_object_paths(); objs.append(obj);
link(objs, config.output, link_libraries(), config.frameworks, config.flags, config.target);
if config.bundle_path.len > 0 { bundle_app(config); } } // bundle_app = today's sx bundler
on_build : (BuildConfig) abi(.compiler) = default_build; // the override slot
// user overrides: build :: (config: BuildConfig) abi(.compiler) { ... } #run on_build = build;
The compiler's whole post-IR role: codegen → build the CLI-derived BuildConfig → read on_build → invoke
on_build(config) on the VM; a raise fails the build. Plain sx run fires none of it.
Steps (each its own green step; depends on 4E first):
- P5.1 — 4E prereq — DONE (2026-06-19).
core.invokeByFuncIdroutes the post-link callback through the VM (comptime_vm.tryEval), NO fallback (a side-effecting callback can't double-execute): a bail is a hard build error (comptime_vm.last_bail_reasonsurfaced bymain.printInterpBailDiag).BuildConfig+import_sourcesthreaded in;flushInterpOutputdeleted (VMoutwrites direct via host-FFI). Smoke testexamples/1661-platform-post-link-vm-list(AOT): a post-link callback GROWS aList(0141 — works on the VM, bails on legacy withstruct_get), so the build succeeds (exit 0) only via the VM. Non-empty callbackargsrejected loudly (theon_build(config)arg-marshaling entry is P5.3). 702/0 both gates. - P5.2 — primitives. Split: the read-only metadata queries are DONE (2026-06-19) —
c_object_paths() -> List(string)+link_libraries() -> List(string)asabi(.compiler)fns (stdliblibrary/modules/compiler.sx), serviced bycomptime_vm.callCompilerFnoverBuildConfigfieldsmain.zigforwards; new VMmakeStringListbuilds theList(string)in flat memory from the call's result type (ins.tynow threaded throughinvoke/callCompilerFn). Smoke test1662-platform-build-pipeline-queries(AOT + C companion). 703/0 both gates.emit_object() -> stringis also DONE (2026-06-19) as a QUERY (not an action): the Zig driver emits the object eagerly, so the primitive just returns the path fromBuildConfig.object_path(no vtable). So all three QUERY primitives are done. P5.2b —link(...)(the one genuine ACTION) — DONE (2026-06-19). USER DECISION: the build callback is NOT fallible, solinkis plain VOID (no-> !) and a failure BAILS (hard build error) — no failable-tuple construction. It dispatches through a host-installedcompiler_hooks.BuildHooksvtable (comptime_vm.zigcan't depend on the driver);main.LinkHooksCtx.linkadapts totarget.link. New VM readersreadStringList/readStringArg(inverse ofmakeStringList). Smoke test1663-platform-build-pipeline-link(AOT): a post-link callback re-links the build's objects to a temp output — the relinked binary RUNS; negative-probe verified. The Zig driver still auto-links (removed in P5.4). 704/0. - P5.3 —
on_buildslot: a comptime-assignable compiler slot (GENERALIZES today'spost_link_callback_fn: an assignable typed global with a stdlib default, vs a setter).#run on_build = build;captures theFuncId; the compiler invokes it post-codegen with the CLI-derivedBuildConfig. - P5.4 — sx
default_build+BuildConfig: write the stdlib pipeline; move config/orchestration into sx. Delete#compiler/compiler_call/compiler_hooks(HookFn/Registry) + the S5abuild_options/set_post_link_callback(config is now sx data passed as primitive args). - 4E — post-link bundler on the VM (role C). Depends on the FFI escape (done) + the
BuildConfig-on-the-VM threading above. Routecore.invokeByFuncId/main.zig's post-link call through the VM. No corpus coverage (only runs onsx build --bundle/--apk) — add dedicated bundle smoke tests (min.app+.apk); gate on real bundle builds, not justzig build test. Riskiest phase. - 4F — flip + delete (the end state). Flip the VM to default (retire
-Dcomptime-flat); deleteinterp.zig(Interpreter/Value/defineEnum…/reflectTypeInfo/callExtern); drop theregToValue/valueToRegbridge; simplifycore.zig(invokeByFuncId→ VM) +main.zig(last_bail_*→ VM diagnostics); remove the dual-pathcompiler_libhandlers (keep only VM-nativecallCompilerFn); re-expressdefine/make_enumas sx over the compiler-API (allocation works on the sole evaluator) and land the original 0141 repro as a corpus test.
Dependencies: 4A → (4B, 4C independent) ; abi(.compiler) S1+S2(done) → S3 → S4 → S5 (BuildOptions) ;
FFI(done)+BuildConfig-on-VM → (S5, 4E) → 4F.
Top risks: (1) the bundler has no corpus guard (4E needs dedicated tests); (2) deleting
#compiler/compiler_call + re-expressing BuildOptions over the compiler-API (abi(.compiler)) touches the
whole build/bundle path — stage it behind real bundle builds; (3) S3's emit-skip relies on DCE dropping the
unreferenced compiler-domain declaration — verify no stray runtime reference keeps it alive (link error).
Open questions (resolve as reached, record decisions here)
- Host-ABI vs target-ABI split. The compiler runs on the host, so its OWN exposed records are host-laid-out; user comptime types are target-laid-out. The flat-memory model must carry both regimes (a per-type ABI tag on layout queries). Confirm the boundary where a flat-memory pointer to a compiler record is handed to host Zig code uses host layout.
- Exposing compiler types to sx. Mechanism for projecting
types.zigrecords into the comptime type table with real offsets (the non-weld replacement) — a registry the compiler owns, keyed by sx-visible name → real Zig type's layout + a host-call ABI. - Bytecode shape. IR-derived vs a fresh ISA; register vs stack; fragment caching.
- Pointer escape / lifetime. Flat-memory pointers stored into the persistent type table must be copied into compiler-owned memory at the boundary (as today).
- Old-path retirement. Keep the tagged interpreter until Phase 1 parity, then
delete — confirm no non-comptime consumer depends on
Value.
File map (current → touched)
| Area | File | Phase |
|---|---|---|
| Comptime evaluator | src/ir/interp.zig |
0 (strip weld dispatch), 1–2 (rebuild) |
| Weld registry | src/ir/compiler_lib.zig |
0 (strip), 3 (replace with type/fn exposure) |
| Weld validation | src/ir/lower/nominal.zig |
0 (strip validateWeldedStruct) |
| Comptime-only gate | src/backend/llvm/ops.zig |
0 (re-derive without weld signal) |
| Host-FFI marshalling | src/ir/host_ffi.zig |
1 (struct-by-pointer trims it) |
| Metatype arms | src/ir/interp.zig (defineStruct/…/reflectTypeInfo) |
3 (delete, re-express in sx) |
#compiler / BuildOptions |
library/modules/build.sx, src/ir/compiler_hooks.zig |
3 (migrate, delete #compiler) |
| Surface syntax | src/parser.zig, src/ast.zig (abi/extern/#library) |
kept; revisited Phase 3 |
Status
- Phase 0 — DONE (2026-06-17). The struct-weld machinery is stripped:
compiler_lib.ziglost the type registry (weldStruct/bound_types/BoundType/FieldLayout/findType/SxField/LayoutMismatch/validateStructLayout);nominal.ziglostvalidateWeldedStruct/weldedFieldOrderStr+ thesd.abi == .zigcall; the struct-weld unit tests + examples0625/0627/1183/1186are removed. Decision (recorded): theintern/text_offunction host-call bridge is KEPT — it is a clean scalar dispatch (string→handle), not weld/serialize/marshal, and is the seed Phase 3 grows the compiler-call path from. So thecompiler_weldeddispatch (interp.callExternis unchanged at HEAD — the pre-branch incall()),weldedCompilerFn(decl.zig), theemitCallcomptime-only gate (ops.zig), and examples0626/1184/1185stay. The#library/abi/externSYNTAX stays.zig build testgreen (688 corpus, 0 failed; unit tests pass). - Phase 1 — in progress.
-
Sub-step 1 — DONE.
src/ir/comptime_vm.zig: the flat-memoryMachine(linear byte memory + bump/stack allocator withmark/resetreclamation + scalarreadWord/writeWord(1/2/4/8, little-endian) +bytesviews; addr 0 reserved asnull_addr) andFrame(register file indexed by Ref + stack reclamation ondeinit). A registerRegis a raw u64 — immediate scalar ORAddr. Standalone + unit-tested (comptime_vm.test.zig, in the barrel); does NOT touch the live interpreter, so the corpus stays green (688). No op execution yet. -
Sub-step 2 — DONE. The executor (
Vmincomptime_vm.zig): walks the SAME IRInstover flat-memory frames, mirroring the legacy interp's scalar semantics (i64 wrapping/signed + f64 register words, keyed off the result/operandTypeId). Ported: constants (const_int/float/bool/null/undef), arithmetic (add/sub/mul/div/mod/neg), comparison (cmp_*), logical (bool_and/or/not), conversions (widen/narrow/bitcastpassthrough,int_to_float/float_to_int), terminators (br/cond_br/ret/ret_void) andblock_param(branch args passed as Refs — the same frame persists, SSA-safe). Any other op bails loudly (error.Unsupported+detail = @tagName(op)). Unit-tested on hand-built IR (Fbbuilder): integer add, f64 arithmetic, cond_br branch selection, a block-param loop summing i..1, div-by-zero + unsupported-op bails. Corpus untouched (688 green) — the executor is exercised by unit tests only, not yet wired to real comptime eval. -
Sub-step 3 — DONE. Memory + structs on flat memory.
Vmgained an optionaltable: *const TypeTable(target-aware layout). Portedalloca/load/store(over flat addresses,Store.val_tydrives width) andstruct_init/struct_get/struct_gep(structs laid out at the table's natural offsets). The value model: aKind.word(scalar/pointer ≤8B) sits in a register; aKind.aggregate(struct) lives in flat memory and its "value" IS its address (read returns the address, write memcpys), so nested structs compose andstruct_gepis just base+offset (no field-pointer dance).kindOfbails loudly on the not-yet-ported types (slice/string/any/optional/enum/array/tuple/…). The Addr-based value model survives allocator realloc (offsets are stable; slices are only materialized transiently). Unit-tested: struct_init+get round-trip, alloca+gep+store+load, nested-struct aggregate copy + nested read. Corpus untouched (688 green). -
Sub-step 4a — DONE. Tuples + arrays.
kindOfwidened (tuple/array→ aggregate). Portedtuple_init/tuple_get(positional,tupleFieldOffset),index_get/index_gep(elemAddr= base + idx*elem_size over array/pointer/ many_pointer bases; slice/string bases bail), andlengthon an array value (staticArrayInfo.length). Unit-tested: mixed tuple round-trip,[3]i64gep/store + index_get sum (42), arraylength(3). 688 corpus green. -
Sub-step 4b — DONE. Slices + strings as
{ptr@0 (pointer_size), len@8 (i64)}fat pointers (kindOf: string/slice → aggregate). Portedconst_string(materializes text+NUL in flat memory + a fat pointer),length/data_ptr(read len/ptr fields),array_to_slice,subslice, indexing through a slice/string (elemAddrloads.ptrfirst), andstr_eq/str_ne(len+memcmp). HelpersmakeSlice/sliceLen/sliceData. Unit-tested: string length + str_eq/ne, array→slice + slice index + slice length (23), array subslice (43). 688 corpus green. -
Sub-step 4c — DONE (optionals + payloadless enums).
kindOf:enum→ word;?T→ word if pointer-child (null==0) else{T@0, i1@sizeof(T)}aggregate. Portedoptional_wrap/unwrap/has_value/coalesce(withoptChildIsPtr/optHashelpers;const_null→null_addrreads as none),enum_init(payloadless: tag is the value),enum_tag(payloadless/word). Unit-tested: non-pointer?i64wrap/unwrap/coalesce (91), pointer?*i64null==0 (99), payloadless enum tag (11). 688 corpus green. -
Sub-step 4d — partial (
addr_of/derefDONE).addr_ofpasses through (an aggregate value already IS its address; a pointer is already an address — mirrors the legacy);deref=readFieldthrough the pointer (ins.tyis the pointee). Unit-tested (deref a*i64→ 77; addr_of a struct value + field read → 80). Deferred to the wiring phase (intentionally, not ported blind): tagged-union payload (enum_initw/ payload,enum_payload— the legacy stores untyped Values andfield_indexindexes payload sub-fields, not variants, so a byte model's payload type is ambiguous without a real call site),anyboxing, closures, and the bitwise ops. These have subtleties best resolved against actual corpus cases — the VM's louderror.Unsupported+detailwill name exactly what each real eval needs. -
Sub-step 1.5 — direct
callDONE.Vmgainedmodule: *const Module(resolves a calleeFuncId) + adepth/max_depthrecursion guard.callmarshals arg Refs → Reg words and recursivelyruns the callee; aggregate args/ results pass as theirAddrover the SHARED flat memory (no copy). Stack-lifetime change:Frameno longer reclaims the machine on exit (a returned aggregate's Addr would dangle) — a comptime eval's allocations live toVm.deinit;Machine.mark/resetstay for explicit use. Extern/builtin callees (no blocks) bail loudly (1.5b). Unit-tested: direct call (add(20,22)+100→ 142) and recursion (sum(0..n)→ 15/55). 688 corpus green. -
Sub-step 1.5b —
Reg↔Valueboundary bridge DONE. The builtin/compiler_call/ extern handlers are all coupled to the legacyInterpreter(e.g.compiler_libhandlers take*Interpreter), so the VM can't call them directly — the wiring uses WHOLE-FUNCTION fallback instead (VM runs pure functions; a bail re-runs the whole eval in the legacy). That needs the boundary bridge:valueToReg(hostValuearg → VMReg, materializing aggregates into flat memory) +regToValue(VM result →Value, deep-copied out). Covers scalars + strings + structs (other aggregate shapes bail loudly; added as wiring surfaces them). Transitional — deleted once the VM owns comptime end-to-end. Unit-tested with round-trips. 688 corpus green. -
Then the wiring step (below) — now unblocked.
-
Decision (2026-06-17): pivot from blind op-porting to CALLS + hybrid wiring
The common leaf ops are ported (scalars, control flow, structs, tuples, arrays, slices, strings, optionals, payloadless enums, deref/addr_of) and unit-tested. Continuing to port the rarer ops (tagged-union payload, any, closures) in isolation risks subtle bugs and has low signal. The higher-value path:
- Calls (sub-step 1.5) —
call(direct), thencall_builtin/compiler_call. The shared flat memory makes aggregate args/results pass naturally (they're Addrs). The one design point: aggregate-return lifetime — a callee's stack-reclaim would dangle a returned struct Addr, so for comptime (bounded) the VM should stop reclaiming per-frame and let the whole eval's allocations live untilVm.deinit(keepMachine.mark/resetfor explicit use; drop it fromFrame.deinit). - Hybrid wiring —
-Dcomptime-flatroutes a comptime eval through the VM, falling back to the legacy interp onerror.Unsupported. This makes the VM run the REAL corpus, proving parity incrementally and surfacing exactly which ops each real eval needs — far better signal than more isolated unit tests.