Skip to main content

lex_bytecode/
value.rs

1//! Runtime values.
2
3use crate::program::BodyHash;
4use arrow_array::RecordBatch;
5use indexmap::IndexMap;
6use smol_str::SmolStr;
7use std::collections::{BTreeMap, BTreeSet, VecDeque};
8use std::sync::atomic::AtomicBool;
9use std::sync::{Arc, Mutex};
10
11/// Internal state of a `conc.Actor`. Protected by a `Mutex` so that
12/// the `Lex` handler variant serialises on message delivery (one
13/// message processed at a time, state mutated under the lock). The
14/// `handler` is dispatched on the *calling* VM's thread — no extra
15/// OS thread required — which lets Lex handlers invoke arbitrary
16/// effects (sql, net, …) through the same handler chain.
17///
18/// Serialisation note: the `Native` variant releases the mutex
19/// *before* invoking its closure (`state` is unused for natives —
20/// the "state" is an external resource like a channel), so two
21/// concurrent `conc.tell`s on the same native bridge may invoke
22/// the closure on overlapping threads. Native bridges therefore
23/// need to be internally thread-safe; the `serve_ws_fn_actor`
24/// `mpsc::Sender` bridge is, because `Sender::send` is.
25#[derive(Debug, Clone)]
26pub struct ActorCell {
27    pub state: Value,
28    pub handler: ActorHandler,
29}
30
31/// Two ways an actor's handler can be implemented.
32///
33/// * `Lex(Value::Closure)` is the user-spawned shape from
34///   `conc.spawn(state, fn (s, m) -> (s, r) { … })`. The VM calls
35///   the closure with `(state, msg)` and expects `(new_state, reply)`.
36///
37/// * `Native(...)` is a Rust-side bridge — the actor cell wraps a
38///   `Box<dyn Fn(Value) -> Result<Value, String>>` that lives outside
39///   the VM. The `state` is ignored; the bridge is fire-and-forget
40///   over an out-of-band channel (e.g. a `mpsc::Sender<String>` to
41///   a WebSocket connection — see `lex-runtime::ws::serve_ws_fn_actor`).
42///   `conc.ask` against a native actor returns whatever the bridge
43///   produces; `conc.tell` discards it. v1 is only used internally by
44///   the WS server's outbound-bridge registration; not exposed via the
45///   `conc` builtin surface.
46#[derive(Clone)]
47pub enum ActorHandler {
48    Lex(Value),
49    Native(Arc<NativeActorHandler>),
50}
51
52/// Erased Rust-side handler for `ActorHandler::Native`. Boxed so we
53/// can store any closure that captures (e.g. an `mpsc::Sender`).
54/// Wrapped in `Arc` so cloning an `ActorCell` (which the existing
55/// `conc.tell` flow does — `let handler = guard.handler.clone()`)
56/// is cheap and the closure isn't duplicated.
57pub struct NativeActorHandler {
58    pub send: Box<dyn Fn(Value) -> Result<Value, String> + Send + Sync>,
59}
60
61impl std::fmt::Debug for NativeActorHandler {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "<native actor handler>")
64    }
65}
66
67impl std::fmt::Debug for ActorHandler {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            ActorHandler::Lex(v) => f.debug_tuple("Lex").field(v).finish(),
71            ActorHandler::Native(n) => f.debug_tuple("Native").field(n).finish(),
72        }
73    }
74}
75
76#[derive(Debug, Clone)]
77pub enum Value {
78    Int(i64),
79    Float(f64),
80    Bool(bool),
81    /// String value. `SmolStr` stores strings ≤ 22 bytes inline — no heap
82    /// allocation for identifiers, HTTP methods, status codes, short keys, etc.
83    /// Clone of a short `SmolStr` is a 24-byte stack copy (#389 slice 4).
84    Str(SmolStr),
85    Bytes(Vec<u8>),
86    Unit,
87    /// Shared, copy-on-write list (#774): a clone is a refcount bump,
88    /// and a mutation through `DerefMut` copies only when shared.
89    List(List),
90    Tuple(Vec<Value>),
91    /// Record literal. `shape_id` is the `Program::record_shapes`
92    /// index of the field-name vec the record was built from
93    /// (#462 slice 2), so the `Op::GetField` polymorphic IC can
94    /// match on a single u32 compare instead of walking the
95    /// `IndexMap` by name. Records constructed outside the bytecode
96    /// (JSON decode, SQL row → record, HTTP request mutators, test
97    /// fixtures) have no compile-time shape and carry `NO_SHAPE_ID`
98    /// — the IC unconditionally misses on them and falls through to
99    /// the existing name walk.
100    ///
101    /// `fields` is `Box<IndexMap>` rather than `IndexMap` inline
102    /// because the bare `IndexMap` is ~56B; inlining it plus
103    /// `shape_id` would push `Value`'s enum size from 64B → 72B,
104    /// which measurably regresses the VM stack push/pop loop
105    /// (`Value` is cloned/moved on every push/pop). Boxing keeps
106    /// `Value::Record` at 16B and `Value` at the pre-#462 64B.
107    /// The indirection on every `IndexMap` access costs a few ns
108    /// but the IC drops the field-name string compare on every
109    /// hit, which is the net win on `mono_chain`.
110    ///
111    /// `shape_id` is **not** part of structural equality (see
112    /// `PartialEq` below): two records with identical fields must
113    /// compare equal regardless of provenance, so a JSON-decoded
114    /// record equals a compile-time-built one with the same fields.
115    Record { shape_id: u32, fields: Box<IndexMap<SmolStr, Value>> },
116    /// Frame-local record (#464 step 2). Emitted by
117    /// `Op::AllocStackRecord` at sites the escape analysis proved
118    /// can't outlive the current call frame. `slab_start` indexes
119    /// into `Vm::stack_record_arena`; the `field_count` consecutive
120    /// values starting there are the record's fields, in
121    /// `Program.record_shapes[shape_id]` order (same insertion order
122    /// as `Op::MakeRecord` uses, so the polymorphic-IC offset is
123    /// interoperable with `Value::Record`).
124    ///
125    /// `Op::GetField` is the only consumer that knows how to read
126    /// these — every other observation point (`Op::Return`,
127    /// `Op::Call`, `Op::MakeRecord` as a field value, …) is an
128    /// escape op that the analysis prevents this variant from
129    /// reaching. If a `StackRecord` ever does reach an unexpected
130    /// site (escape-analysis bug), it surfaces as a panic at the
131    /// boundary, not undefined behavior — the arena is plain
132    /// `Vec<Value>` in safe Rust.
133    ///
134    /// Size: 4 (shape_id) + 4 (slab_start) + 2 (field_count) = 10
135    /// bytes payload + tag, comfortably inside the 64B `Value`
136    /// envelope.
137    StackRecord { shape_id: u32, slab_start: u32, field_count: u16 },
138    /// Frame-local tuple (#464 tuple codegen). The stack-alloc
139    /// analogue of `Value::Tuple`, emitted by `Op::AllocStackTuple` at
140    /// sites the escape analysis proved can't outlive the current
141    /// frame. `slab_start` indexes into `Vm::stack_record_arena` (the
142    /// arena is shared with `StackRecord` — both are flat `Value`
143    /// slabs released together on `Op::Return`); the `arity`
144    /// consecutive values starting there are the tuple elements in
145    /// positional order.
146    ///
147    /// Like `StackRecord`, the only consumer that knows how to read
148    /// these is `Op::GetElem` — every other observation point
149    /// (`Return`, `Call`, a `MakeTuple`/`MakeRecord` field value,
150    /// equality, JSON) is an escape op the analysis prevents this
151    /// variant from reaching. An unexpected arrival surfaces as a
152    /// panic at the boundary, not UB (the arena is safe `Vec<Value>`).
153    StackTuple { slab_start: u32, arity: u16 },
154    /// Request-scoped arena record (#463 slice 2a). Same handle shape
155    /// as `Value::StackRecord` but indexes `Vm::arena_slab` (request
156    /// lifetime) instead of `Vm::stack_record_arena` (frame lifetime).
157    /// Emitted by `Op::AllocArenaRecord` at sites
158    /// `arena::build_arena_index` proves do not escape the request
159    /// scope opened by `EffectHandler::enter_request_scope`. Reads via
160    /// `Op::GetField` (polymorphic across `Record` / `StackRecord` /
161    /// `ArenaRecord`).
162    ///
163    /// **Inspection paths (`to_json`, equality, memo hash, generic
164    /// clone) defensively panic on this variant, same contract as
165    /// `StackRecord`.** Slice 1's `arena::build_arena_index` analysis
166    /// proves these paths are unreachable in well-routed code (any
167    /// reach is a soundness bug — analysis or codegen). The
168    /// scoping doc (`docs/design/arena-plumbing.md` § "Arena handles
169    /// MUST be readable at serialization") flags this as the place
170    /// where arena diverges from #464: a future slice will materialize
171    /// arena handles at the response-serialization boundary so the
172    /// `Response`'s `to_json` reads through to the slab. That
173    /// materialization is **out of scope for slice 2a**; today
174    /// arena ops only ship for hand-crafted bytecode tests, which
175    /// avoid the inspection paths.
176    ArenaRecord { shape_id: u32, slab_start: u32, field_count: u16 },
177    /// Request-scoped arena tuple (#463 slice 2a). Tuple analogue of
178    /// `ArenaRecord`; same lifetime / fallback / inspection-panic
179    /// contract.
180    ArenaTuple { slab_start: u32, arity: u16 },
181    Variant { name: String, args: Vec<Value> },
182    /// First-class function value (a lambda + its captured locals). The
183    /// function's first `captures.len()` params bind to `captures`; the
184    /// remaining params are supplied at call time.
185    ///
186    /// `fn_id` is a dense compile-time index into `Program::functions`
187    /// for fast dispatch; `body_hash` is the **canonical identity** —
188    /// two closures with identical bytecode bodies compare equal even
189    /// when their `fn_id`s differ (which they will, when the source
190    /// has the same closure literal at two locations). See `PartialEq`
191    /// below and #222 for the rationale.
192    Closure { fn_id: u32, body_hash: BodyHash, captures: Vec<Value> },
193    /// Dense row-major `f64` matrix. A "fast lane" representation that
194    /// avoids the per-element `Value::Float` boxing of `Value::List`.
195    /// Used by Core's native tensor ops (matmul, dot, …) so end-to-end
196    /// matmul perf hits the §13.7 #1 100ms target without paying for
197    /// 2M Value boxings at the call boundary.
198    F64Array { rows: u32, cols: u32, data: Vec<f64> },
199    /// Persistent map keyed by `MapKey` (`Str` or `Int`). Insertion-
200    /// independent equality (sorted by `BTreeMap`'s `Ord`), so two
201    /// maps built from the same pairs in different orders compare
202    /// equal. Restricting keys to two primitive variants keeps
203    /// `Eq + Hash` requirements off `Value` itself, which has
204    /// closures and floats and can't be hashed soundly.
205    Map(BTreeMap<MapKey, Value>),
206    /// Persistent set with the same key-type discipline as `Map`.
207    Set(BTreeSet<MapKey>),
208    /// Double-ended queue. O(1) push/pop on both ends; otherwise
209    /// behaves like `List` for iteration / equality / JSON shape.
210    /// Lex's type system tracks `Deque[T]` separately from `List[T]`
211    /// so users explicitly opt in to deque semantics; the runtime
212    /// uses this dedicated variant rather than backing a deque on top
213    /// of `Value::List` (which would make `push_front` O(n)).
214    Deque(VecDeque<Value>),
215    /// A handle to a `conc.Actor`. The `Arc<Mutex<ActorCell>>` allows
216    /// cheap cloning and safe concurrent access — the mutex serialises
217    /// message delivery so the actor processes one message at a time.
218    /// Two actor handles compare equal iff they point to the same cell
219    /// (identity equality, not structural equality).
220    Actor(Arc<Mutex<ActorCell>>),
221    /// A periodic-tick handle returned by `conc.every` (#445). The
222    /// `AtomicBool` is the cancel flag — `conc.cancel(t)` sets it and
223    /// the background scheduler thread observes it on its next iteration
224    /// and exits. Two ticker handles compare equal iff they point to the
225    /// same cancel flag.
226    Ticker(Arc<AtomicBool>),
227    /// Apache Arrow `RecordBatch` — an unboxed columnar table. The
228    /// "fast lane" representation for `lex-frame` and any future
229    /// dataframe code: a `Value::ArrowTable` with one int64 column
230    /// of N rows is N×8 bytes of contiguous memory, not N
231    /// `Value::Int(_)` enum tags inside a `VecDeque`. Reductions
232    /// (`arrow.col_sum_int`, `arrow.col_mean`, …) execute as one
233    /// Rust call over the flat buffer, bypassing the bytecode VM
234    /// for the inner loop.
235    ///
236    /// `Arc` makes clone cheap (refcount bump) — Arrow tables are
237    /// already immutable so structural sharing across closures is
238    /// safe. Equality is structural over schema + columns.
239    ArrowTable(Arc<RecordBatch>),
240}
241
242/// Manual `PartialEq` for `Value` (#222). Mirrors the auto-derived
243/// implementation for every variant *except* `Closure`, which compares
244/// on `(body_hash, captures)` only — `fn_id` is a dense compile-time
245/// index that is not stable across source-location-equivalent closure
246/// literals, and including it would defeat the canonicality property
247/// the `body_hash` field exists to provide.
248impl PartialEq for Value {
249    fn eq(&self, other: &Self) -> bool {
250        use Value::*;
251        match (self, other) {
252            (Int(a), Int(b)) => a == b,
253            (Float(a), Float(b)) => a == b,
254            (Bool(a), Bool(b)) => a == b,
255            (Str(a), Str(b)) => a == b,
256            (Bytes(a), Bytes(b)) => a == b,
257            (Unit, Unit) => true,
258            (List(a), List(b)) => a == b,
259            (Tuple(a), Tuple(b)) => a == b,
260            (Record { fields: a, .. }, Record { fields: b, .. }) => a == b,
261            // #464 step 2: a `Value::StackRecord` can only reach
262            // generic equality if it crossed an escape boundary the
263            // analysis was supposed to reject. Treat as a soundness
264            // bug: panic rather than silently lie about equality (a
265            // wrong answer would cascade into mis-routed match arms).
266            // Well-typed Lex source never compares records with
267            // `==` via `bin_eq` — record equality, if added, will
268            // get its own opcode with arena-aware comparison.
269            (StackRecord { .. }, _) | (_, StackRecord { .. }) =>
270                panic!("BUG(#464): Value::StackRecord reached generic equality \
271                        — escape analysis should have flagged its allocation site"),
272            // Same soundness contract as StackRecord above.
273            (StackTuple { .. }, _) | (_, StackTuple { .. }) =>
274                panic!("BUG(#464): Value::StackTuple reached generic equality \
275                        — escape analysis should have flagged its allocation site"),
276            // #463 slice 2a: arena handles must never reach generic
277            // equality — the slice-1 arena-eligibility analysis is the
278            // upstream proof. Materialization at the response boundary
279            // (slice 2a-iii / 2b) will handle the legitimate
280            // serialization case; equality reach is always a soundness
281            // bug, so panic, do not silently lie.
282            (ArenaRecord { .. }, _) | (_, ArenaRecord { .. }) =>
283                panic!("BUG(#463): Value::ArenaRecord reached generic equality \
284                        — arena-eligibility analysis should have flagged its allocation site"),
285            (ArenaTuple { .. }, _) | (_, ArenaTuple { .. }) =>
286                panic!("BUG(#463): Value::ArenaTuple reached generic equality \
287                        — arena-eligibility analysis should have flagged its allocation site"),
288            (Variant { name: an, args: aa }, Variant { name: bn, args: ba }) =>
289                an == bn && aa == ba,
290            (Closure { body_hash: ah, captures: ac, .. },
291             Closure { body_hash: bh, captures: bc, .. }) =>
292                ah == bh && ac == bc,
293            (F64Array { rows: ar, cols: ac, data: ad },
294             F64Array { rows: br, cols: bc, data: bd }) =>
295                ar == br && ac == bc && ad == bd,
296            (Map(a), Map(b)) => a == b,
297            (Set(a), Set(b)) => a == b,
298            (Deque(a), Deque(b)) => a == b,
299            // Actor identity: same if both handles point to the same cell.
300            (Actor(a), Actor(b)) => Arc::ptr_eq(a, b),
301            // Ticker identity: same if both handles point to the same
302            // cancel flag (one ticker spawn → one flag).
303            (Ticker(a), Ticker(b)) => Arc::ptr_eq(a, b),
304            // Arrow table equality: structural over schema + columns.
305            // RecordBatch implements PartialEq directly.
306            (ArrowTable(a), ArrowTable(b)) => a == b,
307            _ => false,
308        }
309    }
310}
311
312/// Hashable, ordered key for `Value::Map` / `Value::Set`. v1
313/// supports `Str` and `Int`; extending to other primitives or to
314/// records is forward-compatible since the type is not exposed
315/// to user code beyond the surface API.
316#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
317pub enum MapKey {
318    Str(String),
319    Int(i64),
320}
321
322impl MapKey {
323    pub fn from_value(v: &Value) -> Result<Self, String> {
324        match v {
325            Value::Str(s) => Ok(MapKey::Str(s.to_string())),
326            Value::Int(n) => Ok(MapKey::Int(*n)),
327            other => Err(format!(
328                "map/set key must be Str or Int, got {other:?}")),
329        }
330    }
331    pub fn into_value(self) -> Value {
332        match self {
333            MapKey::Str(s) => Value::Str(s.into()),
334            MapKey::Int(n) => Value::Int(n),
335        }
336    }
337    pub fn as_value(&self) -> Value {
338        match self {
339            MapKey::Str(s) => Value::Str(s.as_str().into()),
340            MapKey::Int(n) => Value::Int(*n),
341        }
342    }
343}
344
345/// A list value: `VecDeque<Value>` behind an `Arc`, copy-on-write.
346///
347/// Before #774 every `Value::List` held its `VecDeque` by value, so
348/// passing a list to a function (the VM clones a local onto the
349/// operand stack) deep-copied every element, and accumulating a list
350/// one element at a time was O(n²). Now a clone shares the buffer;
351/// `DerefMut` goes through `Arc::make_mut`, which mutates in place
352/// when the list has one owner and copies first otherwise. Together
353/// with the compiler's move-out of a local's last read
354/// (`Op::TakeLocal`), an accumulator threaded through a fold or a
355/// tail call reaches `list.cons` uniquely owned and grows in place.
356///
357/// `Deref<Target = VecDeque<Value>>`, so reads (`iter`, `len`,
358/// indexing, `front`) are unchanged; construct with `.into()` from a
359/// `Vec` or `VecDeque`, or `collect()`.
360#[derive(Clone, Default)]
361pub struct List(Arc<VecDeque<Value>>);
362
363impl List {
364    pub fn new() -> Self {
365        Self::default()
366    }
367
368    pub fn with_capacity(n: usize) -> Self {
369        List(Arc::new(VecDeque::with_capacity(n)))
370    }
371
372    /// The buffer, without copying when this is the only owner.
373    pub fn into_inner(self) -> VecDeque<Value> {
374        Arc::try_unwrap(self.0).unwrap_or_else(|shared| (*shared).clone())
375    }
376
377    /// True when no other `List` shares the buffer, so a mutation
378    /// will not copy.
379    pub fn is_unique(&self) -> bool {
380        Arc::strong_count(&self.0) == 1
381    }
382}
383
384impl std::ops::Deref for List {
385    type Target = VecDeque<Value>;
386    fn deref(&self) -> &VecDeque<Value> {
387        &self.0
388    }
389}
390
391impl std::ops::DerefMut for List {
392    fn deref_mut(&mut self) -> &mut VecDeque<Value> {
393        Arc::make_mut(&mut self.0)
394    }
395}
396
397impl std::fmt::Debug for List {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        self.0.fmt(f)
400    }
401}
402
403impl PartialEq for List {
404    fn eq(&self, other: &Self) -> bool {
405        Arc::ptr_eq(&self.0, &other.0) || *self.0 == *other.0
406    }
407}
408
409impl From<VecDeque<Value>> for List {
410    fn from(v: VecDeque<Value>) -> Self {
411        List(Arc::new(v))
412    }
413}
414
415impl From<Vec<Value>> for List {
416    fn from(v: Vec<Value>) -> Self {
417        List(Arc::new(VecDeque::from(v)))
418    }
419}
420
421impl FromIterator<Value> for List {
422    fn from_iter<I: IntoIterator<Item = Value>>(iter: I) -> Self {
423        List(Arc::new(iter.into_iter().collect()))
424    }
425}
426
427impl Extend<Value> for List {
428    fn extend<I: IntoIterator<Item = Value>>(&mut self, iter: I) {
429        Arc::make_mut(&mut self.0).extend(iter)
430    }
431}
432
433impl IntoIterator for List {
434    type Item = Value;
435    type IntoIter = std::collections::vec_deque::IntoIter<Value>;
436    fn into_iter(self) -> Self::IntoIter {
437        self.into_inner().into_iter()
438    }
439}
440
441impl<'a> IntoIterator for &'a List {
442    type Item = &'a Value;
443    type IntoIter = std::collections::vec_deque::Iter<'a, Value>;
444    fn into_iter(self) -> Self::IntoIter {
445        self.0.iter()
446    }
447}
448
449impl Value {
450    pub fn as_int(&self) -> i64 {
451        match self { Value::Int(n) => *n, other => panic!("expected Int, got {other:?}") }
452    }
453    pub fn as_float(&self) -> f64 {
454        match self { Value::Float(n) => *n, other => panic!("expected Float, got {other:?}") }
455    }
456    pub fn as_bool(&self) -> bool {
457        match self { Value::Bool(b) => *b, other => panic!("expected Bool, got {other:?}") }
458    }
459    pub fn as_str(&self) -> &str {
460        match self { Value::Str(s) => s, other => panic!("expected Str, got {other:?}") }
461    }
462
463    /// Returns `true` if this value is, or transitively contains, an
464    /// `ArenaRecord` or `ArenaTuple`. Used by the memo gate to skip
465    /// memoization when request-scoped arena handles are present in the
466    /// call arguments — such values cannot be safely hashed because the
467    /// memo cache outlives the request arena (#621).
468    pub fn contains_arena_record(&self) -> bool {
469        match self {
470            Value::ArenaRecord { .. } | Value::ArenaTuple { .. } => true,
471            Value::List(items) =>
472                items.iter().any(|v| v.contains_arena_record()),
473            Value::Tuple(items) =>
474                items.iter().any(|v| v.contains_arena_record()),
475            Value::Deque(items) =>
476                items.iter().any(|v| v.contains_arena_record()),
477            Value::Variant { args, .. } =>
478                args.iter().any(|v| v.contains_arena_record()),
479            Value::Record { fields, .. } =>
480                fields.values().any(|v| v.contains_arena_record()),
481            Value::Closure { captures, .. } =>
482                captures.iter().any(|v| v.contains_arena_record()),
483            Value::Map(m) =>
484                m.values().any(|v| v.contains_arena_record()),
485            _ => false,
486        }
487    }
488
489    /// Render this `Value` as a `serde_json::Value` for emission to
490    /// CLI output, the agent API, conformance harness reports, etc.
491    /// Canonical mapping shared across crates; previously every
492    /// boundary had its own copy.
493    ///
494    /// Encoding:
495    /// - `Variant { name, args }` → `{"$variant": name, "args": [...]}`
496    /// - `F64Array { ... }` → `{"$f64_array": true, rows, cols, data}`
497    /// - `Closure { body_hash, .. }` → `"<closure HEX8>"` (first 8 hex
498    ///   chars of the body hash; equivalent closures across source
499    ///   locations render identically — see #222)
500    /// - `Bytes` → `{"$bytes": "deadbeef"}` (lowercase hex). Round-trips
501    ///   through `from_json`. Bare hex strings decode as `Str`, so the
502    ///   marker is required to disambiguate bytes from a string that
503    ///   happens to look like hex.
504    /// - `Map` with all-`Str` keys → JSON object; otherwise array of
505    ///   `[key, value]` pairs (Int keys can't be JSON-object keys)
506    /// - `Set` → JSON array of elements
507    /// - other variants → their natural JSON shape
508    ///
509    /// Note: this form is **not** round-trippable for traces (see
510    /// `lex-trace`'s recorder, which uses a richer marker form).
511    pub fn to_json(&self) -> serde_json::Value {
512        use serde_json::Value as J;
513        match self {
514            Value::Int(n) => J::from(*n),
515            Value::Float(f) => J::from(*f),
516            Value::Bool(b) => J::Bool(*b),
517            Value::Str(s) => J::String(s.to_string()),
518            Value::Bytes(b) => {
519                let hex: String = b.iter().map(|b| format!("{:02x}", b)).collect();
520                let mut m = serde_json::Map::new();
521                m.insert("$bytes".into(), J::String(hex));
522                J::Object(m)
523            }
524            Value::Unit => J::Null,
525            Value::List(items) => J::Array(items.iter().map(Value::to_json).collect()),
526            Value::Tuple(items) => J::Array(items.iter().map(Value::to_json).collect()),
527            Value::Record { fields, .. } => {
528                let mut m = serde_json::Map::new();
529                for (k, v) in fields.iter() { m.insert(k.to_string(), v.to_json()); }
530                J::Object(m)
531            }
532            // #464: should never reach JSON serialization. See PartialEq.
533            Value::StackRecord { .. } =>
534                panic!("BUG(#464): Value::StackRecord reached to_json — \
535                        escape analysis should have prevented escape to a host boundary"),
536            Value::StackTuple { .. } =>
537                panic!("BUG(#464): Value::StackTuple reached to_json — \
538                        escape analysis should have prevented escape to a host boundary"),
539            // #463 slice 2a: arena handles defensively panic at the
540            // host serialization boundary. The materialization helper
541            // (slice 2a-iii / 2b) will resolve handles via the
542            // request slab before this method is called, so a reach
543            // here means either (a) hand-crafted bytecode bypassed
544            // materialization or (b) a real slice-1 analysis bug.
545            // Either way, a panic is the correct response — silent
546            // wrong output would be worse.
547            Value::ArenaRecord { .. } =>
548                panic!("BUG(#463): Value::ArenaRecord reached to_json — \
549                        materialize via Vm::arena_slab before crossing the host boundary"),
550            Value::ArenaTuple { .. } =>
551                panic!("BUG(#463): Value::ArenaTuple reached to_json — \
552                        materialize via Vm::arena_slab before crossing the host boundary"),
553            Value::Variant { name, args } => {
554                let mut m = serde_json::Map::new();
555                m.insert("$variant".into(), J::String(name.clone()));
556                m.insert("args".into(), J::Array(args.iter().map(Value::to_json).collect()));
557                J::Object(m)
558            }
559            Value::Closure { body_hash, .. } => {
560                // Render the first 4 bytes (8 hex chars) of the body
561                // hash. Trace stability follows: equivalent closures
562                // produced from different source locations get the
563                // same string. See #222.
564                let prefix: String = body_hash.iter().take(4)
565                    .map(|b| format!("{b:02x}")).collect();
566                J::String(format!("<closure {prefix}>"))
567            }
568            Value::F64Array { rows, cols, data } => {
569                let mut m = serde_json::Map::new();
570                m.insert("$f64_array".into(), J::Bool(true));
571                m.insert("rows".into(), J::from(*rows));
572                m.insert("cols".into(), J::from(*cols));
573                m.insert("data".into(), J::Array(data.iter().map(|f| J::from(*f)).collect()));
574                J::Object(m)
575            }
576            Value::Map(m) => {
577                let all_str = m.keys().all(|k| matches!(k, MapKey::Str(_)));
578                if all_str {
579                    let mut out = serde_json::Map::new();
580                    for (k, v) in m {
581                        if let MapKey::Str(s) = k {
582                            out.insert(s.clone(), v.to_json());
583                        }
584                    }
585                    J::Object(out)
586                } else {
587                    J::Array(m.iter().map(|(k, v)| {
588                        J::Array(vec![k.as_value().to_json(), v.to_json()])
589                    }).collect())
590                }
591            }
592            Value::Set(s) => J::Array(
593                s.iter().map(|k| k.as_value().to_json()).collect()),
594            Value::Deque(items) => J::Array(items.iter().map(Value::to_json).collect()),
595            Value::Actor(_) => J::String("<actor>".into()),
596            Value::Ticker(_) => J::String("<ticker>".into()),
597            Value::ArrowTable(t) => {
598                // Compact summary: schema + nrows. Full data is intentionally
599                // not emitted — Arrow tables can be GB-scale and a JSON dump
600                // would defeat the point. Callers that need the rows go
601                // through `arrow.row_at` / `arrow.col_to_*_list`.
602                let mut m = serde_json::Map::new();
603                m.insert("$arrow_table".into(), J::Bool(true));
604                m.insert("nrows".into(), J::from(t.num_rows() as i64));
605                m.insert("ncols".into(), J::from(t.num_columns() as i64));
606                let cols: Vec<J> = t
607                    .schema()
608                    .fields()
609                    .iter()
610                    .map(|f| {
611                        let mut o = serde_json::Map::new();
612                        o.insert("name".into(), J::String(f.name().clone()));
613                        o.insert("type".into(), J::String(format!("{}", f.data_type())));
614                        J::Object(o)
615                    })
616                    .collect();
617                m.insert("schema".into(), J::Array(cols));
618                J::Object(m)
619            }
620        }
621    }
622
623    /// Decode a `serde_json::Value` into a `Value`. The inverse of
624    /// [`to_json`](Self::to_json) for the shapes Lex round-trips:
625    ///
626    /// - `{"$variant": "Name", "args": [...]}` → `Value::Variant`
627    /// - `{"$bytes": "deadbeef"}` → `Value::Bytes` (lowercase hex; an
628    ///   odd-length string or non-hex character falls through to
629    ///   `Value::Record`, matching the malformed-`$variant` fallback)
630    /// - JSON object → `Value::Record`
631    /// - JSON array → `Value::List`
632    /// - JSON null → `Value::Unit`
633    /// - JSON string / bool / number → the corresponding scalar
634    ///
635    /// Map, Set, F64Array, and Closure don't round-trip — they decode
636    /// as their natural JSON shape (Object / Array / Object / Str
637    /// respectively), since the CLI / HTTP / VM callers building Values
638    /// from JSON don't have those shapes in their input vocabulary.
639    pub fn from_json(v: &serde_json::Value) -> Value {
640        use serde_json::Value as J;
641        match v {
642            J::Null => Value::Unit,
643            J::Bool(b) => Value::Bool(*b),
644            J::Number(n) => {
645                if let Some(i) = n.as_i64() { Value::Int(i) }
646                else if let Some(f) = n.as_f64() { Value::Float(f) }
647                else { Value::Unit }
648            }
649            J::String(s) => Value::Str(s.as_str().into()),
650            J::Array(items) => Value::List(items.iter().map(Value::from_json).collect()),
651            J::Object(map) => {
652                if let (Some(J::String(name)), Some(J::Array(args))) =
653                    (map.get("$variant"), map.get("args"))
654                {
655                    return Value::Variant {
656                        name: name.clone(),
657                        args: args.iter().map(Value::from_json).collect(),
658                    };
659                }
660                if map.len() == 1 {
661                    if let Some(J::String(hex)) = map.get("$bytes") {
662                        if let Some(bytes) = decode_hex(hex) {
663                            return Value::Bytes(bytes);
664                        }
665                    }
666                }
667                let mut out = indexmap::IndexMap::new();
668                for (k, v) in map {
669                    out.insert(k.clone(), Value::from_json(v));
670                }
671                Value::record_dynamic(out)
672            }
673        }
674    }
675
676    /// Build a `Value::Record` whose fields don't come from an
677    /// `Op::MakeRecord` site — JSON decode, SQL row → record, host
678    /// effect handlers, test fixtures, etc. Interns the field-name
679    /// set in the process-global shape registry (#462 slice 3) so
680    /// records with the same set of field names share a stable
681    /// `shape_id` and hit the same IC slot. Two records with the
682    /// same fields in different insertion order share a `shape_id`
683    /// (the registry sorts the field-name vec before lookup),
684    /// matching the existing `Value::Record` structural-equality
685    /// semantics.
686    ///
687    /// Dynamic shape IDs live in the high half of the `u32` range
688    /// (see `crate::shape_registry::DYNAMIC_SHAPE_ID_BASE`) so they
689    /// can't collide with the per-program shape indices emitted by
690    /// `Op::MakeRecord`. Mixed-flavor IC sites (which the slice-2b
691    /// measurement found at exactly zero occurrences) would still
692    /// be correct under the IC's shape-keyed verifier — they'd just
693    /// churn the cache.
694    /// Build a `Record` from a String-keyed host map (JSON decode, SQL
695    /// rows, builtins). Keys are re-collected into interned `SmolStr`
696    /// (#461 field-name interning). The hot bytecode `MakeRecord` path
697    /// builds `SmolStr`-keyed maps directly and never routes through
698    /// here; callers that already hold an interned map use
699    /// `record_interned`.
700    pub fn record_dynamic(fields: IndexMap<String, Value>) -> Value {
701        let shape_id = crate::shape_registry::intern(fields.keys());
702        let fields: IndexMap<SmolStr, Value> =
703            fields.into_iter().map(|(k, v)| (SmolStr::from(k), v)).collect();
704        Value::Record { shape_id, fields: Box::new(fields) }
705    }
706
707    /// Build a `Record` from an already-interned `SmolStr`-keyed map —
708    /// used by the http builder chain, which threads `SmolStr` keys
709    /// through `with_header`/`with_query`/… without round-tripping back
710    /// to `String` (#461).
711    pub fn record_interned(fields: IndexMap<SmolStr, Value>) -> Value {
712        let shape_id = crate::shape_registry::intern(fields.keys());
713        Value::Record { shape_id, fields: Box::new(fields) }
714    }
715}
716
717/// Sentinel `shape_id` for records constructed outside an
718/// `Op::MakeRecord` site (#462 slice 2). `Program::record_shapes`
719/// is bounded by `u32::MAX - 1` in practice (each compile-time
720/// record literal adds one entry), so reserving the top of the
721/// `u32` range as "no shape" keeps `Value::Record.shape_id` a flat
722/// `u32` — the `Op::GetField` IC's hot path is a single u32
723/// compare, no `Option` discriminant.
724pub const NO_SHAPE_ID: u32 = u32::MAX;
725
726/// Lowercase-hex → bytes. Returns `None` for odd length or non-hex chars
727/// (callers fall through to a record decode rather than erroring).
728fn decode_hex(s: &str) -> Option<Vec<u8>> {
729    if !s.len().is_multiple_of(2) { return None; }
730    let mut out = Vec::with_capacity(s.len() / 2);
731    let bytes = s.as_bytes();
732    for pair in bytes.chunks(2) {
733        let hi = (pair[0] as char).to_digit(16)?;
734        let lo = (pair[1] as char).to_digit(16)?;
735        out.push(((hi << 4) | lo) as u8);
736    }
737    Some(out)
738}