Skip to main content

harn_vm/
chunk.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5
6use harn_parser::TypeExpr;
7use parking_lot::Mutex;
8use serde::{Deserialize, Serialize};
9
10use crate::runtime_guards::RuntimeParamGuard;
11
12/// Sentinel value stored in [`Chunk::inline_cache_index`] for code offsets
13/// that have no inline-cache slot registered. Chosen as `u32::MAX` so the
14/// hot dispatch path can treat the side-table as a flat `Vec<u32>` without
15/// an `Option` wrapper — the comparison against the sentinel collapses to a
16/// single integer compare. The compile-time max useful slot count is bounded
17/// by code length (one slot per cacheable opcode), so `u32::MAX` is safely
18/// out of the addressable slot range.
19pub(crate) const NO_INLINE_CACHE_SLOT: u32 = u32::MAX;
20static NEXT_CHUNK_CACHE_ID: AtomicU64 = AtomicU64::new(1);
21
22fn next_chunk_cache_id() -> u64 {
23    NEXT_CHUNK_CACHE_ID.fetch_add(1, Ordering::Relaxed)
24}
25
26/// Bytecode opcodes for the Harn VM. The enum, the byte-to-variant
27/// mapping, the sync and async dispatch tables, the disassembly
28/// renderer, and the per-opcode classification helpers are all emitted
29/// by `harn_opcode_macros::define_opcodes!` in [`crate::vm::ops`].
30/// Re-exported here so callers that import `crate::chunk::Op` need no
31/// awareness of the macro layout.
32pub use crate::vm::ops::Op;
33pub(crate) use crate::vm::ops::{is_adaptive_binary_op, op_reads_outer_name};
34
35/// A constant value in the constant pool.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub enum Constant {
38    Int(i64),
39    Float(f64),
40    String(String),
41    Bool(bool),
42    Nil,
43    Duration(i64),
44}
45
46/// Runtime-only inline-cache state for bytecode instructions that repeatedly
47/// see the same dynamic shape. Lookup caches stay monomorphic on a name and
48/// receiver shape. Adaptive caches warm on a stable operand or call target,
49/// then fall back through the generic opcode and replace or reset state when
50/// the observed shape changes.
51///
52/// This vector is intentionally excluded from [`CachedChunk`]: bytecode cache
53/// artifacts keep the slot layout but start with empty runtime feedback in each
54/// process.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub(crate) enum InlineCacheEntry {
57    Empty,
58    Property {
59        name_idx: u16,
60        target: PropertyCacheTarget,
61    },
62    Method {
63        name_idx: u16,
64        argc: usize,
65        target: MethodCacheTarget,
66    },
67    AdaptiveBinary {
68        op: AdaptiveBinaryOp,
69        state: AdaptiveBinaryState,
70    },
71    DirectCall {
72        state: DirectCallState,
73    },
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub(crate) enum AdaptiveBinaryOp {
78    Add,
79    Sub,
80    Mul,
81    Div,
82    Mod,
83    Equal,
84    NotEqual,
85    Less,
86    Greater,
87    LessEqual,
88    GreaterEqual,
89}
90
91/// Adaptive-binary IC state. All fields are scalar `Copy` (shape is a
92/// `Copy` enum, hit/miss counters are integers), so the struct as a whole
93/// is `Copy`. This lets `execute_adaptive_binary` extract the cached state
94/// by value for the specialization check without cloning the wrapping
95/// `InlineCacheEntry` on every dispatch.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub(crate) enum AdaptiveBinaryState {
98    Warmup {
99        shape: BinaryShape,
100        hits: u8,
101    },
102    Specialized {
103        shape: BinaryShape,
104        hits: u64,
105        misses: u64,
106    },
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub(crate) enum BinaryShape {
111    Int,
112    Float,
113    Bool,
114    String,
115}
116
117#[derive(Debug, Clone)]
118pub(crate) enum DirectCallState {
119    Warmup {
120        argc: usize,
121        target: DirectCallTarget,
122        hits: u8,
123    },
124    Specialized {
125        argc: usize,
126        target: DirectCallTarget,
127        hits: u64,
128        misses: u64,
129    },
130}
131
132#[derive(Debug, Clone)]
133pub(crate) enum DirectCallTarget {
134    Closure(Arc<crate::value::VmClosure>),
135}
136
137impl PartialEq for DirectCallTarget {
138    fn eq(&self, other: &Self) -> bool {
139        match (self, other) {
140            (Self::Closure(left), Self::Closure(right)) => Arc::ptr_eq(left, right),
141        }
142    }
143}
144
145impl Eq for DirectCallTarget {}
146
147impl PartialEq for DirectCallState {
148    fn eq(&self, other: &Self) -> bool {
149        match (self, other) {
150            (
151                Self::Warmup {
152                    argc: left_argc,
153                    target: left_target,
154                    hits: left_hits,
155                },
156                Self::Warmup {
157                    argc: right_argc,
158                    target: right_target,
159                    hits: right_hits,
160                },
161            ) => left_argc == right_argc && left_target == right_target && left_hits == right_hits,
162            (
163                Self::Specialized {
164                    argc: left_argc,
165                    target: left_target,
166                    hits: left_hits,
167                    misses: left_misses,
168                },
169                Self::Specialized {
170                    argc: right_argc,
171                    target: right_target,
172                    hits: right_hits,
173                    misses: right_misses,
174                },
175            ) => {
176                left_argc == right_argc
177                    && left_target == right_target
178                    && left_hits == right_hits
179                    && left_misses == right_misses
180            }
181            _ => false,
182        }
183    }
184}
185
186impl Eq for DirectCallState {}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub(crate) enum PropertyCacheTarget {
190    DictField(Arc<str>),
191    StructField { field_name: Arc<str>, index: usize },
192    ListCount,
193    ListEmpty,
194    ListFirst,
195    ListLast,
196    StringCount,
197    StringEmpty,
198    PairFirst,
199    PairSecond,
200    EnumVariant,
201    EnumFields,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub(crate) enum MethodCacheTarget {
206    ListCount,
207    ListEmpty,
208    ListContains,
209    StringCount,
210    StringEmpty,
211    StringContains,
212    DictCount,
213    DictHas,
214    RangeCount,
215    RangeLen,
216    RangeEmpty,
217    RangeFirst,
218    RangeLast,
219    SetCount,
220    SetLen,
221    SetEmpty,
222    SetContains,
223}
224
225/// Debug metadata for a slot-indexed local in a compiled chunk.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct LocalSlotInfo {
228    pub name: String,
229    pub mutable: bool,
230    pub scope_depth: usize,
231}
232
233impl fmt::Display for Constant {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        match self {
236            Constant::Int(n) => write!(f, "{n}"),
237            Constant::Float(n) => write!(f, "{n}"),
238            Constant::String(s) => write!(f, "\"{s}\""),
239            Constant::Bool(b) => write!(f, "{b}"),
240            Constant::Nil => write!(f, "nil"),
241            Constant::Duration(ms) => write!(f, "{ms}ms"),
242        }
243    }
244}
245
246/// A compiled chunk of bytecode.
247#[derive(Debug)]
248pub struct Chunk {
249    /// Runtime-only identity for VM-local inline cache storage. It is not
250    /// serialized; freshly compiled or loaded chunks get new ids, while clones
251    /// keep the same id because they represent the same bytecode object.
252    cache_id: u64,
253    /// The bytecode instructions.
254    pub code: Vec<u8>,
255    /// Constant pool.
256    pub constants: Vec<Constant>,
257    /// Source line numbers for each instruction (for error reporting).
258    pub lines: Vec<u32>,
259    /// Source column numbers for each instruction (for error reporting).
260    /// Parallel to `lines`; 0 means no column info available.
261    pub columns: Vec<u32>,
262    /// Source file that this chunk was compiled from, when known. Set for
263    /// chunks compiled from imported modules so runtime errors can report
264    /// the correct file path for each frame instead of always pointing at
265    /// the entry-point pipeline.
266    pub source_file: Option<String>,
267    /// Current column to use when emitting instructions (set by compiler).
268    current_col: u32,
269    /// Compiled function bodies (for closures).
270    pub functions: Vec<CompiledFunctionRef>,
271    /// Instruction offset to inline-cache slot. Slots are assigned at emit time
272    /// for cacheable instructions while bytecode bytes remain immutable.
273    /// Preserved as the serialization-stable representation that round-trips
274    /// through [`CachedChunk`]; the runtime hot path reads
275    /// [`Chunk::inline_cache_index`] instead.
276    inline_cache_slots: BTreeMap<usize, usize>,
277    /// Flat side-table indexed by code offset that returns the inline-cache
278    /// slot index (or [`NO_INLINE_CACHE_SLOT`] for "no slot at this offset").
279    /// Built alongside [`Chunk::inline_cache_slots`] at emit/load time so the
280    /// per-dispatch lookup that fires on every adaptive binary op, `Op::Call`,
281    /// `Op::MethodCall`, and `Op::GetProperty` is one cache-friendly `Vec`
282    /// index instead of a `BTreeMap::get` (O(1) vs O(log n) with the
283    /// associated pointer chasing). Derived; intentionally not serialized.
284    inline_cache_index: Vec<u32>,
285    /// Test/bench scratch entries for validating inline-cache transitions.
286    /// Runtime execution keeps live cache entries on each `Vm` isolate so
287    /// parallel workers do not contend on shared compiled chunks.
288    inline_caches: Arc<Mutex<Vec<InlineCacheEntry>>>,
289    /// Lazily-materialized shared string cache for `Constant::String` entries,
290    /// parallel to `constants`. String constants are materialized once per
291    /// unique constant; subsequent pushes are an `Arc` refcount bump.
292    constant_strings: Arc<Mutex<Vec<Option<Arc<str>>>>>,
293    /// Source-name metadata for slot-indexed locals in this chunk.
294    pub(crate) local_slots: Vec<LocalSlotInfo>,
295    /// True when this chunk's bytecode emits an opcode that resolves a
296    /// name through the runtime env (`GetVar`, `SetVar`, `CallBuiltin`,
297    /// `CallBuiltinSpread`, `CheckType`). The closure-call hot path uses
298    /// this as a cheap static guard: if a closure body never reads
299    /// outer names by name, the caller-scope late-bind walks in
300    /// [`Vm::closure_call_env`] and
301    /// [`Vm::closure_call_env_for_current_frame`] are pure overhead and
302    /// can be skipped, leaving the closure's captured env as-is.
303    ///
304    /// Walks exist to inject late-bound closure-typed names — typically
305    /// for self/mutually-recursive local fns and for fns whose captured
306    /// env predates a sibling definition. Inline arithmetic / comparison
307    /// callbacks (the `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
308    /// shape) emit none of the flagged opcodes, so the walk is wasted
309    /// work on every invocation.
310    pub(crate) references_outer_names: bool,
311    /// Compile-time operand-stack-depth tracking for the debug-build
312    /// balance assertion (issue #2622). `balance_depth` is the running net
313    /// effect of every *linearly-modeled* opcode emitted so far;
314    /// `balance_nonlinear` counts emits whose effect can't be tracked by a
315    /// straight-line sum (jumps, `return`, async/handler ops, variadic ops
316    /// whose count isn't an emit argument). A statement is "balance-exact"
317    /// only when `balance_nonlinear` is unchanged across its compilation,
318    /// at which point `balance_depth`'s delta is its true net stack effect.
319    /// Transient compile-time state: reset by [`Chunk::new`], never
320    /// serialized into [`CachedChunk`], and read only by debug assertions —
321    /// so a wrong absolute value (which a non-exact statement can leave
322    /// behind) is harmless; only per-statement *deltas over exact spans*
323    /// are ever trusted.
324    #[cfg(debug_assertions)]
325    balance_depth: i32,
326    #[cfg(debug_assertions)]
327    balance_nonlinear: u32,
328}
329
330pub type ChunkRef = Arc<Chunk>;
331pub type CompiledFunctionRef = Arc<CompiledFunction>;
332
333impl Clone for Chunk {
334    fn clone(&self) -> Self {
335        Self {
336            cache_id: self.cache_id,
337            code: self.code.clone(),
338            constants: self.constants.clone(),
339            lines: self.lines.clone(),
340            columns: self.columns.clone(),
341            source_file: self.source_file.clone(),
342            current_col: self.current_col,
343            functions: self.functions.clone(),
344            inline_cache_slots: self.inline_cache_slots.clone(),
345            inline_cache_index: self.inline_cache_index.clone(),
346            inline_caches: Arc::new(Mutex::new(vec![
347                InlineCacheEntry::Empty;
348                self.inline_cache_slot_count()
349            ])),
350            constant_strings: Arc::new(Mutex::new(vec![None; self.constants.len()])),
351            local_slots: self.local_slots.clone(),
352            references_outer_names: self.references_outer_names,
353            #[cfg(debug_assertions)]
354            balance_depth: self.balance_depth,
355            #[cfg(debug_assertions)]
356            balance_nonlinear: self.balance_nonlinear,
357        }
358    }
359}
360
361/// Serializable snapshot of a [`Chunk`] suitable for the on-disk bytecode
362/// cache and for in-memory stdlib artifact caches. Inline-cache state is
363/// dropped at freeze time because it warms at runtime per VM isolate; the
364/// rest of the chunk round-trips byte-identically.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct CachedChunk {
367    pub(crate) code: Vec<u8>,
368    pub(crate) constants: Vec<Constant>,
369    pub(crate) lines: Vec<u32>,
370    pub(crate) columns: Vec<u32>,
371    pub(crate) source_file: Option<String>,
372    pub(crate) current_col: u32,
373    pub(crate) functions: Vec<CachedCompiledFunction>,
374    pub(crate) inline_cache_slots: BTreeMap<usize, usize>,
375    pub(crate) local_slots: Vec<LocalSlotInfo>,
376    #[serde(default)]
377    pub(crate) references_outer_names: bool,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct CachedCompiledFunction {
382    pub(crate) name: String,
383    pub(crate) type_params: Vec<String>,
384    pub(crate) nominal_type_names: Vec<String>,
385    pub(crate) params: Vec<CachedParamSlot>,
386    pub(crate) default_start: Option<usize>,
387    pub(crate) chunk: CachedChunk,
388    pub(crate) is_generator: bool,
389    pub(crate) is_stream: bool,
390    pub(crate) has_rest_param: bool,
391    pub(crate) has_runtime_type_checks: bool,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub(crate) struct CachedParamSlot {
396    pub(crate) name: String,
397    pub(crate) type_expr: Option<TypeExpr>,
398    pub(crate) has_default: bool,
399}
400
401impl CachedParamSlot {
402    fn thaw(&self) -> ParamSlot {
403        ParamSlot {
404            name: self.name.clone(),
405            type_expr: self.type_expr.clone(),
406            runtime_guard: self
407                .type_expr
408                .as_ref()
409                .map(RuntimeParamGuard::from_type_expr),
410            has_default: self.has_default,
411        }
412    }
413}
414
415/// One parameter slot of a compiled user-defined function. Carries the
416/// declared name, the (optional) declared type expression, and a flag
417/// for whether a default value was provided. The runtime consults the
418/// type expression in `bind_param_slots` to enforce declared types
419/// against the values supplied at the call site.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct ParamSlot {
422    pub name: String,
423    /// Declared parameter type. `None` for untyped parameters (gradual
424    /// typing); the runtime skips type assertion when absent.
425    pub type_expr: Option<TypeExpr>,
426    /// Precomputed runtime validation metadata derived from `type_expr`.
427    /// Bytecode-cache artifacts omit this field and rebuild it at load time.
428    #[serde(skip)]
429    pub(crate) runtime_guard: Option<RuntimeParamGuard>,
430    /// True when the parameter has a default-value clause. Diagnostic
431    /// only — the canonical authority for arity ranges is
432    /// [`CompiledFunction::default_start`].
433    pub has_default: bool,
434}
435
436impl ParamSlot {
437    /// Build a [`ParamSlot`] from a parser-side [`harn_parser::TypedParam`].
438    /// Centralizes the conversion so every compile path stays in lockstep.
439    pub fn from_typed_param(param: &harn_parser::TypedParam) -> Self {
440        Self {
441            name: param.name.clone(),
442            type_expr: param.type_expr.clone(),
443            runtime_guard: param
444                .type_expr
445                .as_ref()
446                .map(RuntimeParamGuard::from_type_expr),
447            has_default: param.default_value.is_some(),
448        }
449    }
450
451    fn freeze_for_cache(&self) -> CachedParamSlot {
452        CachedParamSlot {
453            name: self.name.clone(),
454            type_expr: self.type_expr.clone(),
455            has_default: self.has_default,
456        }
457    }
458
459    /// Build a `Vec<ParamSlot>` from a slice of parser-side typed
460    /// parameters. Used pervasively at compile sites instead of
461    /// `TypedParam::names` (which discarded the type info we now need
462    /// at runtime).
463    pub fn vec_from_typed(params: &[harn_parser::TypedParam]) -> Vec<Self> {
464        params.iter().map(Self::from_typed_param).collect()
465    }
466}
467
468/// A compiled function (closure body).
469#[derive(Debug, Clone)]
470pub struct CompiledFunction {
471    pub name: String,
472    /// Generic type parameters declared by this function. Runtime
473    /// validation treats these as static-only constraints because the VM
474    /// does not monomorphize function bodies.
475    pub type_params: Vec<String>,
476    /// User-defined struct and enum names visible when this function was
477    /// compiled. These are the only non-primitive named types with runtime
478    /// nominal identity; aliases and interfaces remain static-only.
479    pub nominal_type_names: Vec<String>,
480    pub params: Vec<ParamSlot>,
481    /// Index of the first parameter with a default value, or None if all required.
482    pub default_start: Option<usize>,
483    pub chunk: ChunkRef,
484    /// True if the function body contains `yield` expressions (generator function).
485    pub is_generator: bool,
486    /// True if the function was declared as `gen fn` and should return Stream.
487    pub is_stream: bool,
488    /// True if the last parameter is a rest parameter (`...name`).
489    pub has_rest_param: bool,
490    /// True when at least one parameter has a runtime-visible type
491    /// assertion. Untyped closures dominate collection callback hot paths,
492    /// so this lets the VM skip the per-argument metadata walk after the
493    /// arity check.
494    pub has_runtime_type_checks: bool,
495}
496
497impl CompiledFunction {
498    pub(crate) fn has_runtime_type_checks_for_params(params: &[ParamSlot]) -> bool {
499        params.iter().any(|param| param.type_expr.is_some())
500    }
501
502    /// Returns just the parameter names — convenience for code paths that
503    /// don't care about types or defaults.
504    pub fn param_names(&self) -> impl Iterator<Item = &str> {
505        self.params.iter().map(|p| p.name.as_str())
506    }
507
508    /// Number of required parameters (those before `default_start`).
509    pub fn required_param_count(&self) -> usize {
510        self.default_start.unwrap_or(self.params.len())
511    }
512
513    pub fn declares_type_param(&self, name: &str) -> bool {
514        self.type_params.iter().any(|param| param == name)
515    }
516
517    pub fn has_nominal_type(&self, name: &str) -> bool {
518        self.nominal_type_names.iter().any(|ty| ty == name)
519    }
520
521    pub(crate) fn freeze_for_cache(&self) -> CachedCompiledFunction {
522        CachedCompiledFunction {
523            name: self.name.clone(),
524            type_params: self.type_params.clone(),
525            nominal_type_names: self.nominal_type_names.clone(),
526            params: self
527                .params
528                .iter()
529                .map(ParamSlot::freeze_for_cache)
530                .collect(),
531            default_start: self.default_start,
532            chunk: self.chunk.freeze_for_cache(),
533            is_generator: self.is_generator,
534            is_stream: self.is_stream,
535            has_rest_param: self.has_rest_param,
536            has_runtime_type_checks: self.has_runtime_type_checks,
537        }
538    }
539
540    pub(crate) fn from_cached(cached: &CachedCompiledFunction) -> Self {
541        Self {
542            name: cached.name.clone(),
543            type_params: cached.type_params.clone(),
544            nominal_type_names: cached.nominal_type_names.clone(),
545            params: cached.params.iter().map(CachedParamSlot::thaw).collect(),
546            default_start: cached.default_start,
547            chunk: Arc::new(Chunk::from_cached(&cached.chunk)),
548            is_generator: cached.is_generator,
549            is_stream: cached.is_stream,
550            has_rest_param: cached.has_rest_param,
551            has_runtime_type_checks: cached.has_runtime_type_checks,
552        }
553    }
554}
555
556/// A snapshot of [`Chunk`]'s compile-time balance model, returned by
557/// [`Chunk::balance_probe`] and consumed by [`Chunk::balance_delta_since`].
558#[cfg(debug_assertions)]
559#[derive(Clone, Copy)]
560pub(crate) struct BalanceProbe {
561    depth: i32,
562    nonlinear: u32,
563}
564
565/// Net operand-stack effect (`pushes - pops`) of one emitted opcode, for
566/// the debug-build balance assertion (issue #2622). `count` is the opcode's
567/// variadic arity when that arity is the emit-call argument (`BuildList`
568/// length, `Call` argc, …) and `0` otherwise.
569///
570/// `Some(delta)` means the effect is exactly modeled. `None` marks an
571/// opcode a straight-line running sum can't track — control flow that
572/// branches or terminates (`Jump*`, `Return`, `Throw`, `TailCall`),
573/// async/handler ops, and variadic ops whose arity rides in a raw operand
574/// byte rather than the emit argument (`BuildEnum`, `MatchEnum`). Such an
575/// opcode taints its enclosing statement as non-exact, so the assertion
576/// skips it instead of risking a false trip.
577///
578/// The `match` is intentionally exhaustive with no `_` arm: adding an
579/// opcode forces a classification here (a compile error otherwise), so the
580/// balance model can't silently drift out of sync with the instruction set.
581#[cfg(debug_assertions)]
582fn op_stack_delta(op: Op, count: u16) -> Option<i32> {
583    use Op::*;
584    let count = count as i32;
585    Some(match op {
586        // Push one value.
587        Constant | Nil | True | False | GetVar | GetArgc | GetLocalSlot | Closure | Dup => 1,
588        // Consume one value (into a binding / property / discard). `SetVar`,
589        // `SetProperty` and the local-slot stores read their target by name
590        // or slot index, so they only pop the value being stored.
591        DefLet | DefVar | SetVar | DefLocalSlot | SetLocalSlot | SetProperty | Pop => -1,
592        // Value-preserving: unary ops, by-name lookups/checks, and scope /
593        // iterator / exception-handler bookkeeping (the last three touch
594        // side stacks, not the operand stack).
595        Negate | Not | GetProperty | GetPropertyOpt | CheckType | TryUnwrap | TryWrapOk | Swap
596        | PushScope | PopScope | PopIterator | PopHandler => 0,
597        // Pop two, push one.
598        Add | Sub | Mul | Div | Mod | Pow | AddInt | SubInt | MulInt | DivInt | ModInt
599        | AddFloat | SubFloat | MulFloat | DivFloat | ModFloat | Equal | NotEqual | Less
600        | Greater | LessEqual | GreaterEqual | EqualInt | NotEqualInt | LessInt | GreaterInt
601        | LessEqualInt | GreaterEqualInt | EqualFloat | NotEqualFloat | LessFloat
602        | GreaterFloat | LessEqualFloat | GreaterEqualFloat | EqualBool | NotEqualBool
603        | EqualString | NotEqualString | Contains | Subscript | SubscriptOpt => -1,
604        // `IterInit` consumes the iterable and pushes nothing (the iterator
605        // lives on a side stack).
606        IterInit => -1,
607        // Pop three (or two values + a by-name target), push one.
608        Slice | SetSubscript => -2,
609        // Variadic whose arity is the emit argument: pop `count`, push one.
610        BuildList | Concat | CallBuiltin => 1 - count,
611        BuildDict => 1 - 2 * count,
612        // Calls also pop the callee/receiver beneath the args.
613        Call | MethodCall | MethodCallOpt => -count,
614        // Non-linear (see doc comment): branches, terminators, async/handler
615        // ops, and variadic ops whose arity isn't the emit argument.
616        Jump | JumpIfFalse | JumpIfTrue | IterNext | Return | TailCall | Throw | TryCatchSetup
617        | Spawn | Pipe | Parallel | ParallelMap | ParallelMapStream | ParallelSettle
618        | SyncMutexEnter | Import | SelectiveImport | DeadlineSetup | DeadlineEnd | BuildEnum
619        | MatchEnum | Yield | CallSpread | CallBuiltinSpread | MethodCallSpread => return None,
620    })
621}
622
623impl Chunk {
624    pub fn new() -> Self {
625        Self {
626            cache_id: next_chunk_cache_id(),
627            code: Vec::new(),
628            constants: Vec::new(),
629            lines: Vec::new(),
630            columns: Vec::new(),
631            source_file: None,
632            current_col: 0,
633            functions: Vec::new(),
634            inline_cache_slots: BTreeMap::new(),
635            inline_cache_index: Vec::new(),
636            inline_caches: Arc::new(Mutex::new(Vec::new())),
637            constant_strings: Arc::new(Mutex::new(Vec::new())),
638            local_slots: Vec::new(),
639            references_outer_names: false,
640            #[cfg(debug_assertions)]
641            balance_depth: 0,
642            #[cfg(debug_assertions)]
643            balance_nonlinear: 0,
644        }
645    }
646
647    /// Set the current column for subsequent emit calls.
648    pub fn set_column(&mut self, col: u32) {
649        self.current_col = col;
650    }
651
652    /// Add a constant and return its index.
653    pub fn add_constant(&mut self, constant: Constant) -> u16 {
654        for (i, c) in self.constants.iter().enumerate() {
655            if c == &constant {
656                return i as u16;
657            }
658        }
659        let idx = self.constants.len();
660        self.constants.push(constant);
661        idx as u16
662    }
663
664    /// Emit a single-byte instruction.
665    pub fn emit(&mut self, op: Op, line: u32) {
666        #[cfg(debug_assertions)]
667        self.note_balance(op, 0);
668        let col = self.current_col;
669        let op_offset = self.code.len();
670        self.code.push(op as u8);
671        self.lines.push(line);
672        self.columns.push(col);
673        if is_adaptive_binary_op(op) {
674            self.register_inline_cache(op_offset);
675        }
676        if op_reads_outer_name(op) {
677            self.references_outer_names = true;
678        }
679    }
680
681    /// Emit an instruction with a u16 argument.
682    pub fn emit_u16(&mut self, op: Op, arg: u16, line: u32) {
683        #[cfg(debug_assertions)]
684        self.note_balance(op, arg);
685        let col = self.current_col;
686        let op_offset = self.code.len();
687        self.code.push(op as u8);
688        self.code.push((arg >> 8) as u8);
689        self.code.push((arg & 0xFF) as u8);
690        self.lines.push(line);
691        self.lines.push(line);
692        self.lines.push(line);
693        self.columns.push(col);
694        self.columns.push(col);
695        self.columns.push(col);
696        if matches!(
697            op,
698            Op::GetProperty | Op::GetPropertyOpt | Op::MethodCallSpread
699        ) {
700            self.register_inline_cache(op_offset);
701        }
702        if op_reads_outer_name(op) {
703            self.references_outer_names = true;
704        }
705    }
706
707    /// Emit an instruction with a u8 argument.
708    pub fn emit_u8(&mut self, op: Op, arg: u8, line: u32) {
709        #[cfg(debug_assertions)]
710        self.note_balance(op, arg as u16);
711        let col = self.current_col;
712        let op_offset = self.code.len();
713        self.code.push(op as u8);
714        self.code.push(arg);
715        self.lines.push(line);
716        self.lines.push(line);
717        self.columns.push(col);
718        self.columns.push(col);
719        if matches!(op, Op::Call) {
720            self.register_inline_cache(op_offset);
721        }
722        if op_reads_outer_name(op) {
723            self.references_outer_names = true;
724        }
725    }
726
727    /// Emit a direct builtin call.
728    pub fn emit_call_builtin(
729        &mut self,
730        id: crate::BuiltinId,
731        name_idx: u16,
732        arg_count: u8,
733        line: u32,
734    ) {
735        #[cfg(debug_assertions)]
736        self.note_balance(Op::CallBuiltin, arg_count as u16);
737        let col = self.current_col;
738        let op_offset = self.code.len();
739        self.code.push(Op::CallBuiltin as u8);
740        self.code.extend_from_slice(&id.raw().to_be_bytes());
741        self.code.push((name_idx >> 8) as u8);
742        self.code.push((name_idx & 0xFF) as u8);
743        self.code.push(arg_count);
744        for _ in 0..12 {
745            self.lines.push(line);
746            self.columns.push(col);
747        }
748        self.register_inline_cache(op_offset);
749        self.references_outer_names = true;
750    }
751
752    /// Emit a direct builtin spread call.
753    pub fn emit_call_builtin_spread(&mut self, id: crate::BuiltinId, name_idx: u16, line: u32) {
754        #[cfg(debug_assertions)]
755        self.note_balance(Op::CallBuiltinSpread, 0);
756        let col = self.current_col;
757        self.code.push(Op::CallBuiltinSpread as u8);
758        self.code.extend_from_slice(&id.raw().to_be_bytes());
759        self.code.push((name_idx >> 8) as u8);
760        self.code.push((name_idx & 0xFF) as u8);
761        for _ in 0..11 {
762            self.lines.push(line);
763            self.columns.push(col);
764        }
765        self.references_outer_names = true;
766    }
767
768    /// Emit a method call: op + u16 (method name) + u8 (arg count).
769    pub fn emit_method_call(&mut self, name_idx: u16, arg_count: u8, line: u32) {
770        self.emit_method_call_inner(Op::MethodCall, name_idx, arg_count, line);
771    }
772
773    /// Emit an optional method call (?.) — returns nil if receiver is nil.
774    pub fn emit_method_call_opt(&mut self, name_idx: u16, arg_count: u8, line: u32) {
775        self.emit_method_call_inner(Op::MethodCallOpt, name_idx, arg_count, line);
776    }
777
778    fn emit_method_call_inner(&mut self, op: Op, name_idx: u16, arg_count: u8, line: u32) {
779        #[cfg(debug_assertions)]
780        self.note_balance(op, arg_count as u16);
781        let col = self.current_col;
782        let op_offset = self.code.len();
783        self.code.push(op as u8);
784        self.code.push((name_idx >> 8) as u8);
785        self.code.push((name_idx & 0xFF) as u8);
786        self.code.push(arg_count);
787        self.lines.push(line);
788        self.lines.push(line);
789        self.lines.push(line);
790        self.lines.push(line);
791        self.columns.push(col);
792        self.columns.push(col);
793        self.columns.push(col);
794        self.columns.push(col);
795        self.register_inline_cache(op_offset);
796    }
797
798    /// Current code offset (for jump patching).
799    pub fn current_offset(&self) -> usize {
800        self.code.len()
801    }
802
803    /// Emit a jump instruction with a placeholder offset. Returns the position to patch.
804    pub fn emit_jump(&mut self, op: Op, line: u32) -> usize {
805        #[cfg(debug_assertions)]
806        self.note_balance(op, 0);
807        let col = self.current_col;
808        self.code.push(op as u8);
809        let patch_pos = self.code.len();
810        self.code.push(0xFF);
811        self.code.push(0xFF);
812        self.lines.push(line);
813        self.lines.push(line);
814        self.lines.push(line);
815        self.columns.push(col);
816        self.columns.push(col);
817        self.columns.push(col);
818        patch_pos
819    }
820
821    /// Patch a jump instruction at the given position to jump to the current offset.
822    pub fn patch_jump(&mut self, patch_pos: usize) {
823        let target = self.code.len() as u16;
824        self.code[patch_pos] = (target >> 8) as u8;
825        self.code[patch_pos + 1] = (target & 0xFF) as u8;
826    }
827
828    /// Patch a jump to a specific target position.
829    pub fn patch_jump_to(&mut self, patch_pos: usize, target: usize) {
830        let target = target as u16;
831        self.code[patch_pos] = (target >> 8) as u8;
832        self.code[patch_pos + 1] = (target & 0xFF) as u8;
833    }
834
835    /// Read a u16 argument at the given position.
836    pub fn read_u16(&self, pos: usize) -> u16 {
837        ((self.code[pos] as u16) << 8) | (self.code[pos + 1] as u16)
838    }
839
840    /// Fold one just-emitted opcode into the compile-time operand-stack
841    /// balance model (issue #2622). See [`op_stack_delta`] for the
842    /// linear-vs-non-linear classification.
843    #[cfg(debug_assertions)]
844    fn note_balance(&mut self, op: Op, count: u16) {
845        match op_stack_delta(op, count) {
846            Some(delta) => self.balance_depth += delta,
847            None => self.balance_nonlinear += 1,
848        }
849    }
850
851    /// Snapshot the balance model before compiling a statement; pair with
852    /// [`Chunk::balance_delta_since`].
853    #[cfg(debug_assertions)]
854    pub(crate) fn balance_probe(&self) -> BalanceProbe {
855        BalanceProbe {
856            depth: self.balance_depth,
857            nonlinear: self.balance_nonlinear,
858        }
859    }
860
861    /// Net operand-stack effect emitted since `probe`, or `None` when any
862    /// non-linearly-modeled opcode was emitted in that span (which makes
863    /// the running sum untrustworthy, so callers must not assert on it).
864    /// The absolute `balance_depth` may be meaningless after a non-exact
865    /// span — only deltas over a fully-exact span are valid.
866    #[cfg(debug_assertions)]
867    pub(crate) fn balance_delta_since(&self, probe: BalanceProbe) -> Option<i32> {
868        if self.balance_nonlinear == probe.nonlinear {
869            Some(self.balance_depth - probe.depth)
870        } else {
871            None
872        }
873    }
874
875    fn register_inline_cache(&mut self, op_offset: usize) {
876        if self.inline_cache_slots.contains_key(&op_offset) {
877            return;
878        }
879        let mut entries = self.inline_caches.lock();
880        let slot = entries.len();
881        entries.push(InlineCacheEntry::Empty);
882        self.inline_cache_slots.insert(op_offset, slot);
883        Self::write_inline_cache_index(&mut self.inline_cache_index, op_offset, slot);
884    }
885
886    /// Fast-path side-table writer. Pulled out as an associated fn so both
887    /// the live emit path and [`Chunk::from_cached`] share the same growth
888    /// strategy. Cache slots fit comfortably in `u32` because the slot count
889    /// is bounded by the cacheable-opcode count in `code`.
890    fn write_inline_cache_index(index: &mut Vec<u32>, op_offset: usize, slot: usize) {
891        if op_offset >= index.len() {
892            index.resize(op_offset + 1, NO_INLINE_CACHE_SLOT);
893        }
894        index[op_offset] = slot as u32;
895    }
896
897    /// Look up the inline-cache slot for the opcode at `op_offset`. This is
898    /// called on every dispatch of an adaptive binary op (Add/Sub/Mul/Div/
899    /// Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq), `Op::Call`, `Op::MethodCall`
900    /// (and `MethodCallOpt`/`MethodCallSpread`), and `Op::GetProperty`
901    /// (`GetPropertyOpt`). Backed by [`Chunk::inline_cache_index`] — a flat
902    /// `Vec<u32>` indexed by code offset — so the lookup is a single bounds-
903    /// checked array read instead of the prior `BTreeMap::get` which walked
904    /// internal nodes for every dispatched op.
905    #[inline]
906    pub(crate) fn inline_cache_slot(&self, op_offset: usize) -> Option<usize> {
907        match self.inline_cache_index.get(op_offset).copied() {
908            None | Some(NO_INLINE_CACHE_SLOT) => None,
909            Some(slot) => Some(slot as usize),
910        }
911    }
912
913    pub(crate) fn inline_cache_slot_count(&self) -> usize {
914        self.inline_cache_slots.len()
915    }
916
917    pub(crate) fn cache_id(&self) -> u64 {
918        self.cache_id
919    }
920
921    /// Pre-optimization control path: the `BTreeMap`-backed lookup the
922    /// dispatcher used before the flat `Vec<u32>` side-table. Exposed
923    /// only behind the `vm-bench-internals` feature so the criterion
924    /// microbench can A/B the two paths inside one binary on identical
925    /// hardware. The production hot path must keep using
926    /// [`Chunk::inline_cache_slot`].
927    #[cfg(feature = "vm-bench-internals")]
928    pub fn inline_cache_slot_via_btreemap_for_bench(&self, op_offset: usize) -> Option<usize> {
929        self.inline_cache_slots.get(&op_offset).copied()
930    }
931
932    /// Returns a shared string for a `Constant::String` at the given pool
933    /// index, materializing it on first access and caching for reuse.
934    /// Returns `None` when the constant at `idx` is not a string (the
935    /// caller should fall back to the regular `Constant` match).
936    pub(crate) fn constant_string_rc(&self, idx: usize) -> Option<Arc<str>> {
937        // Borrow the side table mutably so we can lazily extend / fill
938        // entries. The borrow is scope-confined to this function; the
939        // VM never re-enters constant_string_rc for the same chunk
940        // during a single materialization, so no nested-borrow risk.
941        let mut entries = self.constant_strings.lock();
942        if entries.len() < self.constants.len() {
943            entries.resize(self.constants.len(), None);
944        }
945        if let Some(Some(existing)) = entries.get(idx) {
946            return Some(Arc::clone(existing));
947        }
948        let materialized = match self.constants.get(idx)? {
949            Constant::String(s) => Arc::<str>::from(s.as_str()),
950            _ => return None,
951        };
952        entries[idx] = Some(Arc::clone(&materialized));
953        Some(materialized)
954    }
955
956    #[cfg(feature = "vm-bench-internals")]
957    pub(crate) fn inline_cache_entry(&self, slot: usize) -> InlineCacheEntry {
958        self.inline_caches
959            .lock()
960            .get(slot)
961            .cloned()
962            .unwrap_or(InlineCacheEntry::Empty)
963    }
964
965    /// Adaptive-binary fast path read. Returns the cached
966    /// `(op, state)` pair by value (both `Copy`) when slot holds an
967    /// `AdaptiveBinary` entry, else `None`. Skips the
968    /// `InlineCacheEntry::clone` that `inline_cache_entry` performs:
969    /// since `AdaptiveBinaryState: Copy`, the read does a single
970    /// scalar move out of the cache instead of a 24-32B memcpy of the
971    /// wrapping enum (which the variant-checking match destructures
972    /// and throws away anyway). Fires on every Add/Sub/Mul/Div/Mod/Eq/
973    /// Neq/Less/Greater/LessEq/GreaterEq dispatch, so the per-op
974    /// savings compound across the millions of dispatches a typical
975    /// loop body issues.
976    #[inline]
977    #[cfg(any(test, feature = "vm-bench-internals"))]
978    pub(crate) fn peek_adaptive_binary_cache(
979        &self,
980        slot: usize,
981    ) -> Option<(AdaptiveBinaryOp, AdaptiveBinaryState)> {
982        match self.inline_caches.lock().get(slot)? {
983            &InlineCacheEntry::AdaptiveBinary { op, state } => Some((op, state)),
984            _ => None,
985        }
986    }
987
988    /// Method-cache fast path read. Returns the cached `(name_idx, argc,
989    /// target)` triple by value (all three are `Copy`) when `slot` holds a
990    /// `Method` entry, else `None`. Skips the full `InlineCacheEntry::clone`
991    /// that `inline_cache_entry` performs on every `Op::MethodCall`,
992    /// `Op::MethodCallOpt`, and `Op::MethodCallSpread` dispatch: the
993    /// variant-checking `let-else` in `try_cached_method` destructures and
994    /// throws the wrapping enum away anyway, so reading the payload by `Copy`
995    /// avoids the 32-48B enum memcpy. Method-call dispatch is the second-
996    /// hottest IC-keyed opcode class after the adaptive binary ops, so the
997    /// per-dispatch savings compound across the millions of method calls a
998    /// typical pipeline (`xs.filter(...).map(...).count()`) issues.
999    #[inline]
1000    #[cfg(any(test, feature = "vm-bench-internals"))]
1001    pub(crate) fn peek_method_cache(&self, slot: usize) -> Option<(u16, usize, MethodCacheTarget)> {
1002        match self.inline_caches.lock().get(slot)? {
1003            &InlineCacheEntry::Method {
1004                name_idx,
1005                argc,
1006                target,
1007            } => Some((name_idx, argc, target)),
1008            _ => None,
1009        }
1010    }
1011
1012    /// Property-cache fast path read. Returns the cached `(name_idx, target)`
1013    /// pair by value when `slot` holds a `Property` entry, else `None`. The
1014    /// outer `InlineCacheEntry` is the worst-case-sized variant (DirectCall
1015    /// at ~48 bytes including padding); cloning it just to discard four other
1016    /// variants in `try_cached_property`'s variant-check is wasted work. The
1017    /// peek returns just the `Property` payload (`u16` + `PropertyCacheTarget`),
1018    /// skipping the outer enum tag init and the padding-to-largest-variant
1019    /// memcpy. Fires on every `Op::GetProperty` / `Op::GetPropertyOpt`
1020    /// dispatch, which is the dominant opcode for any field-read-heavy code.
1021    #[inline]
1022    #[cfg(any(test, feature = "vm-bench-internals"))]
1023    pub(crate) fn peek_property_cache(&self, slot: usize) -> Option<(u16, PropertyCacheTarget)> {
1024        match self.inline_caches.lock().get(slot)? {
1025            InlineCacheEntry::Property { name_idx, target } => Some((*name_idx, target.clone())),
1026            _ => None,
1027        }
1028    }
1029
1030    /// Direct-call cache state read. Returns just the inner `DirectCallState`
1031    /// by value when `slot` holds a `DirectCall` entry, else `None`. Used by
1032    /// both `try_cached_direct_call(_)` (steady-state Specialized hit check)
1033    /// and `next_direct_call_entry` (Warmup → Specialized state-machine
1034    /// transition). Peeking the inner state directly skips the outer
1035    /// `InlineCacheEntry` discriminant check and tag init that the dispatcher
1036    /// otherwise pays on every `Op::Call` (closure callee) and the named-fn
1037    /// fast path inside `Op::CallBuiltin`. Single peek per dispatch covers
1038    /// both the read check and the write-back computation.
1039    #[inline]
1040    #[cfg(any(test, feature = "vm-bench-internals"))]
1041    pub(crate) fn peek_direct_call_state(&self, slot: usize) -> Option<DirectCallState> {
1042        match self.inline_caches.lock().get(slot)? {
1043            InlineCacheEntry::DirectCall { state } => Some(state.clone()),
1044            _ => None,
1045        }
1046    }
1047
1048    #[cfg(any(test, feature = "vm-bench-internals"))]
1049    pub(crate) fn set_inline_cache_entry(&self, slot: usize, entry: InlineCacheEntry) {
1050        if let Some(existing) = self.inline_caches.lock().get_mut(slot) {
1051            *existing = entry;
1052        }
1053    }
1054
1055    pub fn freeze_for_cache(&self) -> CachedChunk {
1056        CachedChunk {
1057            code: self.code.clone(),
1058            constants: self.constants.clone(),
1059            lines: self.lines.clone(),
1060            columns: self.columns.clone(),
1061            source_file: self.source_file.clone(),
1062            current_col: self.current_col,
1063            functions: self
1064                .functions
1065                .iter()
1066                .map(|function| function.freeze_for_cache())
1067                .collect(),
1068            inline_cache_slots: self.inline_cache_slots.clone(),
1069            local_slots: self.local_slots.clone(),
1070            references_outer_names: self.references_outer_names,
1071        }
1072    }
1073
1074    pub fn from_cached(cached: &CachedChunk) -> Self {
1075        let inline_cache_count = cached.inline_cache_slots.len();
1076        let constants_count = cached.constants.len();
1077        // Project the cached `BTreeMap<op_offset, slot>` into the flat
1078        // dispatch-side lookup table. Sized to `code.len()` so the hottest
1079        // hot opcodes (binary ops at the end of a long chunk) still hit the
1080        // fast-path bounds check rather than falling through to the
1081        // none-found branch. The size is bounded by code length, so the
1082        // memory footprint is tiny — a few KB for typical chunks.
1083        let mut inline_cache_index = Vec::new();
1084        inline_cache_index.resize(cached.code.len(), NO_INLINE_CACHE_SLOT);
1085        for (&op_offset, &slot) in cached.inline_cache_slots.iter() {
1086            if op_offset < inline_cache_index.len() {
1087                inline_cache_index[op_offset] = slot as u32;
1088            }
1089        }
1090        Self {
1091            cache_id: next_chunk_cache_id(),
1092            code: cached.code.clone(),
1093            constants: cached.constants.clone(),
1094            lines: cached.lines.clone(),
1095            columns: cached.columns.clone(),
1096            source_file: cached.source_file.clone(),
1097            current_col: cached.current_col,
1098            functions: cached
1099                .functions
1100                .iter()
1101                .map(|function| Arc::new(CompiledFunction::from_cached(function)))
1102                .collect(),
1103            inline_cache_slots: cached.inline_cache_slots.clone(),
1104            inline_cache_index,
1105            inline_caches: Arc::new(Mutex::new(vec![
1106                InlineCacheEntry::Empty;
1107                inline_cache_count
1108            ])),
1109            constant_strings: Arc::new(Mutex::new(vec![None; constants_count])),
1110            local_slots: cached.local_slots.clone(),
1111            references_outer_names: cached.references_outer_names,
1112            #[cfg(debug_assertions)]
1113            balance_depth: 0,
1114            #[cfg(debug_assertions)]
1115            balance_nonlinear: 0,
1116        }
1117    }
1118
1119    pub(crate) fn add_local_slot(
1120        &mut self,
1121        name: String,
1122        mutable: bool,
1123        scope_depth: usize,
1124    ) -> u16 {
1125        let idx = self.local_slots.len();
1126        self.local_slots.push(LocalSlotInfo {
1127            name,
1128            mutable,
1129            scope_depth,
1130        });
1131        idx as u16
1132    }
1133
1134    /// Read a u64 argument at the given position.
1135    pub fn read_u64(&self, pos: usize) -> u64 {
1136        u64::from_be_bytes([
1137            self.code[pos],
1138            self.code[pos + 1],
1139            self.code[pos + 2],
1140            self.code[pos + 3],
1141            self.code[pos + 4],
1142            self.code[pos + 5],
1143            self.code[pos + 6],
1144            self.code[pos + 7],
1145        ])
1146    }
1147
1148    /// Disassemble the chunk for debugging. The per-opcode rendering is
1149    /// macro-generated alongside the dispatch tables in
1150    /// `crate::vm::ops` — see [`Self::disassemble_op`].
1151    pub fn disassemble(&self, name: &str) -> String {
1152        let mut out = format!("== {name} ==\n");
1153        let mut ip = 0;
1154        while ip < self.code.len() {
1155            let op_byte = self.code[ip];
1156            let line = self.lines.get(ip).copied().unwrap_or(0);
1157            out.push_str(&format!("{ip:04} [{line:>4}] "));
1158            ip += 1;
1159
1160            if let Some(op) = Op::from_byte(op_byte) {
1161                self.disassemble_op(op, &mut ip, &mut out);
1162            } else {
1163                out.push_str(&format!("UNKNOWN(0x{op_byte:02x})\n"));
1164            }
1165        }
1166        out
1167    }
1168}
1169
1170/// Disassembly helpers consumed by the macro-generated
1171/// [`Chunk::disassemble_op`]. Each helper takes the current code position
1172/// (already advanced past the opcode byte), advances it over the operand
1173/// bytes the opcode carries, and renders one human-readable line without
1174/// a trailing newline (the dispatcher appends it).
1175///
1176/// Defining one helper per operand layout — and not one per opcode —
1177/// keeps adding an opcode a one-line edit in the `define_opcodes!` table
1178/// rather than a paired edit here. New layouts live with the helpers;
1179/// new opcodes live with the dispatch.
1180pub(crate) fn disasm_bare(_chunk: &Chunk, _ip: &mut usize, label: &str) -> String {
1181    label.to_string()
1182}
1183
1184pub(crate) fn disasm_u8(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1185    let arg = chunk.code[*ip];
1186    *ip += 1;
1187    format!("{label} {arg:>4}")
1188}
1189
1190pub(crate) fn disasm_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1191    let arg = chunk.read_u16(*ip);
1192    *ip += 2;
1193    format!("{label} {arg:>4}")
1194}
1195
1196pub(crate) fn disasm_const_pool_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1197    let idx = chunk.read_u16(*ip);
1198    *ip += 2;
1199    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1200}
1201
1202pub(crate) fn disasm_local_slot_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1203    let slot = chunk.read_u16(*ip);
1204    *ip += 2;
1205    let mut out = format!("{label} {slot:>4}");
1206    if let Some(info) = chunk.local_slots.get(slot as usize) {
1207        out.push_str(&format!(" ({})", info.name));
1208    }
1209    out
1210}
1211
1212pub(crate) fn disasm_method_call(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1213    let idx = chunk.read_u16(*ip);
1214    *ip += 2;
1215    let argc = chunk.code[*ip];
1216    *ip += 1;
1217    format!(
1218        "{label} {idx:>4} ({}) argc={argc}",
1219        chunk.constants[idx as usize]
1220    )
1221}
1222
1223pub(crate) fn disasm_match_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1224    let enum_idx = chunk.read_u16(*ip);
1225    *ip += 2;
1226    let var_idx = chunk.read_u16(*ip);
1227    *ip += 2;
1228    format!(
1229        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({})",
1230        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1231    )
1232}
1233
1234pub(crate) fn disasm_build_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1235    let enum_idx = chunk.read_u16(*ip);
1236    *ip += 2;
1237    let var_idx = chunk.read_u16(*ip);
1238    *ip += 2;
1239    let field_count = chunk.read_u16(*ip);
1240    *ip += 2;
1241    format!(
1242        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({}) fields={field_count}",
1243        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1244    )
1245}
1246
1247pub(crate) fn disasm_selective_import(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1248    let path_idx = chunk.read_u16(*ip);
1249    *ip += 2;
1250    let names_idx = chunk.read_u16(*ip);
1251    *ip += 2;
1252    format!(
1253        "{label} {path_idx:>4} ({}) names: {names_idx:>4} ({})",
1254        chunk.constants[path_idx as usize], chunk.constants[names_idx as usize],
1255    )
1256}
1257
1258pub(crate) fn disasm_check_type(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1259    let var_idx = chunk.read_u16(*ip);
1260    *ip += 2;
1261    let type_idx = chunk.read_u16(*ip);
1262    *ip += 2;
1263    format!(
1264        "{label} {var_idx:>4} ({}) -> {type_idx:>4} ({})",
1265        chunk.constants[var_idx as usize], chunk.constants[type_idx as usize],
1266    )
1267}
1268
1269pub(crate) fn disasm_call_builtin(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1270    let id = chunk.read_u64(*ip);
1271    *ip += 8;
1272    let idx = chunk.read_u16(*ip);
1273    *ip += 2;
1274    let argc = chunk.code[*ip];
1275    *ip += 1;
1276    format!(
1277        "{label} {id:#018x} {idx:>4} ({}) argc={argc}",
1278        chunk.constants[idx as usize],
1279    )
1280}
1281
1282pub(crate) fn disasm_call_builtin_spread(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1283    let id = chunk.read_u64(*ip);
1284    *ip += 8;
1285    let idx = chunk.read_u16(*ip);
1286    *ip += 2;
1287    format!(
1288        "{label} {id:#018x} {idx:>4} ({})",
1289        chunk.constants[idx as usize],
1290    )
1291}
1292
1293pub(crate) fn disasm_method_call_spread(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1294    // emit_u16(Op::MethodCallSpread, name_idx, ...) writes opcode + 2
1295    // bytes of u16 name_idx, so the operand is read at *ip with the
1296    // usual `read_u16`. The previous hand-written disasm read at
1297    // `ip + 1`, which displayed the wrong constant index — silently
1298    // corrupting any disassembly that hit a `MethodCallSpread` opcode.
1299    let idx = chunk.read_u16(*ip);
1300    *ip += 2;
1301    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1302}
1303
1304impl Default for Chunk {
1305    fn default() -> Self {
1306        Self::new()
1307    }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use std::sync::Arc;
1313
1314    use super::{
1315        Chunk, DirectCallState, DirectCallTarget, InlineCacheEntry, MethodCacheTarget, Op,
1316        PropertyCacheTarget,
1317    };
1318    use crate::BuiltinId;
1319
1320    #[test]
1321    fn op_from_byte_matches_repr_order() {
1322        for (byte, op) in Op::ALL.iter().copied().enumerate() {
1323            assert_eq!(byte as u8, op as u8);
1324            assert_eq!(Op::from_byte(byte as u8), Some(op));
1325        }
1326        assert_eq!(Op::from_byte(Op::ALL.len() as u8), None);
1327        assert_eq!(Op::COUNT, Op::ALL.len());
1328    }
1329
1330    #[test]
1331    fn disassemble_covers_every_opcode_variant() {
1332        // The macro-generated `disassemble_op` match is exhaustive on
1333        // `Op`, so this is a compile-time guarantee. The runtime check
1334        // pins that no helper falls through to `UNKNOWN(...)` for a
1335        // valid opcode byte — catching any future macro refactor that
1336        // silently drops a helper arm. Each opcode is exercised in
1337        // isolation against a hand-built chunk so the test logic does
1338        // not depend on operand sizes (and so a single short opcode
1339        // does not bleed into reading trailing padding as a follow-on
1340        // opcode in the chunk-level loop).
1341        for op in Op::ALL.iter().copied() {
1342            let mut chunk = Chunk::new();
1343            chunk.add_constant(super::Constant::String("__probe__".to_string()));
1344            // Pad to the worst-case operand width (CallBuiltin: u64 +
1345            // u16 + u8 = 11 bytes) so any helper has well-formed bytes
1346            // to consume regardless of its layout.
1347            for _ in 0..16 {
1348                chunk.code.push(0);
1349            }
1350            let mut ip: usize = 0;
1351            let mut out = String::new();
1352            chunk.disassemble_op(op, &mut ip, &mut out);
1353            assert!(
1354                !out.contains("UNKNOWN"),
1355                "disasm emitted UNKNOWN for {op:?}: {out}",
1356            );
1357            assert!(!out.is_empty(), "disasm produced no output for {op:?}");
1358        }
1359    }
1360
1361    // --- references_outer_names tracking ---
1362    //
1363    // Drives the compile-time guard used in `Vm::closure_call_env`
1364    // and `Vm::closure_call_env_for_current_frame` to skip the
1365    // per-invocation caller-scope late-bind walks. Coverage parity
1366    // matters because false negatives would regress recursive /
1367    // mutually-recursive fns.
1368
1369    #[test]
1370    fn empty_chunk_does_not_reference_outer_names() {
1371        let chunk = Chunk::new();
1372        assert!(!chunk.references_outer_names);
1373    }
1374
1375    #[test]
1376    fn arithmetic_only_chunk_does_not_reference_outer_names() {
1377        // The hot `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
1378        // shape: pure stack/arithmetic ops and slot locals, no env
1379        // reads. Must NOT flag — that's the whole point of the
1380        // optimization.
1381        let mut chunk = Chunk::new();
1382        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1383        chunk.emit_u16(Op::Constant, 0, 1);
1384        chunk.emit(Op::MulInt, 1);
1385        chunk.emit(Op::Pop, 1);
1386        chunk.emit(Op::Return, 1);
1387        assert!(!chunk.references_outer_names);
1388    }
1389
1390    #[test]
1391    fn slot_only_chunk_does_not_reference_outer_names() {
1392        // Compiler-resolved locals never need env-based late-bind.
1393        let mut chunk = Chunk::new();
1394        chunk.emit_u16(Op::DefLocalSlot, 0, 1);
1395        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1396        chunk.emit_u16(Op::SetLocalSlot, 0, 1);
1397        assert!(!chunk.references_outer_names);
1398    }
1399
1400    #[test]
1401    fn get_var_flags_outer_name_reference() {
1402        let mut chunk = Chunk::new();
1403        chunk.emit_u16(Op::GetVar, 0, 1);
1404        assert!(chunk.references_outer_names);
1405    }
1406
1407    #[test]
1408    fn set_var_flags_outer_name_reference() {
1409        let mut chunk = Chunk::new();
1410        chunk.emit_u16(Op::SetVar, 0, 1);
1411        assert!(chunk.references_outer_names);
1412    }
1413
1414    #[test]
1415    fn check_type_flags_outer_name_reference() {
1416        let mut chunk = Chunk::new();
1417        chunk.emit_u16(Op::CheckType, 0, 1);
1418        assert!(chunk.references_outer_names);
1419    }
1420
1421    #[test]
1422    fn call_builtin_flags_outer_name_reference() {
1423        let mut chunk = Chunk::new();
1424        chunk.emit_call_builtin(BuiltinId::from_name("any_name"), 0, 1, 1);
1425        assert!(chunk.references_outer_names);
1426    }
1427
1428    #[test]
1429    fn call_builtin_spread_flags_outer_name_reference() {
1430        let mut chunk = Chunk::new();
1431        chunk.emit_call_builtin_spread(BuiltinId::from_name("any_name"), 0, 1);
1432        assert!(chunk.references_outer_names);
1433    }
1434
1435    #[test]
1436    fn tail_call_flags_outer_name_reference() {
1437        // `return fn_name(...)` compiles to Constant + TailCall —
1438        // TailCall does a runtime name lookup, so it has to flag.
1439        let mut chunk = Chunk::new();
1440        chunk.emit_u8(Op::TailCall, 1, 1);
1441        assert!(chunk.references_outer_names);
1442    }
1443
1444    #[test]
1445    fn call_flags_outer_name_reference() {
1446        // Op::Call can receive a String callee from the stack (the
1447        // by-name dispatch shape), so it has to flag too.
1448        let mut chunk = Chunk::new();
1449        chunk.emit_u8(Op::Call, 1, 1);
1450        assert!(chunk.references_outer_names);
1451    }
1452
1453    #[test]
1454    fn pipe_flags_outer_name_reference() {
1455        // `x |> name` resolves `name` through env when the value on
1456        // the stack is a String / BuiltinRef.
1457        let mut chunk = Chunk::new();
1458        chunk.emit(Op::Pipe, 1);
1459        assert!(chunk.references_outer_names);
1460    }
1461
1462    #[test]
1463    fn method_call_does_not_flag_outer_name_reference() {
1464        // Method receivers come off the operand stack, not the env;
1465        // emitting MethodCall alone must not force the walk.
1466        let mut chunk = Chunk::new();
1467        chunk.emit_method_call(0, 1, 1);
1468        chunk.emit_method_call_opt(0, 1, 1);
1469        assert!(!chunk.references_outer_names);
1470    }
1471
1472    #[test]
1473    fn jump_and_control_flow_do_not_flag_outer_name_reference() {
1474        // Jumps, returns, pops — control flow stays inside the
1475        // frame and never touches env lookups.
1476        let mut chunk = Chunk::new();
1477        chunk.emit_u16(Op::Constant, 0, 1);
1478        chunk.emit(Op::JumpIfFalse, 1);
1479        chunk.emit(Op::Jump, 1);
1480        chunk.emit(Op::Return, 1);
1481        chunk.emit(Op::Pop, 1);
1482        assert!(!chunk.references_outer_names);
1483    }
1484
1485    #[test]
1486    fn references_outer_names_is_monotonic() {
1487        // Once flagged, subsequent non-flagging emits must not
1488        // clear the bit — flags are sticky.
1489        let mut chunk = Chunk::new();
1490        chunk.emit_u16(Op::GetVar, 0, 1);
1491        assert!(chunk.references_outer_names);
1492        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1493        chunk.emit(Op::MulInt, 1);
1494        assert!(chunk.references_outer_names);
1495    }
1496
1497    #[test]
1498    fn freeze_thaw_round_trips_references_outer_names() {
1499        // Bytecode-cache hits must observe the same flag as a
1500        // fresh compile — otherwise the first call after a cache
1501        // hit would either over- or under-skip the walk.
1502        let mut chunk = Chunk::new();
1503        chunk.emit_u16(Op::GetVar, 0, 1);
1504        assert!(chunk.references_outer_names);
1505        let frozen = chunk.freeze_for_cache();
1506        let thawed = Chunk::from_cached(&frozen);
1507        assert!(thawed.references_outer_names);
1508
1509        let plain = Chunk::new();
1510        assert!(!plain.references_outer_names);
1511        let frozen_plain = plain.freeze_for_cache();
1512        let thawed_plain = Chunk::from_cached(&frozen_plain);
1513        assert!(!thawed_plain.references_outer_names);
1514    }
1515
1516    // --- inline_cache_slot flat-index parity ---
1517    //
1518    // Slot lookups fire on every dispatch of an adaptive binary op
1519    // (Add/Sub/Mul/Div/Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq),
1520    // every `Op::Call`, every `Op::MethodCall(Opt)`, and every
1521    // `Op::GetProperty(Opt)`. The flat `Vec<u32>` index has to stay
1522    // perfectly in sync with the serialization-stable BTreeMap or
1523    // a cached call site would either skip its inline cache (slow
1524    // path with no learning) or read a stale slot (silently
1525    // mis-specialized arithmetic). These tests pin the contract.
1526
1527    #[test]
1528    fn inline_cache_slot_returns_none_for_non_cacheable_offsets() {
1529        // GetLocalSlot is a sync-fast-path opcode with no inline
1530        // cache; the index must report no slot.
1531        let mut chunk = Chunk::new();
1532        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1533        chunk.emit(Op::Pop, 1);
1534        chunk.emit(Op::Return, 1);
1535        assert!(chunk.inline_cache_slot(0).is_none());
1536        assert!(chunk.inline_cache_slot(3).is_none());
1537        assert!(chunk.inline_cache_slot(4).is_none());
1538    }
1539
1540    #[test]
1541    fn inline_cache_slot_registered_for_adaptive_binary_op() {
1542        // Pure-arithmetic ops use the adaptive-binary IC for shape
1543        // specialization. The slot has to be 0 because the chunk is
1544        // otherwise empty.
1545        let mut chunk = Chunk::new();
1546        chunk.emit(Op::Add, 1);
1547        assert_eq!(chunk.inline_cache_slot(0), Some(0));
1548    }
1549
1550    #[test]
1551    fn inline_cache_slot_distinct_for_sequential_adaptive_binary_ops() {
1552        // Three back-to-back Adds must get three distinct slots so
1553        // each instruction's shape feedback evolves independently
1554        // (otherwise the same call site would clobber a neighbor's
1555        // learning every dispatch).
1556        let mut chunk = Chunk::new();
1557        chunk.emit(Op::Add, 1);
1558        chunk.emit(Op::Sub, 1);
1559        chunk.emit(Op::Mul, 1);
1560        let s0 = chunk.inline_cache_slot(0).expect("Add slot");
1561        let s1 = chunk.inline_cache_slot(1).expect("Sub slot");
1562        let s2 = chunk.inline_cache_slot(2).expect("Mul slot");
1563        assert_ne!(s0, s1);
1564        assert_ne!(s1, s2);
1565        assert_ne!(s0, s2);
1566    }
1567
1568    #[test]
1569    fn inline_cache_slot_returns_none_for_out_of_bounds_offset() {
1570        // The dispatcher derives `op_offset` from `ip - 1`; an
1571        // out-of-bounds query must return None rather than panic.
1572        let mut chunk = Chunk::new();
1573        chunk.emit(Op::Add, 1);
1574        assert!(chunk.inline_cache_slot(usize::MAX).is_none());
1575        assert!(chunk.inline_cache_slot(chunk.code.len()).is_none());
1576        assert!(chunk.inline_cache_slot(chunk.code.len() + 16).is_none());
1577    }
1578
1579    #[test]
1580    fn inline_cache_slot_for_get_property_and_method_call() {
1581        // GetProperty(Opt) and MethodCall(Opt) both register an IC
1582        // slot at emit time — adaptive method-call dispatch and
1583        // monomorphic property-cache learning depend on it.
1584        let mut chunk = Chunk::new();
1585        chunk.emit_u16(Op::GetProperty, 0, 1); // offset 0..3
1586        chunk.emit_method_call(0, 1, 1); // offset 3..7
1587        chunk.emit_method_call_opt(0, 1, 1); // offset 7..11
1588        chunk.emit_u16(Op::GetPropertyOpt, 0, 1); // offset 11..14
1589        assert!(chunk.inline_cache_slot(0).is_some(), "GetProperty");
1590        assert!(chunk.inline_cache_slot(3).is_some(), "MethodCall");
1591        assert!(chunk.inline_cache_slot(7).is_some(), "MethodCallOpt");
1592        assert!(chunk.inline_cache_slot(11).is_some(), "GetPropertyOpt");
1593    }
1594
1595    #[test]
1596    fn inline_cache_slot_for_call_and_call_builtin() {
1597        // Both `Op::Call` (closure / by-name callee) and
1598        // `emit_call_builtin` register IC slots. The latter is the
1599        // adaptive-call fast path used for every direct user-fn
1600        // invocation.
1601        let mut chunk = Chunk::new();
1602        chunk.emit_u8(Op::Call, 1, 1); // offset 0..2
1603        let call_builtin_offset = chunk.code.len();
1604        chunk.emit_call_builtin(BuiltinId::from_name("any"), 0, 1, 1);
1605        assert!(chunk.inline_cache_slot(0).is_some(), "Op::Call IC slot");
1606        assert!(
1607            chunk.inline_cache_slot(call_builtin_offset).is_some(),
1608            "Op::CallBuiltin IC slot"
1609        );
1610    }
1611
1612    #[test]
1613    fn inline_cache_slot_register_is_idempotent_for_same_offset() {
1614        // The compile path uses `BTreeMap::contains_key` to dedup
1615        // re-registration at the same offset (eg. when a helper
1616        // re-emits into a still-live position). The flat index has
1617        // to honor the same semantics — never silently overwriting
1618        // an existing slot with a fresh one.
1619        let mut chunk = Chunk::new();
1620        chunk.emit(Op::Add, 1);
1621        let slot_before = chunk.inline_cache_slot(0).expect("first registration");
1622        // Manually re-register the same offset to confirm dedup.
1623        chunk.register_inline_cache(0);
1624        let slot_after = chunk.inline_cache_slot(0).expect("re-registration");
1625        assert_eq!(slot_before, slot_after);
1626    }
1627
1628    #[test]
1629    fn inline_cache_index_round_trips_through_cached_chunk() {
1630        // The cache freeze drops the flat index (it's derived from
1631        // the BTreeMap that *is* serialized). On thaw, the flat
1632        // index must be rebuilt so the first hot dispatch of a
1633        // cached chunk doesn't fall off the IC-slot cliff (which
1634        // would silently disable shape specialization until the
1635        // chunk is recompiled from source).
1636        let mut chunk = Chunk::new();
1637        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1638        chunk.emit_u16(Op::Constant, 0, 1);
1639        chunk.emit(Op::Add, 1);
1640        chunk.emit(Op::Sub, 1);
1641        chunk.emit_method_call(0, 1, 1);
1642        chunk.emit_u8(Op::Call, 1, 1);
1643        let live_slots: Vec<(usize, Option<usize>)> = (0..chunk.code.len())
1644            .map(|o| (o, chunk.inline_cache_slot(o)))
1645            .collect();
1646        let frozen = chunk.freeze_for_cache();
1647        let thawed = Chunk::from_cached(&frozen);
1648        let thawed_slots: Vec<(usize, Option<usize>)> = (0..thawed.code.len())
1649            .map(|o| (o, thawed.inline_cache_slot(o)))
1650            .collect();
1651        assert_eq!(live_slots, thawed_slots);
1652    }
1653
1654    #[test]
1655    fn inline_cache_index_agrees_with_btreemap_view() {
1656        // Authoritative parity check: for every code offset, the
1657        // flat-index `inline_cache_slot` must return exactly what
1658        // the underlying BTreeMap would (mod the `Option` boxing).
1659        // Catches any future emit path that grows `inline_cache_slots`
1660        // without going through `register_inline_cache`.
1661        let mut chunk = Chunk::new();
1662        chunk.emit(Op::Add, 1);
1663        chunk.emit_u16(Op::GetVar, 0, 1);
1664        chunk.emit(Op::LessInt, 1);
1665        chunk.emit_u8(Op::Call, 2, 1);
1666        chunk.emit(Op::Equal, 1);
1667        chunk.emit_u16(Op::GetProperty, 0, 1);
1668        chunk.emit_method_call_opt(0, 0, 1);
1669        for offset in 0..chunk.code.len() {
1670            let from_map = chunk.inline_cache_slots.get(&offset).copied();
1671            let from_index = chunk.inline_cache_slot(offset);
1672            assert_eq!(from_index, from_map, "parity broken at offset {offset}");
1673        }
1674    }
1675
1676    // --- peek_adaptive_binary_cache contract ---
1677    //
1678    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1679    // hottest opcode class (Add / Sub / Mul / Div / Mod / Eq / Neq /
1680    // Less / Greater / LessEq / GreaterEq). It must return None for
1681    // unrelated IC variants — silently mis-extracting a `Property` /
1682    // `DirectCall` / `Method` slot as `AdaptiveBinary` would feed
1683    // garbage into `try_specialized_binary` and either spec-mis-fire or
1684    // crash. These tests pin the variant gate.
1685
1686    #[test]
1687    fn peek_adaptive_binary_returns_none_for_empty_slot() {
1688        let mut chunk = Chunk::new();
1689        chunk.emit(Op::Add, 1);
1690        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1691        // Default state of a freshly-emitted slot is Empty.
1692        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
1693    }
1694
1695    #[test]
1696    fn peek_adaptive_binary_returns_op_and_state_after_warmup() {
1697        use super::{AdaptiveBinaryOp, AdaptiveBinaryState, BinaryShape, InlineCacheEntry};
1698        let mut chunk = Chunk::new();
1699        chunk.emit(Op::Add, 1);
1700        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1701        chunk.set_inline_cache_entry(
1702            slot,
1703            InlineCacheEntry::AdaptiveBinary {
1704                op: AdaptiveBinaryOp::Add,
1705                state: AdaptiveBinaryState::Warmup {
1706                    shape: BinaryShape::Int,
1707                    hits: 2,
1708                },
1709            },
1710        );
1711        let (op, state) = chunk
1712            .peek_adaptive_binary_cache(slot)
1713            .expect("warmed slot peek");
1714        assert_eq!(op, AdaptiveBinaryOp::Add);
1715        assert!(matches!(
1716            state,
1717            AdaptiveBinaryState::Warmup {
1718                shape: BinaryShape::Int,
1719                hits: 2
1720            }
1721        ));
1722    }
1723
1724    #[test]
1725    fn peek_adaptive_binary_returns_none_for_non_binary_variants() {
1726        // The cache slot may legitimately hold a `Property`, `Method`,
1727        // or `DirectCall` entry (eg. a Property slot at the offset
1728        // sequence happens to alias an Add slot during a code rewrite —
1729        // currently this cannot happen, but the peek must defensively
1730        // refuse non-AdaptiveBinary variants regardless).
1731        use super::{InlineCacheEntry, PropertyCacheTarget};
1732        let mut chunk = Chunk::new();
1733        chunk.emit(Op::Add, 1);
1734        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1735        chunk.set_inline_cache_entry(
1736            slot,
1737            InlineCacheEntry::Property {
1738                name_idx: 0,
1739                target: PropertyCacheTarget::ListCount,
1740            },
1741        );
1742        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
1743    }
1744
1745    #[test]
1746    fn peek_adaptive_binary_returns_none_for_out_of_bounds_slot() {
1747        // Defensive: `execute_adaptive_binary` filters its `slot`
1748        // through `inline_cache_slot` first, but
1749        // `peek_adaptive_binary_cache` should still return None for an
1750        // unmapped slot rather than panicking.
1751        let chunk = Chunk::new();
1752        assert!(chunk.peek_adaptive_binary_cache(0).is_none());
1753        assert!(chunk.peek_adaptive_binary_cache(usize::MAX).is_none());
1754    }
1755
1756    #[test]
1757    fn peek_adaptive_binary_state_is_copy() {
1758        // Compile-time assertion: `AdaptiveBinaryState: Copy` is the
1759        // whole point of this optimization — if a future variant adds
1760        // a non-Copy field, the static check below will fail at compile
1761        // time before the dispatcher silently regresses to the heavy
1762        // `InlineCacheEntry::clone` path.
1763        fn assert_copy<T: Copy>() {}
1764        assert_copy::<super::AdaptiveBinaryState>();
1765        assert_copy::<super::AdaptiveBinaryOp>();
1766        assert_copy::<super::BinaryShape>();
1767    }
1768
1769    // --- peek_method_cache contract ---
1770    //
1771    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1772    // method-call dispatch sites (`Op::MethodCall`, `Op::MethodCallOpt`,
1773    // `Op::MethodCallSpread`). It must return None for unrelated IC variants
1774    // — silently mis-extracting a `Property` / `DirectCall` / `AdaptiveBinary`
1775    // slot as `Method` would feed garbage into `try_cached_method` and either
1776    // spec-mis-fire (wrong target/argc) or skip the cache entirely on a real
1777    // hit. These tests pin the variant gate.
1778
1779    #[test]
1780    fn peek_method_cache_returns_none_for_empty_slot() {
1781        let mut chunk = Chunk::new();
1782        chunk.emit_method_call(0, 0, 1);
1783        let slot = chunk
1784            .inline_cache_slot(0)
1785            .expect("MethodCall registers a slot");
1786        assert!(chunk.peek_method_cache(slot).is_none());
1787    }
1788
1789    #[test]
1790    fn peek_method_cache_returns_triple_after_warmup() {
1791        let mut chunk = Chunk::new();
1792        chunk.emit_method_call(7, 2, 1);
1793        let slot = chunk
1794            .inline_cache_slot(0)
1795            .expect("MethodCall registers a slot");
1796        chunk.set_inline_cache_entry(
1797            slot,
1798            InlineCacheEntry::Method {
1799                name_idx: 7,
1800                argc: 2,
1801                target: MethodCacheTarget::ListContains,
1802            },
1803        );
1804        let (name_idx, argc, target) = chunk.peek_method_cache(slot).expect("warmed slot peek");
1805        assert_eq!(name_idx, 7);
1806        assert_eq!(argc, 2);
1807        assert_eq!(target, MethodCacheTarget::ListContains);
1808    }
1809
1810    #[test]
1811    fn peek_method_cache_returns_none_for_non_method_variants() {
1812        // The cache slot may legitimately hold an `AdaptiveBinary`,
1813        // `Property`, or `DirectCall` entry. The peek must defensively
1814        // refuse non-Method variants regardless.
1815        let mut chunk = Chunk::new();
1816        chunk.emit_method_call(0, 0, 1);
1817        let slot = chunk
1818            .inline_cache_slot(0)
1819            .expect("MethodCall registers a slot");
1820
1821        chunk.set_inline_cache_entry(
1822            slot,
1823            InlineCacheEntry::Property {
1824                name_idx: 0,
1825                target: PropertyCacheTarget::ListCount,
1826            },
1827        );
1828        assert!(chunk.peek_method_cache(slot).is_none());
1829    }
1830
1831    #[test]
1832    fn peek_method_cache_returns_none_for_out_of_bounds_slot() {
1833        let chunk = Chunk::new();
1834        assert!(chunk.peek_method_cache(0).is_none());
1835        assert!(chunk.peek_method_cache(usize::MAX).is_none());
1836    }
1837
1838    #[test]
1839    fn peek_method_cache_target_is_copy() {
1840        // Compile-time assertion: `MethodCacheTarget: Copy` is the whole
1841        // point of this peek path — if a future variant adds a non-Copy
1842        // field (eg. an `Arc<str>` for a dynamic method name), the static
1843        // check below will fail at compile time before the dispatcher
1844        // silently regresses to the heavy `InlineCacheEntry::clone` path.
1845        fn assert_copy<T: Copy>() {}
1846        assert_copy::<super::MethodCacheTarget>();
1847    }
1848
1849    // --- peek_property_cache contract ---
1850    //
1851    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1852    // property-read path (`Op::GetProperty` / `Op::GetPropertyOpt`). It
1853    // must return None for unrelated IC variants — silently mis-extracting
1854    // a `Method` / `DirectCall` / `AdaptiveBinary` slot as `Property` would
1855    // feed garbage into `try_cached_property` (wrong target match, possibly
1856    // a panic on the field-name lookup). These tests pin the variant gate.
1857
1858    #[test]
1859    fn peek_property_cache_returns_none_for_empty_slot() {
1860        let mut chunk = Chunk::new();
1861        chunk.emit_u16(Op::GetProperty, 0, 1);
1862        let slot = chunk
1863            .inline_cache_slot(0)
1864            .expect("GetProperty registers a slot");
1865        assert!(chunk.peek_property_cache(slot).is_none());
1866    }
1867
1868    #[test]
1869    fn peek_property_cache_returns_pair_after_warmup_for_dict_field() {
1870        let mut chunk = Chunk::new();
1871        chunk.emit_u16(Op::GetProperty, 0, 1);
1872        let slot = chunk
1873            .inline_cache_slot(0)
1874            .expect("GetProperty registers a slot");
1875        chunk.set_inline_cache_entry(
1876            slot,
1877            InlineCacheEntry::Property {
1878                name_idx: 11,
1879                target: PropertyCacheTarget::DictField(Arc::from("count")),
1880            },
1881        );
1882        let (name_idx, target) = chunk
1883            .peek_property_cache(slot)
1884            .expect("warmed property slot peek");
1885        assert_eq!(name_idx, 11);
1886        match target {
1887            PropertyCacheTarget::DictField(field) => assert_eq!(field.as_ref(), "count"),
1888            other => panic!("expected DictField, got {other:?}"),
1889        }
1890    }
1891
1892    #[test]
1893    fn peek_property_cache_returns_pair_for_unit_target() {
1894        // Unit targets (eg. ListCount, ListEmpty, PairFirst) carry no Arc,
1895        // so the cloned PropertyCacheTarget is a pure scalar move at the
1896        // peek boundary. The hottest case in practice.
1897        let mut chunk = Chunk::new();
1898        chunk.emit_u16(Op::GetProperty, 0, 1);
1899        let slot = chunk
1900            .inline_cache_slot(0)
1901            .expect("GetProperty registers a slot");
1902        chunk.set_inline_cache_entry(
1903            slot,
1904            InlineCacheEntry::Property {
1905                name_idx: 3,
1906                target: PropertyCacheTarget::ListCount,
1907            },
1908        );
1909        let (name_idx, target) = chunk
1910            .peek_property_cache(slot)
1911            .expect("warmed property slot peek");
1912        assert_eq!(name_idx, 3);
1913        assert_eq!(target, PropertyCacheTarget::ListCount);
1914    }
1915
1916    #[test]
1917    fn peek_property_cache_returns_none_for_non_property_variants() {
1918        let mut chunk = Chunk::new();
1919        chunk.emit_u16(Op::GetProperty, 0, 1);
1920        let slot = chunk
1921            .inline_cache_slot(0)
1922            .expect("GetProperty registers a slot");
1923        chunk.set_inline_cache_entry(
1924            slot,
1925            InlineCacheEntry::Method {
1926                name_idx: 0,
1927                argc: 0,
1928                target: MethodCacheTarget::ListCount,
1929            },
1930        );
1931        assert!(chunk.peek_property_cache(slot).is_none());
1932    }
1933
1934    #[test]
1935    fn peek_property_cache_returns_none_for_out_of_bounds_slot() {
1936        let chunk = Chunk::new();
1937        assert!(chunk.peek_property_cache(0).is_none());
1938        assert!(chunk.peek_property_cache(usize::MAX).is_none());
1939    }
1940
1941    // --- peek_direct_call_state contract ---
1942    //
1943    // Used on both the hot Specialized-hit check path (`try_cached_direct_call`
1944    // / `try_cached_named_direct_call`) and the state-machine write-back
1945    // (`next_direct_call_entry`). Returning None for the non-DirectCall slot
1946    // shapes is critical: a mis-extracted Method/Property/AdaptiveBinary slot
1947    // would have the dispatcher attempt a closure call with the wrong argc
1948    // or Arc::ptr_eq against an unrelated closure.
1949
1950    #[test]
1951    fn peek_direct_call_state_returns_none_for_empty_slot() {
1952        let mut chunk = Chunk::new();
1953        chunk.emit_u8(Op::Call, 0, 1);
1954        let slot = chunk
1955            .inline_cache_slot(0)
1956            .expect("Op::Call registers a slot");
1957        assert!(chunk.peek_direct_call_state(slot).is_none());
1958    }
1959
1960    #[test]
1961    fn peek_direct_call_state_returns_warmup_state() {
1962        let mut chunk = Chunk::new();
1963        chunk.emit_u8(Op::Call, 0, 1);
1964        let slot = chunk
1965            .inline_cache_slot(0)
1966            .expect("Op::Call registers a slot");
1967        let target = synthetic_direct_call_target();
1968        chunk.set_inline_cache_entry(
1969            slot,
1970            InlineCacheEntry::DirectCall {
1971                state: DirectCallState::Warmup {
1972                    argc: 2,
1973                    target: target.clone(),
1974                    hits: 1,
1975                },
1976            },
1977        );
1978        let state = chunk
1979            .peek_direct_call_state(slot)
1980            .expect("warmed direct-call slot peek");
1981        match state {
1982            DirectCallState::Warmup {
1983                argc,
1984                target: peeked_target,
1985                hits,
1986            } => {
1987                assert_eq!(argc, 2);
1988                assert_eq!(hits, 1);
1989                assert_eq!(peeked_target, target);
1990            }
1991            other => panic!("expected Warmup, got {other:?}"),
1992        }
1993    }
1994
1995    #[test]
1996    fn peek_direct_call_state_returns_specialized_state() {
1997        let mut chunk = Chunk::new();
1998        chunk.emit_u8(Op::Call, 0, 1);
1999        let slot = chunk
2000            .inline_cache_slot(0)
2001            .expect("Op::Call registers a slot");
2002        let target = synthetic_direct_call_target();
2003        chunk.set_inline_cache_entry(
2004            slot,
2005            InlineCacheEntry::DirectCall {
2006                state: DirectCallState::Specialized {
2007                    argc: 3,
2008                    target: target.clone(),
2009                    hits: 100,
2010                    misses: 0,
2011                },
2012            },
2013        );
2014        let state = chunk
2015            .peek_direct_call_state(slot)
2016            .expect("warmed direct-call slot peek");
2017        match state {
2018            DirectCallState::Specialized {
2019                argc,
2020                target: peeked_target,
2021                hits,
2022                misses,
2023            } => {
2024                assert_eq!(argc, 3);
2025                assert_eq!(hits, 100);
2026                assert_eq!(misses, 0);
2027                assert_eq!(peeked_target, target);
2028            }
2029            other => panic!("expected Specialized, got {other:?}"),
2030        }
2031    }
2032
2033    #[test]
2034    fn peek_direct_call_state_returns_none_for_non_direct_call_variants() {
2035        let mut chunk = Chunk::new();
2036        chunk.emit_u8(Op::Call, 0, 1);
2037        let slot = chunk
2038            .inline_cache_slot(0)
2039            .expect("Op::Call registers a slot");
2040
2041        chunk.set_inline_cache_entry(
2042            slot,
2043            InlineCacheEntry::Property {
2044                name_idx: 0,
2045                target: PropertyCacheTarget::ListCount,
2046            },
2047        );
2048        assert!(chunk.peek_direct_call_state(slot).is_none());
2049    }
2050
2051    #[test]
2052    fn peek_direct_call_state_returns_none_for_out_of_bounds_slot() {
2053        let chunk = Chunk::new();
2054        assert!(chunk.peek_direct_call_state(0).is_none());
2055        assert!(chunk.peek_direct_call_state(usize::MAX).is_none());
2056    }
2057
2058    /// Build a synthetic `DirectCallTarget::Closure` for direct-call peek
2059    /// tests. The closure has an empty body — the IC peek only inspects
2060    /// the wrapping `Arc`, not the closure internals.
2061    fn synthetic_direct_call_target() -> DirectCallTarget {
2062        use crate::value::VmClosure;
2063        use crate::{CompiledFunction, VmEnv};
2064        let func = CompiledFunction {
2065            name: "synthetic".to_string(),
2066            type_params: Vec::new(),
2067            nominal_type_names: Vec::new(),
2068            params: Vec::new(),
2069            default_start: None,
2070            chunk: Arc::new(Chunk::new()),
2071            is_generator: false,
2072            is_stream: false,
2073            has_rest_param: false,
2074            has_runtime_type_checks: false,
2075        };
2076        DirectCallTarget::Closure(Arc::new(VmClosure {
2077            func: Arc::new(func),
2078            env: VmEnv::new(),
2079            source_dir: None,
2080            module_functions: None,
2081            module_state: None,
2082        }))
2083    }
2084}