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
653        | SetLocalSlotProperty | Pop => -1,
654        // Value-preserving: unary ops, by-name lookups/checks, and scope /
655        // iterator / exception-handler bookkeeping (the last three touch
656        // side stacks, not the operand stack).
657        Negate | Not | GetProperty | GetPropertyOpt | CheckType | TryUnwrap | TryWrapOk | Swap
658        | PushScope | PopScope | PopIterator | PopHandler => 0,
659        // Pop two, push one.
660        Add | Sub | Mul | Div | Mod | Pow | AddInt | SubInt | MulInt | DivInt | ModInt
661        | AddFloat | SubFloat | MulFloat | DivFloat | ModFloat | Equal | NotEqual | Less
662        | Greater | LessEqual | GreaterEqual | EqualInt | NotEqualInt | LessInt | GreaterInt
663        | LessEqualInt | GreaterEqualInt | EqualFloat | NotEqualFloat | LessFloat
664        | GreaterFloat | LessEqualFloat | GreaterEqualFloat | EqualBool | NotEqualBool
665        | EqualString | NotEqualString | Contains | Subscript | SubscriptOpt => -1,
666        // `IterInit` consumes the iterable and pushes nothing (the iterator
667        // lives on a side stack).
668        IterInit => -1,
669        // Net -2: `Slice` pops object/start/end and pushes one value;
670        // subscript stores pop value/index and read the target from bytecode.
671        Slice | SetSubscript | SetLocalSlotSubscript => -2,
672        // Variadic whose arity is the emit argument: pop `count`, push one.
673        BuildList | Concat | CallBuiltin => 1 - count,
674        BuildDict => 1 - 2 * count,
675        // Calls also pop the callee/receiver beneath the args.
676        Call | MethodCall | MethodCallOpt => -count,
677        // Non-linear (see doc comment): branches, terminators, async/handler
678        // ops, and variadic ops whose arity isn't the emit argument.
679        Jump | JumpIfFalse | JumpIfTrue | IterNext | Return | TailCall | Throw | TryCatchSetup
680        | Spawn | Pipe | Parallel | ParallelMap | ParallelMapStream | ParallelSettle
681        | SyncMutexEnter | SyncMutexEnterKeyed | TaskScopeEnter | TaskScopeExit | Import
682        | SelectiveImport | DeadlineSetup | DeadlineEnd | BuildEnum | MatchEnum | Yield
683        | CallSpread | CallBuiltinSpread | MethodCallSpread => return None,
684    })
685}
686
687impl Chunk {
688    pub fn new() -> Self {
689        Self {
690            cache_id: next_chunk_cache_id(),
691            code: Vec::new(),
692            constants: Vec::new(),
693            constant_index: HashMap::new(),
694            lines: Vec::new(),
695            columns: Vec::new(),
696            source_file: None,
697            current_col: 0,
698            functions: Vec::new(),
699            inline_cache_slots: BTreeMap::new(),
700            inline_cache_index: Vec::new(),
701            inline_caches: Arc::new(Mutex::new(Vec::new())),
702            constant_strings: Arc::new(Mutex::new(Vec::new())),
703            local_slots: Vec::new(),
704            references_outer_names: false,
705            #[cfg(debug_assertions)]
706            balance_depth: 0,
707            #[cfg(debug_assertions)]
708            balance_nonlinear: 0,
709        }
710    }
711
712    /// Set the current column for subsequent emit calls.
713    pub fn set_column(&mut self, col: u32) {
714        self.current_col = col;
715    }
716
717    /// Add a constant and return its index.
718    pub fn add_constant(&mut self, constant: Constant) -> u16 {
719        debug_assert!(
720            self.constant_index.len() <= self.constants.len(),
721            "constant side index cannot outgrow the constant pool"
722        );
723        let key = ConstantKey::from(&constant);
724        if let Some(index) = self.constant_index.get(&key) {
725            debug_assert!(
726                self.constants
727                    .get(*index as usize)
728                    .is_some_and(|existing| constants_identical(existing, &constant)),
729                "constant side index drifted from the constant pool"
730            );
731            return *index;
732        }
733        let idx = self.constants.len();
734        let idx = u16::try_from(idx).expect("constant pool exceeded u16 operand space");
735        self.constants.push(constant);
736        self.constant_index.insert(key, idx);
737        idx
738    }
739
740    /// Emit a single-byte instruction.
741    pub fn emit(&mut self, op: Op, line: u32) {
742        #[cfg(debug_assertions)]
743        self.note_balance(op, 0);
744        let col = self.current_col;
745        let op_offset = self.code.len();
746        self.code.push(op as u8);
747        self.lines.push(line);
748        self.columns.push(col);
749        if is_adaptive_binary_op(op) {
750            self.register_inline_cache(op_offset);
751        }
752        if op_reads_outer_name(op) {
753            self.references_outer_names = true;
754        }
755    }
756
757    /// Emit an instruction with a u16 argument.
758    pub fn emit_u16(&mut self, op: Op, arg: u16, line: u32) {
759        #[cfg(debug_assertions)]
760        self.note_balance(op, arg);
761        let col = self.current_col;
762        let op_offset = self.code.len();
763        self.code.push(op as u8);
764        self.code.push((arg >> 8) as u8);
765        self.code.push((arg & 0xFF) as u8);
766        self.lines.push(line);
767        self.lines.push(line);
768        self.lines.push(line);
769        self.columns.push(col);
770        self.columns.push(col);
771        self.columns.push(col);
772        if matches!(
773            op,
774            Op::GetProperty | Op::GetPropertyOpt | Op::MethodCallSpread
775        ) {
776            self.register_inline_cache(op_offset);
777        }
778        if op_reads_outer_name(op) {
779            self.references_outer_names = true;
780        }
781    }
782
783    /// Emit a local-slot property assignment:
784    /// opcode + u16 property constant index + u16 local slot index.
785    pub fn emit_set_local_slot_property(&mut self, prop_idx: u16, slot: u16, line: u32) {
786        #[cfg(debug_assertions)]
787        self.note_balance(Op::SetLocalSlotProperty, 0);
788        let col = self.current_col;
789        self.code.push(Op::SetLocalSlotProperty as u8);
790        self.code.push((prop_idx >> 8) as u8);
791        self.code.push((prop_idx & 0xFF) as u8);
792        self.code.push((slot >> 8) as u8);
793        self.code.push((slot & 0xFF) as u8);
794        for _ in 0..5 {
795            self.lines.push(line);
796            self.columns.push(col);
797        }
798    }
799
800    /// Emit an instruction with a u8 argument.
801    pub fn emit_u8(&mut self, op: Op, arg: u8, line: u32) {
802        #[cfg(debug_assertions)]
803        self.note_balance(op, arg as u16);
804        let col = self.current_col;
805        let op_offset = self.code.len();
806        self.code.push(op as u8);
807        self.code.push(arg);
808        self.lines.push(line);
809        self.lines.push(line);
810        self.columns.push(col);
811        self.columns.push(col);
812        if matches!(op, Op::Call) {
813            self.register_inline_cache(op_offset);
814        }
815        if op_reads_outer_name(op) {
816            self.references_outer_names = true;
817        }
818    }
819
820    /// Emit a direct builtin call.
821    pub fn emit_call_builtin(
822        &mut self,
823        id: crate::BuiltinId,
824        name_idx: u16,
825        arg_count: u8,
826        line: u32,
827    ) {
828        #[cfg(debug_assertions)]
829        self.note_balance(Op::CallBuiltin, arg_count as u16);
830        let col = self.current_col;
831        let op_offset = self.code.len();
832        self.code.push(Op::CallBuiltin as u8);
833        self.code.extend_from_slice(&id.raw().to_be_bytes());
834        self.code.push((name_idx >> 8) as u8);
835        self.code.push((name_idx & 0xFF) as u8);
836        self.code.push(arg_count);
837        for _ in 0..12 {
838            self.lines.push(line);
839            self.columns.push(col);
840        }
841        self.register_inline_cache(op_offset);
842        self.references_outer_names = true;
843    }
844
845    /// Emit a direct builtin spread call.
846    pub fn emit_call_builtin_spread(&mut self, id: crate::BuiltinId, name_idx: u16, line: u32) {
847        #[cfg(debug_assertions)]
848        self.note_balance(Op::CallBuiltinSpread, 0);
849        let col = self.current_col;
850        self.code.push(Op::CallBuiltinSpread as u8);
851        self.code.extend_from_slice(&id.raw().to_be_bytes());
852        self.code.push((name_idx >> 8) as u8);
853        self.code.push((name_idx & 0xFF) as u8);
854        for _ in 0..11 {
855            self.lines.push(line);
856            self.columns.push(col);
857        }
858        self.references_outer_names = true;
859    }
860
861    /// Emit a method call: op + u16 (method name) + u8 (arg count).
862    pub fn emit_method_call(&mut self, name_idx: u16, arg_count: u8, line: u32) {
863        self.emit_method_call_inner(Op::MethodCall, name_idx, arg_count, line);
864    }
865
866    /// Emit an optional method call (?.) — returns nil if receiver is nil.
867    pub fn emit_method_call_opt(&mut self, name_idx: u16, arg_count: u8, line: u32) {
868        self.emit_method_call_inner(Op::MethodCallOpt, name_idx, arg_count, line);
869    }
870
871    fn emit_method_call_inner(&mut self, op: Op, name_idx: u16, arg_count: u8, line: u32) {
872        #[cfg(debug_assertions)]
873        self.note_balance(op, arg_count as u16);
874        let col = self.current_col;
875        let op_offset = self.code.len();
876        self.code.push(op as u8);
877        self.code.push((name_idx >> 8) as u8);
878        self.code.push((name_idx & 0xFF) as u8);
879        self.code.push(arg_count);
880        self.lines.push(line);
881        self.lines.push(line);
882        self.lines.push(line);
883        self.lines.push(line);
884        self.columns.push(col);
885        self.columns.push(col);
886        self.columns.push(col);
887        self.columns.push(col);
888        self.register_inline_cache(op_offset);
889    }
890
891    /// Current code offset (for jump patching).
892    pub fn current_offset(&self) -> usize {
893        self.code.len()
894    }
895
896    /// Emit a jump instruction with a placeholder offset. Returns the position to patch.
897    pub fn emit_jump(&mut self, op: Op, line: u32) -> usize {
898        #[cfg(debug_assertions)]
899        self.note_balance(op, 0);
900        let col = self.current_col;
901        self.code.push(op as u8);
902        let patch_pos = self.code.len();
903        self.code.push(0xFF);
904        self.code.push(0xFF);
905        self.lines.push(line);
906        self.lines.push(line);
907        self.lines.push(line);
908        self.columns.push(col);
909        self.columns.push(col);
910        self.columns.push(col);
911        patch_pos
912    }
913
914    /// Patch a jump instruction at the given position to jump to the current offset.
915    pub fn patch_jump(&mut self, patch_pos: usize) {
916        let target = self.code.len() as u16;
917        self.code[patch_pos] = (target >> 8) as u8;
918        self.code[patch_pos + 1] = (target & 0xFF) as u8;
919    }
920
921    /// Patch a jump to a specific target position.
922    pub fn patch_jump_to(&mut self, patch_pos: usize, target: usize) {
923        let target = target as u16;
924        self.code[patch_pos] = (target >> 8) as u8;
925        self.code[patch_pos + 1] = (target & 0xFF) as u8;
926    }
927
928    /// Read a u16 argument at the given position.
929    pub fn read_u16(&self, pos: usize) -> u16 {
930        ((self.code[pos] as u16) << 8) | (self.code[pos + 1] as u16)
931    }
932
933    /// Fold one just-emitted opcode into the compile-time operand-stack
934    /// balance model (issue #2622). See [`op_stack_delta`] for the
935    /// linear-vs-non-linear classification.
936    #[cfg(debug_assertions)]
937    fn note_balance(&mut self, op: Op, count: u16) {
938        match op_stack_delta(op, count) {
939            Some(delta) => self.balance_depth += delta,
940            None => self.balance_nonlinear += 1,
941        }
942    }
943
944    /// Snapshot the balance model before compiling a statement; pair with
945    /// [`Chunk::balance_delta_since`].
946    #[cfg(debug_assertions)]
947    pub(crate) fn balance_probe(&self) -> BalanceProbe {
948        BalanceProbe {
949            depth: self.balance_depth,
950            nonlinear: self.balance_nonlinear,
951        }
952    }
953
954    /// Net operand-stack effect emitted since `probe`, or `None` when any
955    /// non-linearly-modeled opcode was emitted in that span (which makes
956    /// the running sum untrustworthy, so callers must not assert on it).
957    /// The absolute `balance_depth` may be meaningless after a non-exact
958    /// span — only deltas over a fully-exact span are valid.
959    #[cfg(debug_assertions)]
960    pub(crate) fn balance_delta_since(&self, probe: BalanceProbe) -> Option<i32> {
961        if self.balance_nonlinear == probe.nonlinear {
962            Some(self.balance_depth - probe.depth)
963        } else {
964            None
965        }
966    }
967
968    fn register_inline_cache(&mut self, op_offset: usize) {
969        if self.inline_cache_slots.contains_key(&op_offset) {
970            return;
971        }
972        let mut entries = self.inline_caches.lock();
973        let slot = entries.len();
974        entries.push(InlineCacheEntry::Empty);
975        self.inline_cache_slots.insert(op_offset, slot);
976        Self::write_inline_cache_index(&mut self.inline_cache_index, op_offset, slot);
977    }
978
979    /// Fast-path side-table writer. Pulled out as an associated fn so both
980    /// the live emit path and [`Chunk::from_cached`] share the same growth
981    /// strategy. Cache slots fit comfortably in `u32` because the slot count
982    /// is bounded by the cacheable-opcode count in `code`.
983    fn write_inline_cache_index(index: &mut Vec<u32>, op_offset: usize, slot: usize) {
984        if op_offset >= index.len() {
985            index.resize(op_offset + 1, NO_INLINE_CACHE_SLOT);
986        }
987        index[op_offset] = slot as u32;
988    }
989
990    /// Look up the inline-cache slot for the opcode at `op_offset`. This is
991    /// called on every dispatch of an adaptive binary op (Add/Sub/Mul/Div/
992    /// Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq), `Op::Call`, `Op::MethodCall`
993    /// (and `MethodCallOpt`/`MethodCallSpread`), and `Op::GetProperty`
994    /// (`GetPropertyOpt`). Backed by [`Chunk::inline_cache_index`] — a flat
995    /// `Vec<u32>` indexed by code offset — so the lookup is a single bounds-
996    /// checked array read instead of the prior `BTreeMap::get` which walked
997    /// internal nodes for every dispatched op.
998    #[inline]
999    pub(crate) fn inline_cache_slot(&self, op_offset: usize) -> Option<usize> {
1000        match self.inline_cache_index.get(op_offset).copied() {
1001            None | Some(NO_INLINE_CACHE_SLOT) => None,
1002            Some(slot) => Some(slot as usize),
1003        }
1004    }
1005
1006    pub(crate) fn inline_cache_slot_count(&self) -> usize {
1007        self.inline_cache_slots.len()
1008    }
1009
1010    pub(crate) fn cache_id(&self) -> u64 {
1011        self.cache_id
1012    }
1013
1014    /// Pre-optimization control path: the `BTreeMap`-backed lookup the
1015    /// dispatcher used before the flat `Vec<u32>` side-table. Exposed
1016    /// only behind the `vm-bench-internals` feature so the criterion
1017    /// microbench can A/B the two paths inside one binary on identical
1018    /// hardware. The production hot path must keep using
1019    /// [`Chunk::inline_cache_slot`].
1020    #[cfg(feature = "vm-bench-internals")]
1021    pub fn inline_cache_slot_via_btreemap_for_bench(&self, op_offset: usize) -> Option<usize> {
1022        self.inline_cache_slots.get(&op_offset).copied()
1023    }
1024
1025    /// Returns a shared string for a `Constant::String` at the given pool
1026    /// index, materializing it on first access and caching for reuse.
1027    /// Returns `None` when the constant at `idx` is not a string (the
1028    /// caller should fall back to the regular `Constant` match).
1029    pub(crate) fn constant_string_rc(&self, idx: usize) -> Option<Arc<str>> {
1030        // Borrow the side table mutably so we can lazily extend / fill
1031        // entries. The borrow is scope-confined to this function; the
1032        // VM never re-enters constant_string_rc for the same chunk
1033        // during a single materialization, so no nested-borrow risk.
1034        let mut entries = self.constant_strings.lock();
1035        if entries.len() < self.constants.len() {
1036            entries.resize(self.constants.len(), None);
1037        }
1038        if let Some(Some(existing)) = entries.get(idx) {
1039            return Some(Arc::clone(existing));
1040        }
1041        let materialized = match self.constants.get(idx)? {
1042            Constant::String(s) => Arc::<str>::from(s.as_str()),
1043            _ => return None,
1044        };
1045        entries[idx] = Some(Arc::clone(&materialized));
1046        Some(materialized)
1047    }
1048
1049    #[cfg(feature = "vm-bench-internals")]
1050    pub(crate) fn inline_cache_entry(&self, slot: usize) -> InlineCacheEntry {
1051        self.inline_caches
1052            .lock()
1053            .get(slot)
1054            .cloned()
1055            .unwrap_or(InlineCacheEntry::Empty)
1056    }
1057
1058    /// Adaptive-binary fast path read. Returns the cached
1059    /// `(op, state)` pair by value (both `Copy`) when slot holds an
1060    /// `AdaptiveBinary` entry, else `None`. Skips the
1061    /// `InlineCacheEntry::clone` that `inline_cache_entry` performs:
1062    /// since `AdaptiveBinaryState: Copy`, the read does a single
1063    /// scalar move out of the cache instead of a 24-32B memcpy of the
1064    /// wrapping enum (which the variant-checking match destructures
1065    /// and throws away anyway). Fires on every Add/Sub/Mul/Div/Mod/Eq/
1066    /// Neq/Less/Greater/LessEq/GreaterEq dispatch, so the per-op
1067    /// savings compound across the millions of dispatches a typical
1068    /// loop body issues.
1069    #[inline]
1070    #[cfg(any(test, feature = "vm-bench-internals"))]
1071    pub(crate) fn peek_adaptive_binary_cache(
1072        &self,
1073        slot: usize,
1074    ) -> Option<(AdaptiveBinaryOp, AdaptiveBinaryState)> {
1075        match self.inline_caches.lock().get(slot)? {
1076            &InlineCacheEntry::AdaptiveBinary { op, state } => Some((op, state)),
1077            _ => None,
1078        }
1079    }
1080
1081    /// Method-cache fast path read. Returns the cached `(name_idx, argc,
1082    /// target)` triple by value (all three are `Copy`) when `slot` holds a
1083    /// `Method` entry, else `None`. Skips the full `InlineCacheEntry::clone`
1084    /// that `inline_cache_entry` performs on every `Op::MethodCall`,
1085    /// `Op::MethodCallOpt`, and `Op::MethodCallSpread` dispatch: the
1086    /// variant-checking `let-else` in `try_cached_method` destructures and
1087    /// throws the wrapping enum away anyway, so reading the payload by `Copy`
1088    /// avoids the 32-48B enum memcpy. Method-call dispatch is the second-
1089    /// hottest IC-keyed opcode class after the adaptive binary ops, so the
1090    /// per-dispatch savings compound across the millions of method calls a
1091    /// typical pipeline (`xs.filter(...).map(...).count()`) issues.
1092    #[inline]
1093    #[cfg(any(test, feature = "vm-bench-internals"))]
1094    pub(crate) fn peek_method_cache(&self, slot: usize) -> Option<(u16, usize, MethodCacheTarget)> {
1095        match self.inline_caches.lock().get(slot)? {
1096            &InlineCacheEntry::Method {
1097                name_idx,
1098                argc,
1099                target,
1100            } => Some((name_idx, argc, target)),
1101            _ => None,
1102        }
1103    }
1104
1105    /// Property-cache fast path read. Returns the cached `(name_idx, target)`
1106    /// pair by value when `slot` holds a `Property` entry, else `None`. The
1107    /// outer `InlineCacheEntry` is the worst-case-sized variant (DirectCall
1108    /// at ~48 bytes including padding); cloning it just to discard four other
1109    /// variants in `try_cached_property`'s variant-check is wasted work. The
1110    /// peek returns just the `Property` payload (`u16` + `PropertyCacheTarget`),
1111    /// skipping the outer enum tag init and the padding-to-largest-variant
1112    /// memcpy. Fires on every `Op::GetProperty` / `Op::GetPropertyOpt`
1113    /// dispatch, which is the dominant opcode for any field-read-heavy code.
1114    #[inline]
1115    #[cfg(any(test, feature = "vm-bench-internals"))]
1116    pub(crate) fn peek_property_cache(&self, slot: usize) -> Option<(u16, PropertyCacheTarget)> {
1117        match self.inline_caches.lock().get(slot)? {
1118            InlineCacheEntry::Property { name_idx, target } => Some((*name_idx, target.clone())),
1119            _ => None,
1120        }
1121    }
1122
1123    /// Direct-call cache state read. Returns just the inner `DirectCallState`
1124    /// by value when `slot` holds a `DirectCall` entry, else `None`. Used by
1125    /// both `try_cached_direct_call(_)` (steady-state Specialized hit check)
1126    /// and `next_direct_call_entry` (Warmup → Specialized state-machine
1127    /// transition). Peeking the inner state directly skips the outer
1128    /// `InlineCacheEntry` discriminant check and tag init that the dispatcher
1129    /// otherwise pays on every `Op::Call` (closure callee) and the named-fn
1130    /// fast path inside `Op::CallBuiltin`. Single peek per dispatch covers
1131    /// both the read check and the write-back computation.
1132    #[inline]
1133    #[cfg(any(test, feature = "vm-bench-internals"))]
1134    pub(crate) fn peek_direct_call_state(&self, slot: usize) -> Option<DirectCallState> {
1135        match self.inline_caches.lock().get(slot)? {
1136            InlineCacheEntry::DirectCall { state } => Some(state.clone()),
1137            _ => None,
1138        }
1139    }
1140
1141    #[cfg(any(test, feature = "vm-bench-internals"))]
1142    pub(crate) fn set_inline_cache_entry(&self, slot: usize, entry: InlineCacheEntry) {
1143        if let Some(existing) = self.inline_caches.lock().get_mut(slot) {
1144            *existing = entry;
1145        }
1146    }
1147
1148    pub fn freeze_for_cache(&self) -> CachedChunk {
1149        CachedChunk {
1150            code: self.code.clone(),
1151            constants: self.constants.clone(),
1152            lines: self.lines.clone(),
1153            columns: self.columns.clone(),
1154            source_file: self.source_file.clone(),
1155            current_col: self.current_col,
1156            functions: self
1157                .functions
1158                .iter()
1159                .map(|function| function.freeze_for_cache())
1160                .collect(),
1161            inline_cache_slots: self.inline_cache_slots.clone(),
1162            local_slots: self.local_slots.clone(),
1163            references_outer_names: self.references_outer_names,
1164        }
1165    }
1166
1167    pub fn from_cached(cached: &CachedChunk) -> Self {
1168        let inline_cache_count = cached.inline_cache_slots.len();
1169        let constants_count = cached.constants.len();
1170        // Project the cached `BTreeMap<op_offset, slot>` into the flat
1171        // dispatch-side lookup table. Sized to `code.len()` so the hottest
1172        // hot opcodes (binary ops at the end of a long chunk) still hit the
1173        // fast-path bounds check rather than falling through to the
1174        // none-found branch. The size is bounded by code length, so the
1175        // memory footprint is tiny — a few KB for typical chunks.
1176        let mut inline_cache_index = Vec::new();
1177        inline_cache_index.resize(cached.code.len(), NO_INLINE_CACHE_SLOT);
1178        for (&op_offset, &slot) in cached.inline_cache_slots.iter() {
1179            if op_offset < inline_cache_index.len() {
1180                inline_cache_index[op_offset] = slot as u32;
1181            }
1182        }
1183        Self {
1184            cache_id: next_chunk_cache_id(),
1185            code: cached.code.clone(),
1186            constants: cached.constants.clone(),
1187            constant_index: build_constant_index(&cached.constants),
1188            lines: cached.lines.clone(),
1189            columns: cached.columns.clone(),
1190            source_file: cached.source_file.clone(),
1191            current_col: cached.current_col,
1192            functions: cached
1193                .functions
1194                .iter()
1195                .map(|function| Arc::new(CompiledFunction::from_cached(function)))
1196                .collect(),
1197            inline_cache_slots: cached.inline_cache_slots.clone(),
1198            inline_cache_index,
1199            inline_caches: Arc::new(Mutex::new(vec![
1200                InlineCacheEntry::Empty;
1201                inline_cache_count
1202            ])),
1203            constant_strings: Arc::new(Mutex::new(vec![None; constants_count])),
1204            local_slots: cached.local_slots.clone(),
1205            references_outer_names: cached.references_outer_names,
1206            #[cfg(debug_assertions)]
1207            balance_depth: 0,
1208            #[cfg(debug_assertions)]
1209            balance_nonlinear: 0,
1210        }
1211    }
1212
1213    pub(crate) fn add_local_slot(
1214        &mut self,
1215        name: String,
1216        mutable: bool,
1217        scope_depth: usize,
1218    ) -> u16 {
1219        let idx = self.local_slots.len();
1220        self.local_slots.push(LocalSlotInfo {
1221            name,
1222            mutable,
1223            scope_depth,
1224        });
1225        idx as u16
1226    }
1227
1228    /// Read a u64 argument at the given position.
1229    pub fn read_u64(&self, pos: usize) -> u64 {
1230        u64::from_be_bytes([
1231            self.code[pos],
1232            self.code[pos + 1],
1233            self.code[pos + 2],
1234            self.code[pos + 3],
1235            self.code[pos + 4],
1236            self.code[pos + 5],
1237            self.code[pos + 6],
1238            self.code[pos + 7],
1239        ])
1240    }
1241
1242    /// Disassemble the chunk for debugging. The per-opcode rendering is
1243    /// macro-generated alongside the dispatch tables in
1244    /// `crate::vm::ops` — see [`Self::disassemble_op`].
1245    pub fn disassemble(&self, name: &str) -> String {
1246        let mut out = format!("== {name} ==\n");
1247        let mut ip = 0;
1248        while ip < self.code.len() {
1249            let op_byte = self.code[ip];
1250            let line = self.lines.get(ip).copied().unwrap_or(0);
1251            out.push_str(&format!("{ip:04} [{line:>4}] "));
1252            ip += 1;
1253
1254            if let Some(op) = Op::from_byte(op_byte) {
1255                self.disassemble_op(op, &mut ip, &mut out);
1256            } else {
1257                out.push_str(&format!("UNKNOWN(0x{op_byte:02x})\n"));
1258            }
1259        }
1260        out
1261    }
1262}
1263
1264/// Disassembly helpers consumed by the macro-generated
1265/// [`Chunk::disassemble_op`]. Each helper takes the current code position
1266/// (already advanced past the opcode byte), advances it over the operand
1267/// bytes the opcode carries, and renders one human-readable line without
1268/// a trailing newline (the dispatcher appends it).
1269///
1270/// Defining one helper per operand layout — and not one per opcode —
1271/// keeps adding an opcode a one-line edit in the `define_opcodes!` table
1272/// rather than a paired edit here. New layouts live with the helpers;
1273/// new opcodes live with the dispatch.
1274pub(crate) fn disasm_bare(_chunk: &Chunk, _ip: &mut usize, label: &str) -> String {
1275    label.to_string()
1276}
1277
1278pub(crate) fn disasm_u8(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1279    let arg = chunk.code[*ip];
1280    *ip += 1;
1281    format!("{label} {arg:>4}")
1282}
1283
1284pub(crate) fn disasm_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1285    let arg = chunk.read_u16(*ip);
1286    *ip += 2;
1287    format!("{label} {arg:>4}")
1288}
1289
1290pub(crate) fn disasm_try_catch_setup(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1291    let catch_offset = chunk.read_u16(*ip);
1292    *ip += 2;
1293    let type_idx = chunk.read_u16(*ip);
1294    *ip += 2;
1295    if let Some(type_name) = chunk.constants.get(type_idx as usize) {
1296        format!("{label} {catch_offset:>4} type {type_idx:>4} ({type_name})")
1297    } else {
1298        format!("{label} {catch_offset:>4} type {type_idx:>4}")
1299    }
1300}
1301
1302pub(crate) fn disasm_const_pool_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1303    let idx = chunk.read_u16(*ip);
1304    *ip += 2;
1305    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1306}
1307
1308pub(crate) fn disasm_local_slot_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1309    let slot = chunk.read_u16(*ip);
1310    *ip += 2;
1311    let mut out = format!("{label} {slot:>4}");
1312    if let Some(info) = chunk.local_slots.get(slot as usize) {
1313        out.push_str(&format!(" ({})", info.name));
1314    }
1315    out
1316}
1317
1318pub(crate) fn disasm_const_pool_local_slot(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1319    let prop = chunk.read_u16(*ip);
1320    *ip += 2;
1321    let slot = chunk.read_u16(*ip);
1322    *ip += 2;
1323    let mut out = format!(
1324        "{label} prop {prop:>4} ({}) slot {slot:>4}",
1325        chunk.constants[prop as usize]
1326    );
1327    if let Some(info) = chunk.local_slots.get(slot as usize) {
1328        out.push_str(&format!(" ({})", info.name));
1329    }
1330    out
1331}
1332
1333pub(crate) fn disasm_method_call(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1334    let idx = chunk.read_u16(*ip);
1335    *ip += 2;
1336    let argc = chunk.code[*ip];
1337    *ip += 1;
1338    format!(
1339        "{label} {idx:>4} ({}) argc={argc}",
1340        chunk.constants[idx as usize]
1341    )
1342}
1343
1344pub(crate) fn disasm_match_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1345    let enum_idx = chunk.read_u16(*ip);
1346    *ip += 2;
1347    let var_idx = chunk.read_u16(*ip);
1348    *ip += 2;
1349    format!(
1350        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({})",
1351        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1352    )
1353}
1354
1355pub(crate) fn disasm_build_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1356    let enum_idx = chunk.read_u16(*ip);
1357    *ip += 2;
1358    let var_idx = chunk.read_u16(*ip);
1359    *ip += 2;
1360    let field_count = chunk.read_u16(*ip);
1361    *ip += 2;
1362    format!(
1363        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({}) fields={field_count}",
1364        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1365    )
1366}
1367
1368pub(crate) fn disasm_selective_import(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1369    let path_idx = chunk.read_u16(*ip);
1370    *ip += 2;
1371    let names_idx = chunk.read_u16(*ip);
1372    *ip += 2;
1373    format!(
1374        "{label} {path_idx:>4} ({}) names: {names_idx:>4} ({})",
1375        chunk.constants[path_idx as usize], chunk.constants[names_idx as usize],
1376    )
1377}
1378
1379pub(crate) fn disasm_check_type(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1380    let var_idx = chunk.read_u16(*ip);
1381    *ip += 2;
1382    let type_idx = chunk.read_u16(*ip);
1383    *ip += 2;
1384    format!(
1385        "{label} {var_idx:>4} ({}) -> {type_idx:>4} ({})",
1386        chunk.constants[var_idx as usize], chunk.constants[type_idx as usize],
1387    )
1388}
1389
1390pub(crate) fn disasm_call_builtin(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1391    let id = chunk.read_u64(*ip);
1392    *ip += 8;
1393    let idx = chunk.read_u16(*ip);
1394    *ip += 2;
1395    let argc = chunk.code[*ip];
1396    *ip += 1;
1397    format!(
1398        "{label} {id:#018x} {idx:>4} ({}) argc={argc}",
1399        chunk.constants[idx as usize],
1400    )
1401}
1402
1403pub(crate) fn disasm_call_builtin_spread(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1404    let id = chunk.read_u64(*ip);
1405    *ip += 8;
1406    let idx = chunk.read_u16(*ip);
1407    *ip += 2;
1408    format!(
1409        "{label} {id:#018x} {idx:>4} ({})",
1410        chunk.constants[idx as usize],
1411    )
1412}
1413
1414pub(crate) fn disasm_method_call_spread(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1415    // emit_u16(Op::MethodCallSpread, name_idx, ...) writes opcode + 2
1416    // bytes of u16 name_idx, so the operand is read at *ip with the
1417    // usual `read_u16`. The previous hand-written disasm read at
1418    // `ip + 1`, which displayed the wrong constant index — silently
1419    // corrupting any disassembly that hit a `MethodCallSpread` opcode.
1420    let idx = chunk.read_u16(*ip);
1421    *ip += 2;
1422    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1423}
1424
1425impl Default for Chunk {
1426    fn default() -> Self {
1427        Self::new()
1428    }
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433    use std::sync::Arc;
1434
1435    use super::{
1436        Chunk, Constant, DirectCallState, DirectCallTarget, InlineCacheEntry, MethodCacheTarget,
1437        Op, PropertyCacheTarget,
1438    };
1439    use crate::BuiltinId;
1440
1441    #[test]
1442    fn op_from_byte_matches_repr_order() {
1443        for (byte, op) in Op::ALL.iter().copied().enumerate() {
1444            assert_eq!(byte as u8, op as u8);
1445            assert_eq!(Op::from_byte(byte as u8), Some(op));
1446        }
1447        assert_eq!(Op::from_byte(Op::ALL.len() as u8), None);
1448        assert_eq!(Op::COUNT, Op::ALL.len());
1449    }
1450
1451    #[test]
1452    fn disassemble_covers_every_opcode_variant() {
1453        // The macro-generated `disassemble_op` match is exhaustive on
1454        // `Op`, so this is a compile-time guarantee. The runtime check
1455        // pins that no helper falls through to `UNKNOWN(...)` for a
1456        // valid opcode byte — catching any future macro refactor that
1457        // silently drops a helper arm. Each opcode is exercised in
1458        // isolation against a hand-built chunk so the test logic does
1459        // not depend on operand sizes (and so a single short opcode
1460        // does not bleed into reading trailing padding as a follow-on
1461        // opcode in the chunk-level loop).
1462        for op in Op::ALL.iter().copied() {
1463            let mut chunk = Chunk::new();
1464            chunk.add_constant(super::Constant::String("__probe__".to_string()));
1465            // Pad to the worst-case operand width (CallBuiltin: u64 +
1466            // u16 + u8 = 11 bytes) so any helper has well-formed bytes
1467            // to consume regardless of its layout.
1468            for _ in 0..16 {
1469                chunk.code.push(0);
1470            }
1471            let mut ip: usize = 0;
1472            let mut out = String::new();
1473            chunk.disassemble_op(op, &mut ip, &mut out);
1474            assert!(
1475                !out.contains("UNKNOWN"),
1476                "disasm emitted UNKNOWN for {op:?}: {out}",
1477            );
1478            assert!(!out.is_empty(), "disasm produced no output for {op:?}");
1479        }
1480    }
1481
1482    // --- references_outer_names tracking ---
1483    //
1484    // Drives the compile-time guard used in `Vm::closure_call_env`
1485    // and `Vm::closure_call_env_for_current_frame` to skip the
1486    // per-invocation caller-scope late-bind walks. Coverage parity
1487    // matters because false negatives would regress recursive /
1488    // mutually-recursive fns.
1489
1490    #[test]
1491    fn empty_chunk_does_not_reference_outer_names() {
1492        let chunk = Chunk::new();
1493        assert!(!chunk.references_outer_names);
1494    }
1495
1496    #[test]
1497    fn arithmetic_only_chunk_does_not_reference_outer_names() {
1498        // The hot `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
1499        // shape: pure stack/arithmetic ops and slot locals, no env
1500        // reads. Must NOT flag — that's the whole point of the
1501        // optimization.
1502        let mut chunk = Chunk::new();
1503        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1504        chunk.emit_u16(Op::Constant, 0, 1);
1505        chunk.emit(Op::MulInt, 1);
1506        chunk.emit(Op::Pop, 1);
1507        chunk.emit(Op::Return, 1);
1508        assert!(!chunk.references_outer_names);
1509    }
1510
1511    #[test]
1512    fn slot_only_chunk_does_not_reference_outer_names() {
1513        // Compiler-resolved locals never need env-based late-bind.
1514        let mut chunk = Chunk::new();
1515        chunk.emit_u16(Op::DefLocalSlot, 0, 1);
1516        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1517        chunk.emit_u16(Op::SetLocalSlot, 0, 1);
1518        assert!(!chunk.references_outer_names);
1519    }
1520
1521    #[test]
1522    fn get_var_flags_outer_name_reference() {
1523        let mut chunk = Chunk::new();
1524        chunk.emit_u16(Op::GetVar, 0, 1);
1525        assert!(chunk.references_outer_names);
1526    }
1527
1528    #[test]
1529    fn set_var_flags_outer_name_reference() {
1530        let mut chunk = Chunk::new();
1531        chunk.emit_u16(Op::SetVar, 0, 1);
1532        assert!(chunk.references_outer_names);
1533    }
1534
1535    #[test]
1536    fn check_type_flags_outer_name_reference() {
1537        let mut chunk = Chunk::new();
1538        chunk.emit_u16(Op::CheckType, 0, 1);
1539        assert!(chunk.references_outer_names);
1540    }
1541
1542    #[test]
1543    fn call_builtin_flags_outer_name_reference() {
1544        let mut chunk = Chunk::new();
1545        chunk.emit_call_builtin(BuiltinId::from_name("any_name"), 0, 1, 1);
1546        assert!(chunk.references_outer_names);
1547    }
1548
1549    #[test]
1550    fn call_builtin_spread_flags_outer_name_reference() {
1551        let mut chunk = Chunk::new();
1552        chunk.emit_call_builtin_spread(BuiltinId::from_name("any_name"), 0, 1);
1553        assert!(chunk.references_outer_names);
1554    }
1555
1556    #[test]
1557    fn tail_call_flags_outer_name_reference() {
1558        // `return fn_name(...)` compiles to Constant + TailCall —
1559        // TailCall does a runtime name lookup, so it has to flag.
1560        let mut chunk = Chunk::new();
1561        chunk.emit_u8(Op::TailCall, 1, 1);
1562        assert!(chunk.references_outer_names);
1563    }
1564
1565    #[test]
1566    fn call_flags_outer_name_reference() {
1567        // Op::Call can receive a String callee from the stack (the
1568        // by-name dispatch shape), so it has to flag too.
1569        let mut chunk = Chunk::new();
1570        chunk.emit_u8(Op::Call, 1, 1);
1571        assert!(chunk.references_outer_names);
1572    }
1573
1574    #[test]
1575    fn pipe_flags_outer_name_reference() {
1576        // `x |> name` resolves `name` through env when the value on
1577        // the stack is a String / BuiltinRef.
1578        let mut chunk = Chunk::new();
1579        chunk.emit(Op::Pipe, 1);
1580        assert!(chunk.references_outer_names);
1581    }
1582
1583    #[test]
1584    fn method_call_does_not_flag_outer_name_reference() {
1585        // Method receivers come off the operand stack, not the env;
1586        // emitting MethodCall alone must not force the walk.
1587        let mut chunk = Chunk::new();
1588        chunk.emit_method_call(0, 1, 1);
1589        chunk.emit_method_call_opt(0, 1, 1);
1590        assert!(!chunk.references_outer_names);
1591    }
1592
1593    #[test]
1594    fn jump_and_control_flow_do_not_flag_outer_name_reference() {
1595        // Jumps, returns, pops — control flow stays inside the
1596        // frame and never touches env lookups.
1597        let mut chunk = Chunk::new();
1598        chunk.emit_u16(Op::Constant, 0, 1);
1599        chunk.emit(Op::JumpIfFalse, 1);
1600        chunk.emit(Op::Jump, 1);
1601        chunk.emit(Op::Return, 1);
1602        chunk.emit(Op::Pop, 1);
1603        assert!(!chunk.references_outer_names);
1604    }
1605
1606    #[test]
1607    fn references_outer_names_is_monotonic() {
1608        // Once flagged, subsequent non-flagging emits must not
1609        // clear the bit — flags are sticky.
1610        let mut chunk = Chunk::new();
1611        chunk.emit_u16(Op::GetVar, 0, 1);
1612        assert!(chunk.references_outer_names);
1613        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1614        chunk.emit(Op::MulInt, 1);
1615        assert!(chunk.references_outer_names);
1616    }
1617
1618    #[test]
1619    fn freeze_thaw_round_trips_references_outer_names() {
1620        // Bytecode-cache hits must observe the same flag as a
1621        // fresh compile — otherwise the first call after a cache
1622        // hit would either over- or under-skip the walk.
1623        let mut chunk = Chunk::new();
1624        chunk.emit_u16(Op::GetVar, 0, 1);
1625        assert!(chunk.references_outer_names);
1626        let frozen = chunk.freeze_for_cache();
1627        let thawed = Chunk::from_cached(&frozen);
1628        assert!(thawed.references_outer_names);
1629
1630        let plain = Chunk::new();
1631        assert!(!plain.references_outer_names);
1632        let frozen_plain = plain.freeze_for_cache();
1633        let thawed_plain = Chunk::from_cached(&frozen_plain);
1634        assert!(!thawed_plain.references_outer_names);
1635    }
1636
1637    // --- inline_cache_slot flat-index parity ---
1638    //
1639    // Slot lookups fire on every dispatch of an adaptive binary op
1640    // (Add/Sub/Mul/Div/Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq),
1641    // every `Op::Call`, every `Op::MethodCall(Opt)`, and every
1642    // `Op::GetProperty(Opt)`. The flat `Vec<u32>` index has to stay
1643    // perfectly in sync with the serialization-stable BTreeMap or
1644    // a cached call site would either skip its inline cache (slow
1645    // path with no learning) or read a stale slot (silently
1646    // mis-specialized arithmetic). These tests pin the contract.
1647
1648    #[test]
1649    fn inline_cache_slot_returns_none_for_non_cacheable_offsets() {
1650        // GetLocalSlot is a sync-fast-path opcode with no inline
1651        // cache; the index must report no slot.
1652        let mut chunk = Chunk::new();
1653        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1654        chunk.emit(Op::Pop, 1);
1655        chunk.emit(Op::Return, 1);
1656        assert!(chunk.inline_cache_slot(0).is_none());
1657        assert!(chunk.inline_cache_slot(3).is_none());
1658        assert!(chunk.inline_cache_slot(4).is_none());
1659    }
1660
1661    #[test]
1662    fn inline_cache_slot_registered_for_adaptive_binary_op() {
1663        // Pure-arithmetic ops use the adaptive-binary IC for shape
1664        // specialization. The slot has to be 0 because the chunk is
1665        // otherwise empty.
1666        let mut chunk = Chunk::new();
1667        chunk.emit(Op::Add, 1);
1668        assert_eq!(chunk.inline_cache_slot(0), Some(0));
1669    }
1670
1671    #[test]
1672    fn inline_cache_slot_distinct_for_sequential_adaptive_binary_ops() {
1673        // Three back-to-back Adds must get three distinct slots so
1674        // each instruction's shape feedback evolves independently
1675        // (otherwise the same call site would clobber a neighbor's
1676        // learning every dispatch).
1677        let mut chunk = Chunk::new();
1678        chunk.emit(Op::Add, 1);
1679        chunk.emit(Op::Sub, 1);
1680        chunk.emit(Op::Mul, 1);
1681        let s0 = chunk.inline_cache_slot(0).expect("Add slot");
1682        let s1 = chunk.inline_cache_slot(1).expect("Sub slot");
1683        let s2 = chunk.inline_cache_slot(2).expect("Mul slot");
1684        assert_ne!(s0, s1);
1685        assert_ne!(s1, s2);
1686        assert_ne!(s0, s2);
1687    }
1688
1689    #[test]
1690    fn inline_cache_slot_returns_none_for_out_of_bounds_offset() {
1691        // The dispatcher derives `op_offset` from `ip - 1`; an
1692        // out-of-bounds query must return None rather than panic.
1693        let mut chunk = Chunk::new();
1694        chunk.emit(Op::Add, 1);
1695        assert!(chunk.inline_cache_slot(usize::MAX).is_none());
1696        assert!(chunk.inline_cache_slot(chunk.code.len()).is_none());
1697        assert!(chunk.inline_cache_slot(chunk.code.len() + 16).is_none());
1698    }
1699
1700    #[test]
1701    fn inline_cache_slot_for_get_property_and_method_call() {
1702        // GetProperty(Opt) and MethodCall(Opt) both register an IC
1703        // slot at emit time — adaptive method-call dispatch and
1704        // monomorphic property-cache learning depend on it.
1705        let mut chunk = Chunk::new();
1706        chunk.emit_u16(Op::GetProperty, 0, 1); // offset 0..3
1707        chunk.emit_method_call(0, 1, 1); // offset 3..7
1708        chunk.emit_method_call_opt(0, 1, 1); // offset 7..11
1709        chunk.emit_u16(Op::GetPropertyOpt, 0, 1); // offset 11..14
1710        assert!(chunk.inline_cache_slot(0).is_some(), "GetProperty");
1711        assert!(chunk.inline_cache_slot(3).is_some(), "MethodCall");
1712        assert!(chunk.inline_cache_slot(7).is_some(), "MethodCallOpt");
1713        assert!(chunk.inline_cache_slot(11).is_some(), "GetPropertyOpt");
1714    }
1715
1716    #[test]
1717    fn inline_cache_slot_for_call_and_call_builtin() {
1718        // Both `Op::Call` (closure / by-name callee) and
1719        // `emit_call_builtin` register IC slots. The latter is the
1720        // adaptive-call fast path used for every direct user-fn
1721        // invocation.
1722        let mut chunk = Chunk::new();
1723        chunk.emit_u8(Op::Call, 1, 1); // offset 0..2
1724        let call_builtin_offset = chunk.code.len();
1725        chunk.emit_call_builtin(BuiltinId::from_name("any"), 0, 1, 1);
1726        assert!(chunk.inline_cache_slot(0).is_some(), "Op::Call IC slot");
1727        assert!(
1728            chunk.inline_cache_slot(call_builtin_offset).is_some(),
1729            "Op::CallBuiltin IC slot"
1730        );
1731    }
1732
1733    #[test]
1734    fn inline_cache_slot_register_is_idempotent_for_same_offset() {
1735        // The compile path uses `BTreeMap::contains_key` to dedup
1736        // re-registration at the same offset (eg. when a helper
1737        // re-emits into a still-live position). The flat index has
1738        // to honor the same semantics — never silently overwriting
1739        // an existing slot with a fresh one.
1740        let mut chunk = Chunk::new();
1741        chunk.emit(Op::Add, 1);
1742        let slot_before = chunk.inline_cache_slot(0).expect("first registration");
1743        // Manually re-register the same offset to confirm dedup.
1744        chunk.register_inline_cache(0);
1745        let slot_after = chunk.inline_cache_slot(0).expect("re-registration");
1746        assert_eq!(slot_before, slot_after);
1747    }
1748
1749    #[test]
1750    fn inline_cache_index_round_trips_through_cached_chunk() {
1751        // The cache freeze drops the flat index (it's derived from
1752        // the BTreeMap that *is* serialized). On thaw, the flat
1753        // index must be rebuilt so the first hot dispatch of a
1754        // cached chunk doesn't fall off the IC-slot cliff (which
1755        // would silently disable shape specialization until the
1756        // chunk is recompiled from source).
1757        let mut chunk = Chunk::new();
1758        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1759        chunk.emit_u16(Op::Constant, 0, 1);
1760        chunk.emit(Op::Add, 1);
1761        chunk.emit(Op::Sub, 1);
1762        chunk.emit_method_call(0, 1, 1);
1763        chunk.emit_u8(Op::Call, 1, 1);
1764        let live_slots: Vec<(usize, Option<usize>)> = (0..chunk.code.len())
1765            .map(|o| (o, chunk.inline_cache_slot(o)))
1766            .collect();
1767        let frozen = chunk.freeze_for_cache();
1768        let thawed = Chunk::from_cached(&frozen);
1769        let thawed_slots: Vec<(usize, Option<usize>)> = (0..thawed.code.len())
1770            .map(|o| (o, thawed.inline_cache_slot(o)))
1771            .collect();
1772        assert_eq!(live_slots, thawed_slots);
1773    }
1774
1775    #[test]
1776    fn inline_cache_index_agrees_with_btreemap_view() {
1777        // Authoritative parity check: for every code offset, the
1778        // flat-index `inline_cache_slot` must return exactly what
1779        // the underlying BTreeMap would (mod the `Option` boxing).
1780        // Catches any future emit path that grows `inline_cache_slots`
1781        // without going through `register_inline_cache`.
1782        let mut chunk = Chunk::new();
1783        chunk.emit(Op::Add, 1);
1784        chunk.emit_u16(Op::GetVar, 0, 1);
1785        chunk.emit(Op::LessInt, 1);
1786        chunk.emit_u8(Op::Call, 2, 1);
1787        chunk.emit(Op::Equal, 1);
1788        chunk.emit_u16(Op::GetProperty, 0, 1);
1789        chunk.emit_method_call_opt(0, 0, 1);
1790        for offset in 0..chunk.code.len() {
1791            let from_map = chunk.inline_cache_slots.get(&offset).copied();
1792            let from_index = chunk.inline_cache_slot(offset);
1793            assert_eq!(from_index, from_map, "parity broken at offset {offset}");
1794        }
1795    }
1796
1797    // --- peek_adaptive_binary_cache contract ---
1798    //
1799    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1800    // hottest opcode class (Add / Sub / Mul / Div / Mod / Eq / Neq /
1801    // Less / Greater / LessEq / GreaterEq). It must return None for
1802    // unrelated IC variants — silently mis-extracting a `Property` /
1803    // `DirectCall` / `Method` slot as `AdaptiveBinary` would feed
1804    // garbage into `try_specialized_binary` and either spec-mis-fire or
1805    // crash. These tests pin the variant gate.
1806
1807    #[test]
1808    fn peek_adaptive_binary_returns_none_for_empty_slot() {
1809        let mut chunk = Chunk::new();
1810        chunk.emit(Op::Add, 1);
1811        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1812        // Default state of a freshly-emitted slot is Empty.
1813        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
1814    }
1815
1816    #[test]
1817    fn peek_adaptive_binary_returns_op_and_state_after_warmup() {
1818        use super::{AdaptiveBinaryOp, AdaptiveBinaryState, BinaryShape, InlineCacheEntry};
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::AdaptiveBinary {
1825                op: AdaptiveBinaryOp::Add,
1826                state: AdaptiveBinaryState::Warmup {
1827                    shape: BinaryShape::Int,
1828                    hits: 2,
1829                },
1830            },
1831        );
1832        let (op, state) = chunk
1833            .peek_adaptive_binary_cache(slot)
1834            .expect("warmed slot peek");
1835        assert_eq!(op, AdaptiveBinaryOp::Add);
1836        assert!(matches!(
1837            state,
1838            AdaptiveBinaryState::Warmup {
1839                shape: BinaryShape::Int,
1840                hits: 2
1841            }
1842        ));
1843    }
1844
1845    #[test]
1846    fn peek_adaptive_binary_returns_none_for_non_binary_variants() {
1847        // The cache slot may legitimately hold a `Property`, `Method`,
1848        // or `DirectCall` entry (eg. a Property slot at the offset
1849        // sequence happens to alias an Add slot during a code rewrite —
1850        // currently this cannot happen, but the peek must defensively
1851        // refuse non-AdaptiveBinary variants regardless).
1852        use super::{InlineCacheEntry, PropertyCacheTarget};
1853        let mut chunk = Chunk::new();
1854        chunk.emit(Op::Add, 1);
1855        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1856        chunk.set_inline_cache_entry(
1857            slot,
1858            InlineCacheEntry::Property {
1859                name_idx: 0,
1860                target: PropertyCacheTarget::ListCount,
1861            },
1862        );
1863        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
1864    }
1865
1866    #[test]
1867    fn peek_adaptive_binary_returns_none_for_out_of_bounds_slot() {
1868        // Defensive: `execute_adaptive_binary` filters its `slot`
1869        // through `inline_cache_slot` first, but
1870        // `peek_adaptive_binary_cache` should still return None for an
1871        // unmapped slot rather than panicking.
1872        let chunk = Chunk::new();
1873        assert!(chunk.peek_adaptive_binary_cache(0).is_none());
1874        assert!(chunk.peek_adaptive_binary_cache(usize::MAX).is_none());
1875    }
1876
1877    #[test]
1878    fn peek_adaptive_binary_state_is_copy() {
1879        // Compile-time assertion: `AdaptiveBinaryState: Copy` is the
1880        // whole point of this optimization — if a future variant adds
1881        // a non-Copy field, the static check below will fail at compile
1882        // time before the dispatcher silently regresses to the heavy
1883        // `InlineCacheEntry::clone` path.
1884        fn assert_copy<T: Copy>() {}
1885        assert_copy::<super::AdaptiveBinaryState>();
1886        assert_copy::<super::AdaptiveBinaryOp>();
1887        assert_copy::<super::BinaryShape>();
1888    }
1889
1890    // --- peek_method_cache contract ---
1891    //
1892    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1893    // method-call dispatch sites (`Op::MethodCall`, `Op::MethodCallOpt`,
1894    // `Op::MethodCallSpread`). It must return None for unrelated IC variants
1895    // — silently mis-extracting a `Property` / `DirectCall` / `AdaptiveBinary`
1896    // slot as `Method` would feed garbage into `try_cached_method` and either
1897    // spec-mis-fire (wrong target/argc) or skip the cache entirely on a real
1898    // hit. These tests pin the variant gate.
1899
1900    #[test]
1901    fn peek_method_cache_returns_none_for_empty_slot() {
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        assert!(chunk.peek_method_cache(slot).is_none());
1908    }
1909
1910    #[test]
1911    fn peek_method_cache_returns_triple_after_warmup() {
1912        let mut chunk = Chunk::new();
1913        chunk.emit_method_call(7, 2, 1);
1914        let slot = chunk
1915            .inline_cache_slot(0)
1916            .expect("MethodCall registers a slot");
1917        chunk.set_inline_cache_entry(
1918            slot,
1919            InlineCacheEntry::Method {
1920                name_idx: 7,
1921                argc: 2,
1922                target: MethodCacheTarget::ListContains,
1923            },
1924        );
1925        let (name_idx, argc, target) = chunk.peek_method_cache(slot).expect("warmed slot peek");
1926        assert_eq!(name_idx, 7);
1927        assert_eq!(argc, 2);
1928        assert_eq!(target, MethodCacheTarget::ListContains);
1929    }
1930
1931    #[test]
1932    fn peek_method_cache_returns_none_for_non_method_variants() {
1933        // The cache slot may legitimately hold an `AdaptiveBinary`,
1934        // `Property`, or `DirectCall` entry. The peek must defensively
1935        // refuse non-Method variants regardless.
1936        let mut chunk = Chunk::new();
1937        chunk.emit_method_call(0, 0, 1);
1938        let slot = chunk
1939            .inline_cache_slot(0)
1940            .expect("MethodCall registers a slot");
1941
1942        chunk.set_inline_cache_entry(
1943            slot,
1944            InlineCacheEntry::Property {
1945                name_idx: 0,
1946                target: PropertyCacheTarget::ListCount,
1947            },
1948        );
1949        assert!(chunk.peek_method_cache(slot).is_none());
1950    }
1951
1952    #[test]
1953    fn peek_method_cache_returns_none_for_out_of_bounds_slot() {
1954        let chunk = Chunk::new();
1955        assert!(chunk.peek_method_cache(0).is_none());
1956        assert!(chunk.peek_method_cache(usize::MAX).is_none());
1957    }
1958
1959    #[test]
1960    fn peek_method_cache_target_is_copy() {
1961        // Compile-time assertion: `MethodCacheTarget: Copy` is the whole
1962        // point of this peek path — if a future variant adds a non-Copy
1963        // field (eg. an `Arc<str>` for a dynamic method name), the static
1964        // check below will fail at compile time before the dispatcher
1965        // silently regresses to the heavy `InlineCacheEntry::clone` path.
1966        fn assert_copy<T: Copy>() {}
1967        assert_copy::<super::MethodCacheTarget>();
1968    }
1969
1970    // --- peek_property_cache contract ---
1971    //
1972    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1973    // property-read path (`Op::GetProperty` / `Op::GetPropertyOpt`). It
1974    // must return None for unrelated IC variants — silently mis-extracting
1975    // a `Method` / `DirectCall` / `AdaptiveBinary` slot as `Property` would
1976    // feed garbage into `try_cached_property` (wrong target match, possibly
1977    // a panic on the field-name lookup). These tests pin the variant gate.
1978
1979    #[test]
1980    fn peek_property_cache_returns_none_for_empty_slot() {
1981        let mut chunk = Chunk::new();
1982        chunk.emit_u16(Op::GetProperty, 0, 1);
1983        let slot = chunk
1984            .inline_cache_slot(0)
1985            .expect("GetProperty registers a slot");
1986        assert!(chunk.peek_property_cache(slot).is_none());
1987    }
1988
1989    #[test]
1990    fn peek_property_cache_returns_pair_after_warmup_for_dict_field() {
1991        let mut chunk = Chunk::new();
1992        chunk.emit_u16(Op::GetProperty, 0, 1);
1993        let slot = chunk
1994            .inline_cache_slot(0)
1995            .expect("GetProperty registers a slot");
1996        chunk.set_inline_cache_entry(
1997            slot,
1998            InlineCacheEntry::Property {
1999                name_idx: 11,
2000                target: PropertyCacheTarget::DictField(Arc::from("count")),
2001            },
2002        );
2003        let (name_idx, target) = chunk
2004            .peek_property_cache(slot)
2005            .expect("warmed property slot peek");
2006        assert_eq!(name_idx, 11);
2007        match target {
2008            PropertyCacheTarget::DictField(field) => assert_eq!(field.as_ref(), "count"),
2009            other => panic!("expected DictField, got {other:?}"),
2010        }
2011    }
2012
2013    #[test]
2014    fn peek_property_cache_returns_pair_for_unit_target() {
2015        // Unit targets (eg. ListCount, ListEmpty, PairFirst) carry no Arc,
2016        // so the cloned PropertyCacheTarget is a pure scalar move at the
2017        // peek boundary. The hottest case in practice.
2018        let mut chunk = Chunk::new();
2019        chunk.emit_u16(Op::GetProperty, 0, 1);
2020        let slot = chunk
2021            .inline_cache_slot(0)
2022            .expect("GetProperty registers a slot");
2023        chunk.set_inline_cache_entry(
2024            slot,
2025            InlineCacheEntry::Property {
2026                name_idx: 3,
2027                target: PropertyCacheTarget::ListCount,
2028            },
2029        );
2030        let (name_idx, target) = chunk
2031            .peek_property_cache(slot)
2032            .expect("warmed property slot peek");
2033        assert_eq!(name_idx, 3);
2034        assert_eq!(target, PropertyCacheTarget::ListCount);
2035    }
2036
2037    #[test]
2038    fn peek_property_cache_returns_none_for_non_property_variants() {
2039        let mut chunk = Chunk::new();
2040        chunk.emit_u16(Op::GetProperty, 0, 1);
2041        let slot = chunk
2042            .inline_cache_slot(0)
2043            .expect("GetProperty registers a slot");
2044        chunk.set_inline_cache_entry(
2045            slot,
2046            InlineCacheEntry::Method {
2047                name_idx: 0,
2048                argc: 0,
2049                target: MethodCacheTarget::ListCount,
2050            },
2051        );
2052        assert!(chunk.peek_property_cache(slot).is_none());
2053    }
2054
2055    #[test]
2056    fn peek_property_cache_returns_none_for_out_of_bounds_slot() {
2057        let chunk = Chunk::new();
2058        assert!(chunk.peek_property_cache(0).is_none());
2059        assert!(chunk.peek_property_cache(usize::MAX).is_none());
2060    }
2061
2062    // --- peek_direct_call_state contract ---
2063    //
2064    // Used on both the hot Specialized-hit check path (`try_cached_direct_call`
2065    // / `try_cached_named_direct_call`) and the state-machine write-back
2066    // (`next_direct_call_entry`). Returning None for the non-DirectCall slot
2067    // shapes is critical: a mis-extracted Method/Property/AdaptiveBinary slot
2068    // would have the dispatcher attempt a closure call with the wrong argc
2069    // or Arc::ptr_eq against an unrelated closure.
2070
2071    #[test]
2072    fn add_constant_keeps_signed_zero_and_nan_distinct() {
2073        let mut chunk = Chunk::new();
2074        // +0.0 and -0.0 are `==` under IEEE 754 but must NOT share a pool slot,
2075        // or the sign of `1.0 / 0.0` vs `1.0 / -0.0` would depend on intern order.
2076        let pos = chunk.add_constant(Constant::Float(0.0));
2077        let neg = chunk.add_constant(Constant::Float(-0.0));
2078        assert_ne!(pos, neg, "+0.0 and -0.0 must get distinct constant slots");
2079        // Re-adding the identical bit pattern still dedups.
2080        assert_eq!(pos, chunk.add_constant(Constant::Float(0.0)));
2081        assert_eq!(neg, chunk.add_constant(Constant::Float(-0.0)));
2082        // Ordinary floats still dedup by value.
2083        let a = chunk.add_constant(Constant::Float(1.5));
2084        assert_eq!(a, chunk.add_constant(Constant::Float(1.5)));
2085        let nan_a = chunk.add_constant(Constant::Float(f64::from_bits(0x7ff8_0000_0000_0001)));
2086        let nan_b = chunk.add_constant(Constant::Float(f64::from_bits(0x7ff8_0000_0000_0002)));
2087        assert_ne!(
2088            nan_a, nan_b,
2089            "distinct NaN payloads must get distinct constant slots"
2090        );
2091        assert_eq!(
2092            nan_a,
2093            chunk.add_constant(Constant::Float(f64::from_bits(0x7ff8_0000_0000_0001)))
2094        );
2095        // Non-float constants are unaffected.
2096        let s = chunk.add_constant(Constant::Int(7));
2097        assert_eq!(s, chunk.add_constant(Constant::Int(7)));
2098    }
2099
2100    #[test]
2101    fn add_constant_uses_first_slot_after_many_unique_constants() {
2102        let mut chunk = Chunk::new();
2103        let first = chunk.add_constant(Constant::String("shared".to_string()));
2104        for index in 0..10_000 {
2105            let slot = chunk.add_constant(Constant::String(format!("unique_{index}")));
2106            assert_eq!(slot as usize, index + 1);
2107        }
2108        assert_eq!(
2109            first,
2110            chunk.add_constant(Constant::String("shared".to_string())),
2111            "duplicate lookup must return the original slot after index growth"
2112        );
2113    }
2114
2115    #[test]
2116    fn constant_index_round_trips_through_cached_chunk() {
2117        let mut chunk = Chunk::new();
2118        let shared = chunk.add_constant(Constant::String("shared".to_string()));
2119        for index in 0..128 {
2120            chunk.add_constant(Constant::Int(index));
2121        }
2122
2123        let frozen = chunk.freeze_for_cache();
2124        let mut thawed = Chunk::from_cached(&frozen);
2125        assert_eq!(
2126            shared,
2127            thawed.add_constant(Constant::String("shared".to_string())),
2128            "cache thaw must rebuild the constant side index"
2129        );
2130        let next = thawed.add_constant(Constant::String("new".to_string()));
2131        assert_eq!(next as usize, frozen.constants.len());
2132    }
2133
2134    #[test]
2135    fn peek_direct_call_state_returns_none_for_empty_slot() {
2136        let mut chunk = Chunk::new();
2137        chunk.emit_u8(Op::Call, 0, 1);
2138        let slot = chunk
2139            .inline_cache_slot(0)
2140            .expect("Op::Call registers a slot");
2141        assert!(chunk.peek_direct_call_state(slot).is_none());
2142    }
2143
2144    #[test]
2145    fn peek_direct_call_state_returns_warmup_state() {
2146        let mut chunk = Chunk::new();
2147        chunk.emit_u8(Op::Call, 0, 1);
2148        let slot = chunk
2149            .inline_cache_slot(0)
2150            .expect("Op::Call registers a slot");
2151        let target = synthetic_direct_call_target();
2152        chunk.set_inline_cache_entry(
2153            slot,
2154            InlineCacheEntry::DirectCall {
2155                state: DirectCallState::Warmup {
2156                    argc: 2,
2157                    target: target.clone(),
2158                    hits: 1,
2159                },
2160            },
2161        );
2162        let state = chunk
2163            .peek_direct_call_state(slot)
2164            .expect("warmed direct-call slot peek");
2165        match state {
2166            DirectCallState::Warmup {
2167                argc,
2168                target: peeked_target,
2169                hits,
2170            } => {
2171                assert_eq!(argc, 2);
2172                assert_eq!(hits, 1);
2173                assert_eq!(peeked_target, target);
2174            }
2175            other => panic!("expected Warmup, got {other:?}"),
2176        }
2177    }
2178
2179    #[test]
2180    fn peek_direct_call_state_returns_specialized_state() {
2181        let mut chunk = Chunk::new();
2182        chunk.emit_u8(Op::Call, 0, 1);
2183        let slot = chunk
2184            .inline_cache_slot(0)
2185            .expect("Op::Call registers a slot");
2186        let target = synthetic_direct_call_target();
2187        chunk.set_inline_cache_entry(
2188            slot,
2189            InlineCacheEntry::DirectCall {
2190                state: DirectCallState::Specialized {
2191                    argc: 3,
2192                    target: target.clone(),
2193                    hits: 100,
2194                    misses: 0,
2195                },
2196            },
2197        );
2198        let state = chunk
2199            .peek_direct_call_state(slot)
2200            .expect("warmed direct-call slot peek");
2201        match state {
2202            DirectCallState::Specialized {
2203                argc,
2204                target: peeked_target,
2205                hits,
2206                misses,
2207            } => {
2208                assert_eq!(argc, 3);
2209                assert_eq!(hits, 100);
2210                assert_eq!(misses, 0);
2211                assert_eq!(peeked_target, target);
2212            }
2213            other => panic!("expected Specialized, got {other:?}"),
2214        }
2215    }
2216
2217    #[test]
2218    fn peek_direct_call_state_returns_none_for_non_direct_call_variants() {
2219        let mut chunk = Chunk::new();
2220        chunk.emit_u8(Op::Call, 0, 1);
2221        let slot = chunk
2222            .inline_cache_slot(0)
2223            .expect("Op::Call registers a slot");
2224
2225        chunk.set_inline_cache_entry(
2226            slot,
2227            InlineCacheEntry::Property {
2228                name_idx: 0,
2229                target: PropertyCacheTarget::ListCount,
2230            },
2231        );
2232        assert!(chunk.peek_direct_call_state(slot).is_none());
2233    }
2234
2235    #[test]
2236    fn peek_direct_call_state_returns_none_for_out_of_bounds_slot() {
2237        let chunk = Chunk::new();
2238        assert!(chunk.peek_direct_call_state(0).is_none());
2239        assert!(chunk.peek_direct_call_state(usize::MAX).is_none());
2240    }
2241
2242    /// Build a synthetic `DirectCallTarget::Closure` for direct-call peek
2243    /// tests. The closure has an empty body — the IC peek only inspects
2244    /// the wrapping `Arc`, not the closure internals.
2245    fn synthetic_direct_call_target() -> DirectCallTarget {
2246        use crate::value::VmClosure;
2247        use crate::{CompiledFunction, VmEnv};
2248        let func = CompiledFunction {
2249            name: "synthetic".to_string(),
2250            type_params: Vec::new(),
2251            nominal_type_names: Vec::new(),
2252            params: Vec::new(),
2253            default_start: None,
2254            chunk: Arc::new(Chunk::new()),
2255            is_generator: false,
2256            is_stream: false,
2257            has_rest_param: false,
2258            has_runtime_type_checks: false,
2259        };
2260        DirectCallTarget::Closure(Arc::new(VmClosure {
2261            func: Arc::new(func),
2262            env: VmEnv::new(),
2263            source_dir: None,
2264            module_functions: None,
2265            module_state: None,
2266        }))
2267    }
2268}