fusevm 0.22.0

Language-agnostic bytecode VM with fused superinstructions and a 3-tier Cranelift JIT (linear/block/tracing)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
```
 ███████╗██╗   ██╗███████╗███████╗██╗   ██╗███╗   ███╗
 ██╔════╝██║   ██║██╔════╝██╔════╝██║   ██║████╗ ████║
 █████╗  ██║   ██║███████╗█████╗  ██║   ██║██╔████╔██║
 ██╔══╝  ██║   ██║╚════██║██╔══╝  ╚██╗ ██╔╝██║╚██╔╝██║
 ██║     ╚██████╔╝███████║███████╗ ╚████╔╝ ██║ ╚═╝ ██║
 ╚═╝      ╚═════╝ ╚══════╝╚══════╝  ╚═══╝  ╚═╝     ╚═╝
```

[![CI](https://github.com/MenkeTechnologies/fusevm/actions/workflows/ci.yml/badge.svg)](https://github.com/MenkeTechnologies/fusevm/actions/workflows/ci.yml)
[![Crates.io](https://img.shields.io/crates/v/fusevm.svg)](https://crates.io/crates/fusevm)
[![Downloads](https://img.shields.io/crates/d/fusevm.svg)](https://crates.io/crates/fusevm)
[![Docs.rs](https://docs.rs/fusevm/badge.svg)](https://docs.rs/fusevm)
 [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://menketechnologies.github.io/fusevm/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

### `[LANGUAGE-AGNOSTIC BYTECODE VM WITH FUSED SUPERINSTRUCTIONS POWERING THE FASTEST INTERPRETED LANGUAGES]`

> *"One VM to run them all."*

## `[PATENT PENDING]`

A language-agnostic bytecode virtual machine with fused superinstructions and 3 stage (linear, block, tracing) Cranelift JIT. Any language frontend compiles to fusevm opcodes and gets fused hot-loop dispatch, extension opcode tables, stack-based execution with slot-indexed fast paths, and native code compilation via Cranelift — for free. 235 opcodes across 22 sections, 11 fused superinstructions, 29 first-class shell ops, 61 first-class AWK ops. Cranelift 0.130 behind `jit` feature flag.

```sh
cargo add fusevm --features jit   # with Cranelift JIT
cargo add fusevm                  # interpreter only
```

### [`Read the Docs`](https://menketechnologies.github.io/fusevm/) · [`Engineering Report`](https://menketechnologies.github.io/fusevm/report.html) · [`API Reference`](https://docs.rs/fusevm) · [`Crates.io`](https://crates.io/crates/fusevm) · [`strykelang`](https://github.com/MenkeTechnologies/strykelang) · [`zshrs`](https://github.com/MenkeTechnologies/zshrs)

---

## Table of Contents

- [\[0x00\] Overview](#0x00-overview)
- [\[0x01\] Install](#0x01-install)
- [\[0x02\] Usage](#0x02-usage)
- [\[0x03\] Architecture](#0x03-architecture)
- [\[0x04\] Fused Superinstructions](#0x04-fused-superinstructions)
- [\[0x05\] Op Categories](#0x05-op-categories)
- [\[0x06\] Extension Mechanism](#0x06-extension-mechanism)
- [\[0x07\] JIT Compilation](#0x07-jit-compilation)
- [\[0x08\] Ahead-of-Time Compilation](#0x08-ahead-of-time-compilation)
- [\[0x09\] Value Representation](#0x09-value-representation)
- [\[0x0A\] Benchmarks](#0x0a-benchmarks)
- [\[0x0B\] WebAssembly / Web Worker](#0x0b-webassembly--web-worker)
- [\[0x0C\] Cooperative Concurrency](#0x0c-cooperative-concurrency)
- [\[0x0D\] Host Hooks & Chunk Introspection](#0x0d-host-hooks--chunk-introspection)
- [\[0xFF\] License](#0xff-license)

---

## [0x00] OVERVIEW

fusevm is the shared execution engine behind seventeen language frontends — [zshrs](https://github.com/MenkeTechnologies/zshrs), [strykelang](https://github.com/MenkeTechnologies/strykelang), [awkrs](https://github.com/MenkeTechnologies/awkrs), [vimlrs](https://github.com/MenkeTechnologies/vimlrs), [elisprs](https://github.com/MenkeTechnologies/elisprs), [rubylang](https://github.com/MenkeTechnologies/rubylang), [arb](https://github.com/MenkeTechnologies/arb), [pythonrs](https://github.com/MenkeTechnologies/pythonrs), [phplang](https://github.com/MenkeTechnologies/phplang), [node-js](https://github.com/MenkeTechnologies/node-js), [rlang](https://github.com/MenkeTechnologies/rlang)/R, [javars](https://github.com/MenkeTechnologies/javars), [kotlinrs](https://github.com/MenkeTechnologies/kotlinrs), [scalars](https://github.com/MenkeTechnologies/scalars), [groovyrs](https://github.com/MenkeTechnologies/groovyrs), [go-rs](https://github.com/MenkeTechnologies/go-rs), and [tclrs](https://github.com/MenkeTechnologies/tclrs)/Tcl. They all compile to the same `Op` enum. The VM doesn't care which language produced the bytecodes.

```
zshrs  ──► shell  compiler ──┐
stryke ──► stryke compiler ──┤
awk    ──► awk    compiler ──┤
viml   ──► viml   compiler ──┤
elisp  ──► elisp  compiler ──┤
ruby   ──► ruby   compiler ──┤
rlang  ──► r      compiler ──┤
go-rs  ──► go     compiler ──┤
arb    ──► arb    compiler ──┼──► fusevm::Op ──► VM::run() ──┐
python ──► python compiler ──┤                              │
php    ──► php    compiler ──┤                              │
node   ──► node   compiler ──┤                              │
java   ──► java   compiler ──┤                              │
kotlin ──► kotlin compiler ──┤                              │
scala  ──► scala  compiler ──┤                              │
groovy ──► groovy compiler ──┤                              │
tcl    ──► tcl    compiler ──┘                              │
                                                            ▼
                                   JitCompiler tiers (Cranelift 0.130)
                                   ├── Linear JIT (straight-line, instant)
                                   ├── Block JIT (CFG, threshold 1)
                                   └── Tracing JIT (hot loop, threshold 50,
                                                   deopts on guard miss)
                                               │
                                               ▼
                                     native x86-64 / aarch64
```

- **Fused superinstructions** — the compiler detects hot patterns and emits single ops instead of multi-op sequences
- **Extension dispatch** — language-specific opcodes via `Extended(u16, u8)` with registered handler tables
- **Stack + slots** — stack-based execution with slot-indexed fast paths for locals
- **Three-tier Cranelift JIT** — Linear JIT (straight-line, compile-on-first-call), Block JIT (CFG-aware, threshold 1), Tracing JIT (records hot loop paths, threshold 50, deopts on type-guard miss)
- **Zero-clone dispatch** — ops borrowed from chunk, in-place array/hash mutation, `Cow<str>` string coercion
- **Lean foundational dependencies** — pure Rust, no unsafe in the core; runtime deps are durable, widely-vetted crates (`serde`, `tracing`, `glob`, `chrono`); Cranelift JIT and `libc` disk-cache are opt-in feature flags

---

## [0x01] INSTALL

```sh
cargo add fusevm
# or from source
git clone https://github.com/MenkeTechnologies/fusevm && cd fusevm && cargo build
```

**Cargo features:**

| Feature | Effect |
|---------|--------|
| `jit` | Cranelift-backed native JIT (linear, block, and tracing tiers). |
| `jit-disk-cache` | Persists compiled native code to `~/.cache/fusevm-jit` so codegen is skipped across process restarts. Implies `jit`; on by default once enabled (see [JIT Compilation](#0x07-jit-compilation)). |

---

## [0x02] USAGE

```rust
use fusevm::{Op, ChunkBuilder, VM, VMResult, Value};

let mut b = ChunkBuilder::new();
b.emit(Op::LoadInt(40), 1);
b.emit(Op::LoadInt(2), 1);
b.emit(Op::Add, 1);

let mut vm = VM::new(b.build());
// Optional: enable tracing JIT — hot loops will be recorded and
// JIT-compiled at runtime. Requires `--features jit`.
#[cfg(feature = "jit")]
vm.enable_tracing_jit();

match vm.run() {
    VMResult::Ok(val) => println!("result: {}", val.to_str()),  // "42"
    VMResult::Error(e) => eprintln!("error: {}", e),
    VMResult::Halted => {}
}
```

---

## [0x03] ARCHITECTURE

```
                  ┌──────────────────────────────────┐
                  │         Language Frontend         │
                  │   (stryke, zshrs, or your own)    │
                  └──────────────┬───────────────────┘
                                 │ compile
                                 ▼
                  ┌──────────────────────────────────┐
                  │       ChunkBuilder::emit()       │
                  │   Op enum ──► Chunk (bytecodes)  │
                  └──────────────┬───────────────────┘
                                 │
                    ┌────────────┴────────────┐
                    ▼                         ▼
          ┌─────────────────┐     ┌─────────────────────┐
          │   VM::run()     │     │   JitCompiler       │
          │  match-dispatch │     │  Cranelift codegen   │
          │   interpreter   │     │  (eligible chunks)   │
          └─────────────────┘     └─────────────────────┘
```

### Execution tiers — one semantic source of truth

fusevm has four ways to execute a chunk, and they all agree on what every op *means* because they route through — or fall back to — a single function, `VM::exec_op` (`src/vm.rs`), documented in-source as *"the single source of truth for op semantics."*

| Tier | Entry | How it runs an op |
|------|-------|-------------------|
| **Interpreter** | `VM::run` (`src/vm.rs`) | Dispatch loop calls `exec_op(ops, ip, …)` per op; the returned `ExecFlow` says continue or terminate. |
| **Linear / Block / Tracing JIT** | `JitCompiler` (`src/jit.rs`) | Emits specialized Cranelift IR for the eligible integer/float/slot subset; anything ineligible **bails or deopts back to the interpreter** — i.e. back to `exec_op`. |
| **AOT** | `aot::compile_object` (`src/aot.rs`) | Native driver, one Cranelift block per op; unspecialized ops call `exec_op` through the `extern "C"` `fusevm_aot_exec_op` shim (`VM::aot_exec_op`). |

Consequences that fall out of the single-source design:

1. **Semantics never fork.** A new op is implemented once, in `exec_op`, and every tier inherits it — the JIT/AOT specialize *performance*, never *behavior*.
2. **Specialize the hot subset, lean on the interpreter for the tail.** JIT and AOT only lower the scalar (int/float/bool/slot) ops that pay off; string/array/hash/host ops stay in `exec_op`.
3. **Deopt is just "resume `exec_op` at this ip."** On a tracing-JIT guard miss, `materialize_deopt_frames` (`src/vm.rs`) rebuilds the value stack (`stack_buf` + per-entry `stack_kinds` so floats bit-cast back through `f64::from_bits`) and the inlined call frames (`return_ip` + slot values), so the interpreter picks up mid-loop with byte-identical state.

---

## [0x04] FUSED SUPERINSTRUCTIONS

The performance secret. The compiler detects hot patterns and emits single ops instead of multi-op sequences:

| Fused Op | Replaces | Effect |
|----------|----------|--------|
| `AccumSumLoop(sum, i, limit)` | `GetSlot + GetSlot + Add + SetSlot + PreInc + NumLt + JumpIfFalse` | Entire counted sum loop in one dispatch |
| `SlotIncLtIntJumpBack(slot, limit, target)` | `PreIncSlot + SlotLtIntJumpIfFalse` | Loop backedge in one dispatch |
| `ConcatConstLoop(const, s, i, limit)` | `LoadConst + ConcatAppendSlot + SlotIncLtIntJumpBack` | String append loop in one dispatch |
| `PushIntRangeLoop(arr, i, limit)` | `GetSlot + PushArray + ArrayLen + Pop + SlotIncLtIntJumpBack` | Array push loop in one dispatch |

Each fused op eliminates N-1 dispatch cycles, stack pushes, and branch mispredictions from the hot path.

---

## [0x05] OP CATEGORIES

235 opcodes across 22 sections in `src/op.rs`:

| Category | Count | Examples |
|----------|-------|---------|
| Constants & Stack | 12 | `Nop`, `LoadInt`, `LoadFloat`, `Pop`, `Dup`, `Swap` |
| Variables | 7 | `GetVar`, `SetVar`, `GetSlot`, `SetSlot`, `SlotArrayGet` |
| Arrays & Hashes | 20 | `ArrayPush`, `HashGet`, `MakeArray`, `HashKeys` |
| Arithmetic | 9 | `Add`, `Sub`, `Mul`, `Div`, `Pow` |
| String | 3 | `Concat`, `StringRepeat`, `StringLen` |
| Comparison | 14 | `NumEq`, `StrLt`, `Spaceship`, `StrCmp` |
| Logical / Bitwise | 10 | `RubyTruthy`, `LogNot`, `LogAnd`, `BitAnd`, `Shl`, `Shr` |
| Control Flow | 5 | `Jump`, `JumpIfFalse`, `JumpIfTrueKeep` |
| Functions / Scope | 5 | `Call`, `Return`, `PushFrame`, `PopFrame` |
| I/O | 3 | `Print`, `PrintLn`, `ReadLine` |
| Collections | 2 | `Range`, `RangeStep` |
| Higher-Order | 5 | `MapBlock`, `GrepBlock`, `SortBlock`, `ForEachBlock` |
| **Fused** | **11** | `AccumSumLoop`, `SlotIncLtIntJumpBack`, `ConcatConstLoop`, `PreIncSlot`, `PostIncSlot`, `PreDecSlot`, `PostDecSlot` |
| Builtins | 1 | `CallBuiltin(id, argc)` (140 IDs in `shell_builtins.rs`) |
| Shell Ops | 29 | `Exec`, `PipelineBegin`, `Redirect`, `Glob`, `TestFile`, `RegexMatch` |
| AWK Ops | 61 | `AwkFieldGet`, `AwkPrint`, `AwkStrtonum`, `AwkDivJit`, `AwkModJit`, `AwkGensub`, `AwkOrd`, `AwkChr`, `AwkMkbool`, `AwkIntdiv` |
| Float / Int Math | 26 | `SqrtFloat`, `Atan2Float`, `Log2Float`, `RoundFloat`, `GcdInt`, `LcmInt`, `TimeInt` |
| Cooperative Concurrency | 10 | `Go`, `ChanMake`, `ChanSend`, `ChanRecv`, `ChanRecvOk`, `ChanClose`, `Select`, `CallDynamic`, `MulModFloor`, `MulAddModFloor` |
| Extension | 2 | `Extended(u16, u8)`, `ExtendedWide(u16, usize)` |

---

## [0x06] EXTENSION MECHANISM

Language-specific opcodes use `Extended(u16, u8)` which dispatches through a handler table registered by the frontend:

```rust
let mut vm = VM::new(chunk);
vm.set_extension_handler(Box::new(|vm, id, arg| {
    match id {
        0 => { /* language-specific op 0 */ }
        1 => { /* language-specific op 1 */ }
        _ => {}
    }
}));
```

stryke registers ~450 extended ops. zshrs registers ~20. awkrs registers ~95. elisprs registers 10. vimlrs takes the other route — ~510 builtin IDs through `CallBuiltin` rather than extended ops. They don't conflict — each frontend owns its own ID space.

### Shell Host (0.10.0+)

Shell-specific runtime ops (`Glob`, `TildeExpand`, `BraceExpand`, `WordSplit`, `ExpandParam`, `CmdSubst`, `ProcessSubIn`/`Out`, `Redirect`, `HereDoc`, `HereString`, `PipelineBegin`/`Stage`/`End`, `SubshellBegin`/`End`, `TrapSet`/`TrapCheck`, `WithRedirectsBegin`/`End`, `CallFunction`, `StrMatch`, `RegexMatch`) dispatch through the `ShellHost` trait. The frontend (zshrs) provides a real implementation; without one, the VM uses minimal stubs that keep stack discipline correct.

```rust
use fusevm::{ShellHost, VM, Chunk, Value};

struct MyHost;
impl ShellHost for MyHost {
    fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> { /* … */ vec![] }
    fn tilde_expand(&mut self, s: &str) -> String { /* … */ s.into() }
    fn cmd_subst(&mut self, sub: &Chunk) -> String { /* run sub, capture stdout */ String::new() }
    // … other methods have default impls
}

let mut vm = VM::new(chunk);
vm.set_shell_host(Box::new(MyHost));
```

Sub-execution (cmd substitution, process substitution, trap handlers) is delivered to the host as `&Chunk` references taken from the parent's `sub_chunks` table. Build them with `ChunkBuilder::add_sub_chunk(sub) -> u16` and reference by index in `Op::CmdSubst(idx)`, `Op::ProcessSubIn(idx)`, `Op::ProcessSubOut(idx)`, `Op::TrapSet(idx)`.

### AWK Host (0.13.0+)

The 61 first-class `Op::Awk*` variants dispatch through the `AwkHost` trait. AWK's data model (numeric-string duality, `CONVFMT`/`OFMT` coercion, `$0`/`$n`/`NF` field coupling, `SUBSEP` arrays, regex, `getline`/`printf` IO) lives in the frontend (awkrs), so most AWK ops require a registered host; without one they stay inert but stack-balanced.

Twenty-nine builtins are the exception — they execute natively **even with no host registered**. Most are pure on `fusevm::Value`; `rand`/`srand` run against a VM-owned PRNG seed (execution-intrinsic state, reset with the VM); `strftime`/`mktime` read the system timezone but need no AWK runtime state:

- **Strings:** `substr`, `index`, `tolower`, `toupper`, scalar `length(s)`.
- **Characters (gawk):** `ord` (first char → codepoint), `chr` (codepoint → char, empty if invalid).
- **Math:** `int`, `sqrt`, `sin`, `cos`, `exp`, `log`, `atan2` (pure `f64`), `intdiv` (truncating integer quotient; `Undef` on divide-by-zero), `intdiv0` (same, but `0` on divide-by-zero), `mkbool` (`1`/`0` by truthiness).
- **Bitwise (gawk):** `and`, `or`, `xor`, `compl`, `lshift`, `rshift` (operands truncated to integers).
- **Conversion (gawk):** `strtonum` (`0x…` hex, `0…` octal, else longest decimal/float prefix).
- **Time (gawk):** `systime`, `strftime`, `mktime` (`chrono`-backed; local-tz and UTC paths).
- **PRNG (POSIX/gawk):** `rand`, `srand` (glibc LCG over a VM-owned seed initialized to 1; deterministic without a host).
- **Arithmetic (POSIX awk):** `AwkDiv` (`a / b`), `AwkMod` (`a % b`) — float divide/modulo that raise a fatal `"division by zero attempted"` / `"division by zero attempted in \`%'"` runtime error on a zero divisor (vs the shell-arithmetic `Op::Div`/`Op::Mod`, which yield `Undef`/`0`). Host-independent; interpreter-only (not block/trace-JIT-eligible, since they conditionally trap). `AwkDivJit` / `AwkModJit` are block-JIT-eligible variants with byte-identical interpreter semantics: the block JIT emits a **guarded early-exit** (compare the divisor to `0.0`; on equality call the `fusevm_jit_awk_div_trap` libcall with a code — `1` div / `2` mod — and `return` a sentinel, else `fdiv`/`fmod`). The VM's block-dispatch path reads the trap channel after the compiled run and converts a set code into the same fatal error the interpreter raises, so a JIT-compiled `for(;;) x = 1/0` traps instead of producing `inf`/`NaN` or hanging. The trap libcall is not a registered host-helper id, so `AwkDivJit`/`AwkModJit` chunks skip on-disk cache persistence (in-process JIT only) and never touch the shared cache schema — zshrs/stryke (which emit only `Op::Div`/`Op::Mod`) get byte-identical native code.

**AWK control flow** has no `fusevm::Value` representation (`next`/`nextfile`/`exit` are statements, not expressions). `Op::AwkSignal(code)` carries it host-free: it halts the current chunk and stashes `code` (`awk_builtins::signal::{NEXT, NEXTFILE, EXIT}`) in the VM, which the frontend driver reads via `VM::awk_signal()` after `run()` to drive its own record/file/exit flow. zshrs/stryke never emit it, so `awk_signal()` stays `None` for them and `Halted` is byte-identical to before — the channel is a VM-state side effect, not a new `VMResult` variant. Interpreter-only.

```rust
use fusevm::{VM, ChunkBuilder, Op, Value};

let mut b = ChunkBuilder::new();
let s = b.add_constant(Value::str("hello"));
b.emit(Op::LoadConst(s), 1);
b.emit(Op::LoadInt(2), 1);
b.emit(Op::LoadInt(3), 1);
b.emit(Op::AwkSubstr(3), 1);          // substr("hello", 2, 3)
let mut vm = VM::new(b.build());      // no set_awk_host needed
// vm.run() → "ell"
```

A registered host may still override these (e.g. locale-aware casing, MPFR-precision math, or gawk's fatal-error on negative bitwise operands); the native path is used only when no host is present. `length($0)` and `length(arr)` always need the host (field/array state). `rand`/`srand` also need the host (RNG seed state).

---

## [0x07] JIT COMPILATION

The `JitCompiler` compiles eligible chunks to native code via Cranelift 0.130. Enable with `cargo add fusevm --features jit`.

```rust
use fusevm::{JitCompiler, ChunkBuilder, Op, Value};

let mut b = ChunkBuilder::new();
b.emit(Op::LoadInt(40), 1);
b.emit(Op::LoadInt(2), 1);
b.emit(Op::Add, 1);
let chunk = b.build();

let jit = JitCompiler::new();
if jit.is_linear_eligible(&chunk) {
    // Compiles to native x86-64/aarch64, caches, and runs
    let result = jit.try_run_linear(&chunk, &[]);  // Some(Int(42))
}
```

### Linear JIT — eligible ops

| Category | JIT'd Ops |
|----------|-----------|
| Constants | `LoadInt`, `LoadFloat`, `LoadConst` (int/float), `LoadTrue`, `LoadFalse` |
| Arithmetic | `Add`, `Sub`, `Mul`, `Div`, `Mod`, `Pow`, `Negate`, `Inc`, `Dec` |
| Comparison | `NumEq`/`Ne`/`Lt`/`Gt`/`Le`/`Ge`, `Spaceship` |
| Bitwise | `BitAnd`/`Or`/`Xor`/`Not`, `Shl`, `Shr` |
| Logic | `LogNot` |
| Stack | `Pop`, `Dup`, `Swap`, `Rot` |
| Slots | `GetSlot`, `SetSlot`, `PreIncSlot`, `PreIncSlotVoid`, `AddAssignSlotVoid` |

Int/float promotion: when either operand is float, both are promoted to `f64`. Cranelift emits `iadd`/`fadd`/`fcvt_from_sint` as needed. Runtime helpers for `Pow` (wrapping integer + `f64::powf`) and `Mod` (float `fmod`).

### JIT tier ladder

fusevm runs three JIT tiers in increasing order of optimization power and compile cost. A given chunk can be served by exactly one tier — they cover disjoint cases:

| Tier | Trigger | Coverage | Speculation |
|------|---------|----------|-------------|
| **Linear** | `is_linear_eligible` + first call | Straight-line expression chunks; returns `Value` (int or float) | None — IR matches bytecode exactly |
| **Block** | `is_block_eligible` + 1 invocation | Whole-chunk CFG (loops, branches, fused backedges) | None — slot ops assume i64 |
| **Tracing** | 50 backedges through any loop header | Hot path through anything; recorded loop body compiled with type-specialized IR | Slot-type entry guard; deopt to interpreter on guard miss |

#### Tuning warmup for re-run-heavy workloads

The block (default **1**) and tracing (default **50**) warmup thresholds are how many times a chunk must run before that tier compiles it. They are tunable two ways:

- **Per process, no recompile** — set environment variables (great for a shell rc when you re-run the same scripts constantly):

  ```sh
  export FUSEVM_JIT_BLOCK_THRESHOLD=0   # block-JIT the whole chunk on its FIRST run (max eager)
  export FUSEVM_JIT_TRACE_THRESHOLD=10  # arm hot-loop traces sooner
  ```

  These are read once per thread when the JIT is first touched, applied on top of the compiled defaults.

- **Per thread, programmatically** — via `TraceJitConfig` (`block_threshold` / `trace_threshold`) and `JitCompiler::set_config`.

For workloads that run the same scripts over and over, combine a low warmup with the **`jit-disk-cache`** feature (on by default): the warmup decides *when* a tier engages, and the disk cache makes the resulting native code free to reload on the next run — so you get AOT-like speed without explicitly AOT-compiling. Setting `FUSEVM_JIT_BLOCK_THRESHOLD=0` is the most aggressive: every block-eligible chunk is compiled to native on its first invocation and reloaded from `~/.cache/fusevm-jit` on subsequent runs. The trade-off is a one-time codegen cost the very first time a chunk is ever seen (paid once, then cached), so raise the thresholds again for scripts that genuinely run only once.

Tracing JIT is opt-in per VM (`vm.enable_tracing_jit()`). The recorder anchors at backward branches, captures the executed op sequence on the next iteration through the header, and installs a compiled trace that runs the loop body in native code until the loop's exit condition becomes false. Slot type changes between invocations cause the entry guard to refuse the trace; after 5 such guard mismatches the trace is blacklisted and never retried.

**Cross-call inlining (phase 2).** `Op::Call` to a sub-entry resolves to the callee's bytecode IP at recording time, and the callee body inlines into the trace IR. Each inlined frame gets its own slot-variable scope (caller slots eagerly promoted from the slot pointer; callee slots lazily allocated zero-initialized). `Op::Return` and `Op::ReturnValue` truncate the abstract stack to the frame's entry mark, mirroring interpreter semantics. Args travel via the value stack — no movement to slots is required.

**Caller-frame internal branches with side-exits (phase 3).** Loops with `if`/`else` bodies are now traceable. The recorder captures the executed direction at each conditional jump (via parallel `recorded_ips`), and the compiler emits a `brif` guard at every internal branch: the runtime condition must match the recorded direction, otherwise control transfers to a per-branch side-exit block that spills the caller's slot variables and returns the un-recorded direction's IP for the interpreter to resume from.

**Callee-frame branches with frame materialization on deopt (phase 4).** Branches are now allowed inside inlined callees, not just the caller frame. When a side-exit fires from inside an inlined callee, the trace populates a `DeoptInfo` out-parameter the VM uses to materialize synthetic `Frame`s on `vm.frames` — each with its `return_ip` pointing back to the post-`Op::Call` IP in the parent, and slot values copied from the trace's per-frame Cranelift Variables. The interpreter then resumes mid-callee with a correctly shaped call stack; when the callee eventually hits `Op::Return`, the synthetic frame is popped and execution continues in the parent. Bounds: max 4 inlined frames at any side-exit, max 16 slot indices per inlined frame.

**Value-stack reconstruction on deopt (phase 5).** The "abstract stack empty at branch" restriction is lifted: branches can fire while the trace's abstract stack still holds intermediate values. At side-exit, those values are written into `DeoptInfo.stack_buf` (capacity 32) and the VM pushes them onto `vm.stack` so the interpreter resumes with the same stack state the bytecode would have at the deopt IP. Phase 5b adds a parallel `stack_kinds` tag array so Float entries get bit-cast through `f64::from_bits` and materialized as `Value::Float` (not just `Value::Int`). This unlocks short-circuit `&&`/`||` patterns and any branch where intermediate float/int computations live on the value stack.

**Side-exit deopt counter + auto-blacklist (phase 6).** Each compiled trace's `TraceCacheEntry` tracks a `side_exit_count` distinct from the entry-guard `deopt_count`. When a brif guard inside the trace fires (the trace returns a resume IP that isn't the loop fallthrough), the counter increments; after `MAX_SIDE_EXITS` (50) misses the trace is blacklisted and never retried. This avoids the pathological case where the recorded path doesn't match runtime and every iteration pays trace+deopt+interpret cost. Note: full side-trace stitching — recording from the side-exit IP and linking the new trace into the main one — is deferred (it's substantial work on its own).

**Persistent trace metadata (phase 7).** `TraceMetadata` is a serde-serializable struct (chunk hash, anchor IP, fallthrough IP, op sequence, recorded IPs, slot-kind snapshot). `JitCompiler::trace_export` extracts it from a compiled-trace cache entry; `trace_import` re-installs it on a fresh `JitCompiler` after verifying `chunk_op_hash` still matches. Persistence format is intentionally caller-owned — fusevm doesn't ship a file layout, so users can pick JSON, bincode, sqlite, or anything else with serde support.

**Bounded recursion inlining (phase 8).** The recorder's hard-no on self-recursive calls is relaxed to a depth cap (`MAX_INLINE_RECURSION` = 4 levels). A self-call up to that depth is inlined like any other Call; deeper recursion aborts the trace and the interpreter handles it. Combined with phase 4's frame materialization, this enables tracing of tail-recursive helpers up to the cap.

**Side-trace stitching (phase 9).** When a main trace's side-exit fires repeatedly at the same IP, the recorder rearms at that IP and records a *side trace*: the bytecode path from the side-exit forward to the loop's backward branch. `TraceRecorder` splits its anchor into `record_anchor_ip` (cache key — the side-exit IP) and `close_anchor_ip` (the enclosing loop's header where the closing branch lands). Side traces compile via `trace_install_with_kind` and don't loop in their own IR — both directions of the closing branch exit, returning either the close target (so the main trace runs the next iteration) or the loop's fallthrough IP (loop done). The VM's chained-dispatch path runs after each main-trace deopt: if a side trace is registered at the resume IP, dispatch it; otherwise bump the main trace's `side_exit_count` toward auto-blacklist. Chains are bounded by `MAX_TRACE_CHAIN` (4) per backward-branch hop. Phase 6's blacklist counter is reserved for cases where no side trace is helping — productive deopts don't penalize the main trace. Side traces use the same eligibility rules as main traces and don't recursively spawn further side traces from their own deopts (their side-exits still bump the main trace's blacklist counter).

**Loops over globals (phase 10).** The tracing tier no longer bails when a loop
body touches a global. A trace records the globals it referenced and promotes
them to native registers for the duration of the loop, spilling back on exit or
deopt. The entry guard is extended with a per-referenced-index check — whether
each referenced global is still a number — rather than the whole-frame check
used for slots, so an unrelated global changing type does not invalidate the
trace. `TraceMetadata` carries a `global_kinds_at_anchor` fingerprint beside the
existing slot fingerprint; it is `serde(default)`, so a trace persisted by a
build that predates global support still deserializes (an empty vector says
exactly what it means — that trace referenced no global). An imported trace
re-checks the live global table before it runs rather than trusting the
persisted kinds.

**Persistent native-code disk cache (`jit-disk-cache`).** Enable with `cargo add fusevm --features jit-disk-cache` to cache compiled **native code** to disk, skipping Cranelift codegen across process restarts — a big win for workloads that re-launch the VM repeatedly (e.g. running a large test suite over and over). The cache covers **all three tiers** (linear, block, tracing) and is **on by default once the feature is enabled**, writing to `~/.cache/fusevm-jit`. Override the directory with the `FUSEVM_JIT_CACHE_DIR` env var or `JitCompiler::set_jit_cache_dir(Some(dir))`; disable at runtime with `FUSEVM_JIT_CACHE_DIR=off` or `set_jit_cache_dir(None)`.

Cache files are tier-tagged (`.lin.` / `.blk.` / `.trc.`) and keyed by the chunk's op-hash (the tracing tier additionally keys on the record-anchor IP and verifies a content hash over the recorded ops, IPs, slot types, and constants, so divergent recorded paths never collide). Blobs store the native code plus a small relocation table re-patched on load; loading mmaps the code with W^X handling (`pthread_jit_write_protect_np` + icache invalidation on Apple Silicon, `mprotect` elsewhere). Writes publish via a unique temp file + atomic rename, so the cache is safe under many concurrent processes. The loader is **conservative**: any chunk whose code carries a relocation other than a known host-helper call falls back to the in-memory JIT, so an untested target degrades to "no caching" rather than miscompiling. The cache is **behavior-transparent** — it only eliminates Cranelift codegen time; tier selection, warmup thresholds, and results are identical to an uncached run. Benchmark (`cargo bench --features jit-disk-cache --bench jit_disk_cache`): a cached block load is ~35µs versus ~152µs for cold codegen.

**Size control.** Each blob is small — roughly 100 bytes for a linear chunk, up to a few KB for block/trace — and the cache writes one blob per unique JITable segment per script version, so it grows slowly but is never *automatically* trimmed by op-hash (an edited script produces new hashes; the old blobs linger). To keep it bounded there's a **total-size cap, default 256 MiB**, enforced by **oldest-first (mtime) eviction** down to 80% of the cap, applied opportunistically as new blobs are written (so no scan cost on most writes). Controls:

| Knob | Effect |
|------|--------|
| `FUSEVM_JIT_CACHE_MAX_BYTES` | Cap as bytes or with a `k`/`m`/`g` suffix (e.g. `512m`, `2g`). `0`/`off`/`unlimited` disables eviction. Overridden by the programmatic setter. |
| `JitCompiler::set_jit_cache_max_bytes(Some(n))` | Same cap programmatically; `Some(0)` = unlimited, `None` = restore env/default resolution. |
| `JitCompiler::jit_cache_size_bytes()` | Current total cache size in bytes (`None` if disabled). |
| `JitCompiler::prune_jit_cache()` | Force an immediate eviction pass against the cap; returns bytes freed. |
| `JitCompiler::clear_jit_cache()` | Delete every blob (repopulates lazily next run); returns files removed. |
| `rm -rf ~/.cache/fusevm-jit` | Manual nuke. |

The cached blob's fingerprint hashes the crate version, so a cache written by
an older fusevm is never loaded by a newer one — a lowering fix can't be
resurrected from a warm cache.

### The interpreter is the specification

A chunk can run interpreted or native depending only on how hot it has become,
so **every tier must produce the same answer as the interpreter, including the
numeric type**. Anything else is a wrong answer whose value depends on JIT
warmup state — the same chunk giving different results on row 1 and row 2 of
the same input.

Two ops make this non-obvious, because they are float-only in the interpreter
where a native lowering would "naturally" be integer:

| Op | Contract | Notes |
|---|---|---|
| `Div` | **Always `Float`**, and `Undef` when the divisor is zero | `1 / 2` is `Float(0.5)`, never `Int(0)`. `-0.0` counts as a zero divisor (the test is `b.to_float() == 0.0`). |
| `Pow` | **Always `Float`** | `2 ** -1` is `Float(0.5)`, never `Int(0)`. |

Native code has no `Undef`, so the tiers handle the zero divisor differently
by necessity: AOT emits `fdiv` and deopts to the interpreter on zero, while
the linear/block/tracing tiers have no mid-chunk deopt and instead decline to
compile a `Div` whose divisor is not a provably nonzero constant. `Mod` is
declined the same way unless its divisor is a constant with `|k| >= 2` or a
float, because native `srem` traps on a zero divisor and on `i64::MIN % -1`
where the interpreter answers `0`.

`tests/jit_block.rs` pins tier agreement for these directly: each case runs
with `block_threshold` at `u32::MAX` (interpreter) and `0` (native) and
requires the two results to match.

`Value::Bool` is the other kind native code cannot hold, and each tier answers
it differently by design:

- **Linear** returns a `Value`, so it reproduces the interpreter exactly: the
  boolean rides the integer return register as 0/1 and is boxed back to
  `Bool` from the abstract simulation's result kind. A chunk that *consumes* a
  boolean numerically declines instead, because `Value::Bool` is not a native
  number — `arith_int_fast` routes it through `to_float`, so `true + 1` is
  `Float(2.0)`, not `Int(2)`, and native code has no way to produce that from
  an integer register.
- **AOT** carries `Kind::Bool` through its own lattice and boxes the same way.
- **Block and tracing** have no boolean kind at all: `JitTy` and the public
  `BlockNum` are both Int-or-Float, so they **decline** instead. A boolean may
  only be produced when the very next op consumes it as a truth value
  (`JumpIfTrue`/`JumpIfFalse`) or discards it (`Pop`); anything else — storing
  it to a slot, feeding it to arithmetic, or leaving it as the chunk's result —
  makes the chunk ineligible and the interpreter runs it. Declining is always a
  correct answer, so the hot loop shape (`GetSlot`, `LoadInt`, `NumLt`,
  `JumpIfTrue`) is unaffected while the divergent shapes fall back.

  Before that rule, `VM::run` boxed `BlockNum::Int(n)` as `Value::Int(n)` before
  any frontend saw it, so a boolean-valued chunk changed variant on its second
  run, once the block cache went warm:

  | chunk | 1st call (interpreter) | 2nd call (block JIT), before the fix |
  |---|---|---|
  | `1 < 2` | `Bool(true)` | `Int(1)` |
  | `1 > 2` | `Bool(false)` | `Int(0)` |
  | `LoadFalse` | `Bool(false)` | `Int(0)` |
  | `true + 1` | `Float(2.0)` | `Int(2)` |

  The difference is observable: `Bool(false).to_str()` is `""` where
  `Int(0).to_str()` is `"0"`, and arithmetic on a `Bool` coerces through
  `to_float`. In the tracing tier it was worse than a variant error — a boolean
  stored into a float-kinded slot was written to the raw `i64` slot buffer and
  read back as an `f64` *bit pattern*, so `0 + true` answered `Float(5e-324)`
  (the bits of `1`) and `0 - true` answered `NaN` (the bits of `-1`) where the
  interpreter answers `Float(1.0)` and `Float(-1.0)`.

  Widening the lattice — a `JitTy::Bool` through the whole block/tracing
  pipeline plus a `BlockNum` variant — would let these chunks compile instead of
  declining, and remains open.

`tests/tier_matrix_diff.rs` pins all of it: every native-lowerable op crossed
with the operand edges — `0`, `-1`, `i64::MIN`, `2^53+1`, `-0.0`, `±1e30`,
booleans — run through the interpreter, the linear JIT, the block JIT, the
tracing JIT, and the AOT compiler, compared on the `Value` variant and on raw
float bits.

A tier that declines a case is skipped, never scored as agreement — so a
harness whose corpus never reaches a tier would report a clean run it never
earned. That is not hypothetical: the tracing tier anchors only on a
**conditional backward branch**, and every chunk in this file was straight-line
until `diff_trace` wrapped each op in a hot do-while loop. The trace harness
therefore also asserts that at least one case actually compiled a trace, so it
fails loudly rather than passing vacuously if the loop shape ever stops
closing.

The
`±1e30` edge is what separates Cranelift's trapping `fcvt_to_sint` from the
saturating `fcvt_to_sint_sat`: `Inc`/`Dec` on a float operand used the former
and executed an illegal instruction where the interpreter returns
`Int(i64::MIN)` (`Value::to_int` is `f as i64`, which saturates) and the AOT
tier declines the operand outright. Nothing caught it because
`is_block_eligible_op` rejects `Inc`/`Dec` only under `strict_numeric()` — the
non-default path.

---

## [0x08] AHEAD-OF-TIME COMPILATION

The `aot` feature (`src/aot.rs`) compiles a whole `Chunk` to a native object
file via Cranelift's `ObjectModule`, then links it against the fusevm runtime
into a standalone executable — with no interpreter dispatch loop at run time.
It's a closed-world compiler shared by every frontend, so AOT lives here once
and each frontend's `--build` calls into it.

### Threaded-code baseline

The bytecode dispatch loop (`VM::run`) is replaced by a native function with one
Cranelift block per op. Each op block calls the per-op runtime step
(`VM::aot_exec_op`, reached through the `extern "C"` `fusevm_aot_exec_op` shim),
which runs that op via the same `VM::exec_op` the interpreter uses, and returns
the **next instruction index** (or `-1` to terminate). The native code branches
on that through a central `dispatch` block:

```text
entry → dispatch(0)
dispatch(ip): br_table ip → [block_0, …, block_{n-1}]  (default → ret)
block_i:      next = exec_op(vm, i);  if next < 0 → ret  else → dispatch(next)
ret:          finish(vm); return
```

Routing every op through `dispatch` (rather than static fall-through) keeps the
lowering uniform for data-dependent targets — `Op::Jump`, the `JumpIf*` family,
and intra-chunk `Op::Call`/`Op::Return`, whose target is only known at run time —
without the native code ever reading the `VM` struct layout. The interpreter
dispatch loop is gone; the *work* each op does is unchanged.

### Native op specialization

Layered on top of the threaded path, `build_entry` lowers chunks that are
scalar computations directly to native IR (no per-op shim call). `analyze_native`
runs an abstract interpretation over the operand stack — tracking int-vs-bool
`Kind`s, finding basic-block leaders, checking join consistency — and when a
region qualifies, `build_entry_native` emits one Cranelift block per leader with
the operand stack held in frontend `Variable`s (an `i64` and an `f64` per stack
position; the plan's `Kind`s say which is live). This covers:

- **Integer and float arithmetic/comparison**, including `int→float` promotion mirroring the interpreter, `Mod` (integer `srem` with trap-divisor guards, or an `fmod` libcall for floats), and `Pow`/`PowFloat` via a `powf` libcall.
- **Math intrinsics** — `Abs`/`Sqrt`/`Ceil`/`Floor`/`Trunc`/`Round` as single instructions; `Sin`/`Cos`/`Tan`/`Exp`/`Log`/`Atan2` via libcalls; `GcdInt`/`LcmInt` as internal Euclid loops; the awk scalar ops (`AwkDiv`/`AwkMod` and their JIT twins, `AwkSqrtJit`/`AwkLogJit` with warn-and-return-NaN on a negative argument).
- **Bitwise/shift**, `Inc`/`Dec`, booleans (`LoadTrue`/`LoadFalse`/`LogNot`/`LogAnd`/`LogOr`), three-way `Spaceship`, stack shuffles, native control flow (`Jump`/`JumpIf*`, including value-keeping `JumpIf*Keep`).
- **Integer slots and globals** (`GetVar`/`SetVar`/`DeclareVar`) held in SSA registers under a definite-assignment analysis, plus the fused hot-loop slot super-ops (`PreIncSlot`/…, `AddAssignSlotVoid`, `SlotIncLtIntJumpBack`, and `AccumSumLoop` — whose internal `while i < limit { sum += i; i += 1 }` becomes a real native loop).

A fully-scalar loop runs entirely in registers; only the final result is boxed
back into the VM.

### Inline/shim boundary

Chunks that mix scalar work with heap ops don't fall back wholesale. For **sink
ops** (`Print`/`PrintLn`) the native code spills the top register scalars onto
the boxed `vm.stack` (per `Kind`), runs the op via the shim, and continues — so a
hot numeric loop with embedded output stays native. For **source ops whose result
kind is statically known** (`AwkGetFieldNum`, always `Float`) it runs the op via
the shim then reloads the pushed value into a register with no type guard. Slots
and globals are typed by a chunk-wide inferred kind, so a float accumulator
(`sum += 0.5`) lowers to an `f64` register.

### Partial deopt (one-way exit to the interpreter)

Anything the native path can't handle at a given op — a string/array/hash/heap
op, a heap constant load, or an operand-type mismatch — becomes a **deopt point**:
the analysis lowers everything around it, stops propagating past it, and codegen
emits a deopt there. `emit_deopt` writes the **definitely-assigned**
register-cached slots/globals back to the VM (a merely *maybe*-assigned slot at a
deopt point forces a wholesale threaded fallback, since a register can't
distinguish a real `0` from `Undef`), spills the live operand stack, and calls
`fusevm_aot_resume` to hand the rest of the run to the interpreter at the deopt
ip. `Op::Div` uses this for its rare divide-by-zero (native `fdiv` on the common
path, deopt only on a zero divisor); `GetStatus` (`$?`) is lowered as a
statically-typed `Status` source. A chunk falls back to threaded wholesale only
for genuinely structural reasons (stack underflow, inconsistent kind join,
mixed-kind slot, non-numeric final result).

`build_entry` is generic over Cranelift's `Module`, so the in-memory JIT path
that validates the compiler (`run_chunk_native`) and the on-disk `ObjectModule`
path share identical codegen.

| API | Purpose |
|---|---|
| `aot::compile_object(&chunk, path)` | Emit a relocatable `.o` exporting `fusevm_aot_entry` plus the serialized chunk (`fusevm_aot_chunk_blob` / `…_len`). |
| `aot::run_chunk_native(&chunk, register)` | Compile in-process via Cranelift and run it — validates codegen end to end. |
| `aot::fusevm_aot_run_embedded()` | Runtime entry for a linked binary: rebuilds the VM from the embedded chunk, calls the frontend's `fusevm_aot_register_builtins`, runs the native entry, and maps the result to an exit code. |
| `aot::compile_program_object(…)` | **Per-chunk native lowering.** Lowers a whole program — the top-level chunk *and* every named sub-chunk — into one object, so each callable gets its own native symbol instead of only the entry point. |
| `aot::build_named(…)` | Emits one named native function for a single chunk. Generic over Cranelift's `Module`, so the in-memory and `ObjectModule` paths share codegen. The building block `compile_program_object` calls per chunk. |

**`Chunk::native_id`** (`u32`) tags a chunk with the native function lowered
for it, which is how a call site reaches a sibling chunk's compiled body
instead of falling back to the interpreter.

**Seeded param slots.** `Chunk::aot_seeded_slots` declares how many leading
frame slots (`0..aot_seeded_slots`) the *caller* fills before the run. The
native driver's entry guard treats exactly those as definitely-assigned
integer slots, so a chunk invoked with pre-filled parameters is still
natively lowerable — without the declaration those slots read as
possibly-undefined and the whole chunk would deopt to threaded dispatch.

Link the emitted object against a frontend runtime (which provides
`fusevm_aot_register_builtins`) to produce the standalone binary. On macOS the
link needs `-framework CoreFoundation`. The `staticlib` crate-type in
`Cargo.toml` builds `libfusevm.a` so the object can be linked against the
runtime.

---

## [0x09] VALUE REPRESENTATION

`Value` is a tagged enum with fast-path immediates:

| Variant | Representation | Size |
|---------|---------------|------|
| `Undef` | Tag only | 0 bytes payload |
| `Int(i64)` | Inline | 8 bytes |
| `Float(f64)` | Inline | 8 bytes |
| `Bool(bool)` | Inline | 1 byte |
| `Str(Arc<String>)` | Heap | pointer |
| `Array(Arc<Vec<Value>>)` | Heap, shared buffer + copy-on-write | pointer |
| `Hash(HashMap<String, Value>)` | Heap, in-place mutation | 7 words |
| `Status(i32)` | Inline | 4 bytes |
| `Ref(Box<Value>)` | Heap | pointer |
| `NativeFn(u16)` | Inline | 2 bytes |

String coercion returns `Cow<str>` via `as_str_cow()` — borrows the inner `Arc<String>` for `Str` variants, avoiding allocation on string comparisons, concatenation, hash key lookup, and I/O.

### Arrays are shared on read, copied on write

`Array` holds an `Arc<Vec<Value>>`, so cloning a `Value` — which happens on every stack push, slot read, and global read — is a refcount bump, not a copy of the sequence. Arrays remain **values**, not references: every mutation goes through `Value::array_mut` (`Arc::make_mut`), which copies the buffer first when another `Value` still holds it, so a copy taken before a write never observes that write.

Construct arrays with `Value::array(vec)`; read with `as_array()`, mutate with `array_mut()`, and take ownership with `into_array()` (which skips the copy when it is the sole owner).

This is what keeps per-element iteration linear. A frontend that lowers `seq[i]` as "load the collection, then index it" used to pay one full copy of the sequence per iteration — O(n) per step, O(n²) overall. Measured on a 16,000-element indexed read loop, the interpreter went from 1.5330 s to 0.0016 s, and the growth from ~4x per doubling to ~2x.

The serde encoding is unaffected: serde's `rc` feature encodes `Arc<T>` exactly as `T`, so the bincode/JSON bytes for a `Value` — and therefore for a `Chunk` in a frontend's on-disk bytecode cache — are unchanged. `tests/array_hash_ops.rs` pins those bytes.

Array and hash mutations (`ArrayPush`, `ArrayPop`, `ArrayShift`, `ArraySet`, `SlotArraySet`, `HashSet`, `HashDelete`) operate in place — no clone-modify-writeback cycle. Read-only access (`ArrayGet`, `SlotArrayGet`, `ArrayLen`, `HashGet`, `HashExists`, `HashKeys`, `HashValues`) borrows directly from the globals vector or the frame's slots.

---

## [0x0A] BENCHMARKS

All benchmarks run via [criterion](https://crates.io/crates/criterion) on Apple M-series. `cargo bench` for all, `cargo bench --features jit --bench jit_vs_interp` for JIT comparisons. HTML report at `target/criterion/report/index.html`.

### Classic algorithms

| Benchmark | Time | Ops/sec |
|-----------|------|---------|
| `fib_iterative(35)` | 2.7 µs | 374k |
| `fib_recursive(20)` — 21,891 calls | 1.28 ms | 783 |
| `ackermann(3,4)` — 10,547 calls | 774 µs | 1.3k |
| `sum(1..1M)` fused `AccumSumLoop` | 142 ns | 7.0M |
| `sum(1..1M)` unfused loop ops | 31.0 ms | 32 |
| `nested_loop(100×100)` | 352 µs | 2.8k |
| `dispatch_nop_1M` — raw dispatch overhead | 819 µs | **1.22 Gops/sec** |
| `string_build(10k)` via `ConcatConstLoop` | 11.9 µs | 84k |

### Interpreter vs Cranelift JIT vs native Rust

Slot-based inputs prevent constant folding — honest apples-to-apples comparison:

| Workload | Interpreter | JIT (cached) | Native Rust | JIT vs interp | JIT vs native |
|----------|-------------|--------------|-------------|---------------|---------------|
| `slot_mixed × 100` | 2.2 µs | **75 ns** | 42 ns | **29x faster** | 1.8x slower |
| `slot_bitwise × 200` | 6.6 µs | **130 ns** | 74 ns | **51x faster** | 1.8x slower |
| `slot_float × 200` | 3.1 µs | **246 ns** | 137 ns | **13x faster** | 1.8x slower |

JIT cache lookup is O(1) — chunk hash precomputed at build time (24ns overhead). The linear JIT is consistently ~1.8x slower than LLVM `-O3` on real computation and 13–51x faster than the interpreter.

### Block JIT — loops and branches compiled to native code

The block JIT handles real control flow — loops, conditionals, fused backedges:

| Benchmark | Interpreter | Block JIT | Speedup |
|-----------|-------------|-----------|---------|
| `sum(1..1M)` unfused loop | 30.0 ms | **315 µs** | **95x** |
| `nested_loop(100×100)` | 340 µs | **9.5 µs** | **36x** |

The block JIT compiles the full CFG to native code via Cranelift. All mutable state flows through the slots pointer (`*mut i64`), and `AccumSumLoop` is register-allocated with block parameters — no memory traffic in the inner loop.

**Float slots (`SlotKind::Float`).** Slots are promoted to Cranelift `i64` variables holding raw bits. When a slot's kind is `Float`, the `i64` *is* the `f64` bit pattern: `GetSlot` bitcasts `I64 → F64` (and integer operands are converted with `fcvt_from_sint` before float arithmetic), `SetSlot` bitcasts `F64 → I64`. Pass slot kinds via `try_run_block_kinded` / `try_run_block_eager_kinded`; the kind vector is folded into the native-code cache key (TLS and the on-disk `*.blk.fjit` blob) so float-specialized code is never reused for an integer slot or vice-versa. The default `try_run_block` / `try_run_block_eager` (no kinds) treat every slot as `Int`. Storing a **float** into a slot the caller declared `Int` has no `i64` encoding, so the block tier now **declines** the chunk rather than truncating: `x = 3.5` into a slot that entered the chunk holding an integer used to read back as `Int(3)` once the block cache warmed (the interpreter answers `Float(3.5)` every time), and `x = 1e30` reached Cranelift's *trapping* `fcvt_to_sint` and killed the process with SIGILL. Declare the slot `SlotKind::Float` and the value round-trips exactly. This is what lets a frontend block-JIT-compile `f64` AWK numeric chunks (e.g. `x = trunc(x + c)`, lowered through `Op::TruncFloat`) and persist them to the shared on-disk cache. Integer-only fused superinstructions (`PreIncSlot`, `AccumSumLoop`, `SlotIncLtIntJumpBack`, …) bail to the interpreter on a `Float` slot rather than miscompute it.

**AWK math ops in the JIT.** `Op::AwkInt` is **never** compiled natively, in
any tier. It is host-dispatched — `VM::run` sends it to `AwkHost::int`, an
overridable trait method — and the implementations in use disagree on the
result *variant*: the default `awk_host::awk_int` narrows to `Value::Int` when
the truncation fits an `i64` (`int(3.7)` is `Int(3)`), while a frontend that
models every AWK number as an `f64` answers `Float(3.0)`. Cranelift code sees
neither, so any fixed lowering is a silent wrong answer for some host. Because
the block tier warms up, the kind-preserving `trunc` it used to emit made the
*same* `VM` running the *same* `Chunk` answer `Int(3)` on the first call and
`Float(3.0)` on every call after. Past `2^53` there was a second, independent
error: `awk_host::awk_int` truncates through an `f64`, so `int(2^53 + 1)` is
`Int(9007199254740992)`, while native code that never left an integer register
answered `Int(9007199254740993)`. A frontend that wants awk's `int()` to run in
native code should emit `Op::TruncFloat`, whose contract is pure
(`Float(trunc(x))` for every operand, no host consulted) and identical in all
four tiers. The general rule: an op that reaches `AwkHost` may be lowered
natively only if every conforming host produces exactly the value the lowering
does. The transcendentals `Op::AwkSin` / `AwkCos` / `AwkExp` / `AwkAtan2` compile to Cranelift libcalls into small `extern "C"` Rust helpers (`fusevm_jit_sin_f64`, …) that canonicalize a NaN result to `+nan` to match gawk/awkrs. These follow the same `None`-guarded import pattern as the existing `pow`/`fmod`/`lognot` libcalls — the helper imports are declared only when the op appears in the chunk (`MathIds::declare`), so chunks without them compile to byte-identical native code. For the on-disk cache the helper relocations are keyed by stable host-helper ids (`H_SIN_F64`…`H_ATAN2_F64`), carried in the per-function `[Option<FuncId>; 8]` helper table and re-resolved on load via `host_addr` (cache `SCHEMA_VERSION` 16). The gawk bitwise builtins `Op::AwkAnd` / `AwkOr` / `AwkXor` (variadic, ≥2 args) also compile natively: each operand is converted to `i64` with a **saturating** `fcvt_to_sint_sat` (matching awkrs's `num_to_u64`, which truncates and saturates NaN→0 / ±inf→i64 bounds rather than trapping), folded with Cranelift `band`/`bor`/`bxor`, and pushed back as an integer. No libcall and no host needed — pure integer arithmetic — so they are admitted to `is_block_eligible_op` directly.

**Trapping div/mod in the JIT (guarded early-exit).** `Op::AwkDivJit` / `AwkModJit` are the block-JIT-eligible counterparts of the interpreter-only `AwkDiv`/`AwkMod`. Float `fdiv`/`fmod` do not hardware-trap (they yield `inf`/`NaN`), so a JIT-compiled awk division must check the divisor explicitly: the codegen pops divisor then dividend, emits `fcmp eq divisor, 0.0`, and branches — the trap block calls the `fusevm_jit_awk_div_trap(code)` libcall (`code` = `1` for div, `2` for mod) into a thread-local channel and `return`s a sentinel, while the continuation block computes `fdiv` (div) or the `fmod` libcall (mod). After the compiled block returns, the VM's block-dispatch path calls `take_awk_div_trap()` and, if a code was set, raises the same fatal `"division by zero attempted"` / `…in \`%'` error the interpreter raises — *before* writing slots back. Because the trap libcall is not a registered host-helper id, these chunks skip on-disk persistence (in-process JIT only) and add nothing to the cache schema; frontends that never emit them (zshrs/stryke) are byte-identical.

### Tracing JIT — hot loop bodies compiled to native code

`cargo bench --features jit --bench jit_trace` (Apple M-series). Trace recorded at threshold 5 (default 50 in production) so the cache is primed before measurement; all reported times are steady-state hot-path execution.

**Synergistic three-tier dispatch (phase 10).** When `enable_tracing_jit()` is called, `VM::run` consults all three Cranelift tiers in priority order: block JIT first if the chunk is fully eligible (zero VM-side overhead, direct fn-ptr through the slot pointer), tracing JIT for hot loops in chunks block JIT can't handle, interpreter for cold paths and edge cases. Block-eligible chunks short-circuit before tracing JIT records anything — the two tiers never compete on the same chunk.

| Benchmark | Iterations | Interpreter | Block JIT (direct) | Tracing-JIT VM | VM vs Interp | VM vs Block |
|---|---|---|---|---|---|---|
| `counter_loop` | 1,000 | 24.0 µs | 309 ns | **474 ns** | **51x** | 1.53x slower |
| `counter_loop` | 10,000 | 236.1 µs | 2.69 µs | **2.79 µs** | **84x** | 1.04x slower |
| `counter_loop` | 100,000 | 2,354 µs | 26.71 µs | **26.95 µs** | **87x** | 1.01x slower |
| `loop_with_branch` | 1,000 | 40.2 µs | 300 ns | **474 ns** | **85x** | 1.58x slower |
| `loop_with_branch` | 10,000 | 410.3 µs | 2.68 µs | **2.83 µs** | **145x** | 1.06x slower |
| `loop_with_branch` | 100,000 | 3,942 µs | 26.46 µs | **26.64 µs** | **148x** | 1.01x slower |

`counter_loop` is a tight `for i { i++ }` integer counter — about as friendly to a JIT as bytecode gets. `loop_with_branch` adds an internal `if i > 0 { ... }` inside the body to exercise the phase-3 branch-guard machinery; the recorded path's brif compares slot value to zero each iteration.

The "Block JIT (direct)" column measures `JitCompiler::try_run_block` invoked directly with no VM around it — the floor for what's achievable through the JIT pipeline. The "Tracing-JIT VM" column measures `VM::run()` with `enable_tracing_jit()` set on a block-eligible chunk; the VM auto-dispatches block JIT before reaching the interpreter. The remaining 1.0–1.7x gap between the two is purely VM construction + slot copy-in/out overhead per `vm.run()` call (constant, ~150-200 ns); native execution itself is identical.

For chunks that aren't block-eligible (anything with extension ops, host builtins, or polymorphic types), block JIT bows out and the same `VM::run` path falls through to the interpreter with tracing JIT's recorder armed at backward branches — that's where tracing JIT earns its keep, accelerating loops in code block JIT can't take. The two tiers cover disjoint cases at runtime.

### `VMPool` — VM reuse for callers running many small chunks

`VMPool` recycles `VM` instances so callers running many short-lived chunks (REPL, eval loops, batch evaluation) can skip the per-call `VM::new()` cost. `acquire` pops a recycled VM and resets its state via `VM::reset`; `release` returns it for reuse.

```rust
use fusevm::{ChunkBuilder, Op, VMPool, VMResult, Value};

let mut pool = VMPool::new();
for _ in 0..1000 {
    let mut b = ChunkBuilder::new();
    b.emit(Op::LoadInt(40), 1);
    b.emit(Op::LoadInt(2), 1);
    b.emit(Op::Add, 1);
    pool.with(b.build(), |vm| {
        assert!(matches!(vm.run(), VMResult::Ok(Value::Int(42))));
    });
}
```

**When the pool actually helps:** chunks where `VM::new()` cost dominates the run. Measured on a 3-op chunk (`LoadInt(40); LoadInt(2); Add`):

| Pattern | Time/call |
|---|---|
| `VM::new(chunk)` per call | 130 ns |
| `pool.acquire(chunk)` per call | 163 ns |

For tiny chunks the pool is *slower* — `reset` does more bookkeeping (drop the old chunk, clear globals, zero the deopt buffer) than `VM::new` skips. The pool wins for chunks where:
- Globals/name pool is large (>16 entries — reset's resize is amortized vs `vec![Value::Undef; n]`)
- Many slots get used (frame.slots Vec capacity is preserved across reuse)
- Tracing JIT runs (deopt buffer is already zeroed and cached eligibility carries over… well, doesn't, since chunk hash differs — gets recomputed)

Honest read: VMPool is useful for **multi-chunk evaluation loops with non-trivial chunk shapes**. For uniform tight loops, pure `VM::new` is fine. The API is shipped so callers can pick. ~10 LOC if your call site looks like `for chunk in ... { VM::new(chunk).run() }`.

**Frontend adoption.** All seventeen sibling frontends (strykelang, awkrs, zshrs, vimlrs, elisprs, rubylang, phplang, pythonrs, node-js, arb, rlang, javars, kotlinrs, scalars, groovyrs, go-rs, tclrs) drive `fusevm::VM` through bridge layers, NOT direct emit. The common pattern is: (1) frontend-side eligibility analysis (which subroutines / bodies / per-record rules can be lowered to fusevm ops at all), (2) op-vector → `fusevm::Chunk` translation cached behind frontend-owned `OnceCell` / `HashMap` so the 2-pass translation runs once per source program region, not per call, (3) `VMPool` on the frontend `Runtime` so `VM::reset(chunk)` recycles slot/stack/globals Vec capacities across invocations, (4) narrow writeback driven by a precomputed `Vec<u16>` of `Op::SetSlot` targets so only mutated slots get copied back to the frontend's runtime. The on-disk JIT cache (keyed by op-hash) handles compiled-code persistence; the per-frontend in-process caches above handle the upstream chunk-build and runtime-setup costs the disk cache can't touch. strykelang adds `STK_VAL_LOAD_CONST` to make LoadConst-bearing chunks disk-cache safe (index-based, not per-process pointer).

### Tracking improvements

```sh
cargo bench --bench vm_bench -- --save-baseline before   # save baseline
# ... make changes ...
cargo bench --bench vm_bench -- --baseline before        # compare
open target/criterion/report/index.html                  # HTML graphs
```

---

## [0x0B] WEBASSEMBLY / WEB WORKER

fusevm compiles to `wasm32-unknown-unknown` so a frontend (stryke/zshrs/awkrs)
can parse source to a `Chunk` and run `VM::run()` inside a browser web worker.
The worker build is the **default, feature-less** build:

```sh
cargo build --target wasm32-unknown-unknown          # interpreter only
```

The `jit`, `aot`, `jit-disk-cache`, and `ffi` features must stay **off** on
wasm: they pull in Cranelift, `libc` (dlopen / executable mmap), and a runtime
`rustc` shell-out, none of which target wasm. The interpreter is the whole
worker runtime.

### Clock

`std::time::SystemTime::now()` panics on `wasm32-unknown-unknown` (no clock), so
the VM's clock ops (`Op::TimeInt`, awk `systime`/`srand`) read through
[`fusevm::sysclock`], which is `chrono`-backed. On wasm, `chrono` reads
`Date.now()` through the JS host (its `wasmbind` feature, active automatically
for wasm targets), so the clock works in a worker with no extra wiring.

### I/O bridging

wasm has no real stdout/stdin. Install an **output sink** and (optionally) an
**input source** on the `VM` so `Op::Print`/`Op::PrintLn`/`Op::ReadLine` route
to the JS host instead. With neither installed, output/input are byte-for-byte
the previous direct-stdio behaviour, so native frontends are unaffected.

```rust
use std::sync::{Arc, Mutex};
use fusevm::VM;

let captured = Arc::new(Mutex::new(String::new()));
let buf = Arc::clone(&captured);

let mut vm = VM::new(chunk);
// Send-safe: the sink writes to an Arc<Mutex<_>>, not a JS handle, so VM
// stays Send (usable from VMPool on native).
vm.set_output_sink(Box::new(move |s: &str| buf.lock().unwrap().push_str(s)));
// Pre-loaded stdin; returns None at end of input (ReadLine then pushes Undef).
let mut lines = vec!["line 2".into(), "line 1".into()];
vm.set_input_source(Box::new(move || lines.pop()));

vm.run();
// In a worker: postMessage(captured). See examples/wasm_worker_host.rs.
```

`examples/wasm_worker_host.rs` is a runnable, pure-Rust demonstration (no
`wasm-bindgen` dependency) that builds and runs on native and wasm alike:

```sh
cargo run   --example wasm_worker_host                              # prints "hello 42"
cargo build --example wasm_worker_host --target wasm32-unknown-unknown
```

### Frontend glue

fusevm is a library; it carries no `wasm-bindgen` dependency. The frontend crate
owns the `#[wasm_bindgen]` entry and the worker JS. A representative frontend
entry and `worker.js`:

```rust
// in the frontend crate (compiled to a cdylib for wasm)
#[wasm_bindgen]
pub fn run_source(src: &str) -> String {
    let chunk = my_frontend::compile(src);        // source -> fusevm::Chunk
    let captured = std::rc::Rc::new(...);          // or Arc<Mutex<String>>
    let mut vm = fusevm::VM::new(chunk);
    vm.set_output_sink(/* append to captured */);
    vm.run();
    captured_string                                // returned to JS
}
```

```js
// worker.js
import init, { run_source } from "./my_frontend.js";
await init();
self.onmessage = (e) => self.postMessage(run_source(e.data));
```

### Caveats

- **External commands.** `Op::Exec`/`ExecBg`/`CallFunction` have no process
  model in a worker. A host-less wasm build returns exit status `127`; a
  frontend overrides `ShellHost::exec` to bridge to the JS host if it needs to.
- **Interactive stdin.** The input-source hook covers pre-loaded input.
  *Blocking* interactive reads inside a worker require `SharedArrayBuffer` +
  `Atomics.wait` (which need cross-origin-isolation headers) and are the
  frontend's responsibility.
- **Filesystem/glob.** `ShellHost::glob` and awk file `getline` have no fs in a
  worker; override the relevant host methods to bridge as needed.

---

## [0x0C] COOPERATIVE CONCURRENCY

`sched::Scheduler` layers green-thread **goroutines and channels** over the
single-VM dispatch loop, so a frontend (e.g. Go) can express `go`, `make(chan)`,
`<-`, and `close` without a bespoke runtime.

Each goroutine is its own `VM` sharing the program `Chunk` and the frontend's
thread-local heap. Five interpreter-only ops raise a scheduling request in the
VM and halt the chunk — the same "op stashes a value, halts, driver reads it
after `run()`" pattern as `Op::AwkSignal`:

| Op | Meaning |
|---|---|
| `Go(name_idx, argc)` | spawn a goroutine running sub `name_idx` with `argc` args |
| `ChanMake` | allocate a channel (capacity popped); pushes its id |
| `ChanSend` | send the popped value on the popped channel (may block) |
| `ChanRecv` | receive from the popped channel, pushing the value (may block) |
| `ChanRecvOk` | two-value receive: pushes `[value, ok]`, `ok` on top (`0` = closed and drained) |
| `ChanClose` | close the popped channel |

The scheduler owns the channel table and a run queue, reads each request via
`VM::take_sched`, and resumes a VM by delivering results directly onto its stack
(the op has already advanced `ip`, so a resumed VM continues *past* the op — no
rewind, no re-execution). Channels follow CSP semantics: a buffered channel holds
up to `cap` values; an unbuffered channel hands a value straight from a blocked
sender to a blocked receiver. When every goroutine is blocked, the scheduler
reports a deadlock (`all goroutines are asleep`).

A receive on a closed, drained channel yields the frontend's zero value
(`Scheduler::with_recv_zero`), which `ChanRecv` alone cannot distinguish from a
zero a sender really sent. `ChanRecvOk` is the two-value form that can: it pushes
`[value, ok]` with `ok` on top — `1` for a real value, `0` once the channel is
closed and drained — which is what `for v := range ch` and `v, ok := <-ch` need
to terminate. Blocking is identical; a parked `ChanRecvOk` is delivered `ok = 1`
when a sender wakes it and `ok = 0` when a `close` does.

```rust
use fusevm::{Scheduler, VM};

// `chunk` emits Go/ChanMake/ChanSend/ChanRecv; `make_vm` builds a fresh,
// fully-configured VM per goroutine (same chunk + builtins + hooks).
let fc = chunk.clone();
Scheduler::new(move || VM::new(fc.clone())).run(VM::new(chunk))?;
```

The model is single-threaded and cooperative — goroutines yield only at channel
operations and completion — which is faithful for channel-driven programs
without the data races an OS-thread model would introduce over a thread-local
heap. Frontends that never emit these ops are wholly unaffected.

---

## [0x0D] HOST HOOKS & CHUNK INTROSPECTION

The VM hands a frontend three decision points it cannot express in bytecode:
what an unset read means, where an arithmetic op came from, and what a frame
slot is called. All three are opt-in — a frontend that installs nothing keeps
the previous behaviour byte for byte.

| API | Purpose |
|---|---|
| `VM::set_undef_hook(hook)` | **Strict-undef mode.** A read of an unset variable asks the host instead of yielding `Undef`. The hook takes an `UndefRead` and answers `Ok(Value)` to substitute a value or `Err(String)` to raise. |
| `VM::is_strict_undef()` | Whether an undef hook is installed. |
| `VM::set_sited_numeric_hook(hook)` | Like a `NumericHook`, but the callback receives a `NumericCall` carrying the *site* of the arithmetic. Wins over a plain `NumericHook` when both are set; either puts the VM in strict numeric mode. |
| `VM::frame_slot_names()` | The current frame's slot names. |
| `VM::slot_names_at(up)` | The same, `up` frames out from the current one. |
| `ChunkBuilder::set_sub_slot_names(entry_ip, names)` | Name a sub-chunk's frame slots at build time. |
| `Chunk::sub_slot_names_at(entry_ip)` | Read them back. |

`UndefRead` identifies the read precisely enough that a frontend can refuse
one and allow another: `name` (the interned name for a global, `None` for a
frame slot — a slot is addressed by index and carries no name), `index`,
`from_slot`, plus the owning chunk, because an `ip` alone does not identify a
site once a frontend compiles nested scripts (an `eval`, a lambda) that hold
more than one chunk.

Because a frame slot carries no name in the bytecode, the slot-name APIs are
what let a strict-undef frontend report `x is not defined` rather than
`slot 3 is not defined`.

### A full pool refuses instead of aliasing

The constant and name pools are `u16`-indexed. `add_constant` / `add_name`
cannot report exhaustion, so a frontend that overflows a pool would silently
get an index that aliases an existing entry — a wrong-constant bug at run time
with no diagnostic. The fallible forms return `None` instead:

```rust
let Some(idx) = b.try_add_constant(Value::str("hello")) else {
    return Err("constant pool exhausted".into());
};
```

`try_add_name` behaves the same for the name pool. Prefer both in any frontend
whose input size is not bounded at compile time.

---

## [0xFF] LICENSE

MIT — Copyright (c) 2026 [MenkeTechnologies](https://github.com/MenkeTechnologies)