Skip to main content

luna_core/runtime/
table.rs

1//! Lua table: hybrid array + hash.
2//!
3//! Array part uses split tag/payload storage (9 bytes/slot — the Lua 5.5
4//! "compact arrays" layout, bench-validated in benches/value_repr.rs).
5//! Hash part is the PUC node layout: main-position chaining with relocation
6//! (Brent's variation), capacity a power of two, rehash sizing per
7//! luaH_rehash/computesizes.
8
9use crate::runtime::heap::{Gc, GcHeader, Heap, Marker};
10use crate::runtime::value::{RawVal, Value, f2i_exact, raw};
11
12/// Errors that table mutation can raise back to the interpreter.
13#[derive(Clone, Copy, PartialEq, Eq, Debug)]
14pub enum TableError {
15    /// `t[nil] = …` — `nil` is forbidden as a key.
16    NilIndex,
17    /// `t[0/0] = …` — NaN floats are forbidden as keys.
18    NanIndex,
19    /// `next` called with a key not present in the table.
20    InvalidNext,
21    /// PUC `luaH_resizearray` — the array part would have to grow past
22    /// `MAXASIZE`, or the hash part past `MAXHBITS`. Raised back as
23    /// "table overflow" so a runaway `a[i] = i` loop walls within budget
24    /// (5.5/5.4 heavy.lua's `toomanyidx` pcalls exactly this scenario).
25    Overflow,
26}
27
28/// PUC `MAXASIZE` analogue: the highest power of two an array part may
29/// grow to. Choose a cap that comfortably fits in the gate's 60-second
30/// budget (each grow is O(n), so 2^27 entries × 16 bytes ≈ 2 GB is the
31/// effective ceiling). Beyond this `rehash` returns `TableError::Overflow`.
32pub(crate) const MAX_ASIZE: usize = 1 << 27;
33
34/// JIT layout constants for table-field IC.
35///
36/// luna-jit's trace lowerer needs to emit direct loads against
37/// `Table.nodes` (the hash part) without paying the helper-call ABI
38/// for each `Op::GetField` / `Op::SetField`. These constants expose
39/// the field offsets so the cranelift IR can be parameterised at
40/// compile time. The `Node` struct itself remains `pub(crate)` — only
41/// the offsets cross the crate boundary.
42///
43/// Layout assumptions:
44/// - `Box<[Node]>` is a fat pointer `(data_ptr, len)` on 64-bit
45///   targets (16 bytes total). The data pointer occupies the low 8
46///   bytes, length the high 8. This is the de-facto Rust ABI for
47///   `Box<[T]>` / `&[T]` but isn't formally guaranteed; the unit
48///   test `node_layout_pinned` and the const assertion
49///   on `size_of::<Box<[Node]>>()` catch drift.
50/// - `Value` is `#[repr(C, u8)]` so the discriminant byte sits at
51///   offset 0 and the payload starts at offset 8 (after 7 bytes of
52///   alignment padding). Total size 16 bytes per the existing
53///   `value_is_16_bytes` test in `runtime/value.rs`.
54/// - `Node` is `#[derive(Clone, Copy)]` with field order
55///   `(key: Value, val: Value, next: i32, dead_key: bool)`, so
56///   `key` lives at offset 0 and `val` at offset 16. The trailing
57///   `next + dead_key` fields are not read by the IC.
58pub mod jit_layout {
59    use super::Node;
60    use crate::runtime::Table;
61
62    /// Byte offset of the `nodes: Box<[Node]>` field within `Table`.
63    /// The fat-ptr low word (data ptr) lives at this offset; the
64    /// high word (length) at `TABLE_NODES_OFFSET + 8`. luna-jit
65    /// adds `TABLE_NODES_PTR_OFFSET` / `TABLE_NODES_LEN_OFFSET`
66    /// constants in `jit_backend/mod.rs` to express that split.
67    pub const TABLE_NODES_OFFSET: usize = std::mem::offset_of!(Table, nodes);
68
69    /// Byte offset of `key: Value` within `Node` (= 0).
70    pub const NODE_KEY_OFFSET: usize = std::mem::offset_of!(Node, key);
71
72    /// Byte offset of `val: Value` within `Node` (= 16 — `key` is 16-byte
73    /// `Value`, no inner padding).
74    pub const NODE_VAL_OFFSET: usize = std::mem::offset_of!(Node, val);
75
76    /// Total `Node` size in bytes (= 40 on 64-bit). Used as the stride
77    /// in `node_addr = nodes_ptr + slot_idx * SIZEOF_NODE`.
78    pub const SIZEOF_NODE: usize = std::mem::size_of::<Node>();
79
80    /// Static guard: pin the assumptions luna-jit relies on at compile
81    /// time. Layout drift here breaks IR emit, so trap it at compile
82    /// time rather than at trace-fire time.
83    ///
84    /// `Box<[T]>` is a fat pointer of `2 * usize` — 16 bytes on 64-bit
85    /// targets, 8 bytes on 32-bit (e.g. `wasm32`). Use a width-aware
86    /// expected size so the wasm32-unknown-unknown CI build does not
87    /// trip the assertion. The runtime layout still matters for luna-jit
88    /// IR emit on 64-bit hosts (the only platforms where Cranelift JIT
89    /// runs); the 32-bit branch documents the size in passing.
90    const _: () = {
91        assert!(std::mem::size_of::<Box<[Node]>>() == 2 * std::mem::size_of::<usize>());
92        assert!(NODE_KEY_OFFSET == 0);
93        assert!(NODE_VAL_OFFSET == 16);
94        assert!(SIZEOF_NODE >= 32);
95    };
96}
97
98#[derive(Clone, Copy)]
99pub(crate) struct Node {
100    key: Value,
101    val: Value,
102    /// absolute index of the next node in this chain, or NONE
103    next: i32,
104    /// PUC `setdeadkey` analogue: the key was a collectable that got swept
105    /// out of a weak table. The Gc pointer in `key` is now dangling — its
106    /// memory may have been reused for a new allocation with potentially
107    /// equal content. Marking the node "dead-key" lets `find_node` skip the
108    /// raw_eq probe (which could spuriously match a reallocated object) and
109    /// `insert_new` treat the slot as available for a fresh main-position
110    /// owner while leaving chain back-links intact for traversal.
111    dead_key: bool,
112}
113
114const NONE: i32 = -1;
115
116impl Node {
117    const EMPTY: Node = Node {
118        key: Value::Nil,
119        val: Value::Nil,
120        next: NONE,
121        dead_key: false,
122    };
123}
124
125/// SoA Robin Hood meta-word layout.
126///
127/// Each `meta[idx]` slot encodes the open-addressing slot state in a
128/// single u16:
129/// - bit 15 (`OCCUPIED_BIT`): 0 = empty, 1 = occupied
130/// - bit 14 (`TOMBSTONE_BIT`): 0 = live, 1 = tombstoned-occupied
131/// - bits 13..0 (`PSL_MASK`): probe-sequence length (0..16383)
132///
133/// The 14-bit PSL field is **far** beyond any realistic Robin Hood
134/// max-PSL at load ≤ 0.75 (expected max ~20 on 1024 slots; even the
135/// long-tail outliers seen empirically with luna's existing hash
136/// distributions stay under 200). 2 bytes/slot is still 20× smaller
137/// than the 40-byte Node, so the SoA bandwidth gain is preserved.
138///
139/// A 1-byte meta with a 6-bit PSL cap of 63 is too narrow: under load
140/// 0.676 on cap=1024 the LuaStr+mix64 hash distribution produces a
141/// long-tail PSL of 64+.
142///
143/// Tombstones do NOT free the slot for `find` (probe continues past), but
144/// DO free it for `insert` (write the new entry, clear the tomb bit). They
145/// accumulate; the rehash path compacts them periodically.
146#[allow(dead_code)]
147pub(crate) mod meta_bits {
148    pub const OCCUPIED_BIT: u16 = 0b1000_0000_0000_0000;
149    pub const TOMBSTONE_BIT: u16 = 0b0100_0000_0000_0000;
150    pub const PSL_MASK: u16 = 0b0011_1111_1111_1111;
151    pub const PSL_MAX: u16 = PSL_MASK;
152    /// Empty slot — bit 15 = 0, all others 0.
153    pub const EMPTY: u16 = 0;
154
155    #[inline(always)]
156    pub fn is_occupied(m: u16) -> bool {
157        (m & OCCUPIED_BIT) != 0
158    }
159    #[inline(always)]
160    pub fn is_tombstone(m: u16) -> bool {
161        (m & TOMBSTONE_BIT) != 0
162    }
163    /// Live = occupied AND not tombstoned. `next()` iteration cursor returns
164    /// these. `find_slot_rh` short-circuits on a live match.
165    #[inline(always)]
166    pub fn is_live(m: u16) -> bool {
167        (m & (OCCUPIED_BIT | TOMBSTONE_BIT)) == OCCUPIED_BIT
168    }
169    #[inline(always)]
170    pub fn psl(m: u16) -> u16 {
171        m & PSL_MASK
172    }
173    #[inline(always)]
174    pub fn pack(psl: u16, tomb: bool) -> u16 {
175        debug_assert!(psl <= PSL_MAX);
176        let mut m = OCCUPIED_BIT | (psl & PSL_MASK);
177        if tomb {
178            m |= TOMBSTONE_BIT;
179        }
180        m
181    }
182}
183
184/// Inline storage threshold. Tables whose array part has
185/// `asize <= INLINE_ASIZE` keep their atags+avals inside the Table
186/// struct itself (`inline_storage`), skipping the slab Box entirely
187/// — binary_trees's `{nil, nil}` and `{...}` 2-element leaves live
188/// here, sparing one allocator round-trip per NewTable.
189pub(crate) const INLINE_ASIZE: u64 = 2;
190/// `INLINE_ASIZE` u64 slots for avals + `ceil(INLINE_ASIZE / 8)` u64
191/// slots covering the atags bytes (with trailing pad). For
192/// `INLINE_ASIZE = 2`: 2 avals + 1 atags = 3 u64s = 24 bytes.
193pub(crate) const INLINE_U64S: usize = INLINE_ASIZE as usize + INLINE_ASIZE.div_ceil(8) as usize;
194
195/// Lua table — hybrid array + hash storage, with optional metatable and
196/// weak-mode flags.
197#[repr(C)]
198pub struct Table {
199    /// read through raw casts by the GC, not by field access
200    #[allow(dead_code)]
201    pub(crate) hdr: GcHeader,
202    /// Single backing pointer for the array part. Points
203    /// to `inline_storage` (asize <= INLINE_ASIZE) or `slab.as_ptr()`
204    /// (asize > INLINE_ASIZE). The JIT inline aset reads this with one
205    /// `load i64`, no branch — the choice between inline and slab is
206    /// already encoded in the pointer. Initialised in `Heap::new_table`
207    /// AFTER the Table reaches its final heap address (so that
208    /// `&mut self.inline_storage` is the stable heap pointer, not a
209    /// stack-local one). Updated by `Table::resize`.
210    pub array_ptr: *mut u8,
211    /// External backing for the array part when
212    /// `asize > INLINE_ASIZE`. Layout: `[avals: asize × 8 bytes][atags:
213    /// asize bytes]`. Empty box (dangling, no alloc) when the inline
214    /// path is in use.
215    pub(crate) slab: Box<[u64]>,
216    /// Length of the array part in slots. u64 (rather than `usize` or
217    /// `u32`) so the JIT can load it with a single `load i64`.
218    pub asize: u64,
219    /// Inline backing used when `asize <= INLINE_ASIZE`.
220    /// Same layout as the slab: avals at low addresses (`asize * 8`
221    /// bytes from offset 0), atags at the trailing `asize` bytes.
222    ///
223    /// `UnsafeCell` because `array_ptr` is a SELF-REFERENTIAL cached
224    /// pointer into this field. Under Stacked Borrows, every
225    /// `&mut self` method call's function-entry retag re-tags the
226    /// whole `*self` byte range Unique and would pop the cached
227    /// pointer's tag — subsequent `array_ptr` accesses would be UB (Miri:
228    /// "retag ... tag does not exist in the borrow stack").
229    /// An `UnsafeCell` region instead receives SharedReadWrite on
230    /// retag, which coexists with the pointer derived from
231    /// `UnsafeCell::get`. All reads/writes of the inline bytes MUST
232    /// go through `array_ptr` / `.get()` — never through a direct
233    /// `&`/`&mut` borrow of the array contents.
234    pub(crate) inline_storage: std::cell::UnsafeCell<[u64; INLINE_U64S]>,
235    /// hash part: power-of-two length (or empty)
236    /// hash part: power-of-two length (or empty)
237    /// `pub(crate)` so `Heap::free_obj` (pool recycle path) can reset.
238    pub(crate) nodes: Box<[Node]>,
239    /// free-slot search position, counts down (PUC lastfree).
240    /// `pub(crate)` so `Heap::new_table` can reset on pool recycle.
241    pub(crate) lastfree: u32,
242    /// SoA Robin Hood hash part, kept parallel to `nodes`. It is not
243    /// on the public get/set/next path yet: the chain `nodes` stay
244    /// authoritative and only the `soa_*` methods touch these arrays.
245    /// `meta` layout per the `meta_bits` module above. Empty
246    /// `Box::new([])` until a `soa_insert` grows it.
247    pub(crate) keys: Box<[Value]>,
248    pub(crate) vals: Box<[Value]>,
249    pub(crate) meta: Box<[u16]>,
250    /// Count of tombstoned-occupied meta slots; rehash trigger.
251    pub(crate) tombstones: u32,
252    /// Iterator-guard counter. Meant to count in-flight `pairs`/`next`
253    /// traversals; while > 0 the SoA path MUST defer rehash (which
254    /// would rebase slot indices and break the PUC
255    /// `nextvar.lua:520-521` invariant). Nothing increments it yet, so
256    /// it stays 0.
257    pub(crate) iter_depth: u32,
258    /// Visible outside the module so the JIT can
259    /// take its field offset at compile time and emit an inline
260    /// "metatable.is_none()" guard before the inline aget fast path.
261    /// `Option<Gc<Table>>` is 8 bytes via the NonNull-pointer-opt: 0
262    /// ⇔ None, non-zero ⇔ Some.
263    pub metatable: Option<Gc<Table>>,
264    /// reserved for an absent-metamethod cache (PUC `flags`); currently
265    /// unread — luna's mm lookup walks `metatable.get` each time
266    #[allow(dead_code)]
267    pub(crate) flags: u8,
268}
269
270// SAFETY: `array_ptr` looks like an unprotected raw pointer field, but
271// it always refers to memory the same Table owns (either its own inline
272// storage or its `slab` Box). The Table is heap-allocated and never
273// moved post-adoption, so the pointer stays valid for the table's
274// lifetime. No thread-unsafety concern: tables are accessed only
275// through the Vm, single-threaded.
276unsafe impl Send for Table {}
277unsafe impl Sync for Table {}
278
279impl Table {
280    pub(crate) fn new(hdr: GcHeader) -> Table {
281        Table {
282            hdr,
283            // `array_ptr` is fixed up in
284            // `Heap::new_table` after the Table reaches its final heap
285            // address (so that `&inline_storage` is the heap address,
286            // not a stack-local one). Null sentinel here so a
287            // bug-detection invariant flags any pre-fixup read.
288            array_ptr: std::ptr::null_mut(),
289            slab: Box::new([]),
290            asize: 0,
291            inline_storage: std::cell::UnsafeCell::new([0; INLINE_U64S]),
292            nodes: Box::new([]),
293            lastfree: 0,
294            keys: Box::new([]),
295            vals: Box::new([]),
296            meta: Box::new([]),
297            tombstones: 0,
298            iter_depth: 0,
299            metatable: None,
300            flags: 0,
301        }
302    }
303
304    /// Set `array_ptr` to the inline storage's stable heap
305    /// address. Called by `Heap::new_table` once the Table is at its
306    /// final location.
307    #[inline]
308    pub(crate) fn init_array_ptr(&mut self) {
309        self.array_ptr = self.inline_storage.get() as *mut u8;
310    }
311
312    /// Freshly-derived base pointer for the array part. Rust-side
313    /// accessors MUST use this instead of the cached `array_ptr`
314    /// field when the backing is inline: a pointer into `*self`
315    /// cached across `&mut self` boundaries is invalidated by every
316    /// function-entry retag under Stacked Borrows — `&mut` retags
317    /// ignore `UnsafeCell` (only `&` retags respect it), so the
318    /// cached tag dies on the next method call (Miri:
319    /// `retag ... tag does not exist in the borrow stack`). Deriving
320    /// through `UnsafeCell::get()` at each use gives a fresh
321    /// SharedReadWrite tag valid for reads AND writes even from
322    /// `&self`. The slab case keeps the cached pointer: its tag
323    /// lives on the heap allocation, outside `*self`, untouched by
324    /// entry retags. `array_ptr` itself stays maintained for the
325    /// JIT, whose emitted code loads the field directly (no Rust
326    /// borrows involved).
327    #[inline(always)]
328    fn array_base(&self) -> *mut u8 {
329        if self.asize <= INLINE_ASIZE {
330            self.inline_storage.get() as *mut u8
331        } else {
332            self.array_ptr
333        }
334    }
335
336    /// Read view onto the array-part tag bytes. Trails
337    /// the avals portion in the active backing (inline or slab).
338    #[inline(always)]
339    pub(crate) fn atags(&self) -> &[u8] {
340        let n = self.asize as usize;
341        if n == 0 {
342            return &[];
343        }
344        // SAFETY: `array_ptr` always points to a buffer with `n`
345        // RawVal slots followed by `n` u8 tag bytes (either
346        // `inline_storage` of `INLINE_U64S` u64s, or a `slab` of
347        // `asize + ceil(asize/8)` u64s). The tag bytes start at byte
348        // offset `n * 8` from the buffer base.
349        unsafe {
350            let ptr = self.array_base().add(n * 8);
351            std::slice::from_raw_parts(ptr, n)
352        }
353    }
354
355    #[inline(always)]
356    pub(crate) fn atags_mut(&mut self) -> &mut [u8] {
357        let n = self.asize as usize;
358        if n == 0 {
359            return &mut [];
360        }
361        // SAFETY: `array_ptr` was allocated by `Heap::init_array_ptr` with `array_cap` slots; the table holds it for its lifetime and the heap is single-threaded so no concurrent writers exist.
362        unsafe {
363            let ptr = self.array_base().add(n * 8);
364            std::slice::from_raw_parts_mut(ptr, n)
365        }
366    }
367
368    /// Read view onto the array-part payload slots. Sits
369    /// at the start of the active backing (u64-aligned, identical size
370    /// and layout to `RawVal`).
371    #[inline(always)]
372    pub(crate) fn avals(&self) -> &[RawVal] {
373        let n = self.asize as usize;
374        if n == 0 {
375            return &[];
376        }
377        // SAFETY: inline_storage / slab both store u64s, so the cast
378        // to `*const RawVal` is alignment-safe (RawVal size = 8,
379        // align = 8). The buffer holds at least `n` such slots.
380        unsafe { std::slice::from_raw_parts(self.array_base() as *const RawVal, n) }
381    }
382
383    #[inline(always)]
384    pub(crate) fn avals_mut(&mut self) -> &mut [RawVal] {
385        let n = self.asize as usize;
386        if n == 0 {
387            return &mut [];
388        }
389        // SAFETY: `array_ptr` was allocated by `Heap::init_array_ptr` with `array_cap` slots; the table holds it for its lifetime and the heap is single-threaded so no concurrent writers exist.
390        unsafe { std::slice::from_raw_parts_mut(self.array_base() as *mut RawVal, n) }
391    }
392
393    /// Allocate a fresh external `[avals: asize × 8 bytes][atags: asize
394    /// bytes]` slab. Only used when `asize > INLINE_ASIZE`. The buffer
395    /// is u64-aligned via `Box<[u64]>` and zeroed (avals = `RawVal::
396    /// NIL` aka `0`; atags = `raw::NIL` aka `0`).
397    fn alloc_slab(asize: usize) -> Box<[u64]> {
398        if asize == 0 {
399            return Box::new([]);
400        }
401        let avals_u64s = asize;
402        let atags_u64s = asize.div_ceil(8);
403        let total = avals_u64s + atags_u64s;
404        vec![0u64; total].into_boxed_slice()
405    }
406
407    /// This table's metatable, if any.
408    pub fn metatable(&self) -> Option<Gc<Table>> {
409        self.metatable
410    }
411
412    /// Install (or clear) this table's metatable. Does not perform any
413    /// `__metatable` guarding; that belongs in the Vm-level `setmetatable`.
414    pub fn set_metatable(&mut self, mt: Option<Gc<Table>>) {
415        self.metatable = mt;
416    }
417
418    /// Bytes occupied by the table's *external* internal allocations
419    /// (slab and nodes). Cheap O(1) read — Box len × element size, no
420    /// allocator query. `Heap::free_obj` subtracts this on the way out
421    /// so the credit applied via `set`/`rehash`/`ensure_*` is symmetric.
422    ///
423    /// Inline storage doesn't count toward this (it's part
424    /// of the Table struct itself, accounted for by `size_of::<Table>()`
425    /// at adoption time). When the array part lives inline, the slab
426    /// is empty and contributes nothing here.
427    pub(crate) fn internal_bytes(&self) -> usize {
428        let n = self.asize as usize;
429        let array_external = if n > INLINE_ASIZE as usize {
430            n + n * std::mem::size_of::<RawVal>()
431        } else {
432            0
433        };
434        let soa_external = self.keys.len() * std::mem::size_of::<Value>()
435            + self.vals.len() * std::mem::size_of::<Value>()
436            + self.meta.len() * std::mem::size_of::<u16>();
437        array_external + self.nodes.len() * std::mem::size_of::<Node>() + soa_external
438    }
439
440    fn asize(&self) -> usize {
441        self.asize as usize
442    }
443
444    fn aget(&self, idx: usize) -> Value {
445        // SAFETY: callers gate on `idx < self.asize()` before reaching here
446        // (`get_int`, `iter_array`, etc.). atags and avals are sized
447        // identically by `rehash`, so a bound check passed against atags
448        // covers avals too.
449        unsafe {
450            Value::pack(
451                *self.atags().get_unchecked(idx),
452                *self.avals().get_unchecked(idx),
453            )
454        }
455    }
456
457    fn aset(&mut self, idx: usize, v: Value) {
458        let (t, b) = v.unpack();
459        // SAFETY: see `aget`. callers (`set_norm`, `set_int`) gate on
460        // `idx < self.asize()`. The two `*_mut` calls each take a
461        // distinct `&mut self` borrow whose lifetime ends at the
462        // statement boundary, so they don't overlap.
463        unsafe {
464            *self.atags_mut().get_unchecked_mut(idx) = t;
465            *self.avals_mut().get_unchecked_mut(idx) = b;
466        }
467    }
468
469    // ---- reads ----
470
471    /// Raw lookup (no `__index` metamethod). Returns `Value::Nil` when
472    /// the key is absent. `Value::Nil` and NaN floats return `nil` directly.
473    pub fn get(&self, key: Value) -> Value {
474        match key {
475            Value::Int(i) => self.get_int(i),
476            Value::Float(f) => match f2i_exact(f) {
477                Some(i) => self.get_int(i),
478                None => {
479                    if f.is_nan() {
480                        Value::Nil
481                    } else {
482                        self.get_hash(key)
483                    }
484                }
485            },
486            Value::Nil => Value::Nil,
487            k => self.get_hash(k),
488        }
489    }
490
491    /// Integer-keyed variant of [`Self::get`].
492    pub fn get_int(&self, i: i64) -> Value {
493        if i >= 1 && (i as u64) <= self.asize() as u64 {
494            return self.aget(i as usize - 1);
495        }
496        self.get_hash(Value::Int(i))
497    }
498
499    /// String-keyed variant of [`Self::get`] for the GetField fast
500    /// path: the GetField interp arm always has a `Gc<LuaStr>` key from
501    /// `Proto.consts`. Skips the outer `Value` match (which would only
502    /// take the `_ => self.get_hash(k)` arm anyway) so the dispatcher
503    /// pays one less branch per call. ~5 GetField/iter × 1000 iters/cell
504    /// on the Redis-Lua-shape workload — every shaved nanosecond shows
505    /// up at the bench level.
506    #[inline]
507    pub fn get_str(&self, key: crate::runtime::Gc<crate::runtime::string::LuaStr>) -> Value {
508        self.get_hash(Value::Str(key))
509    }
510
511    fn get_hash(&self, k: Value) -> Value {
512        match self.find_node(k) {
513            Some(idx) => self.nodes[idx].val,
514            None => Value::Nil,
515        }
516    }
517
518    /// Same logic as [`find_node`] but exposed
519    /// to luna-core's recorder so it can capture the slot index for
520    /// the table-field IC snapshot. luna-jit reads neither the
521    /// `nodes` field nor `Node` directly; only the slot index
522    /// crosses the crate boundary (baked into the IR as a `iconst`).
523    #[allow(dead_code)]
524    pub(crate) fn find_node_idx(&self, k: Value) -> Option<usize> {
525        self.find_node(k)
526    }
527
528    /// Accessor for the recorder's
529    /// `FieldIcSnapshot` capture: read the slot's value's tag byte
530    /// for the cached_val_tag field. The recorder needs this to
531    /// match the runtime guard the IC emits. Returns None when
532    /// `idx >= nodes.len()`.
533    #[allow(dead_code)]
534    pub(crate) fn node_val_at(&self, idx: usize) -> Option<Value> {
535        self.nodes.get(idx).map(|n| n.val)
536    }
537
538    /// Accessor for `nodes.len()` so the recorder
539    /// can capture the shape-guard's `nodes_len` field without
540    /// reaching into the private `nodes` member.
541    #[allow(dead_code)]
542    pub(crate) fn nodes_capacity(&self) -> usize {
543        self.nodes.len()
544    }
545
546    /// Walk the chain rooted at the key's main position.
547    fn find_node(&self, k: Value) -> Option<usize> {
548        // read-time probe (gc-verify): both the query key and
549        // every node key compared below must be live. This is the
550        // convergence point of ALL hash lookups, so a dangling string
551        // is named at its dereference site with role attribution.
552        #[cfg(feature = "gc-verify")]
553        {
554            let hdr = |v: Value| -> Option<usize> {
555                match v {
556                    Value::Str(s) => Some(s.as_ptr() as usize),
557                    Value::Table(t) => Some(t.as_ptr() as usize),
558                    _ => None,
559                }
560            };
561            if let Some(p) = hdr(k)
562                && crate::runtime::gc_verify_probe::is_freed(p)
563            {
564                panic!("[gc-verify] find_node QUERY key {p:#x} is freed (dangling)");
565            }
566            for (i, n) in self.nodes.iter().enumerate() {
567                // NOTE: tombstones (val nil, key kept) are NOT skipped —
568                // the walk below raw_eq's their keys too.
569                if n.dead_key {
570                    continue;
571                }
572                if let Some(p) = hdr(n.key)
573                    && crate::runtime::gc_verify_probe::is_freed(p)
574                {
575                    panic!(
576                        "[gc-verify] find_node NODE key {p:#x} (slot {i}, \
577                             tombstone {}, table {:#x}) is freed (dangling)",
578                        n.val.is_nil(),
579                        self as *const Table as usize
580                    );
581                }
582            }
583        }
584        if self.nodes.is_empty() {
585            return None;
586        }
587        let mut idx = self.main_position(k);
588        loop {
589            let n = &self.nodes[idx];
590            // Dead-key slots carry a dangling Gc pointer whose memory may
591            // have been reallocated to a different live object; raw_eq on
592            // such a key can spuriously match the freshly-reused address.
593            // Skip the comparison and only follow `next` (PUC `setdeadkey`
594            // / `equalkey` short-circuit). 5.5 gc.lua :459-:478 was 12%
595            // flaky on this exact path — a swept B-string's slot kept
596            // chaining into A's slot, so `a[k] = nil` (k = A_string) hit
597            // the dead slot and wrote nil there, leaving A's val untouched.
598            if !n.dead_key && n.key.raw_eq(k) {
599                return Some(idx);
600            }
601            if n.next == NONE {
602                return None;
603            }
604            idx = n.next as usize;
605        }
606    }
607
608    // ---- writes ----
609
610    /// Insert / update `(key, val)`. `heap` is used to credit any internal
611    /// Box growth (rehash) to `heap.bytes` so the counter stays in sync with
612    /// real memory; `free_obj` subtracts `internal_bytes()` on the way out.
613    pub fn set(&mut self, heap: &mut Heap, key: Value, val: Value) -> Result<(), TableError> {
614        let k = normalize_set_key(key)?;
615        self.set_norm(heap, k, val)
616    }
617
618    /// PUC `luaV_fastset` / `luaV_finishfastset` analogue: single-walk
619    /// in-place update for an existing key. Returns `true` iff `key` is
620    /// present with a non-nil value and the slot was overwritten with
621    /// `val`. Returns `false` when the key is absent, the slot holds nil,
622    /// or the key normalisation rejects it — the caller is then expected
623    /// to run the `__newindex` chain or fall back to `set` for the raw
624    /// insert.
625    ///
626    /// Collapses the SetField hot path from two hash-chain walks
627    /// (`get` + `set`) to one. The `__newindex` invariant ("fires iff
628    /// `get` would have returned nil") is preserved because this method
629    /// writes only when the existing slot is non-nil — the exact set the
630    /// prior `tb.get(key).is_nil()` gate already excluded from
631    /// `__newindex` eligibility. See
632    /// semantics check.
633    ///
634    /// The caller is responsible for firing `Heap::barrier_back` after a
635    /// `true` return (same contract as the surrounding `raw_set`
636    /// wrapper).
637    pub fn try_set_existing(&mut self, key: Value, val: Value) -> bool {
638        let k = match normalize_set_key(key) {
639            Ok(k) => k,
640            Err(_) => return false,
641        };
642        if let Value::Int(i) = k
643            && i >= 1
644            && (i as u64) <= self.asize() as u64
645        {
646            let idx = i as usize - 1;
647            // SAFETY: `idx < self.asize()` is guarded by the conditional
648            // above, mirroring the bound on `aget`/`aset`.
649            let tag = unsafe { *self.atags().get_unchecked(idx) };
650            if tag != raw::NIL {
651                // Nil-val on a live slot must follow the same tombstone
652                // discipline as `set_norm` — routed through
653                // `clear_existing_slot` so the chain layout and any
654                // future data-layout cutover (SoA) stay aligned.
655                if val.is_nil() {
656                    self.clear_existing_slot(k);
657                } else {
658                    self.aset(idx, val);
659                }
660                return true;
661            }
662            // Array slot present-but-nil → __newindex eligible: do NOT
663            // write. Caller falls through to the metamethod chain.
664            return false;
665        }
666        if let Some(idx) = self.find_node(k)
667            && !self.nodes[idx].val.is_nil()
668        {
669            if val.is_nil() {
670                self.clear_existing_slot(k);
671            } else {
672                self.nodes[idx].val = val;
673            }
674            return true;
675        }
676        false
677    }
678
679    /// Shared "live with val=Nil is illegal" tombstone routine for the
680    /// two write entry points (`set_norm` and `try_set_existing`). The
681    /// slot must already be known live (array slot inside `asize()` /
682    /// node returned by `find_node`).
683    ///
684    /// Chain-world today:
685    ///   - array slot → `aset(_, Nil)` clears the atag, so `next()`'s
686    ///     `tag != raw::NIL` filter skips the slot.
687    ///   - node slot  → soft tombstone (key kept, `val = Nil`); chain
688    ///     `next()` filter `!n.val.is_nil()` skips it, and `find_node`
689    ///     still routes a future re-insert into the same slot without
690    ///     a rehash.
691    ///
692    /// Centralising the discipline here lets a future SoA cutover
693    /// (linear probe, or any layout that switches `next()`'s filter to
694    /// `meta_bits::is_live`) migrate both entry points in lockstep; if
695    /// they diverge, `pairs()` yields `(key, nil)` zombies.
696    fn clear_existing_slot(&mut self, k: Value) {
697        if let Value::Int(i) = k
698            && i >= 1
699            && (i as u64) <= self.asize() as u64
700        {
701            self.aset(i as usize - 1, Value::Nil);
702            return;
703        }
704        if let Some(idx) = self.find_node(k) {
705            self.nodes[idx].val = Value::Nil;
706        }
707    }
708
709    /// Integer-keyed variant of [`Self::set`].
710    pub fn set_int(&mut self, heap: &mut Heap, i: i64, val: Value) -> Result<(), TableError> {
711        self.set_norm(heap, Value::Int(i), val)
712    }
713
714    /// `k` is already normalized (no nil, no NaN, integral floats → Int).
715    fn set_norm(&mut self, heap: &mut Heap, k: Value, v: Value) -> Result<(), TableError> {
716        if let Value::Int(i) = k
717            && i >= 1
718            && (i as u64) <= self.asize() as u64
719        {
720            // Live array slot + Nil write goes through the shared
721            // tombstone routine (see `clear_existing_slot` for the
722            // chain ↔ future-SoA rationale). The non-Nil branch is
723            // identical to a bare `aset` today.
724            if v.is_nil() {
725                self.clear_existing_slot(k);
726            } else {
727                self.aset(i as usize - 1, v);
728            }
729            return Ok(());
730        }
731        if let Some(idx) = self.find_node(k) {
732            if v.is_nil() {
733                self.clear_existing_slot(k);
734            } else {
735                self.nodes[idx].val = v;
736            }
737            return Ok(());
738        }
739        if v.is_nil() {
740            return Ok(()); // absent key set to nil: nothing to record
741        }
742        self.insert_new(heap, k, v)
743    }
744
745    fn insert_new(&mut self, heap: &mut Heap, k: Value, v: Value) -> Result<(), TableError> {
746        if self.nodes.is_empty() {
747            self.rehash(heap, k)?;
748            return self.set_norm(heap, k, v);
749        }
750        let mp = self.main_position(k);
751        // A truly empty slot (key=Nil, !dead_key) is free for direct placement.
752        // A dead-key slot still belongs to some chain (its `next` points to a
753        // live entry the chain reaches), so we treat it as occupied here and
754        // route the new key through the collision path below — that preserves
755        // the back-links into this slot from other nodes' `next` fields.
756        if self.nodes[mp].key.is_nil() && !self.nodes[mp].dead_key {
757            self.nodes[mp] = Node {
758                key: k,
759                val: v,
760                next: NONE,
761                dead_key: false,
762            };
763            return Ok(());
764        }
765        let Some(free) = self.free_pos() else {
766            self.rehash(heap, k)?;
767            return self.set_norm(heap, k, v);
768        };
769        // Dead-key slot: it carries no live key, so by definition nobody else
770        // counts it as "their main position owner". We give it directly to
771        // the new key but preserve `next` so the chain it sits inside still
772        // reaches its downstream entries.
773        if self.nodes[mp].dead_key {
774            let preserved_next = self.nodes[mp].next;
775            self.nodes[mp] = Node {
776                key: k,
777                val: v,
778                next: preserved_next,
779                dead_key: false,
780            };
781            return Ok(());
782        }
783        let other_mp = self.main_position(self.nodes[mp].key);
784        if other_mp != mp {
785            // colliding node is out of its main position: relocate it to the
786            // free slot and take its place
787            let mut prev = other_mp;
788            while self.nodes[prev].next != mp as i32 {
789                prev = self.nodes[prev].next as usize;
790            }
791            self.nodes[prev].next = free as i32;
792            self.nodes[free] = self.nodes[mp];
793            self.nodes[mp] = Node {
794                key: k,
795                val: v,
796                next: NONE,
797                dead_key: false,
798            };
799        } else {
800            // colliding node owns this position: chain the new node behind it
801            self.nodes[free] = Node {
802                key: k,
803                val: v,
804                next: self.nodes[mp].next,
805                dead_key: false,
806            };
807            self.nodes[mp].next = free as i32;
808        }
809        Ok(())
810    }
811
812    fn free_pos(&mut self) -> Option<usize> {
813        while self.lastfree > 0 {
814            self.lastfree -= 1;
815            let n = &self.nodes[self.lastfree as usize];
816            // Dead-key slots are still occupied for chain purposes (their
817            // `next` may be the only path to a downstream entry) — don't
818            // hand them out as free.
819            if n.key.is_nil() && !n.dead_key {
820                return Some(self.lastfree as usize);
821            }
822        }
823        None
824    }
825
826    // ---- rehash (PUC luaH_rehash) ----
827
828    fn rehash(&mut self, heap: &mut Heap, pending: Value) -> Result<(), TableError> {
829        let mut nums = [0usize; 65];
830        let mut int_keys = 0usize;
831        let mut total = 1; // the pending key
832        if let Value::Int(i) = pending
833            && i >= 1
834        {
835            nums[ceil_log2(i as u64)] += 1;
836            int_keys += 1;
837        }
838        let atags = self.atags();
839        for (i, &tag) in atags.iter().enumerate() {
840            if tag != raw::NIL {
841                nums[ceil_log2(i as u64 + 1)] += 1;
842                int_keys += 1;
843                total += 1;
844            }
845        }
846        for n in self.nodes.iter() {
847            if !n.val.is_nil() {
848                total += 1;
849                if let Value::Int(i) = n.key
850                    && i >= 1
851                {
852                    nums[ceil_log2(i as u64)] += 1;
853                    int_keys += 1;
854                }
855            }
856        }
857        // computesizes: optimal array size = largest 2^i with more than 2^(i-1)
858        // integer keys in [1, 2^i]
859        let mut new_asize = 0usize;
860        let mut in_array = 0usize;
861        let mut a = 0usize;
862        let mut two_to_i = 1usize;
863        let mut i = 0usize;
864        while int_keys > two_to_i / 2 {
865            a += nums[i];
866            if a > two_to_i / 2 {
867                new_asize = two_to_i;
868                in_array = a;
869            }
870            i += 1;
871            match two_to_i.checked_mul(2) {
872                Some(n) => two_to_i = n,
873                None => break,
874            }
875        }
876        // PUC `luaH_resizearray` raises "table overflow" when the array part
877        // would have to grow past MAXASIZE. luna mirrors with `MAX_ASIZE`,
878        // checked on both the array and the hash bucket count (the latter is
879        // a power-of-two of total - in_array entries).
880        if new_asize > MAX_ASIZE {
881            return Err(TableError::Overflow);
882        }
883        let hash_entries = total - in_array;
884        if hash_entries > MAX_ASIZE {
885            return Err(TableError::Overflow);
886        }
887        self.resize(heap, new_asize, hash_entries);
888        Ok(())
889    }
890
891    /// Resize the table's array and hash parts. The array part grows
892    /// (or shrinks) to `new_asize` NIL-initialized slots; the hash
893    /// part rounds to the next power of two ≥ `hash_entries`. Any
894    /// existing entries are re-inserted into the new layout. The
895    /// Box growth is debited/credited to `heap.bytes` so `free_obj`
896    /// can subtract the symmetric amount.
897    ///
898    /// `Heap::new_table_sized` calls this on a freshly
899    /// adopted empty table to pre-allocate the array part, sparing
900    /// the table-fill loop from O(log N) intermediate `rehash`es.
901    pub(crate) fn resize(&mut self, heap: &mut Heap, new_asize: usize, hash_entries: usize) {
902        let before = self.internal_bytes();
903        // snapshot the old array entries before we
904        // re-install the backing. The active buffer can be inline OR
905        // slab; `array_ptr` already points to whichever it is, so
906        // walking via raw offsets works the same for either case.
907        let old_asize = self.asize as usize;
908        let mut old_pairs: Vec<(u8, RawVal)> = Vec::with_capacity(old_asize);
909        if old_asize > 0 {
910            // SAFETY: `array_ptr` was set up by `Heap::new_table` or
911            // an earlier `resize`; it covers `old_asize * 9` bytes
912            // (avals + atags).
913            let avals_base = self.array_base() as *const RawVal;
914            let atags_base = unsafe { self.array_base().add(old_asize * 8) as *const u8 };
915            for i in 0..old_asize {
916                // SAFETY: `i < array_len` is enforced by the surrounding loop bound; `atags_base` / `avals_base` point into the table's parallel arrays allocated in lockstep by `init_array_ptr`.
917                let tag = unsafe { *atags_base.add(i) };
918                // SAFETY: `i < array_len` is enforced by the surrounding loop bound; `atags_base` / `avals_base` point into the table's parallel arrays allocated in lockstep by `init_array_ptr`.
919                let val = unsafe { *avals_base.add(i) };
920                old_pairs.push((tag, val));
921            }
922        }
923        let old_nodes = std::mem::take(&mut self.nodes);
924
925        // Install the new array backing first, then update `array_ptr`
926        // (before potentially dropping the old slab via the assignment
927        // below) so the JIT never observes a stale pointer.
928        self.asize = new_asize as u64;
929        if new_asize <= INLINE_ASIZE as usize {
930            // Inline path — zero the inline buffer; drop any prior
931            // external slab.
932            // SAFETY: exclusive &mut self; write through the cell to
933            // stay on the raw-pointer access path (no &mut borrow of
934            // the array contents is ever formed).
935            unsafe {
936                *self.inline_storage.get() = [0; INLINE_U64S];
937            }
938            self.array_ptr = self.inline_storage.get() as *mut u8;
939            self.slab = Box::new([]);
940        } else {
941            // External slab — allocate, then re-point `array_ptr`.
942            self.slab = Self::alloc_slab(new_asize);
943            self.array_ptr = self.slab.as_mut_ptr() as *mut u8;
944        }
945
946        let hsize = if hash_entries == 0 {
947            0
948        } else {
949            hash_entries.next_power_of_two()
950        };
951        self.nodes = vec![Node::EMPTY; hsize].into_boxed_slice();
952        self.lastfree = hsize as u32;
953        // PUC `g->GCtotalbytes` analogue: credit (or debit) the box-size
954        // delta so `Heap.bytes` reflects this table's actual internal
955        // memory. `free_obj` subtracts `internal_bytes()` on the way out.
956        let after = self.internal_bytes();
957        heap.apply_bytes_delta(before, after);
958        // Re-insert old array entries via the public set_norm path
959        // (which handles rehashing if the new array shrinks below the
960        // entry count).
961        for (i, (tag, val)) in old_pairs.into_iter().enumerate() {
962            if tag != raw::NIL {
963                // SAFETY: `tag` and the raw value come from this table's parallel `atags` / `avals` arrays, which the table writers always keep in sync — the tag byte matches the raw payload's discriminator (see `runtime::value` `raw` module).
964                let v = unsafe { Value::pack(tag, val) };
965                let _ = self.set_norm(heap, Value::Int(i as i64 + 1), v);
966            }
967        }
968        for n in old_nodes.iter() {
969            if !n.val.is_nil() {
970                let _ = self.set_norm(heap, n.key, n.val);
971            }
972        }
973    }
974
975    fn main_position(&self, k: Value) -> usize {
976        debug_assert!(!self.nodes.is_empty());
977        hash_key(k) as usize & (self.nodes.len() - 1)
978    }
979
980    // ---- length / iteration ----
981
982    /// A border: `n` where `t[n]` is non-nil and `t[n+1]` is nil (PUC `luaH_getn`).
983    /// This is Lua `#` semantics, not a container size — an `is_empty`
984    /// counterpart would be meaningless.
985    #[allow(clippy::len_without_is_empty)]
986    pub fn len(&self) -> i64 {
987        let asize = self.asize();
988        let atags = self.atags();
989        if asize > 0 && atags[asize - 1] == raw::NIL {
990            // binary search inside the array part
991            let (mut lo, mut hi) = (0usize, asize);
992            while hi - lo > 1 {
993                let m = lo + (hi - lo) / 2;
994                if atags[m - 1] == raw::NIL {
995                    hi = m;
996                } else {
997                    lo = m;
998                }
999            }
1000            return lo as i64;
1001        }
1002        if self.nodes.is_empty() {
1003            return asize as i64;
1004        }
1005        // array is full (or absent): unbound search through the hash part
1006        let mut lo = asize as i64;
1007        let mut hi = lo + 1;
1008        while !self.get_int(hi).is_nil() {
1009            lo = hi;
1010            match hi.checked_mul(2) {
1011                Some(n) => hi = n,
1012                None => {
1013                    // pathological sparse keys (the doubling overflowed): scan
1014                    // linearly from 1 for the first border, as PUC's
1015                    // unbound_search does — finds a small border fast instead of
1016                    // returning the huge one.
1017                    let mut i = 1i64;
1018                    while !self.get_int(i).is_nil() {
1019                        i += 1;
1020                    }
1021                    return i - 1;
1022                }
1023            }
1024        }
1025        while hi - lo > 1 {
1026            let m = lo + (hi - lo) / 2;
1027            if self.get_int(m).is_nil() {
1028                hi = m;
1029            } else {
1030                lo = m;
1031            }
1032        }
1033        lo
1034    }
1035
1036    /// Lua `next`: iterate array part then hash part.
1037    pub fn next(&self, key: Value) -> Result<Option<(Value, Value)>, TableError> {
1038        let start = match key {
1039            Value::Nil => 0,
1040            k => {
1041                let k = match k {
1042                    Value::Float(f) => match f2i_exact(f) {
1043                        Some(i) => Value::Int(i),
1044                        None => k,
1045                    },
1046                    k => k,
1047                };
1048                if let Value::Int(i) = k
1049                    && i >= 1
1050                    && (i as u64) <= self.asize() as u64
1051                {
1052                    i as usize
1053                } else {
1054                    match self.find_node(k) {
1055                        Some(idx) => self.asize() + idx + 1,
1056                        None => return Err(TableError::InvalidNext),
1057                    }
1058                }
1059            }
1060        };
1061        let atags = self.atags();
1062        for i in start..self.asize() {
1063            if atags[i] != raw::NIL {
1064                return Ok(Some((Value::Int(i as i64 + 1), self.aget(i))));
1065            }
1066        }
1067        let hstart = start.saturating_sub(self.asize());
1068        for (idx, n) in self.nodes.iter().enumerate().skip(hstart) {
1069            if !n.val.is_nil() {
1070                let _ = idx;
1071                return Ok(Some((n.key, n.val)));
1072            }
1073        }
1074        Ok(None)
1075    }
1076
1077    /// `(weak_keys, weak_values)` from the metatable's `__mode` field. Read by
1078    /// scanning the metatable for the `__mode` string (no interned key needed
1079    /// inside the collector).
1080    pub(crate) fn weak_mode(&self) -> (bool, bool) {
1081        let Some(mt) = self.metatable else {
1082            return (false, false);
1083        };
1084        for n in mt.nodes.iter() {
1085            if let (Value::Str(k), Value::Str(mode)) = (n.key, n.val)
1086                && k.as_bytes() == b"__mode"
1087            {
1088                let b = mode.as_bytes();
1089                return (b.contains(&b'k'), b.contains(&b'v'));
1090            }
1091        }
1092        (false, false)
1093    }
1094
1095    /// True when this table holds at least one direct reference (array slot,
1096    /// hash key, or hash value) to a coroutine whose mark bit is still clear.
1097    /// Used by the GC's cycle-finalize check (PUC 5.3 gc.lua :502) to detect
1098    /// the table ↔ thread reference cycle that needs an extra GC round before
1099    /// `__gc` runs. Tag-level scan avoids walking the full reference graph.
1100    pub(crate) fn refs_contain_unmarked_coro(&self) -> bool {
1101        use crate::runtime::heap::header_is_marked;
1102        let atags = self.atags();
1103        let avals = self.avals();
1104        for (i, &tag) in atags.iter().enumerate() {
1105            if tag == raw::CORO {
1106                // SAFETY: raw union access — the tag byte at the same index in `atags` was previously confirmed to be `co` (closure/object pointer) so the `co` variant of `RawVal` holds the valid payload.
1107                let p = unsafe { avals[i].co } as *mut crate::runtime::heap::GcHeader;
1108                if !header_is_marked(p) {
1109                    return true;
1110                }
1111            }
1112        }
1113        for n in self.nodes.iter() {
1114            if let Value::Coro(co) = n.key
1115                && !header_is_marked(co.as_ptr() as *mut crate::runtime::heap::GcHeader)
1116            {
1117                return true;
1118            }
1119            if let Value::Coro(co) = n.val
1120                && !header_is_marked(co.as_ptr() as *mut crate::runtime::heap::GcHeader)
1121            {
1122                return true;
1123            }
1124        }
1125        false
1126    }
1127
1128    /// `gc-verify`: after a completed sweep, every collectable
1129    /// reference this table still holds (array values, node keys/values,
1130    /// metatable) must point at a live heap object. Nodes flagged
1131    /// `dead_key` are the sanctioned exception — their key pointer is
1132    /// documented-dangling and never dereferenced. `describe` receives
1133    /// (what, node-index, tag-byte, ptr) on violation.
1134    #[cfg(feature = "gc-verify")]
1135    pub(crate) fn verify_refs(
1136        &self,
1137        is_live: &dyn Fn(Value) -> bool,
1138        report: &dyn Fn(&str, usize, Value),
1139    ) {
1140        let atags = self.atags();
1141        let avals = self.avals();
1142        for (i, &tag) in atags.iter().enumerate() {
1143            if raw::is_gc(tag) {
1144                // SAFETY: tags/vals parallel arrays kept in sync by all table writers.
1145                let v = unsafe { Value::pack(tag, avals[i]) };
1146                if !is_live(v) {
1147                    report("array value", i, v);
1148                }
1149            }
1150        }
1151        for (i, n) in self.nodes.iter().enumerate() {
1152            if n.val.is_nil() {
1153                continue;
1154            }
1155            if !n.dead_key && !is_live(n.key) {
1156                report("node key", i, n.key);
1157            }
1158            if !is_live(n.val) {
1159                report("node value", i, n.val);
1160            }
1161        }
1162        if let Some(mt) = self.metatable
1163            && !is_live(Value::Table(mt))
1164        {
1165            report("metatable", 0, Value::Table(mt));
1166        }
1167    }
1168
1169    pub(crate) fn trace(&self, m: &mut Marker) {
1170        let (wk, wv) = self.weak_mode();
1171        if wk || wv {
1172            m.weak.push(self as *const Table as *mut Table);
1173        }
1174        // weak keys + strong values = an ephemeron table: its hash values are
1175        // marked only if the key proves reachable (deferred to the convergence
1176        // pass), not here. PUC 5.1 predates ephemerons — under `no_ephemeron`
1177        // a weak-key table marks its values strongly during this pass, which
1178        // is what gc.lua's "weak tables" section requires.
1179        let ephemeron = wk && !wv && !m.no_ephemeron;
1180        if ephemeron {
1181            m.ephemeron.push(self as *const Table as *mut Table);
1182        }
1183        // array keys are integers (never weakly collected); skip values only
1184        // when the table has weak values
1185        if !wv {
1186            let atags = self.atags();
1187            let avals = self.avals();
1188            for (i, &tag) in atags.iter().enumerate() {
1189                if raw::is_gc(tag) {
1190                    // SAFETY: `tag` and the raw value come from this table's parallel `atags` / `avals` arrays, which the table writers always keep in sync — the tag byte matches the raw payload's discriminator (see `runtime::value` `raw` module).
1191                    m.value(unsafe { Value::pack(tag, avals[i]) });
1192                }
1193            }
1194        }
1195        for n in self.nodes.iter() {
1196            if !wk {
1197                m.value(n.key);
1198            }
1199            // ephemeron hash values are deferred; otherwise mark strong values
1200            if !wv && !ephemeron {
1201                m.value(n.val);
1202            }
1203        }
1204        if let Some(mt) = self.metatable {
1205            m.value(Value::Table(mt));
1206        }
1207    }
1208
1209    /// Ephemeron pass: mark the value of every hash entry whose key is alive
1210    /// (`alive` decides — strong/marked keys, plus strings/numbers which are
1211    /// never weakly collected). Returns true if any value was newly marked, so
1212    /// the caller can iterate to a fixpoint (PUC `traverseephemeron`).
1213    pub(crate) fn converge_ephemeron(&self, alive: &dyn Fn(Value) -> bool, m: &mut Marker) -> bool {
1214        let mut changed = false;
1215        for n in self.nodes.iter() {
1216            if !n.val.is_nil() && alive(n.key) {
1217                changed |= m.value(n.val);
1218            }
1219        }
1220        changed
1221    }
1222
1223    /// Clear entries whose weak key/value did not survive marking. `is_dead`
1224    /// reports whether a GC value was left unmarked (about to be swept).
1225    /// Clear weak-table entries whose key/value no longer carries a live
1226    /// reference. `is_dead` is a **pure** check (no side effects); the GC
1227    /// uses `mark_string` to resurrect any string that's still reachable via
1228    /// a *surviving* entry — Lua manual §2.5.4 says strings in weak tables
1229    /// are not collected as long as their entry is, and PUC `iscleared`
1230    /// implements that by marking the string during the same scan.
1231    pub(crate) fn clear_weak(
1232        &mut self,
1233        wk: bool,
1234        wv: bool,
1235        is_dead: &dyn Fn(Value) -> bool,
1236        mark_string: &dyn Fn(Value),
1237    ) {
1238        if wv {
1239            let n = self.asize as usize;
1240            for i in 0..n {
1241                let tag = self.atags()[i];
1242                if raw::is_gc(tag) {
1243                    // SAFETY: `tag` and the raw value come from this table's parallel `atags` / `avals` arrays, which the table writers always keep in sync — the tag byte matches the raw payload's discriminator (see `runtime::value` `raw` module).
1244                    let v = unsafe { Value::pack(tag, self.avals()[i]) };
1245                    if is_dead(v) {
1246                        self.atags_mut()[i] = raw::NIL;
1247                        self.avals_mut()[i] = RawVal::NIL;
1248                    } else {
1249                        mark_string(v);
1250                    }
1251                }
1252            }
1253        }
1254        for n in self.nodes.iter_mut() {
1255            if n.val.is_nil() {
1256                // PUC `clearbykeys`/`clearbyvalues` end with
1257                // `if (isempty(gval(n))) clearkey(n)`: an EMPTY entry's
1258                // collectable key must be demoted to a dead key. A
1259                // tombstone (`t[k] = nil` leaves val nil, key kept for
1260                // chain links) is otherwise invisible to this sweep AND
1261                // unmarked by the weak-key trace, so its string key gets
1262                // freed while `find_node` still raw_eq's it walking the
1263                // chain (a use-after-free ASAN reports on Linux).
1264                if !n.dead_key
1265                    && matches!(
1266                        n.key,
1267                        Value::Table(_)
1268                            | Value::Closure(_)
1269                            | Value::Native(_)
1270                            | Value::Coro(_)
1271                            | Value::Userdata(_)
1272                            | Value::Str(_)
1273                    )
1274                {
1275                    n.key = Value::Nil;
1276                    n.dead_key = true;
1277                }
1278                continue;
1279            }
1280            let key_dead = wk && is_dead(n.key);
1281            let val_dead = wv && is_dead(n.val);
1282            if key_dead || val_dead {
1283                // entry removed. PUC `setdeadkey`: when the key was a
1284                // collectable, drop the Gc pointer so a later raw_eq cannot
1285                // spuriously match a new object that gets allocated at the
1286                // same freed address. Keep `next` so the chain back-links
1287                // through this node still reach downstream entries; the
1288                // `dead_key` flag tells `find_node` to skip the comparison
1289                // and `insert_new` to treat the slot as a free
1290                // main-position owner that may inherit the chain.
1291                n.val = Value::Nil;
1292                if matches!(
1293                    n.key,
1294                    Value::Table(_)
1295                        | Value::Closure(_)
1296                        | Value::Native(_)
1297                        | Value::Coro(_)
1298                        | Value::Userdata(_)
1299                        | Value::Str(_)
1300                ) {
1301                    n.key = Value::Nil;
1302                    n.dead_key = true;
1303                }
1304            } else {
1305                // entry survives — resurrect any string reachable through it
1306                if wk {
1307                    mark_string(n.key);
1308                }
1309                if wv {
1310                    mark_string(n.val);
1311                }
1312            }
1313        }
1314    }
1315}
1316
1317// =====================================================================
1318// SoA + Robin Hood open-addressing hash part.
1319//
1320// Parallel to the chain-walk path: the chain `nodes` / `lastfree` is
1321// the authoritative read path, and nothing outside this block and its
1322// tests calls into it yet. These methods operate only on the `keys` /
1323// `vals` / `meta` / `tombstones` SoA arrays — chain state is never
1324// touched.
1325//
1326// Layout invariants the methods below maintain:
1327//   - `keys.len() == vals.len() == meta.len()`, all power-of-two
1328//     (or zero in the empty-stub state)
1329//   - `meta[i] = meta_bits::EMPTY` iff slot i is free
1330//   - tombstoned slots are scanned past by find but reused by insert
1331//   - `tombstones` counts the meta slots with TOMBSTONE_BIT set
1332//   - load factor (live + tombstone) / cap is kept ≤ 0.75 via
1333//     `soa_grow_if_needed`, which bounds PSL
1334//   - rehash is REFUSED when `iter_depth > 0` (nothing increments the
1335//     counter yet, so the refusal path is unreachable today)
1336// =====================================================================
1337
1338/// Initial SoA capacity when growing from empty. Power of two.
1339/// Picked at 4 so a 3-element table doesn't trigger an immediate
1340/// regrowth.
1341#[allow(dead_code)] // not yet wired into the public table paths
1342pub(crate) const SOA_INITIAL_CAP: usize = 4;
1343
1344/// High load-factor threshold (3/4). SoA grow trigger. PSL_MAX is the u16 14-bit value so
1345/// long-tail PSL overruns are recoverable via grow-retry.
1346#[allow(dead_code)]
1347const SOA_LOAD_NUM: usize = 3;
1348#[allow(dead_code)]
1349const SOA_LOAD_DEN: usize = 4;
1350
1351/// Tombstone density threshold (1/4). When tombstones/cap ≥ 25%
1352/// the next non-resize-triggering rehash compacts them.
1353#[allow(dead_code)]
1354const SOA_TOMB_NUM: usize = 1;
1355#[allow(dead_code)]
1356const SOA_TOMB_DEN: usize = 4;
1357
1358#[allow(dead_code)] // not yet wired into public set/get/next
1359impl Table {
1360    /// Current SoA hash-part capacity in slots (0 = empty stub).
1361    #[inline]
1362    pub(crate) fn soa_cap(&self) -> usize {
1363        self.meta.len()
1364    }
1365
1366    /// Count of live (occupied & not tombstone) SoA slots.
1367    /// O(n) — only used by the equivalence tests; the
1368    /// hot rehash trigger uses `live_estimate = cap*3/4 - tombstones`
1369    /// implicitly via `soa_grow_if_needed`.
1370    #[cfg(test)]
1371    pub(crate) fn soa_live_count(&self) -> usize {
1372        self.meta.iter().filter(|&&m| meta_bits::is_live(m)).count()
1373    }
1374
1375    /// Count of occupied (live OR tombstoned) SoA slots; this is
1376    /// the value the load factor compares against `cap * 3/4`.
1377    #[inline]
1378    fn soa_occupied_count(&self) -> usize {
1379        // O(n) sweep on each insert, so the worst case is bounded by
1380        // per-insert amortised cost. A counter maintained incrementally
1381        // would avoid the sweep if it ever shows up in profiles.
1382        self.meta
1383            .iter()
1384            .filter(|&&m| meta_bits::is_occupied(m))
1385            .count()
1386    }
1387
1388    /// Robin Hood lookup. Returns the slot index of a *live*
1389    /// matching key, or None if absent. Walks past tombstones (they
1390    /// preserve probe chains). Returns None if the SoA cap is zero
1391    /// (empty-stub state). Bound by `cap` probes; in practice
1392    /// expected ≤ 8 at load 0.75.
1393    pub(crate) fn soa_find_slot(&self, k: Value) -> Option<usize> {
1394        let cap = self.meta.len();
1395        if cap == 0 {
1396            return None;
1397        }
1398        let mask = cap - 1;
1399        let mut idx = (hash_key(k) as usize) & mask;
1400        // Walk until empty slot or wrap. The `steps <= cap` bound
1401        // is a safety net: a properly maintained Robin Hood table
1402        // with load < 1 always has at least one empty slot, so a
1403        // full wrap means table invariant violation.
1404        for _ in 0..cap {
1405            let m = self.meta[idx];
1406            if !meta_bits::is_occupied(m) {
1407                return None;
1408            }
1409            if !meta_bits::is_tombstone(m) && self.keys[idx].raw_eq(k) {
1410                return Some(idx);
1411            }
1412            idx = (idx + 1) & mask;
1413        }
1414        None
1415    }
1416
1417    /// Allocate fresh SoA arrays at `new_cap` (power of two) and
1418    /// re-insert every live entry from the old SoA arrays. Tombstones
1419    /// are dropped (count resets to 0). Used by `soa_grow_if_needed`
1420    /// (new_cap = max(SOA_INITIAL_CAP, 2*cap)) and by tombstone
1421    /// compaction (new_cap = cap).
1422    ///
1423    /// IMPORTANT: rehash MUST NOT fire while `iter_depth > 0`. All
1424    /// current callers enter from non-iteration paths.
1425    fn soa_rehash_to(&mut self, heap: &mut Heap, new_cap: usize) -> Result<(), TableError> {
1426        debug_assert!(new_cap.is_power_of_two() && new_cap > 0);
1427        let before = self.internal_bytes();
1428        // Snapshot old live entries. This list is the canonical
1429        // "must be present after rehash" set; we restart from it on
1430        // any PSL-overflow retry.
1431        let mut survivors: Vec<(Value, Value)> = Vec::with_capacity(self.meta.len());
1432        for i in 0..self.meta.len() {
1433            if meta_bits::is_live(self.meta[i]) {
1434                survivors.push((self.keys[i], self.vals[i]));
1435            }
1436        }
1437        // Install fresh empty arrays at `new_cap`. On PSL overflow
1438        // during the re-insert pass (extremely rare with the 14-bit
1439        // PSL budget — would need a pathological hash distribution),
1440        // double the cap and replay the original `survivors` list
1441        // from scratch. We don't try to salvage partial work — the
1442        // rare-path retry cost is bounded by O(n × max_doublings),
1443        // and max_doublings has a hard MAX_ASIZE ceiling.
1444        let mut cap = new_cap;
1445        loop {
1446            if cap > MAX_ASIZE {
1447                return Err(TableError::Overflow);
1448            }
1449            self.keys = vec![Value::Nil; cap].into_boxed_slice();
1450            self.vals = vec![Value::Nil; cap].into_boxed_slice();
1451            self.meta = vec![meta_bits::EMPTY; cap].into_boxed_slice();
1452            self.tombstones = 0;
1453            let mut overflowed = false;
1454            for (k, v) in survivors.iter().copied() {
1455                if self.soa_place_known_absent(k, v).is_err() {
1456                    overflowed = true;
1457                    break;
1458                }
1459            }
1460            if !overflowed {
1461                break;
1462            }
1463            cap = cap.checked_mul(2).ok_or(TableError::Overflow)?;
1464        }
1465        let after = self.internal_bytes();
1466        heap.apply_bytes_delta(before, after);
1467        Ok(())
1468    }
1469
1470    /// Raw rob-from-rich placement for a key known to be absent
1471    /// from the SoA arrays. Used by `soa_rehash_to` (re-insert pass)
1472    /// and by `soa_insert` (new-key path after the explicit
1473    /// soa_find_slot check). This routine does NOT auto-grow on a
1474    /// load-factor trigger (caller's responsibility), but hands the
1475    /// pending pair back as `Err((k, v))` when the probe sequence
1476    /// passes `meta_bits::PSL_MAX` before an empty slot turns up. The
1477    /// caller (`soa_insert`) grows and retries.
1478    ///
1479    /// On success returns the slot index where the new key landed
1480    /// (after any rob-from-rich shuffle, the original `k` value is at
1481    /// this returned index).
1482    fn soa_place_known_absent(&mut self, k: Value, v: Value) -> Result<usize, (Value, Value)> {
1483        let cap = self.meta.len();
1484        debug_assert!(cap > 0);
1485        let mask = cap - 1;
1486        let landing = (hash_key(k) as usize) & mask;
1487        let mut idx = landing;
1488        let mut cur_psl: u16 = 0;
1489        let mut cur_key = k;
1490        let mut cur_val = v;
1491        let mut placed_at: Option<usize> = None;
1492        for _ in 0..cap {
1493            let m = self.meta[idx];
1494            if !meta_bits::is_occupied(m) || meta_bits::is_tombstone(m) {
1495                if meta_bits::is_tombstone(m) {
1496                    self.tombstones = self.tombstones.saturating_sub(1);
1497                }
1498                self.meta[idx] = meta_bits::pack(cur_psl, false);
1499                self.keys[idx] = cur_key;
1500                self.vals[idx] = cur_val;
1501                return Ok(placed_at.unwrap_or(idx));
1502            }
1503            let stored_psl = meta_bits::psl(m);
1504            if cur_psl > stored_psl {
1505                // Rob: swap cur into this slot, evict stored to continue.
1506                std::mem::swap(&mut cur_key, &mut self.keys[idx]);
1507                std::mem::swap(&mut cur_val, &mut self.vals[idx]);
1508                self.meta[idx] = meta_bits::pack(cur_psl, false);
1509                if placed_at.is_none() {
1510                    placed_at = Some(idx);
1511                }
1512                cur_psl = stored_psl;
1513            }
1514            idx = (idx + 1) & mask;
1515            cur_psl = cur_psl.saturating_add(1);
1516            if cur_psl > meta_bits::PSL_MAX {
1517                // PSL exceeds the 14-bit storage budget — exceptionally
1518                // rare with 16384 max. Caller (soa_insert / rehash
1519                // outer loop) handles by growing & retrying. Partial
1520                // state: all entries are still in the table EXCEPT
1521                // `(cur_key, cur_val)` which is the latest homeless
1522                // evictee — return it so caller can re-issue.
1523                return Err((cur_key, cur_val));
1524            }
1525        }
1526        // Wrapped cap probes with no free slot — invariant violation
1527        // (load < 1 should guarantee at least one empty). Signal as
1528        // PSL-overflow equivalent so caller grows + retries.
1529        Err((cur_key, cur_val))
1530    }
1531
1532    /// Grow SoA capacity if the load factor is at or above the
1533    /// 0.75 trigger. Doubles cap; from empty grows to SOA_INITIAL_CAP.
1534    fn soa_grow_if_needed(&mut self, heap: &mut Heap) -> Result<(), TableError> {
1535        // defer rehash when an iterator is in flight (iter_depth is
1536        // never incremented yet, so this does not fire today)
1537        if self.iter_depth > 0 {
1538            return Ok(());
1539        }
1540        let cap = self.meta.len();
1541        if cap == 0 {
1542            return self.soa_rehash_to(heap, SOA_INITIAL_CAP);
1543        }
1544        let occupied = self.soa_occupied_count();
1545        if occupied * SOA_LOAD_DEN >= cap * SOA_LOAD_NUM {
1546            let new_cap = cap.checked_mul(2).ok_or(TableError::Overflow)?;
1547            return self.soa_rehash_to(heap, new_cap);
1548        }
1549        // Tombstone compaction (same cap, drops tombstones).
1550        if self.tombstones as usize * SOA_TOMB_DEN >= cap * SOA_TOMB_NUM {
1551            return self.soa_rehash_to(heap, cap);
1552        }
1553        Ok(())
1554    }
1555
1556    /// Insert (or update) `(k, v)` in the SoA hash part. Routes
1557    /// through `soa_find_slot` first so an existing key updates its
1558    /// val in place; otherwise rob-from-rich places a new entry.
1559    /// Auto-rehashes if the load factor would exceed 0.75 OR if the
1560    /// place chain runs into a PSL overflow on a pathological hash
1561    /// distribution.
1562    ///
1563    /// Only the equivalence tests call this; it is not yet hooked
1564    /// into public `set` / `set_norm`.
1565    pub(crate) fn soa_insert(
1566        &mut self,
1567        heap: &mut Heap,
1568        k: Value,
1569        v: Value,
1570    ) -> Result<(), TableError> {
1571        debug_assert!(!matches!(k, Value::Nil));
1572        // 1. Update-in-place if key is already present (live slot).
1573        if let Some(idx) = self.soa_find_slot(k) {
1574            self.vals[idx] = v;
1575            return Ok(());
1576        }
1577        // 2. New key: ensure capacity, then place. On PSL-overflow
1578        // from the place chain (extremely rare with 14-bit PSL budget),
1579        // grow + rehash with the homeless evictee merged in.
1580        // `soa_rehash_with_extra` handles further retries internally,
1581        // bounded by MAX_ASIZE.
1582        self.soa_grow_if_needed(heap)?;
1583        match self.soa_place_known_absent(k, v) {
1584            Ok(_) => Ok(()),
1585            Err(homeless) => {
1586                let cap = self.meta.len();
1587                let new_cap = cap.checked_mul(2).ok_or(TableError::Overflow)?;
1588                self.soa_rehash_with_extra(heap, new_cap, homeless)
1589            }
1590        }
1591    }
1592
1593    /// Rehash to `new_cap` while merging in an extra (k, v) pair
1594    /// not currently in the SoA arrays. Used by `soa_insert` to
1595    /// recover from PSL overflow: the homeless evictee from the failed
1596    /// place chain gets appended to the survivor list before the
1597    /// re-insert pass.
1598    fn soa_rehash_with_extra(
1599        &mut self,
1600        heap: &mut Heap,
1601        new_cap: usize,
1602        extra: (Value, Value),
1603    ) -> Result<(), TableError> {
1604        let before = self.internal_bytes();
1605        let mut survivors: Vec<(Value, Value)> = Vec::with_capacity(self.meta.len() + 1);
1606        for i in 0..self.meta.len() {
1607            if meta_bits::is_live(self.meta[i]) {
1608                survivors.push((self.keys[i], self.vals[i]));
1609            }
1610        }
1611        // Avoid duplicating the extra if its key was already placed at
1612        // some slot during the failed rob chain (the rob may have
1613        // landed the original input into a slot before overflowing on
1614        // a downstream evictee — that case the meta-walk above picks
1615        // it up).
1616        if !survivors.iter().any(|(k, _)| k.raw_eq(extra.0)) {
1617            survivors.push(extra);
1618        }
1619        let mut cap = new_cap;
1620        loop {
1621            if cap > MAX_ASIZE {
1622                return Err(TableError::Overflow);
1623            }
1624            self.keys = vec![Value::Nil; cap].into_boxed_slice();
1625            self.vals = vec![Value::Nil; cap].into_boxed_slice();
1626            self.meta = vec![meta_bits::EMPTY; cap].into_boxed_slice();
1627            self.tombstones = 0;
1628            let mut overflowed = false;
1629            for (k, v) in survivors.iter().copied() {
1630                if self.soa_place_known_absent(k, v).is_err() {
1631                    overflowed = true;
1632                    break;
1633                }
1634            }
1635            if !overflowed {
1636                break;
1637            }
1638            cap = cap.checked_mul(2).ok_or(TableError::Overflow)?;
1639        }
1640        let after = self.internal_bytes();
1641        heap.apply_bytes_delta(before, after);
1642        Ok(())
1643    }
1644
1645    /// Read SoA hash part. Mirrors `get_hash` but reads from
1646    /// keys/vals/meta rather than nodes. Used by the equivalence
1647    /// tests; not yet hooked into public `get` / `get_hash`.
1648    pub(crate) fn soa_get(&self, k: Value) -> Value {
1649        match self.soa_find_slot(k) {
1650            Some(idx) => self.vals[idx],
1651            None => Value::Nil,
1652        }
1653    }
1654
1655    /// Tombstone deletion. Marks the live slot for `k` as
1656    /// tombstoned, preserving the slot index (no backward shift).
1657    /// Slot-index stability is the PUC `next()` iteration invariant
1658    /// — `nextvar.lua:520-521` requires that deleting prior keys
1659    /// during a `pairs` traversal does NOT move unvisited keys.
1660    /// Backward-shift deletion would violate this; tombstones are
1661    /// the standard Robin Hood resolution.
1662    ///
1663    /// keys[idx] / vals[idx] are reset to Nil so the GC marker is
1664    /// not held to the previous entries — only the tombstone bit
1665    /// distinguishes "occupied tombstone" from "free empty".
1666    ///
1667    /// Returns true if the key was found and deleted, false if absent.
1668    ///
1669    /// Not yet hooked into public `set(k, Nil)`; that has to move
1670    /// together with `next()`.
1671    pub(crate) fn soa_delete(&mut self, k: Value) -> bool {
1672        if let Some(idx) = self.soa_find_slot(k) {
1673            let psl = meta_bits::psl(self.meta[idx]);
1674            self.meta[idx] = meta_bits::pack(psl, true);
1675            self.keys[idx] = Value::Nil;
1676            self.vals[idx] = Value::Nil;
1677            self.tombstones = self.tombstones.saturating_add(1);
1678            true
1679        } else {
1680            false
1681        }
1682    }
1683}
1684
1685fn normalize_set_key(key: Value) -> Result<Value, TableError> {
1686    match key {
1687        Value::Nil => Err(TableError::NilIndex),
1688        Value::Float(f) => match f2i_exact(f) {
1689            Some(i) => Ok(Value::Int(i)),
1690            None if f.is_nan() => Err(TableError::NanIndex),
1691            None => Ok(key),
1692        },
1693        k => Ok(k),
1694    }
1695}
1696
1697fn hash_key(k: Value) -> u64 {
1698    match k {
1699        Value::Int(i) => i as u64, // identity mod size (PUC hashint)
1700        Value::Float(f) => mix64(f.to_bits()),
1701        Value::Bool(b) => b as u64 + 1,
1702        Value::Str(s) => s.hash() as u64,
1703        Value::Table(t) => mix64(t.as_ptr() as u64),
1704        Value::Closure(c) => mix64(c.as_ptr() as u64),
1705        Value::Native(n) => mix64(n.as_ptr() as u64),
1706        Value::Coro(co) => mix64(co.as_ptr() as u64),
1707        Value::Userdata(u) => mix64(u.as_ptr() as u64),
1708        Value::LightUserdata(p) => mix64(p as u64),
1709        Value::Nil => 0, // unreachable as a stored key
1710    }
1711}
1712
1713/// splitmix64 finalizer.
1714fn mix64(mut x: u64) -> u64 {
1715    x ^= x >> 30;
1716    x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
1717    x ^= x >> 27;
1718    x = x.wrapping_mul(0x94d0_49bb_1331_11eb);
1719    x ^ (x >> 31)
1720}
1721
1722/// For k ≥ 1: the bucket l such that k ∈ (2^(l-1), 2^l].
1723fn ceil_log2(k: u64) -> usize {
1724    (u64::BITS - (k - 1).leading_zeros()) as usize
1725}
1726
1727impl Table {
1728    /// Preallocate the array part (table.create); existing contents are
1729    /// preserved.
1730    pub fn ensure_array(&mut self, heap: &mut Heap, n: usize) {
1731        if n > self.asize() {
1732            let hash_entries = self.nodes.iter().filter(|nd| !nd.val.is_nil()).count();
1733            self.resize(heap, n, hash_entries);
1734        }
1735    }
1736}
1737
1738impl Table {
1739    /// Preallocate hash-part capacity (table.create's second size).
1740    pub fn ensure_hash(&mut self, heap: &mut Heap, n: usize) {
1741        let entries = self.nodes.iter().filter(|nd| !nd.val.is_nil()).count();
1742        if n > self.nodes.len() {
1743            self.resize(heap, self.asize(), n.max(entries));
1744        }
1745    }
1746}
1747
1748#[cfg(test)]
1749mod tests {
1750    use super::*;
1751    use crate::runtime::heap::Heap;
1752
1753    fn with_table(f: impl FnOnce(&mut Heap, &mut Table)) {
1754        let mut heap = Heap::new();
1755        let t = heap.new_table();
1756        f(&mut heap, unsafe { t.as_mut() });
1757    }
1758
1759    fn assert_is_border(t: &Table, n: i64) {
1760        if n == 0 {
1761            assert!(t.get_int(1).is_nil(), "border 0 but t[1] non-nil");
1762        } else {
1763            assert!(!t.get_int(n).is_nil(), "border {n} but t[{n}] is nil");
1764            assert!(
1765                t.get_int(n + 1).is_nil(),
1766                "border {n} but t[{}] non-nil",
1767                n + 1
1768            );
1769        }
1770    }
1771
1772    /// Pin `Box<[Node]>` fat-ptr layout at runtime.
1773    /// The luna-jit table-field IC reads `(ptr, len)` directly out of
1774    /// the `nodes` field assuming the data pointer occupies the low 8
1775    /// bytes and the length the high 8 bytes (de-facto Rust ABI on
1776    /// 64-bit targets but not formally guaranteed). If a future Rust
1777    /// release reorders the fat-ptr, this test fails before IC fires
1778    /// at runtime.
1779    #[test]
1780    #[allow(clippy::assertions_on_constants)]
1781    #[cfg(target_pointer_width = "64")]
1782    fn node_layout_pinned() {
1783        use jit_layout::*;
1784        assert_eq!(std::mem::size_of::<Box<[Node]>>(), 16);
1785        assert_eq!(NODE_KEY_OFFSET, 0);
1786        assert_eq!(NODE_VAL_OFFSET, 16);
1787        assert!(SIZEOF_NODE >= 32);
1788
1789        // Construct a real Box<[Node]> with a known length, then
1790        // peek at the fat-pointer's two halves to confirm the
1791        // (data_ptr, len) order. Use a 4-slot box so the length is
1792        // non-zero and the data pointer is heap-allocated.
1793        let b: Box<[Node]> = vec![Node::EMPTY; 4].into_boxed_slice();
1794        let raw_ptr = b.as_ptr();
1795        let raw_len = b.len();
1796        // SAFETY: reading the fat pointer's two words is exactly the
1797        // layout luna-jit's IR assumes; it's the safest possible test
1798        // of that assumption.
1799        let words: [usize; 2] = unsafe { std::mem::transmute_copy(&b) };
1800        assert_eq!(words[0], raw_ptr as usize, "fat-ptr low word = data ptr");
1801        assert_eq!(words[1], raw_len, "fat-ptr high word = len");
1802        drop(b);
1803    }
1804
1805    #[test]
1806    fn sequence_grows_into_array() {
1807        with_table(|heap, t| {
1808            for i in 1..=1000 {
1809                let _ = t.set_int(heap, i, Value::Int(i * 10));
1810            }
1811            for i in 1..=1000 {
1812                assert!(t.get_int(i).raw_eq(Value::Int(i * 10)));
1813            }
1814            assert_eq!(t.len(), 1000);
1815        });
1816    }
1817
1818    #[test]
1819    fn string_and_mixed_keys() {
1820        with_table(|heap, t| {
1821            let k1 = Value::Str(heap.intern(b"alpha"));
1822            let k2 = Value::Str(heap.intern(b"beta"));
1823            t.set(heap, k1, Value::Int(1)).unwrap();
1824            t.set(heap, k2, Value::Int(2)).unwrap();
1825            t.set(heap, Value::Bool(true), Value::Int(3)).unwrap();
1826            t.set(heap, Value::Int(-5), Value::Int(4)).unwrap();
1827            // re-interned key reaches the same slot
1828            let k1b = Value::Str(heap.intern(b"alpha"));
1829            assert!(t.get(k1b).raw_eq(Value::Int(1)));
1830            assert!(t.get(k2).raw_eq(Value::Int(2)));
1831            assert!(t.get(Value::Bool(true)).raw_eq(Value::Int(3)));
1832            assert!(t.get(Value::Int(-5)).raw_eq(Value::Int(4)));
1833            assert!(t.get(Value::Str(heap.intern(b"gamma"))).is_nil());
1834        });
1835    }
1836
1837    #[test]
1838    fn float_keys_normalize_to_int() {
1839        with_table(|heap, t| {
1840            t.set(heap, Value::Float(2.0), Value::Int(22)).unwrap();
1841            assert!(t.get(Value::Int(2)).raw_eq(Value::Int(22)));
1842            t.set(heap, Value::Int(3), Value::Int(33)).unwrap();
1843            assert!(t.get(Value::Float(3.0)).raw_eq(Value::Int(33)));
1844            // -0.0 is key 0
1845            t.set(heap, Value::Float(-0.0), Value::Int(0)).unwrap();
1846            assert!(t.get(Value::Int(0)).raw_eq(Value::Int(0)));
1847            // non-integral floats are their own keys
1848            t.set(heap, Value::Float(0.5), Value::Int(55)).unwrap();
1849            assert!(t.get(Value::Float(0.5)).raw_eq(Value::Int(55)));
1850            assert!(t.get(Value::Int(0)).raw_eq(Value::Int(0)));
1851        });
1852    }
1853
1854    #[test]
1855    fn bad_keys() {
1856        with_table(|heap, t| {
1857            assert_eq!(
1858                t.set(heap, Value::Nil, Value::Int(1)),
1859                Err(TableError::NilIndex)
1860            );
1861            assert_eq!(
1862                t.set(heap, Value::Float(f64::NAN), Value::Int(1)),
1863                Err(TableError::NanIndex)
1864            );
1865            // reads with bad keys are nil, not errors
1866            assert!(t.get(Value::Nil).is_nil());
1867            assert!(t.get(Value::Float(f64::NAN)).is_nil());
1868        });
1869    }
1870
1871    #[test]
1872    fn delete_and_reinsert() {
1873        with_table(|heap, t| {
1874            let k = Value::Str(heap.intern(b"k"));
1875            t.set(heap, k, Value::Int(1)).unwrap();
1876            t.set(heap, k, Value::Nil).unwrap();
1877            assert!(t.get(k).is_nil());
1878            t.set(heap, k, Value::Int(2)).unwrap();
1879            assert!(t.get(k).raw_eq(Value::Int(2)));
1880            // setting an absent key to nil stays absent
1881            let k2 = Value::Str(heap.intern(b"k2"));
1882            t.set(heap, k2, Value::Nil).unwrap();
1883            assert!(t.get(k2).is_nil());
1884        });
1885    }
1886
1887    #[test]
1888    fn borders_with_holes() {
1889        with_table(|heap, t| {
1890            let _ = t.set_int(heap, 1, Value::Int(1));
1891            let _ = t.set_int(heap, 2, Value::Int(2));
1892            assert_eq!(t.len(), 2);
1893            t.set_int(heap, 2, Value::Nil).unwrap();
1894            assert_is_border(t, t.len());
1895            // hash-resident tail
1896            let _ = t.set_int(heap, 1_000_000, Value::Int(1));
1897            assert_is_border(t, t.len());
1898        });
1899    }
1900
1901    #[test]
1902    fn len_on_empty_and_hash_only() {
1903        with_table(|heap, t| {
1904            assert_eq!(t.len(), 0);
1905            let xk = Value::Str(heap.intern(b"x"));
1906            t.set(heap, xk, Value::Int(1)).unwrap();
1907            assert_eq!(t.len(), 0);
1908        });
1909    }
1910
1911    #[test]
1912    fn next_iterates_everything_exactly_once() {
1913        with_table(|heap, t| {
1914            let mut expected = 0i64;
1915            for i in 1..=64 {
1916                let _ = t.set_int(heap, i, Value::Int(i));
1917                expected += i;
1918            }
1919            for i in 0..32 {
1920                let k = Value::Str(heap.intern(format!("s{i}").as_bytes()));
1921                t.set(heap, k, Value::Int(1000 + i)).unwrap();
1922                expected += 1000 + i;
1923            }
1924            t.set(heap, Value::Float(2.5), Value::Int(7)).unwrap();
1925            expected += 7;
1926
1927            let mut sum = 0i64;
1928            let mut count = 0;
1929            let mut key = Value::Nil;
1930            while let Some((k, v)) = t.next(key).unwrap() {
1931                let Value::Int(x) = v else {
1932                    panic!("bad value")
1933                };
1934                sum += x;
1935                count += 1;
1936                key = k;
1937            }
1938            assert_eq!(count, 64 + 32 + 1);
1939            assert_eq!(sum, expected);
1940        });
1941    }
1942
1943    #[test]
1944    fn next_skips_nil_values_and_rejects_alien_keys() {
1945        with_table(|heap, t| {
1946            let _ = t.set_int(heap, 1, Value::Int(1));
1947            let _ = t.set_int(heap, 3, Value::Int(3));
1948            let k = Value::Str(heap.intern(b"gone"));
1949            t.set(heap, k, Value::Int(9)).unwrap();
1950            t.set(heap, k, Value::Nil).unwrap();
1951            let mut seen = Vec::new();
1952            let mut key = Value::Nil;
1953            while let Some((k, v)) = t.next(key).unwrap() {
1954                let Value::Int(x) = v else { panic!() };
1955                seen.push(x);
1956                key = k;
1957            }
1958            assert_eq!(seen, vec![1, 3]);
1959            // a key never inserted is invalid for next
1960            let alien = Value::Str(heap.intern(b"never"));
1961            assert!(matches!(t.next(alien), Err(TableError::InvalidNext)));
1962            // ...but a deleted (nil-valued) key is still a valid cursor
1963            assert!(t.next(k).is_ok());
1964        });
1965    }
1966
1967    #[test]
1968    fn collision_relocation_keeps_chains_intact() {
1969        with_table(|heap, t| {
1970            // dense negative ints all land in the hash part; with identity
1971            // hashing they exercise both chain cases heavily
1972            for i in 0..512 {
1973                let _ = t.set_int(heap, -i, Value::Int(i));
1974            }
1975            for i in 0..512 {
1976                assert!(t.get_int(-i).raw_eq(Value::Int(i)), "lost key {}", -i);
1977            }
1978        });
1979    }
1980
1981    // -----------------------------------------------------------------
1982    // SoA Robin Hood equivalence tests.
1983    //
1984    // Cross-check the new SoA + RH path against the existing chain-walk
1985    // path: replay the same insert/lookup sequence on a table via
1986    // `set` (chain) and another via `soa_insert` (SoA), then assert
1987    // `get == soa_get` for every key.
1988    // -----------------------------------------------------------------
1989
1990    fn replay_chain(heap: &mut Heap, ops: &[(Value, Value)]) -> *mut Table {
1991        let t = heap.new_table();
1992        let tref = unsafe { t.as_mut() };
1993        for (k, v) in ops.iter().copied() {
1994            tref.set(heap, k, v).unwrap();
1995        }
1996        t.as_ptr()
1997    }
1998
1999    fn replay_soa(heap: &mut Heap, ops: &[(Value, Value)]) -> *mut Table {
2000        let t = heap.new_table();
2001        let tref = unsafe { t.as_mut() };
2002        for (k, v) in ops.iter().copied() {
2003            tref.soa_insert(heap, k, v).unwrap();
2004        }
2005        t.as_ptr()
2006    }
2007
2008    #[test]
2009    fn c3_soa_equivalence_string_keys() {
2010        let mut heap = Heap::new();
2011        let mut ops = Vec::new();
2012        for i in 0..40 {
2013            let k = Value::Str(heap.intern(format!("key_{i:03}").as_bytes()));
2014            ops.push((k, Value::Int(i * 7)));
2015        }
2016        let chain = unsafe { &*replay_chain(&mut heap, &ops) };
2017        let soa = unsafe { &*replay_soa(&mut heap, &ops) };
2018        for (k, _) in &ops {
2019            let cv = chain.get(*k);
2020            let sv = soa.soa_get(*k);
2021            assert!(
2022                cv.raw_eq(sv),
2023                "SoA vs chain mismatch on key — chain={:?} soa={:?}",
2024                cv,
2025                sv,
2026            );
2027        }
2028        // Absent key returns nil from both paths.
2029        let absent = Value::Str(heap.intern(b"never"));
2030        assert!(chain.get(absent).is_nil());
2031        assert!(soa.soa_get(absent).is_nil());
2032    }
2033
2034    #[test]
2035    fn c3_soa_equivalence_negative_int_keys() {
2036        // Dense negative ints with identity hashing — same collision
2037        // profile as the existing `collision_relocation_keeps_chains_intact`
2038        // test, but verified through the SoA RH path. Triggers
2039        // rob-from-rich repeatedly.
2040        let mut heap = Heap::new();
2041        let mut ops = Vec::new();
2042        for i in 0..256 {
2043            let k = Value::Int(-i);
2044            ops.push((k, Value::Int(i)));
2045        }
2046        let chain = unsafe { &*replay_chain(&mut heap, &ops) };
2047        let soa = unsafe { &*replay_soa(&mut heap, &ops) };
2048        for (k, _) in &ops {
2049            let cv = chain.get(*k);
2050            let sv = soa.soa_get(*k);
2051            assert!(cv.raw_eq(sv), "SoA mismatch on key {:?}", k);
2052        }
2053    }
2054
2055    #[test]
2056    fn c3_soa_equivalence_mixed_keys_with_updates() {
2057        // Insert, then update the same keys with new values — exercises
2058        // the soa_find_slot in-place update branch.
2059        let mut heap = Heap::new();
2060        let kstr = Value::Str(heap.intern(b"x"));
2061        let kint = Value::Int(42);
2062        let kbool = Value::Bool(true);
2063        let ops: Vec<(Value, Value)> = vec![
2064            (kstr, Value::Int(1)),
2065            (kint, Value::Int(2)),
2066            (kbool, Value::Int(3)),
2067            (kstr, Value::Int(11)),  // update
2068            (kint, Value::Int(22)),  // update
2069            (kbool, Value::Int(33)), // update
2070        ];
2071        let chain = unsafe { &*replay_chain(&mut heap, &ops) };
2072        let soa = unsafe { &*replay_soa(&mut heap, &ops) };
2073        for k in [kstr, kint, kbool] {
2074            assert!(chain.get(k).raw_eq(soa.soa_get(k)));
2075        }
2076    }
2077
2078    #[test]
2079    fn c3_soa_equivalence_delete_then_read() {
2080        // tombstone delete + read on both paths, verify
2081        // matching nil-for-deleted, original-val-for-live.
2082        let mut heap = Heap::new();
2083        let mut ops_insert = Vec::new();
2084        for i in 0..30 {
2085            let k = Value::Str(heap.intern(format!("d_key_{i:03}").as_bytes()));
2086            ops_insert.push((k, Value::Int(i * 11)));
2087        }
2088        let chain = unsafe { &mut *replay_chain(&mut heap, &ops_insert) };
2089        let soa = unsafe { &mut *replay_soa(&mut heap, &ops_insert) };
2090        // Delete every 3rd key.
2091        let mut deleted: Vec<Value> = Vec::new();
2092        for (i, (k, _)) in ops_insert.iter().enumerate() {
2093            if i % 3 == 0 {
2094                // chain: set to Nil is the chain-path's delete equivalent
2095                chain.set(&mut heap, *k, Value::Nil).unwrap();
2096                let was_present = soa.soa_delete(*k);
2097                assert!(was_present, "soa_delete miss on inserted key {:?}", k);
2098                deleted.push(*k);
2099            }
2100        }
2101        // Read each key: deleted → nil, non-deleted → original val.
2102        for (k, v) in &ops_insert {
2103            let cv = chain.get(*k);
2104            let sv = soa.soa_get(*k);
2105            assert!(
2106                cv.raw_eq(sv),
2107                "delete/read mismatch on key {:?} — chain={:?} soa={:?}",
2108                k,
2109                cv,
2110                sv,
2111            );
2112            if deleted.iter().any(|d| d.raw_eq(*k)) {
2113                assert!(cv.is_nil(), "deleted key {:?} chain non-nil", k);
2114                assert!(sv.is_nil(), "deleted key {:?} soa non-nil", k);
2115            } else {
2116                assert!(cv.raw_eq(*v), "live key {:?} chain val drift", k);
2117            }
2118        }
2119        // Deleting an absent key is a no-op (returns false) on SoA.
2120        let absent = Value::Str(heap.intern(b"never_d"));
2121        assert!(!soa.soa_delete(absent));
2122    }
2123
2124    #[test]
2125    fn c3_soa_delete_then_reinsert_uses_tombstone() {
2126        // After delete + reinsert, key is findable with new val. The
2127        // SoA path may reuse the tombstoned slot (preferred) or place
2128        // elsewhere — either is correct as long as soa_get returns
2129        // the new val.
2130        let mut heap = Heap::new();
2131        let t = heap.new_table();
2132        let tref = unsafe { t.as_mut() };
2133        let k = Value::Str(heap.intern(b"reinsert_target"));
2134        tref.soa_insert(&mut heap, k, Value::Int(100)).unwrap();
2135        assert!(tref.soa_get(k).raw_eq(Value::Int(100)));
2136        let pre_tombs = tref.tombstones;
2137        assert!(tref.soa_delete(k));
2138        assert!(tref.tombstones == pre_tombs + 1);
2139        assert!(tref.soa_get(k).is_nil());
2140        // Reinsert with new val.
2141        tref.soa_insert(&mut heap, k, Value::Int(200)).unwrap();
2142        assert!(tref.soa_get(k).raw_eq(Value::Int(200)));
2143        // Tombstone reused — count back to pre_tombs.
2144        assert_eq!(tref.tombstones, pre_tombs);
2145    }
2146
2147    #[test]
2148    fn c3_soa_grows_under_load_pressure() {
2149        // Stress test: insert enough entries to trigger multiple RH
2150        // rehashes (cap doubles at load 0.75). Confirms PSL overflow
2151        // never fires and all keys survive grow cycles.
2152        let mut heap = Heap::new();
2153        let t = heap.new_table();
2154        let tref = unsafe { t.as_mut() };
2155        for i in 0..1024 {
2156            let k = Value::Str(heap.intern(format!("entry_{i:05}").as_bytes()));
2157            tref.soa_insert(&mut heap, k, Value::Int(i)).unwrap();
2158        }
2159        // Verify every key is findable.
2160        for i in 0..1024 {
2161            let k = Value::Str(heap.intern(format!("entry_{i:05}").as_bytes()));
2162            let v = tref.soa_get(k);
2163            assert!(
2164                v.raw_eq(Value::Int(i)),
2165                "SoA lost key entry_{:05} — got {:?}",
2166                i,
2167                v,
2168            );
2169        }
2170        assert!(tref.soa_live_count() == 1024);
2171        // Cap should have grown past the initial SOA_INITIAL_CAP via
2172        // the 0.75 load-factor trigger.
2173        assert!(
2174            tref.soa_cap() >= 2048,
2175            "SoA cap = {} after 1024 inserts — load gate didn't grow",
2176            tref.soa_cap(),
2177        );
2178    }
2179
2180    #[test]
2181    fn rehash_redistributes_into_array() {
2182        with_table(|heap, t| {
2183            // insert 1..n in reverse: starts in hash, rehash must migrate
2184            for i in (1..=256).rev() {
2185                let _ = t.set_int(heap, i, Value::Int(i));
2186            }
2187            assert_eq!(t.len(), 256);
2188            for i in 1..=256 {
2189                assert!(t.get_int(i).raw_eq(Value::Int(i)));
2190            }
2191        });
2192    }
2193}