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