Skip to main content

harn_vm/
chunk.rs

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