1
//! Shared wasmtime primitives used by every host of nomiscript-compiled
2
//! modules: the entity-script `ScriptExecutor` and the rpc eval channel.
3
//!
4
//! Owns the engine config (WasmGC + epoch interruption + optional fuel),
5
//! the per-bytecode `Module` cache, the trap-classification helper, and the
6
//! `decode_eval_result` helper that walks the nomi-eval `(ref null any)`
7
//! return value into a structured [`EvalValue`]. Higher-level consumers
8
//! parameterize over the Store data type and assemble their own Linker on top.
9

            
10
use std::collections::HashMap;
11
use std::sync::{Arc, Mutex};
12

            
13
use thiserror::Error;
14
use uuid::Uuid;
15
use wasmtime::{
16
    AnyRef, AsContextMut, Caller, Config, Engine, FieldType, Linker, Module, Mutability, Rooted,
17
    StorageType, Store, StructRef, StructRefPre, StructType, Val, ValType,
18
};
19

            
20
#[derive(Debug, Error)]
21
pub enum EngineError {
22
    #[error("engine config rejected: {0}")]
23
    Config(String),
24
    #[error("module cache lock poisoned")]
25
    CachePoisoned,
26
    #[error("module compilation failed: {0}")]
27
    Compile(String),
28
    #[error("module instantiation failed: {0}")]
29
    Instantiate(String),
30
    #[error("fuel configuration failed: {0}")]
31
    Fuel(String),
32
    #[error("missing export `{0}`")]
33
    MissingExport(String),
34
    #[error("fuel exhausted before completion")]
35
    OutOfFuel,
36
    #[error("epoch deadline reached before completion")]
37
    EpochInterrupt,
38
    /// `ConvertCommodity` raises this when no Price row links source
39
    /// and target in either direction. Lifted into a dedicated variant
40
    /// so clients can prompt the user to add a price row rather than
41
    /// guess from a generic trap message.
42
    #[error("no conversion: {0}")]
43
    NoConversion(String),
44
    /// A structured error raised in-guest, surfaced via the `__nomi_raise`
45
    /// host fn (`Err(wasmtime::Error::msg("__nomi_raise:CODE:MSG"))`). Two
46
    /// sources converge here (ADR-0026): a script `(error 'code "msg")`, and
47
    /// an engine error like a commodity mismatch — both `throw $nomi_error`
48
    /// in-guest, and the boundary wrapper around each host-invoked body
49
    /// catches an uncaught throw and bridges it to `__nomi_raise`. The
50
    /// classifier parses the marker prefix and surfaces the code symbol
51
    /// (`COMMODITY-MISMATCH`, a script's own symbol, …) onto the wire
52
    /// envelope's `:code` slot. Codes are reader-folded (upper-cased)
53
    /// symbols, not free-form strings.
54
    #[error("script raised {code}: {message}")]
55
    ScriptRaised { code: String, message: String },
56
    #[error("execution trapped: {0}")]
57
    Trap(String),
58
}
59

            
60
/// Marker prefix the `__nomi_raise` host fn embeds in its wasmtime
61
/// error message so the runtime classifier can recognise script-raised
62
/// errors before the unreachable-trap branch fires. Kept here so the
63
/// host-fn body and the classifier agree on the wire format.
64
pub const NOMI_RAISE_MARKER: &str = "__nomi_raise:";
65

            
66
/// Optional profiling strategy. JitDump is the Linux `perf record`
67
/// flow; it writes a `jit-<pid>.dump` file the OS-level profiler can
68
/// read. PerfMap is the simpler symbol-name-only Linux variant. Both
69
/// require the `wasmtime/profiling` cargo feature, which we pull in
70
/// via the `jitdump` feature flag on the scripting crate; non-Linux
71
/// builds should leave this as `None`.
72
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73
pub enum ProfilerStrategy {
74
    #[default]
75
    None,
76
    JitDump,
77
    PerfMap,
78
}
79

            
80
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81
pub struct EngineOpts {
82
    pub fuel: bool,
83
    pub profiler: ProfilerStrategy,
84
}
85

            
86
impl EngineOpts {
87
    #[must_use]
88
26299
    pub const fn baseline() -> Self {
89
26299
        Self {
90
26299
            fuel: false,
91
26299
            profiler: ProfilerStrategy::None,
92
26299
        }
93
26299
    }
94

            
95
    #[must_use]
96
8480
    pub const fn with_fuel(mut self) -> Self {
97
8480
        self.fuel = true;
98
8480
        self
99
8480
    }
100

            
101
    #[must_use]
102
14736
    pub const fn with_profiler(mut self, strategy: ProfilerStrategy) -> Self {
103
14736
        self.profiler = strategy;
104
14736
        self
105
14736
    }
106
}
107

            
108
impl Default for EngineOpts {
109
    fn default() -> Self {
110
        Self::baseline()
111
    }
112
}
113

            
114
26299
pub fn build_engine(opts: EngineOpts) -> Result<Engine, EngineError> {
115
26299
    let mut config = Config::new();
116
26299
    config.wasm_gc(true);
117
26299
    config.wasm_function_references(true);
118
    // Exception-handling proposal: `(error)` lowers to `throw $nomi_error`
119
    // and `(handler-case)` / `(unwind-protect)` lower to `try_table`
120
    // (Tier 3, ADR-0026). Engine traps (`OutOfFuel` / `EpochInterrupt`)
121
    // are not wasm exceptions, so they bypass `try_table` and keep the
122
    // per-Session deadline budget non-catchable.
123
26299
    config.wasm_exceptions(true);
124
26299
    config.epoch_interruption(true);
125
26299
    if opts.fuel {
126
8480
        config.consume_fuel(true);
127
17840
    }
128
26299
    match opts.profiler {
129
26251
        ProfilerStrategy::None => {}
130
        ProfilerStrategy::JitDump => {
131
            config.profiler(wasmtime::ProfilingStrategy::JitDump);
132
        }
133
48
        ProfilerStrategy::PerfMap => {
134
48
            config.profiler(wasmtime::ProfilingStrategy::PerfMap);
135
48
        }
136
    }
137
26299
    Engine::new(&config).map_err(|e| EngineError::Config(e.to_string()))
138
26299
}
139

            
140
94974
pub fn compile_module(engine: &Engine, bytes: &[u8]) -> Result<Module, EngineError> {
141
94974
    Module::new(engine, bytes).map_err(|e| EngineError::Compile(e.to_string()))
142
94974
}
143

            
144
58
pub fn compile_wat(engine: &Engine, source: &str) -> Result<Module, EngineError> {
145
58
    Module::new(engine, source).map_err(|e| EngineError::Compile(e.to_string()))
146
58
}
147

            
148
/// Per-engine bytecode cache keyed by the full module bytes. Cloning a
149
/// [`ModuleCache`] yields a handle into the same inner map; meant to be
150
/// shared between long-lived hosts (Session, ScriptExecutor) and any per-form
151
/// helpers that need the same cached compilation.
152
#[derive(Debug, Default, Clone)]
153
pub struct ModuleCache {
154
    inner: Arc<Mutex<HashMap<Vec<u8>, Module>>>,
155
}
156

            
157
impl ModuleCache {
158
    #[must_use]
159
24196
    pub fn new() -> Self {
160
24196
        Self::default()
161
24196
    }
162

            
163
95427
    pub fn get_or_compile(&self, engine: &Engine, bytecode: &[u8]) -> Result<Module, EngineError> {
164
95427
        if let Some(module) = self.lookup(bytecode)? {
165
865
            return Ok(module);
166
94562
        }
167
94562
        let module = compile_module(engine, bytecode)?;
168
94466
        self.store(bytecode, module.clone())?;
169
94466
        Ok(module)
170
95427
    }
171

            
172
95427
    fn lookup(&self, bytecode: &[u8]) -> Result<Option<Module>, EngineError> {
173
95427
        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
174
95427
        Ok(guard.get(bytecode).cloned())
175
95427
    }
176

            
177
94466
    fn store(&self, bytecode: &[u8], module: Module) -> Result<(), EngineError> {
178
94466
        let mut guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
179
94466
        guard.insert(bytecode.to_vec(), module);
180
94466
        Ok(())
181
94466
    }
182

            
183
2
    pub fn is_empty(&self) -> Result<bool, EngineError> {
184
2
        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
185
2
        Ok(guard.is_empty())
186
2
    }
187

            
188
340
    pub fn len(&self) -> Result<usize, EngineError> {
189
340
        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
190
340
        Ok(guard.len())
191
340
    }
192
}
193

            
194
/// Classifies a [`wasmtime::Error`] thrown during execution into a typed
195
/// [`EngineError`]. Downcast to [`wasmtime::Trap`] handles the structured
196
/// fuel/epoch cases; everything else falls through as `Trap(message)`.
197
1490
pub fn classify_runtime_error(err: &wasmtime::Error) -> EngineError {
198
1490
    if let Some(trap) = err.downcast_ref::<wasmtime::Trap>() {
199
386
        match *trap {
200
337
            wasmtime::Trap::OutOfFuel => return EngineError::OutOfFuel,
201
49
            wasmtime::Trap::Interrupt => return EngineError::EpochInterrupt,
202
            _ => {}
203
        }
204
1104
    }
205
    // Walk the error chain so host-fn `wasmtime::Error::msg("...")` causes
206
    // (e.g. "get-commodity: invalid uuid '...'") surface alongside the
207
    // wasmtime wrapper's "error while executing at wasm backtrace" header.
208
    // `err.to_string()` alone only renders the outermost wrapper, which
209
    // hides the diagnostic the host fn actually emitted.
210
1104
    let mut combined = err.to_string();
211
1104
    for cause in err.chain().skip(1) {
212
960
        combined.push_str(": ");
213
960
        combined.push_str(&cause.to_string());
214
960
    }
215
    // Script-raised errors must classify *before* the unreachable-trap
216
    // branch: `(error 'code "msg")` lowers to `__nomi_raise` returning
217
    // `Err(wasmtime::Error::msg("__nomi_raise:CODE:MSG"))`. Returning
218
    // `Err` from a host fn never trips `unreachable`, so ADR-0014's
219
    // single-unreachable invariant is preserved — but the chain walk
220
    // above stitches the host-fn message into `combined`, and the
221
    // marker prefix lets us recover the symbol/message without parsing
222
    // wasmtime's wrapper text.
223
1104
    if let Some(raised) = parse_nomi_raise_marker(err) {
224
624
        return raised;
225
480
    }
226
480
    if combined.contains("convert-commodity: no Price row")
227
432
        || combined.contains("convert-commodity: inverse price has zero numerator")
228
    {
229
48
        return EngineError::NoConversion(combined);
230
432
    }
231
432
    EngineError::Trap(combined)
232
1490
}
233

            
234
/// Walks the error chain for a `__nomi_raise:CODE:MSG` marker emitted by
235
/// the `__nomi_raise` host fn. Returns the structured `ScriptRaised`
236
/// variant when found, otherwise `None`. Searches the chain rather than
237
/// the combined string so script-raised codes survive intact even when
238
/// MSG itself contains literal `:` characters.
239
1104
fn parse_nomi_raise_marker(err: &wasmtime::Error) -> Option<EngineError> {
240
1104
    err.chain()
241
2064
        .map(|cause| cause.to_string())
242
2064
        .find_map(|cause_str| split_marker(&cause_str))
243
1104
}
244

            
245
2064
fn split_marker(text: &str) -> Option<EngineError> {
246
2064
    let rest = text.strip_prefix(NOMI_RAISE_MARKER)?;
247
624
    let (code, message) = rest.split_once(':')?;
248
624
    Some(EngineError::ScriptRaised {
249
624
        code: code.to_string(),
250
624
        message: message.to_string(),
251
624
    })
252
2064
}
253

            
254
/// Maps an `EngineError` to the `(code, message)` pair scripts and
255
/// batch consumers see when a script fails — code is a kebab-case
256
/// symbol (matching the wire envelope's `:code` slot for catch-each
257
/// cells and `server::script` per-tx reports), message is the
258
/// engine's own diagnostic string.
259
///
260
/// Engine-bound deadlines (`OutOfFuel`, `EpochInterrupt`) also have a
261
/// mapping here so callers that *do* want to surface them to scripts
262
/// (batch runners that don't care about catch-each's "engine deadlines
263
/// aren't catchable" rule) can. catch-each filters the deadlines out
264
/// before reaching this mapper.
265
#[must_use]
266
389
pub fn err_code_and_message(err: &EngineError) -> (String, String) {
267
389
    match err {
268
290
        EngineError::ScriptRaised { code, message } => (code.clone(), message.clone()),
269
1
        EngineError::NoConversion(msg) => ("no-conversion".to_string(), msg.clone()),
270
1
        EngineError::Trap(msg) => ("runtime".to_string(), msg.clone()),
271
96
        EngineError::Compile(msg) => ("compile".to_string(), msg.clone()),
272
        EngineError::Instantiate(msg) => ("runtime".to_string(), msg.clone()),
273
        EngineError::Fuel(msg) => ("runtime".to_string(), msg.clone()),
274
        EngineError::MissingExport(msg) => ("runtime".to_string(), msg.clone()),
275
        EngineError::Config(msg) => ("runtime".to_string(), msg.clone()),
276
        EngineError::CachePoisoned => (
277
            "runtime".to_string(),
278
            "module cache lock poisoned".to_string(),
279
        ),
280
1
        EngineError::OutOfFuel => ("runtime".to_string(), "fuel exhausted".to_string()),
281
        EngineError::EpochInterrupt => ("runtime".to_string(), "epoch deadline".to_string()),
282
    }
283
389
}
284

            
285
/// Allocates an ATOMIC `$commodity` value by re-entering the guest's exported
286
/// `commodity_new` with the four i64 components (numer, denom, commodity_hi,
287
/// commodity_lo). Since ADR-0028 E0 the `$commodity` struct carries a 5th
288
/// `(ref null $unit_term)` field; the host must NOT construct that ref-bearing
289
/// struct itself, so it delegates to the guest helper (which sets the term to
290
/// null = atomic) — the same re-entry pattern as `alloc_pair_chain` / the
291
/// entity allocators. Async because it calls back into the wasm instance.
292
202
pub async fn alloc_commodity_ref<T>(
293
202
    caller: &mut Caller<'_, T>,
294
202
    numer: i64,
295
202
    denom: i64,
296
202
    commodity_id: Uuid,
297
202
) -> wasmtime::Result<Rooted<StructRef>>
298
202
where
299
202
    T: Send,
300
202
{
301
202
    let commodity_new = caller
302
202
        .get_export("commodity_new")
303
202
        .and_then(|e| e.into_func())
304
202
        .ok_or_else(|| {
305
1
            wasmtime::Error::msg(
306
                "module missing 'commodity_new' export — host commodity allocation \
307
                 requires the nomiscript compiler skeleton's exported commodity_new",
308
            )
309
1
        })?;
310
201
    let (hi, lo) = commodity_id.as_u64_pair();
311
201
    let mut results = [Val::AnyRef(None)];
312
201
    commodity_new
313
201
        .call_async(
314
201
            caller.as_context_mut(),
315
201
            &[
316
201
                Val::I64(numer),
317
201
                Val::I64(denom),
318
201
                Val::I64(hi as i64),
319
201
                Val::I64(lo as i64),
320
201
            ],
321
201
            &mut results,
322
201
        )
323
201
        .await?;
324
201
    match &results[0] {
325
201
        Val::AnyRef(Some(any)) => any.unwrap_struct(caller.as_context_mut()),
326
        Val::AnyRef(None) => Err(wasmtime::Error::msg("commodity_new returned null")),
327
        _ => Err(wasmtime::Error::msg(
328
            "commodity_new returned non-anyref Val variant",
329
        )),
330
    }
331
202
}
332

            
333
/// Allocates a `$i8_array` wasm array holding `bytes` and returns a rooted
334
/// reference. Single allocation; callers can format UUID/name payloads into
335
/// a reused `Vec<u8>` and ship the bytes without an intermediate `String`.
336
/// Engine canonicalizes the i8 array type so the host-side allocation
337
/// matches the guest's `(array i8)` declaration in
338
/// `CompileContext::new_skeleton`.
339
3765
pub fn alloc_string_ref<T>(
340
3765
    caller: &mut Caller<'_, T>,
341
3765
    bytes: &[u8],
342
3765
) -> wasmtime::Result<Rooted<wasmtime::ArrayRef>> {
343
3765
    let engine = caller.engine().clone();
344
    // Mutability must match the compiler's `register_type("i8_array")` field
345
    // declaration (mutable: true) — engine canonicalization compares the
346
    // mutability bit, so a `Const` array type would fail the guest's
347
    // `ref.cast (ref $i8_array)` even though the storage type matches.
348
3765
    let ty = wasmtime::ArrayType::new(&engine, FieldType::new(Mutability::Var, StorageType::I8));
349
3765
    let pre = wasmtime::ArrayRefPre::new(caller.as_context_mut(), ty);
350
101924
    let vals: Vec<Val> = bytes.iter().map(|b| Val::I32(i32::from(*b))).collect();
351
3765
    wasmtime::ArrayRef::new_fixed(caller.as_context_mut(), &pre, &vals)
352
3765
}
353

            
354
/// Allocates a `$ratio` wasm struct (2 i64 fields: numer, denom). Mirrors
355
/// `alloc_commodity_ref` for the Ratio numeric stratum — used when a host
356
/// fn returns a typed Ratio without going through the synthesized
357
/// `ratio_new` wrap.
358
306
pub fn alloc_ratio_ref<T>(
359
306
    caller: &mut Caller<'_, T>,
360
306
    numer: i64,
361
306
    denom: i64,
362
306
) -> wasmtime::Result<Rooted<StructRef>> {
363
306
    let engine = caller.engine().clone();
364
306
    let ty = StructType::new(
365
306
        &engine,
366
306
        std::iter::repeat_n(
367
306
            FieldType::new(Mutability::Const, StorageType::ValType(ValType::I64)),
368
            2,
369
        ),
370
    )?;
371
306
    let pre = StructRefPre::new(caller.as_context_mut(), ty);
372
306
    StructRef::new(
373
306
        caller.as_context_mut(),
374
306
        &pre,
375
306
        &[Val::I64(numer), Val::I64(denom)],
376
    )
377
306
}
378

            
379
/// Allocates an entity wasm struct (`$account`, `$commodity_entity`, etc) by
380
/// re-entering the module's exported `alloc_<kind>` function. The host can't
381
/// freshly construct an entity `StructType` via `StructType::new` — fields
382
/// like `(ref null $i8_array)` reference concrete type indices that engine
383
/// canonicalization compares by identity, so any abstract `anyref`-typed
384
/// fresh declaration produces a structurally distinct (and uncastable) type.
385
/// Re-entry through the guest's own allocator (registered in
386
/// `CompileContext::register_entity_allocators`) sidesteps the issue: each
387
/// call returns a struct ref of the exact `$<kind>` type the subsequent
388
/// `ref.cast (ref $<kind>)` in the consuming form accepts.
389
///
390
/// Args are passed in declaration order matching the entity's struct
391
/// field layout (see `CompileContext::new_skeleton`).
392
792
pub async fn alloc_entity_via_export<T>(
393
792
    caller: &mut Caller<'_, T>,
394
792
    export_name: &str,
395
792
    args: &[Val],
396
792
) -> wasmtime::Result<Rooted<StructRef>>
397
792
where
398
792
    T: Send,
399
792
{
400
792
    let alloc = caller
401
792
        .get_export(export_name)
402
792
        .and_then(|e| e.into_func())
403
792
        .ok_or_else(|| {
404
            wasmtime::Error::msg(format!(
405
                "module missing '{export_name}' export — host entity allocation requires \
406
                 the nomiscript compiler skeleton's exported alloc_<kind> function"
407
            ))
408
        })?;
409
792
    let mut results = [Val::AnyRef(None)];
410
792
    alloc
411
792
        .call_async(caller.as_context_mut(), args, &mut results)
412
792
        .await?;
413
792
    let new_entity_any = match &results[0] {
414
792
        Val::AnyRef(any) => *any,
415
        _ => {
416
            return Err(wasmtime::Error::msg(format!(
417
                "{export_name} returned non-anyref Val variant"
418
            )));
419
        }
420
    };
421
792
    new_entity_any
422
792
        .ok_or_else(|| {
423
            wasmtime::Error::msg(format!(
424
                "{export_name} returned null when allocating entity"
425
            ))
426
        })?
427
792
        .unwrap_struct(caller.as_context_mut())
428
792
}
429

            
430
/// Reads a `$i8_array` arg ref into a Rust `String`. `None` is returned for
431
/// null refs (the wasm-level `(ref null $i8_array)` param's null state). The
432
/// underlying byte storage is i8 (mutable per `register_type("i8_array")`),
433
/// so each element is read via `array.get_u` semantics and assembled into a
434
/// `Vec<u8>` then UTF-8 validated. Non-UTF-8 bytes surface as a structured
435
/// trap rather than a silent replacement.
436
3859
pub fn read_string_arg<T>(
437
3859
    caller: &mut Caller<'_, T>,
438
3859
    arg: Option<Rooted<wasmtime::ArrayRef>>,
439
3859
) -> wasmtime::Result<Option<String>> {
440
3859
    let Some(arr) = arg else {
441
        return Ok(None);
442
    };
443
3859
    let len = arr.len(caller.as_context_mut())?;
444
3859
    let mut bytes = Vec::with_capacity(len as usize);
445
124635
    for i in 0..len {
446
124635
        let val = arr.get(caller.as_context_mut(), i)?;
447
124635
        let byte_i32 = val
448
124635
            .i32()
449
124635
            .ok_or_else(|| wasmtime::Error::msg("string arg element is not i32"))?;
450
124635
        bytes.push(byte_i32 as u8);
451
    }
452
3859
    String::from_utf8(bytes)
453
3859
        .map(Some)
454
3859
        .map_err(|err| wasmtime::Error::msg(format!("string arg is not valid UTF-8: {err}")))
455
3859
}
456

            
457
/// Reads a `$commodity` arg ref into its (numer, denom, commodity_id)
458
/// components. Mirrors `read_string_arg` for the Commodity numeric stratum:
459
/// fields 0-1 are numer/denom, fields 2-3 are the UUID halves. `None`
460
/// returns for a null ref; bad shape surfaces as a structured trap.
461
38
pub fn read_commodity_arg<T>(
462
38
    caller: &mut Caller<'_, T>,
463
38
    arg: Option<Rooted<StructRef>>,
464
38
) -> wasmtime::Result<Option<(i64, i64, Uuid)>> {
465
38
    let Some(s) = arg else {
466
        return Ok(None);
467
    };
468
152
    let read_i64 = |c: &mut Caller<'_, T>, idx: usize| -> wasmtime::Result<i64> {
469
152
        let v = s.field(c.as_context_mut(), idx)?;
470
152
        v.i64()
471
152
            .ok_or_else(|| wasmtime::Error::msg(format!("commodity field {idx} is not i64")))
472
152
    };
473
38
    let numer = read_i64(caller, 0)?;
474
38
    let denom = read_i64(caller, 1)?;
475
38
    let hi = read_i64(caller, 2)?;
476
38
    let lo = read_i64(caller, 3)?;
477
38
    let raw = ((hi as u64 as u128) << 64) | (lo as u64 as u128);
478
38
    Ok(Some((numer, denom, Uuid::from_u128(raw))))
479
38
}
480

            
481
/// Reads a `$ratio` arg ref into its `(numer, denom)` components. `None`
482
/// returns for a null ref; a zero denominator is rejected as a structured trap
483
/// so callers never divide by zero. Mirrors `read_commodity_arg` for the
484
/// dimensionless Scalar stratum (a `draft-split` amount).
485
72
pub fn read_ratio_arg<T>(
486
72
    caller: &mut Caller<'_, T>,
487
72
    arg: Option<Rooted<StructRef>>,
488
72
) -> wasmtime::Result<Option<(i64, i64)>> {
489
72
    let Some(s) = arg else {
490
        return Ok(None);
491
    };
492
72
    let numer = s
493
72
        .field(caller.as_context_mut(), 0)?
494
72
        .i64()
495
72
        .ok_or_else(|| wasmtime::Error::msg("ratio field 0 (numer) is not i64"))?;
496
72
    let denom = s
497
72
        .field(caller.as_context_mut(), 1)?
498
72
        .i64()
499
72
        .ok_or_else(|| wasmtime::Error::msg("ratio field 1 (denom) is not i64"))?;
500
72
    if denom == 0 {
501
        return Err(wasmtime::Error::msg("ratio has zero denominator"));
502
72
    }
503
72
    Ok(Some((numer, denom)))
504
72
}
505

            
506
/// Reads a named String field from an entity struct arg, resolving the field's
507
/// slot index from [`nomiscript::entity_layout`] (the single source of struct
508
/// slot order). `None` returns for a null ref. Errors if the kind has no
509
/// layout, the named field is absent or non-String, or the slot is not an
510
/// `$i8_array`. This is how the draft natives read e.g. an account's `id`
511
/// (slot 0) from a `(get-account …)` entity ref passed as an argument.
512
162
pub fn read_entity_string_field<T>(
513
162
    caller: &mut Caller<'_, T>,
514
162
    arg: Option<Rooted<StructRef>>,
515
162
    kind: nomiscript::EntityKind,
516
162
    field_name: &str,
517
162
) -> wasmtime::Result<Option<String>> {
518
162
    let Some(s) = arg else {
519
18
        return Ok(None);
520
    };
521
144
    read_entity_string_field_ctx(caller.as_context_mut(), s, kind, field_name).map(Some)
522
162
}
523

            
524
/// Context-based core of [`read_entity_string_field`], split out so it is
525
/// unit-testable without a `Caller` (tests hold a bare `Store`). Resolves the
526
/// field's slot from the entity layout and reads it as an `$i8_array` string.
527
147
pub fn read_entity_string_field_ctx(
528
147
    mut store: impl AsContextMut,
529
147
    entity: Rooted<StructRef>,
530
147
    kind: nomiscript::EntityKind,
531
147
    field_name: &str,
532
147
) -> wasmtime::Result<String> {
533
147
    let layout = nomiscript::entity_layout(kind)
534
147
        .ok_or_else(|| wasmtime::Error::msg(format!("no entity layout for {kind:?}")))?;
535
147
    let idx = layout
536
147
        .fields
537
147
        .iter()
538
151
        .position(|f| f.name == field_name && f.kind == nomiscript::EntityFieldKind::String)
539
147
        .ok_or_else(|| {
540
1
            wasmtime::Error::msg(format!(
541
                "entity {kind:?} has no String field named '{field_name}'"
542
            ))
543
1
        })?;
544
146
    let arr = match entity.field(store.as_context_mut(), idx)? {
545
146
        Val::AnyRef(Some(any)) => any.unwrap_array(store.as_context_mut())?,
546
        _ => {
547
            return Err(wasmtime::Error::msg(format!(
548
                "entity {kind:?} field '{field_name}' (slot {idx}) is not an i8_array"
549
            )));
550
        }
551
    };
552
146
    let len = arr.len(store.as_context_mut())?;
553
146
    let mut bytes = Vec::with_capacity(len as usize);
554
5201
    for i in 0..len {
555
5201
        let byte = arr
556
5201
            .get(store.as_context_mut(), i)?
557
5201
            .i32()
558
5201
            .ok_or_else(|| wasmtime::Error::msg("entity string element is not i32"))?;
559
5201
        bytes.push(byte as u8);
560
    }
561
146
    String::from_utf8(bytes)
562
146
        .map_err(|err| wasmtime::Error::msg(format!("entity string field not utf-8: {err}")))
563
147
}
564

            
565
/// Folds an iterator of GC-ref elements into a `$pair` chain by re-entering
566
/// the wasm module via its exported `pair_new` function. Returns the chain
567
/// head, or `None` if the iterator is empty.
568
///
569
/// `$pair` is the self-recursive cell type (`{anyref car, ref null $pair
570
/// cdr}`) declared by `CompileContext::new_skeleton`. The host can't freshly
571
/// construct that StructType via `StructType::new` — the cdr field references
572
/// the type itself, which `StructType::new` doesn't model. Instead this
573
/// helper reaches into the module's own type system: `pair_new` is already
574
/// emitted by the compiler skeleton as `register_function("pair_new", ...)`
575
/// which adds it to the export table, so the host fn body can pull it from
576
/// `Caller::get_export` and invoke it per element. Each call produces a
577
/// `Rooted<StructRef>` of the exact `$pair` type that subsequent `ref.cast
578
/// (ref null $pair)` operations in the guest accept.
579
///
580
/// Items are folded right-to-left so the first element of the iterator
581
/// ends up at the chain head — `[a, b, c]` → `(a . (b . (c . nil)))`.
582
740
pub async fn alloc_pair_chain<T>(
583
740
    caller: &mut Caller<'_, T>,
584
740
    items: impl IntoIterator<Item = Rooted<AnyRef>>,
585
740
) -> wasmtime::Result<Option<Rooted<StructRef>>>
586
740
where
587
740
    T: Send,
588
740
{
589
740
    let pair_new = caller
590
740
        .get_export("pair_new")
591
740
        .and_then(|e| e.into_func())
592
740
        .ok_or_else(|| {
593
1
            wasmtime::Error::msg(
594
                "module missing 'pair_new' export — host pair allocation requires \
595
                 the nomiscript compiler skeleton's exported pair_new",
596
            )
597
1
        })?;
598

            
599
739
    let items: Vec<Rooted<AnyRef>> = items.into_iter().collect();
600
739
    let mut head: Option<Rooted<StructRef>> = None;
601
1317
    for item in items.into_iter().rev() {
602
1317
        let cdr_any = head.map(|p| p.to_anyref());
603
1317
        let mut results = [Val::AnyRef(None)];
604
1317
        pair_new
605
1317
            .call_async(
606
1317
                caller.as_context_mut(),
607
1317
                &[Val::AnyRef(Some(item)), Val::AnyRef(cdr_any)],
608
1317
                &mut results,
609
1317
            )
610
1317
            .await?;
611
1317
        let new_pair_any = match &results[0] {
612
1317
            Val::AnyRef(any) => *any,
613
            _ => {
614
                return Err(wasmtime::Error::msg(
615
                    "pair_new returned non-anyref Val variant",
616
                ));
617
            }
618
        };
619
        head = Some(
620
1317
            new_pair_any
621
1317
                .ok_or_else(|| {
622
                    wasmtime::Error::msg("pair_new returned null when chaining elements")
623
                })?
624
1317
                .unwrap_struct(caller.as_context_mut())?,
625
        );
626
    }
627
739
    Ok(head)
628
740
}
629

            
630
/// Instantiates `module` against an empty linker and calls a zero-arg export
631
/// returning a single `i64`. Constraints (fuel cap, epoch deadline) come from
632
/// the caller-supplied `Store`.
633
4
pub fn call_i64_export<T>(
634
4
    engine: &Engine,
635
4
    store: &mut Store<T>,
636
4
    module: &Module,
637
4
    export: &str,
638
4
) -> Result<i64, EngineError> {
639
4
    let linker = Linker::<T>::new(engine);
640
4
    let instance = linker
641
4
        .instantiate(&mut *store, module)
642
4
        .map_err(|e| classify_runtime_error(&e))?;
643
4
    let func = instance
644
4
        .get_typed_func::<(), i64>(&mut *store, export)
645
4
        .map_err(|_| EngineError::MissingExport(export.to_string()))?;
646
3
    func.call(&mut *store, ())
647
3
        .map_err(|e| classify_runtime_error(&e))
648
4
}
649

            
650
/// Final value captured by an eval-mode module via the `nomi_capture_*` host
651
/// fns. Mirrors the subset of [`nomiscript::WasmType`] variants the compiler
652
/// emits as terminal stack types, plus a `Bytes` variant for native fns that
653
/// marshal compound data (server-command results via `scripting-format`,
654
/// chart SVGs, exported files, etc.). Cons/Vector/Closure/Struct still wait
655
/// for the GC migration.
656
#[derive(Debug, Clone, PartialEq)]
657
pub enum EvalValue {
658
    Nil,
659
    Bool(bool),
660
    I32(i32),
661
    Ratio {
662
        numer: i64,
663
        denom: i64,
664
    },
665
    /// Commodity-bearing amount: rational + originating commodity uuid.
666
    /// Distinct from `Ratio` so cross-strata arithmetic is rejected by
667
    /// the compiler before any wire round-trip. Wire form via
668
    /// `format_value`: `(:commodity <ratio> :id "<uuid>")`.
669
    Commodity {
670
        numer: i64,
671
        denom: i64,
672
        commodity_hi: i64,
673
        commodity_lo: i64,
674
    },
675
    String(String),
676
    Bytes(Vec<u8>),
677
}
678

            
679
impl From<EvalValue> for nomiscript::Value {
680
8448
    fn from(value: EvalValue) -> Self {
681
8448
        match value {
682
1056
            EvalValue::Nil => nomiscript::Value::Nil,
683
48
            EvalValue::Bool(b) => nomiscript::Value::Bool(b),
684
2160
            EvalValue::I32(n) => {
685
2160
                nomiscript::Value::Number(nomiscript::Fraction::from_integer(i64::from(n)))
686
            }
687
144
            EvalValue::Ratio { numer, denom } => {
688
144
                nomiscript::Value::Number(nomiscript::Fraction::new(numer, denom))
689
            }
690
            EvalValue::Commodity {
691
144
                numer,
692
144
                denom,
693
144
                commodity_hi,
694
144
                commodity_lo,
695
            } => {
696
                // Reassemble the 16-byte uuid from the wasm-side (hi, lo)
697
                // i64 pair. Both halves get cast to u64 first so negative
698
                // i64 patterns don't sign-extend into bogus high bits.
699
144
                let raw = ((commodity_hi as u64 as u128) << 64) | (commodity_lo as u64 as u128);
700
144
                nomiscript::Value::Commodity {
701
144
                    amount: nomiscript::Fraction::new(numer, denom),
702
144
                    commodity_id: uuid::Uuid::from_u128(raw),
703
144
                }
704
            }
705
4896
            EvalValue::String(s) => nomiscript::Value::String(s),
706
            EvalValue::Bytes(b) => nomiscript::Value::Bytes(b),
707
        }
708
8448
    }
709
}
710

            
711
/// Decodes nomi-eval's anyref return value into an [`EvalValue`] using
712
/// the compile-time-known result type. `None` for the result_ty means
713
/// the form was empty / definition-only and the host should see
714
/// [`EvalValue::Nil`]. Numeric types (`I32`, `Ratio`, `Commodity`) and
715
/// `StringRef` decode directly. `PairRef` walks the chain, decoding
716
/// each car per its declared element type. `EntityRef` returns a
717
/// placeholder until a downstream consumer needs the structured
718
/// shape host-side. Takes any `AsContextMut` so it works with both
719
/// `&mut Store<T>` (sync test paths) and `&mut Caller<'_, T>` (async
720
/// host fn paths).
721
200
pub fn decode_eval_result(
722
200
    mut store: impl AsContextMut,
723
200
    value: Option<Rooted<AnyRef>>,
724
200
    result_ty: Option<nomiscript::WasmType>,
725
200
) -> wasmtime::Result<EvalValue> {
726
200
    let Some(ty) = result_ty else {
727
8
        return Ok(EvalValue::Nil);
728
    };
729
    // Reference-typed results can legitimately be null: empty
730
    // `pair<…>` from `list-accounts`, `Option<Rooted<…>>` returns
731
    // surfacing not-found / missing-string cases. Surface those as
732
    // `Nil` so consumers see an empty-shaped value rather than a
733
    // trap. Primitive (I32) returns can't be null and stay strict.
734
192
    let Some(any) = value else {
735
14
        return match ty {
736
            nomiscript::WasmType::I32 => Err(wasmtime::Error::msg(
737
                "nomi-eval returned null for declared result type i32",
738
            )),
739
7
            nomiscript::WasmType::PairRef(_) => Ok(EvalValue::String("()".into())),
740
7
            _ => Ok(EvalValue::Nil),
741
        };
742
    };
743
178
    decode_anyref(&mut store, any, ty)
744
200
}
745

            
746
178
fn decode_anyref(
747
178
    mut store: impl AsContextMut,
748
178
    any: Rooted<AnyRef>,
749
178
    ty: nomiscript::WasmType,
750
178
) -> wasmtime::Result<EvalValue> {
751
    use nomiscript::WasmType;
752
178
    match ty {
753
        WasmType::I32 => {
754
54
            let i31 = any
755
54
                .unwrap_i31(&mut store)
756
54
                .map_err(|err| wasmtime::Error::msg(format!("expected i31, got {err}")))?;
757
54
            Ok(EvalValue::I32(i31.get_i32()))
758
        }
759
        WasmType::Bool => {
760
            // A boolean result is i31-boxed like an i32, but decodes to the
761
            // falsy-nil / truthy-bool pair nomiscript uses (matches the
762
            // const-fold path): 0 → Nil, nonzero → Bool(true).
763
13
            let i31 = any
764
13
                .unwrap_i31(&mut store)
765
13
                .map_err(|err| wasmtime::Error::msg(format!("expected i31, got {err}")))?;
766
13
            if i31.get_i32() == 0 {
767
8
                Ok(EvalValue::Nil)
768
            } else {
769
5
                Ok(EvalValue::Bool(true))
770
            }
771
        }
772
        WasmType::Ratio => {
773
5
            let s = any.unwrap_struct(&mut store)?;
774
5
            let numer = s
775
5
                .field(&mut store, 0)?
776
5
                .i64()
777
5
                .ok_or_else(|| wasmtime::Error::msg("ratio field 0 (numer) is not i64"))?;
778
5
            let denom = s
779
5
                .field(&mut store, 1)?
780
5
                .i64()
781
5
                .ok_or_else(|| wasmtime::Error::msg("ratio field 1 (denom) is not i64"))?;
782
5
            Ok(EvalValue::Ratio { numer, denom })
783
        }
784
        WasmType::Commodity => {
785
7
            let s = any.unwrap_struct(&mut store)?;
786
7
            let numer = s.field(&mut store, 0)?.i64().unwrap_or(0);
787
7
            let denom = s.field(&mut store, 1)?.i64().unwrap_or(1);
788
            // Field 4 is the unit term (ADR-0028). Null ⇒ ATOMIC single-currency
789
            // money (id in fields 2-3). An empty term ⇒ DIMENSIONLESS (money ÷
790
            // money, same currency) and decodes as a plain Number. A non-empty
791
            // (compound) term — e.g. money×money — has no host wire form yet.
792
7
            match s.field(&mut store, 4)? {
793
                Val::AnyRef(None) => {
794
5
                    let hi = s.field(&mut store, 2)?.i64().unwrap_or(0);
795
5
                    let lo = s.field(&mut store, 3)?.i64().unwrap_or(0);
796
5
                    Ok(EvalValue::Commodity {
797
5
                        numer,
798
5
                        denom,
799
5
                        commodity_hi: hi,
800
5
                        commodity_lo: lo,
801
5
                    })
802
                }
803
2
                Val::AnyRef(Some(term)) => {
804
2
                    let arr = term.unwrap_array(&mut store)?;
805
2
                    if arr.len(&mut store)? == 0 {
806
1
                        Ok(EvalValue::Ratio { numer, denom })
807
                    } else {
808
1
                        Err(wasmtime::Error::msg(
809
1
                            "compound commodity (e.g. money × money) has no host \
810
1
                             representation yet",
811
1
                        ))
812
                    }
813
                }
814
                _ => Err(wasmtime::Error::msg(
815
                    "commodity field 4 (unit term) is not a ref",
816
                )),
817
            }
818
        }
819
        WasmType::StringRef => {
820
95
            let arr = any.unwrap_array(&mut store)?;
821
95
            let len = arr.len(&mut store)?;
822
95
            let mut bytes = Vec::with_capacity(len as usize);
823
3851
            for i in 0..len {
824
3851
                let v = arr.get(&mut store, i)?;
825
3851
                let byte = v
826
3851
                    .i32()
827
3851
                    .ok_or_else(|| wasmtime::Error::msg("string element is not i32"))?;
828
3851
                bytes.push(byte as u8);
829
            }
830
95
            let s = String::from_utf8(bytes)
831
95
                .map_err(|err| wasmtime::Error::msg(format!("not valid utf-8: {err}")))?;
832
95
            Ok(EvalValue::String(s))
833
        }
834
4
        WasmType::PairRef(elem) => {
835
4
            let head = render_pair_as_string(&mut store, any, elem)?;
836
3
            Ok(EvalValue::String(head))
837
        }
838
        WasmType::EntityRef(kind) => {
839
            let entity = any.unwrap_struct(&mut store)?;
840
            Ok(EvalValue::String(render_entity(&mut store, entity, kind)?))
841
        }
842
        WasmType::Closure(_) => {
843
            let _ = any;
844
            Ok(EvalValue::String("<closure>".into()))
845
        }
846
        WasmType::AnyRef => {
847
            // Heterogeneous payload (catch-each result cells, etc.).
848
            // The host renderer cannot statically pick a decoder, so it
849
            // surfaces a placeholder; the script-side accessor natives
850
            // (`ok?` / `err-code` / etc.) are responsible for inspecting
851
            // the contents.
852
            let _ = any;
853
            Ok(EvalValue::String("<anyref>".into()))
854
        }
855
    }
856
178
}
857

            
858
/// Walks a `$pair` chain and renders it as a Lisp-style list-of-cars
859
/// textual form `( <car> <car> ... )`. Element decoding dispatches on
860
/// the compile-time PairElement so each car gets its proper formatter.
861
4
fn render_pair_as_string(
862
4
    mut store: impl AsContextMut,
863
4
    head_any: Rooted<AnyRef>,
864
4
    elem: nomiscript::PairElement,
865
4
) -> wasmtime::Result<String> {
866
4
    let mut out = String::from("(");
867
4
    let mut cur: Option<Rooted<StructRef>> = Some(head_any.unwrap_struct(&mut store)?);
868
4
    let mut first = true;
869
11
    while let Some(node) = cur {
870
8
        if !first {
871
4
            out.push(' ');
872
7
        }
873
8
        first = false;
874
8
        let car_val = node.field(&mut store, 0)?;
875
8
        let car_any = match car_val {
876
8
            Val::AnyRef(Some(a)) => a,
877
            Val::AnyRef(None) => {
878
                out.push_str("nil");
879
                let cdr_val = node.field(&mut store, 1)?;
880
                cur = match cdr_val {
881
                    Val::AnyRef(Some(a)) => Some(a.unwrap_struct(&mut store)?),
882
                    _ => None,
883
                };
884
                continue;
885
            }
886
            _ => {
887
                return Err(wasmtime::Error::msg("pair car is not anyref"));
888
            }
889
        };
890
8
        let car_str = render_car(&mut store, car_any, elem)?;
891
7
        out.push_str(&car_str);
892
7
        let cdr_val = node.field(&mut store, 1)?;
893
7
        cur = match cdr_val {
894
4
            Val::AnyRef(Some(a)) => Some(a.unwrap_struct(&mut store)?),
895
3
            _ => None,
896
        };
897
    }
898
3
    out.push(')');
899
3
    Ok(out)
900
4
}
901

            
902
8
fn render_car(
903
8
    mut store: impl AsContextMut,
904
8
    car_any: Rooted<AnyRef>,
905
8
    elem: nomiscript::PairElement,
906
8
) -> wasmtime::Result<String> {
907
    use nomiscript::PairElement;
908
8
    match elem {
909
        PairElement::I32 => {
910
            let i31 = car_any.unwrap_i31(&mut store)?;
911
            Ok(i31.get_i32().to_string())
912
        }
913
        PairElement::Bool => {
914
            // Shares the i31 car with I32, but renders as a truth value: 0 →
915
            // `nil`, nonzero → `t` (matching the bool/nil wire convention).
916
            let i31 = car_any.unwrap_i31(&mut store)?;
917
            Ok(if i31.get_i32() == 0 { "nil" } else { "t" }.to_string())
918
        }
919
        PairElement::Ratio => {
920
            let s = car_any.unwrap_struct(&mut store)?;
921
            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
922
            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
923
            if d == 1 {
924
                Ok(n.to_string())
925
            } else {
926
                Ok(format!("{n}/{d}"))
927
            }
928
        }
929
        PairElement::Commodity => {
930
2
            let s = car_any.unwrap_struct(&mut store)?;
931
2
            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
932
2
            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
933
            // Field 4 (unit term, ADR-0028) decides the wire form — SAME rules
934
            // as the top-level `decode_anyref` Commodity arm, so a compound
935
            // money riding a `$pair` cell can't slip through as id-zero atomic
936
            // money: null ⇒ atomic (id in fields 2-3), empty ⇒ dimensionless
937
            // Number, non-empty (compound) ⇒ error (no host wire form yet).
938
2
            match s.field(&mut store, 4)? {
939
                Val::AnyRef(None) => {
940
1
                    let hi = s.field(&mut store, 2)?.i64().unwrap_or(0);
941
1
                    let lo = s.field(&mut store, 3)?.i64().unwrap_or(0);
942
1
                    let raw = ((hi as u64 as u128) << 64) | (lo as u64 as u128);
943
1
                    let id = Uuid::from_u128(raw);
944
1
                    if d == 1 {
945
1
                        Ok(format!("(:commodity {n} :id \"{id}\")"))
946
                    } else {
947
                        Ok(format!("(:commodity {n}/{d} :id \"{id}\")"))
948
                    }
949
                }
950
1
                Val::AnyRef(Some(term)) => {
951
1
                    let arr = term.unwrap_array(&mut store)?;
952
1
                    if arr.len(&mut store)? == 0 {
953
                        Ok(if d == 1 {
954
                            n.to_string()
955
                        } else {
956
                            format!("{n}/{d}")
957
                        })
958
                    } else {
959
1
                        Err(wasmtime::Error::msg(
960
1
                            "compound commodity (e.g. money × money) has no host \
961
1
                             representation yet",
962
1
                        ))
963
                    }
964
                }
965
                _ => Err(wasmtime::Error::msg(
966
                    "commodity field 4 (unit term) is not a ref",
967
                )),
968
            }
969
        }
970
        PairElement::StringRef => {
971
            let arr = car_any.unwrap_array(&mut store)?;
972
            let len = arr.len(&mut store)?;
973
            let mut bytes = Vec::with_capacity(len as usize);
974
            for i in 0..len {
975
                let v = arr.get(&mut store, i)?;
976
                bytes.push(v.i32().unwrap_or(0) as u8);
977
            }
978
            let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
979
            Ok(format!("\"{s}\""))
980
        }
981
        PairElement::Entity(kind) => {
982
            let entity = car_any.unwrap_struct(&mut store)?;
983
            render_entity(&mut store, entity, kind)
984
        }
985
6
        PairElement::AnyRef => Ok("<anyref>".into()),
986
    }
987
8
}
988

            
989
/// Renders a typed entity struct as a readable plist —
990
/// `(:commodity :id "…" :symbol "USD" :name "US Dollar")` — by reading each
991
/// field at its `struct.get` slot per the single-source-of-truth
992
/// [`nomiscript::entity_layout`] (generated from `entity_registry.org`). Field
993
/// slot order in the layout matches the wasm struct exactly. A `Pair` field
994
/// (the recursive `report_node.children`) renders as `()`-elided rather than
995
/// walking the tree, since the host renderer has no element-type context there.
996
1
fn render_entity(
997
1
    mut store: impl AsContextMut,
998
1
    entity: Rooted<StructRef>,
999
1
    kind: nomiscript::EntityKind,
1
) -> wasmtime::Result<String> {
    use nomiscript::EntityFieldKind;
1
    let Some(layout) = nomiscript::entity_layout(kind) else {
        // No field layout (e.g. Condition isn't a server entity).
        return Ok(format!("(:{kind:?})"));
    };
1
    let mut out = format!("(:{}", layout.label);
3
    for (slot, field) in layout.fields.iter().enumerate() {
3
        let rendered = match field.kind {
3
            EntityFieldKind::String => read_string_slot(&mut store, entity, slot)?,
            EntityFieldKind::Ratio => read_ratio_slot(&mut store, entity, slot)?,
            EntityFieldKind::I32 => entity
                .field(&mut store, slot)?
                .i32()
                .unwrap_or(0)
                .to_string(),
            // Recursive child list (report_node.children): no element-type
            // context here, so elide rather than mis-decode.
            EntityFieldKind::Pair => "(...)".to_string(),
        };
3
        out.push_str(&format!(" :{} {rendered}", field.name));
    }
1
    out.push(')');
1
    Ok(out)
1
}
/// Reads a `(ref null $i8_array)` string field at `slot`, rendered as a quoted
/// literal. A null/empty slot renders as `""`.
3
fn read_string_slot(
3
    mut store: impl AsContextMut,
3
    entity: Rooted<StructRef>,
3
    slot: usize,
3
) -> wasmtime::Result<String> {
3
    match entity.field(&mut store, slot)? {
3
        Val::AnyRef(Some(a)) => {
3
            let arr = a.unwrap_array(&mut store)?;
3
            let len = arr.len(&mut store)?;
3
            let mut bytes = Vec::with_capacity(len as usize);
20
            for i in 0..len {
20
                bytes.push(arr.get(&mut store, i)?.i32().unwrap_or(0) as u8);
            }
3
            let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
3
            Ok(format!("\"{s}\""))
        }
        _ => Ok("\"\"".to_string()),
    }
3
}
/// Reads a `(ref null $ratio)` field at `slot` (i64 numer/denom in slots 0/1 of
/// the ratio struct), rendered as `n` or `n/d`. A null slot renders as `0`.
fn read_ratio_slot(
    mut store: impl AsContextMut,
    entity: Rooted<StructRef>,
    slot: usize,
) -> wasmtime::Result<String> {
    match entity.field(&mut store, slot)? {
        Val::AnyRef(Some(a)) => {
            let s = a.unwrap_struct(&mut store)?;
            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
            Ok(if d == 1 {
                n.to_string()
            } else {
                format!("{n}/{d}")
            })
        }
        _ => Ok("0".to_string()),
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
1
    fn err_code_uses_script_raised_symbol_verbatim() {
1
        let (code, msg) = err_code_and_message(&EngineError::ScriptRaised {
1
            code: "no-such-account".to_string(),
1
            message: "id=42".to_string(),
1
        });
1
        assert_eq!(code, "no-such-account");
1
        assert_eq!(msg, "id=42");
1
    }
    #[test]
1
    fn err_code_maps_commodity_mismatch_script_raise_to_symbol() {
        // Commodity mismatch now `throw`s `$nomi_error` in-guest (ADR-0026);
        // uncaught, the boundary wrapper bridges it to `__nomi_raise` and the
        // classifier yields `ScriptRaised`. The code is the reader-folded
        // (upper-cased) symbol `COMMODITY-MISMATCH`, like any script raise —
        // `err_code_and_message` passes a `ScriptRaised` code through verbatim.
1
        let (code, msg) = err_code_and_message(&EngineError::ScriptRaised {
1
            code: "COMMODITY-MISMATCH".to_string(),
1
            message: "USD vs EUR".to_string(),
1
        });
1
        assert_eq!(code, "COMMODITY-MISMATCH");
1
        assert_eq!(msg, "USD vs EUR");
1
    }
    #[test]
1
    fn err_code_maps_no_conversion_to_kebab_symbol() {
1
        let (code, msg) =
1
            err_code_and_message(&EngineError::NoConversion("missing price".to_string()));
1
        assert_eq!(code, "no-conversion");
1
        assert_eq!(msg, "missing price");
1
    }
    #[test]
1
    fn err_code_falls_back_to_runtime_for_generic_traps() {
1
        let (code, msg) = err_code_and_message(&EngineError::Trap("oops".to_string()));
1
        assert_eq!(code, "runtime");
1
        assert_eq!(msg, "oops");
1
    }
    #[test]
1
    fn err_code_maps_out_of_fuel_to_runtime_with_diagnostic_message() {
1
        let (code, msg) = err_code_and_message(&EngineError::OutOfFuel);
1
        assert_eq!(code, "runtime");
1
        assert_eq!(msg, "fuel exhausted");
1
    }
3
    fn store_with_fuel<T: Default>(engine: &Engine, fuel: u64) -> Store<T> {
3
        let mut store = Store::new(engine, T::default());
3
        store
3
            .set_fuel(fuel)
3
            .expect("set_fuel must succeed for fresh store");
3
        store.set_epoch_deadline(1);
3
        store
3
    }
    #[test]
1
    fn baseline_engine_omits_fuel() {
1
        let opts = EngineOpts::baseline();
1
        assert!(!opts.fuel);
1
        let _engine = build_engine(opts).expect("baseline engine must build");
1
    }
    #[test]
1
    fn with_fuel_engine_supports_set_fuel() {
1
        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1
        let mut store: Store<()> = Store::new(&engine, ());
1
        store
1
            .set_fuel(1_000)
1
            .expect("set_fuel works only when consume_fuel is on");
1
    }
    #[test]
1
    fn module_cache_returns_same_module_for_same_bytecode() {
1
        let engine = build_engine(EngineOpts::baseline()).unwrap();
1
        let cache = ModuleCache::new();
1
        let wat = r#"(module (func (export "answer") (result i64) (i64.const 42)))"#;
1
        let bytes = wat::parse_str(wat).unwrap();
1
        assert_eq!(cache.len().unwrap(), 0);
1
        let _first = cache.get_or_compile(&engine, &bytes).unwrap();
1
        assert_eq!(cache.len().unwrap(), 1);
1
        let _second = cache.get_or_compile(&engine, &bytes).unwrap();
1
        assert_eq!(cache.len().unwrap(), 1);
1
    }
    #[test]
1
    fn module_cache_clones_share_storage() {
1
        let engine = build_engine(EngineOpts::baseline()).unwrap();
1
        let cache_a = ModuleCache::new();
1
        let cache_b = cache_a.clone();
1
        let wat = r#"(module (func (export "answer") (result i64) (i64.const 42)))"#;
1
        let bytes = wat::parse_str(wat).unwrap();
1
        let _ = cache_a.get_or_compile(&engine, &bytes).unwrap();
1
        assert_eq!(cache_b.len().unwrap(), 1);
1
    }
    #[test]
1
    fn runs_trivial_i64_export() {
1
        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1
        let module = compile_wat(
1
            &engine,
1
            r#"(module (func (export "answer") (result i64) (i64.const 42)))"#,
        )
1
        .unwrap();
1
        let mut store: Store<()> = store_with_fuel(&engine, 100_000);
1
        let result = call_i64_export(&engine, &mut store, &module, "answer").unwrap();
1
        assert_eq!(result, 42);
1
    }
    #[test]
1
    fn missing_export_returns_typed_error() {
1
        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1
        let module = compile_wat(
1
            &engine,
1
            r#"(module (func (export "answer") (result i64) (i64.const 42)))"#,
        )
1
        .unwrap();
1
        let mut store: Store<()> = store_with_fuel(&engine, 100_000);
1
        let err = call_i64_export(&engine, &mut store, &module, "missing").unwrap_err();
1
        assert!(matches!(err, EngineError::MissingExport(name) if name == "missing"));
1
    }
    #[test]
1
    fn fuel_exhaustion_yields_typed_error() {
1
        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1
        let module = compile_wat(
1
            &engine,
1
            r#"
1
            (module
1
              (func (export "spin") (result i64)
1
                (loop (br 0))
1
                (i64.const 0)))
1
            "#,
        )
1
        .unwrap();
1
        let mut store: Store<()> = store_with_fuel(&engine, 1_000);
1
        let err = call_i64_export(&engine, &mut store, &module, "spin").unwrap_err();
1
        assert!(matches!(err, EngineError::OutOfFuel), "got: {err:?}");
1
    }
    #[test]
1
    fn epoch_interrupt_yields_typed_error() {
1
        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1
        let module = compile_wat(
1
            &engine,
1
            r#"
1
            (module
1
              (func (export "spin") (result i64)
1
                (loop (br 0))
1
                (i64.const 0)))
1
            "#,
        )
1
        .unwrap();
1
        let mut store: Store<()> = Store::new(&engine, ());
1
        store.set_fuel(1_000_000_000).unwrap();
1
        store.set_epoch_deadline(1);
1
        engine.increment_epoch();
1
        engine.increment_epoch();
1
        let err = call_i64_export(&engine, &mut store, &module, "spin").unwrap_err();
1
        assert!(
1
            matches!(err, EngineError::EpochInterrupt | EngineError::OutOfFuel),
            "got: {err:?}"
        );
1
    }
    #[test]
1
    fn malformed_module_bytes_yield_compile_error() {
1
        let engine = build_engine(EngineOpts::baseline()).unwrap();
1
        let err = compile_module(&engine, b"not wasm bytes").unwrap_err();
1
        assert!(matches!(err, EngineError::Compile(_)));
1
    }
    #[tokio::test(flavor = "current_thread")]
1
    async fn alloc_pair_chain_builds_list_head_in_order() {
        use wasmtime::I31;
        // Self-recursive $pair shape matching `CompileContext::new_skeleton`.
        // The module exports `pair_new` (the helper alloc_pair_chain re-enters
        // for each element) and a `go` entry that asks the test host fn for a
        // 3-element chain, then walks it to confirm the host-built structure.
1
        let wat = r#"
1
        (module
1
          (rec
1
            (type $pair (struct (field anyref) (field (ref null $pair)))))
1
          (import "test" "make_chain"
1
            (func $make_chain (result (ref null struct))))
1
          (func $pair_new (export "pair_new")
1
            (param $car anyref) (param $cdr (ref null $pair))
1
            (result (ref null $pair))
1
            (struct.new $pair (local.get $car) (local.get $cdr)))
1
          (func $length (param $head (ref null $pair)) (result i32)
1
            (local $count i32)
1
            (block $exit
1
              (loop $more
1
                (br_if $exit (ref.is_null (local.get $head)))
1
                (local.set $count (i32.add (local.get $count) (i32.const 1)))
1
                (local.set $head
1
                  (struct.get $pair 1 (local.get $head)))
1
                (br $more)))
1
            (local.get $count))
1
          (func (export "go") (result i32)
1
            (local $head (ref null $pair))
1
            (local.set $head
1
              (ref.cast (ref null $pair) (call $make_chain)))
1
            (call $length (local.get $head))))
1
        "#;
1
        let engine = build_engine(EngineOpts::baseline()).unwrap();
1
        let module = compile_wat(&engine, wat).unwrap();
1
        let mut linker: Linker<()> = Linker::new(&engine);
1
        linker
1
            .func_wrap_async("test", "make_chain", |mut caller: Caller<'_, ()>, ()| {
1
                Box::new(async move {
1
                    let items: Vec<Rooted<AnyRef>> = (0..3)
3
                        .map(|i| AnyRef::from_i31(caller.as_context_mut(), I31::wrapping_u32(i)))
1
                        .collect();
1
                    alloc_pair_chain(&mut caller, items).await
1
                })
1
            })
1
            .unwrap();
1
        let mut store: Store<()> = Store::new(&engine, ());
1
        store.set_epoch_deadline(1_000);
1
        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1
        let go = instance.get_func(&mut store, "go").unwrap();
1
        let mut results = [Val::I32(0)];
1
        go.call_async(&mut store, &[], &mut results).await.unwrap();
1
        assert_eq!(results[0].i32(), Some(3));
1
    }
    #[tokio::test(flavor = "current_thread")]
1
    async fn alloc_pair_chain_errors_without_pair_new_export() {
        use wasmtime::Func;
        // No `pair_new` export — the host fn must surface the missing-export
        // contract violation rather than panic or silently succeed.
1
        let wat = r#"
1
        (module
1
          (import "test" "try_chain"
1
            (func $try))
1
          (func (export "go") (call $try)))
1
        "#;
1
        let engine = build_engine(EngineOpts::baseline()).unwrap();
1
        let module = compile_wat(&engine, wat).unwrap();
1
        let mut linker: Linker<()> = Linker::new(&engine);
1
        linker
1
            .func_wrap_async("test", "try_chain", |mut caller: Caller<'_, ()>, ()| {
1
                Box::new(async move {
1
                    let empty: Vec<Rooted<AnyRef>> = Vec::new();
1
                    let result = alloc_pair_chain(&mut caller, empty).await;
1
                    match result {
1
                        Err(e) => {
1
                            let msg = e.to_string();
1
                            assert!(
1
                                msg.contains("pair_new"),
                                "expected pair_new-missing error, got: {msg}"
                            );
1
                            Ok(())
                        }
                        Ok(_) => Err(wasmtime::Error::msg(
                            "alloc_pair_chain unexpectedly succeeded without pair_new",
                        )),
                    }
1
                })
1
            })
1
            .unwrap();
1
        let mut store: Store<()> = Store::new(&engine, ());
1
        store.set_epoch_deadline(1_000);
1
        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1
        let go: Func = instance.get_func(&mut store, "go").unwrap();
1
        let mut results: [Val; 0] = [];
1
        go.call_async(&mut store, &[], &mut results).await.unwrap();
1
    }
    #[tokio::test(flavor = "current_thread")]
1
    async fn alloc_commodity_ref_builds_atomic_via_reentry() {
        // ADR-0028 E0: the host builds a commodity by re-entering the module's
        // exported `commodity_new`, which writes a NULL unit-term (= atomic).
        // The `$commodity` shape matches `CompileContext::new_skeleton` (5
        // fields, the 5th a `(ref null $unit_term)`). `go` asks the host for a
        // 7/2 commodity with UUID hi=1/lo=2, then reads numer, hi, lo, and
        // whether the term is null.
1
        let wat = r#"
1
        (module
1
          (type $unit_term (array (mut i64)))
1
          (type $commodity
1
            (struct (field i64) (field i64) (field i64) (field i64)
1
                    (field (ref null $unit_term))))
1
          (import "test" "make_commodity"
1
            (func $make_commodity (result (ref null struct))))
1
          (func $commodity_new (export "commodity_new")
1
            (param $n i64) (param $d i64) (param $hi i64) (param $lo i64)
1
            (result (ref $commodity))
1
            (struct.new $commodity
1
              (local.get $n) (local.get $d) (local.get $hi) (local.get $lo)
1
              (ref.null $unit_term)))
1
          (func (export "go") (result i64 i64 i64 i32)
1
            (local $c (ref $commodity))
1
            (local.set $c
1
              (ref.cast (ref $commodity) (call $make_commodity)))
1
            (struct.get $commodity 0 (local.get $c))
1
            (struct.get $commodity 2 (local.get $c))
1
            (struct.get $commodity 3 (local.get $c))
1
            (ref.is_null (struct.get $commodity 4 (local.get $c)))))
1
        "#;
1
        let engine = build_engine(EngineOpts::baseline()).unwrap();
1
        let module = compile_wat(&engine, wat).unwrap();
1
        let mut linker: Linker<()> = Linker::new(&engine);
1
        linker
1
            .func_wrap_async(
1
                "test",
1
                "make_commodity",
1
                |mut caller: Caller<'_, ()>, ()| {
1
                    Box::new(async move {
1
                        let id = Uuid::from_u128((1u128 << 64) | 2u128);
1
                        Ok(Some(alloc_commodity_ref(&mut caller, 7, 2, id).await?))
1
                    })
1
                },
            )
1
            .unwrap();
1
        let mut store: Store<()> = Store::new(&engine, ());
1
        store.set_epoch_deadline(1_000);
1
        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1
        let go = instance.get_func(&mut store, "go").unwrap();
1
        let mut results = [Val::I64(0), Val::I64(0), Val::I64(0), Val::I32(0)];
1
        go.call_async(&mut store, &[], &mut results).await.unwrap();
1
        assert_eq!(results[0].i64(), Some(7), "numer");
1
        assert_eq!(results[1].i64(), Some(1), "commodity_hi");
1
        assert_eq!(results[2].i64(), Some(2), "commodity_lo");
1
        assert_eq!(results[3].i32(), Some(1), "atomic ⇒ null unit-term");
1
    }
    #[tokio::test(flavor = "current_thread")]
1
    async fn alloc_commodity_ref_errors_without_commodity_new_export() {
        use wasmtime::Func;
        // No `commodity_new` export — the host fn must surface the missing-
        // export contract violation rather than panic or silently succeed.
1
        let wat = r#"
1
        (module
1
          (import "test" "try_make"
1
            (func $try))
1
          (func (export "go") (call $try)))
1
        "#;
1
        let engine = build_engine(EngineOpts::baseline()).unwrap();
1
        let module = compile_wat(&engine, wat).unwrap();
1
        let mut linker: Linker<()> = Linker::new(&engine);
1
        linker
1
            .func_wrap_async("test", "try_make", |mut caller: Caller<'_, ()>, ()| {
1
                Box::new(async move {
1
                    let id = Uuid::from_u128(0);
1
                    match alloc_commodity_ref(&mut caller, 1, 1, id).await {
1
                        Err(e) => {
1
                            let msg = e.to_string();
1
                            assert!(
1
                                msg.contains("commodity_new"),
                                "expected commodity_new-missing error, got: {msg}"
                            );
1
                            Ok(())
                        }
                        Ok(_) => Err(wasmtime::Error::msg(
                            "alloc_commodity_ref unexpectedly succeeded without commodity_new",
                        )),
                    }
1
                })
1
            })
1
            .unwrap();
1
        let mut store: Store<()> = Store::new(&engine, ());
1
        store.set_epoch_deadline(1_000);
1
        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1
        let go: Func = instance.get_func(&mut store, "go").unwrap();
1
        let mut results: [Val; 0] = [];
1
        go.call_async(&mut store, &[], &mut results).await.unwrap();
1
    }
    #[test]
1
    fn unit_term_algebra_merges_sorts_and_cancels() {
        use nomiscript::{Compiler, Reader, SymbolTable};
15
        fn ar(a: Rooted<AnyRef>) -> Val {
15
            Val::AnyRef(Some(a))
15
        }
        // Compile a trivial program just to obtain the skeleton, which exports
        // the unit-term helpers. Then exercise the sorted-merge directly: it is
        // the riskiest hand-written wasm in ADR-0028 E1/E2.
1
        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1
        let mut compiler = Compiler::new();
1
        let mut symbols = SymbolTable::with_builtins();
1
        let program = Reader::parse("0").unwrap();
1
        let (bytes, _) = compiler
1
            .compile_eval_with_type(&program, &mut symbols)
1
            .expect("eval compile");
1
        let module = compile_module(&engine, &bytes).expect("module");
1
        let mut linker: Linker<()> = Linker::new(&engine);
1
        link_nomi_raise_stub(&mut linker, &engine);
1
        let mut store: Store<()> = Store::new(&engine, ());
1
        store.set_fuel(100_000_000).unwrap();
1
        store.set_epoch_deadline(1);
1
        let instance = linker.instantiate(&mut store, &module).unwrap();
7
        let call_ref = |store: &mut Store<()>, name: &str, args: &[Val]| -> Rooted<AnyRef> {
7
            let f = instance.get_func(&mut *store, name).unwrap();
7
            let mut res = [Val::AnyRef(None)];
7
            f.call(&mut *store, args, &mut res).unwrap();
7
            match &res[0] {
7
                Val::AnyRef(Some(a)) => *a,
                other => panic!("{name} returned {other:?}"),
            }
7
        };
7
        let read_term = |store: &mut Store<()>, t: Rooted<AnyRef>| -> Vec<i64> {
7
            let arr = t.unwrap_array(&mut *store).unwrap();
7
            let len = arr.len(&mut *store).unwrap();
7
            (0..len)
24
                .map(|i| arr.get(&mut *store, i).unwrap().i64().unwrap())
7
                .collect()
7
        };
2
        let singleton = |store: &mut Store<()>, hi: i64, lo: i64| -> Rooted<AnyRef> {
2
            call_ref(store, "unit_singleton", &[Val::I64(hi), Val::I64(lo)])
2
        };
1
        let usd = singleton(&mut store, 10, 20);
1
        let eur = singleton(&mut store, 30, 40);
1
        assert_eq!(read_term(&mut store, usd), vec![10, 20, 1]);
1
        assert_eq!(read_term(&mut store, eur), vec![30, 40, 1]);
        // Disjoint merge is sorted by (hi,lo), order-independent.
1
        let usd_eur = call_ref(&mut store, "unit_mul", &[ar(usd), ar(eur)]);
1
        assert_eq!(read_term(&mut store, usd_eur), vec![10, 20, 1, 30, 40, 1]);
1
        let eur_usd = call_ref(&mut store, "unit_mul", &[ar(eur), ar(usd)]);
1
        assert_eq!(read_term(&mut store, eur_usd), vec![10, 20, 1, 30, 40, 1]);
        // Matching key sums exponents.
1
        let usd2 = call_ref(&mut store, "unit_mul", &[ar(usd), ar(usd)]);
1
        assert_eq!(read_term(&mut store, usd2), vec![10, 20, 2]);
        // Cancellation drops the zero-exponent entry → empty (dimensionless).
1
        let canceled = call_ref(&mut store, "unit_div", &[ar(usd), ar(usd)]);
1
        assert_eq!(read_term(&mut store, canceled), Vec::<i64>::new());
        // negate flips exponents.
1
        let neg_usd = call_ref(&mut store, "unit_negate", &[ar(usd)]);
1
        assert_eq!(read_term(&mut store, neg_usd), vec![10, 20, -1]);
3
        let eq = |store: &mut Store<()>, a: Rooted<AnyRef>, b: Rooted<AnyRef>| -> i32 {
3
            let f = instance.get_func(&mut *store, "unit_eq").unwrap();
3
            let mut res = [Val::I32(0)];
3
            f.call(&mut *store, &[ar(a), ar(b)], &mut res).unwrap();
3
            res[0].i32().unwrap()
3
        };
1
        assert_eq!(eq(&mut store, usd, usd), 1);
1
        assert_eq!(eq(&mut store, usd, eur), 0);
        // Same multiset, built two ways, compares equal.
1
        assert_eq!(eq(&mut store, usd_eur, eur_usd), 1);
1
    }
    #[tokio::test(flavor = "current_thread")]
1
    async fn compound_money_arithmetic_end_to_end() {
        use nomiscript::{Compiler, HostFnSpec, Reader, SymbolTable, WasmType};
        // Two distinct currencies, each produced atomic at 3/1 by a host fn.
        const USD: u128 = 0x1111_1111_1111_1111_2222_2222_2222_2222;
        const EUR: u128 = 0x3333_3333_3333_3333_4444_4444_4444_4444;
10
        async fn run(src: &str) -> Result<EvalValue, String> {
10
            let specs = vec![
10
                HostFnSpec::new("usd", "test", "usd").returns(WasmType::Commodity),
10
                HostFnSpec::new("eur", "test", "eur").returns(WasmType::Commodity),
10
                HostFnSpec::new("sink", "test", "sink")
10
                    .with_params(vec![WasmType::Commodity])
10
                    .returns(WasmType::I32),
            ];
10
            let program = Reader::parse(src).unwrap();
10
            let mut compiler = Compiler::with_host_fns(specs.clone());
10
            let mut symbols = SymbolTable::with_builtins();
10
            symbols.register_host_fns(&specs);
10
            let (bytes, result_ty) = compiler
10
                .compile_eval_with_type(&program, &mut symbols)
10
                .map_err(|e| e.to_string())?;
10
            let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
10
            let module = compile_module(&engine, &bytes).map_err(|e| format!("{e:?}"))?;
10
            let mut linker: Linker<()> = Linker::new(&engine);
10
            link_nomi_raise_stub(&mut linker, &engine);
10
            linker
19
                .func_wrap_async("test", "usd", |mut caller: Caller<'_, ()>, ()| {
19
                    Box::new(async move {
                        Ok(Some(
19
                            alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(USD)).await?,
                        ))
19
                    })
19
                })
10
                .unwrap();
10
            linker
10
                .func_wrap_async("test", "eur", |mut caller: Caller<'_, ()>, ()| {
1
                    Box::new(async move {
                        Ok(Some(
1
                            alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(EUR)).await?,
                        ))
1
                    })
1
                })
10
                .unwrap();
            // The sink only reaches its body for ATOMIC args — a compound arg is
            // rejected by the `commodity_assert_atomic` guard before this runs.
10
            linker
10
                .func_wrap_async(
10
                    "test",
10
                    "sink",
2
                    |mut caller: Caller<'_, ()>, (arg,): (Option<Rooted<StructRef>>,)| {
2
                        Box::new(async move {
2
                            read_commodity_arg(&mut caller, arg)?;
2
                            Ok(0i32)
2
                        })
2
                    },
                )
10
                .unwrap();
10
            let mut store: Store<()> = Store::new(&engine, ());
10
            store.set_fuel(1_000_000_000).unwrap();
10
            store.set_epoch_deadline(1);
10
            let instance = linker
10
                .instantiate_async(&mut store, &module)
10
                .await
10
                .map_err(|e| format!("{e:?}"))?;
10
            let func = instance.get_func(&mut store, "nomi-eval").unwrap();
10
            let mut results = [Val::AnyRef(None)];
10
            func.call_async(&mut store, &[], &mut results)
10
                .await
10
                .map_err(|e| e.to_string())?;
8
            let any = match &results[0] {
8
                Val::AnyRef(a) => *a,
                _ => return Err("nomi-eval returned non-anyref".to_string()),
            };
8
            decode_eval_result(&mut store, any, result_ty).map_err(|e| e.to_string())
10
        }
        // money ÷ money, same currency → dimensionless → decodes as a Number.
1
        assert_eq!(
1
            run("(/ (usd) (usd))").await,
            Ok(EvalValue::Ratio { numer: 1, denom: 1 })
        );
        // money + money, same currency → atomic money (the null-term fast path).
1
        match run("(+ (usd) (usd))").await {
1
            Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (6, 1)),
            other => panic!("expected atomic commodity 6/1, got {other:?}"),
        }
        // money + money, different currency → COMMODITY-MISMATCH throw.
1
        assert!(run("(+ (usd) (eur))").await.is_err());
        // money × money → compound, no host wire form yet → decode error.
1
        assert!(
1
            run("(* (usd) (usd))")
1
                .await
1
                .unwrap_err()
1
                .contains("compound")
        );
        // an ATOMIC money passes the host-border guard.
1
        assert_eq!(run("(sink (usd))").await, Ok(EvalValue::I32(0)));
        // a COMPOUND money is rejected at the host border by the guard.
1
        assert!(run("(sink (* (usd) (usd)))").await.is_err());
        // a COMPOUND money riding a $pair cell is rejected by the pair-car
        // renderer too — it must NOT slip through as id-zero atomic money
        // (the pair-decode border is field-4-aware, same as the top-level one).
1
        assert!(
1
            run("(list (* (usd) (usd)))")
1
                .await
1
                .unwrap_err()
1
                .contains("compound")
        );
        // an atomic money in a list cell still renders (sanity: the field-4
        // gate doesn't reject the null-term atomic case).
1
        assert!(run("(list (usd))").await.is_ok());
        // dimensionless × atomic → a `[(usd,1)]` singleton term that
        // `commodity_new_with_term` canonicalizes back to ATOMIC usd: it decodes
        // as an atomic commodity (3/1) and passes the host-border atomic guard.
1
        match run("(* (/ (usd) (usd)) (usd))").await {
1
            Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (3, 1)),
1
            other => panic!("expected atomic commodity 3/1, got {other:?}"),
1
        }
1
        assert_eq!(
1
            run("(sink (* (/ (usd) (usd)) (usd)))").await,
1
            Ok(EvalValue::I32(0))
1
        );
1
    }
    /// The eval-mode `CompileContext` declares `nomi.__nomi_raise` for
    /// `(error 'code "msg")` lowering even when no `(error)` form is
    /// present in the program. The host side lives in the rpc crate;
    /// for the scripting-crate runtime tests we link a never-called
    /// stub so `instantiate()` resolves the import.
27
    fn link_nomi_raise_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
27
        linker
27
            .func_new(
27
                "nomi",
27
                "__nomi_raise",
27
                wasmtime::FuncType::new(
27
                    engine,
27
                    [
27
                        wasmtime::ValType::Ref(wasmtime::RefType::ARRAYREF),
27
                        wasmtime::ValType::Ref(wasmtime::RefType::ARRAYREF),
27
                    ],
27
                    [],
                ),
2
                |_, _, _| {
2
                    Err(wasmtime::Error::msg(
2
                        "__nomi_raise stub: not linked in this test",
2
                    ))
2
                },
            )
27
            .unwrap();
27
        link_log_stub(linker, engine);
27
        link_nomi_catch_each_stub(linker, engine);
27
    }
    /// `env.log` `(i32 level, i32 ptr, i32 len) -> ()` stub. Eval-mode modules
    /// import `env.log` (PRINT / DISPLAY / NEWLINE / DEBUG lower to it); a test
    /// linker that instantiates an eval module must define it or instantiation
    /// fails with "unknown import". Production wires it via `scripting::host`
    /// (script mode) / `rpc::natives::env_io` (eval mode); this no-op stub
    /// suffices for tests that don't assert on logged output.
27
    fn link_log_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
27
        linker
27
            .func_new(
27
                "env",
27
                "log",
27
                wasmtime::FuncType::new(
27
                    engine,
27
                    [
27
                        wasmtime::ValType::I32,
27
                        wasmtime::ValType::I32,
27
                        wasmtime::ValType::I32,
27
                    ],
27
                    [],
                ),
                |_, _, _| Ok(()),
            )
27
            .unwrap();
27
    }
    /// Companion to `link_nomi_raise_stub`. The eval compile context now
    /// declares `__nomi_catch_each` up-front (so its import index is
    /// stable before any user host fn is wired — matches `__nomi_raise`'s
    /// shape), so even programs that don't use `(catch-each ...)` still
    /// need the import resolvable at instantiation time. The stub traps
    /// on call so a regression that accidentally invokes catch-each in
    /// these unit tests surfaces loudly rather than silently no-oping.
27
    fn link_nomi_catch_each_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
27
        let abstract_struct =
27
            wasmtime::ValType::Ref(wasmtime::RefType::new(true, wasmtime::HeapType::Struct));
27
        let funcref = wasmtime::ValType::Ref(wasmtime::RefType::FUNCREF);
27
        let anyref = wasmtime::ValType::Ref(wasmtime::RefType::ANYREF);
27
        linker
27
            .func_new(
27
                "nomi",
27
                "__nomi_catch_each",
27
                wasmtime::FuncType::new(
27
                    engine,
27
                    [funcref, anyref, abstract_struct.clone()],
27
                    [abstract_struct],
                ),
                |_, _, _| {
                    Err(wasmtime::Error::msg(
                        "__nomi_catch_each stub: not linked in this test",
                    ))
                },
            )
27
            .unwrap();
27
    }
    /// End-to-end: nomiscript Compiler emits eval-mode bytecode that
    /// returns the form's final value via nomi-eval's `(ref null any)`
    /// return slot, the runtime instantiates it (no capture host fns
    /// linked — they retired in A6.c), and `decode_eval_result` walks
    /// the anyref into the structured `EvalValue` the rest of the host
    /// renders from.
5
    fn run_nomiscript_eval(program: &nomiscript::Program) -> Option<EvalValue> {
        use nomiscript::{Compiler, SymbolTable};
5
        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
5
        let mut compiler = Compiler::new();
5
        let mut symbols = SymbolTable::with_builtins();
5
        let (bytes, result_ty) = compiler
5
            .compile_eval_with_type(program, &mut symbols)
5
            .expect("eval compile");
5
        let module = compile_module(&engine, &bytes).expect("module");
5
        let mut linker: Linker<()> = Linker::new(&engine);
5
        link_nomi_raise_stub(&mut linker, &engine);
5
        let mut store: Store<()> = Store::new(&engine, ());
5
        store.set_fuel(10_000_000).unwrap();
5
        store.set_epoch_deadline(1);
5
        let instance = linker.instantiate(&mut store, &module).unwrap();
5
        let func = instance.get_func(&mut store, "nomi-eval").unwrap();
5
        let mut results = [Val::AnyRef(None)];
5
        func.call(&mut store, &[], &mut results).unwrap();
5
        let any = match &results[0] {
5
            Val::AnyRef(a) => *a,
            _ => panic!("nomi-eval returned non-anyref"),
        };
5
        Some(decode_eval_result(&mut store, any, result_ty).expect("decode"))
5
    }
    #[test]
1
    fn nomiscript_eval_captures_integer_literal() {
        use nomiscript::{Expr, Fraction, Program};
        // ADR-0028: an integer literal is an Index (I32), decoding as `I32`,
        // not the dimensionless `Ratio` it conflated with before the flip.
1
        let program = Program::new(vec![Expr::Number(Fraction::from_integer(7))]);
1
        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(7)));
1
    }
    #[test]
1
    fn nomiscript_eval_captures_arithmetic_result() {
        use nomiscript::{Expr, Fraction, Program};
        // All-integer (Index) arithmetic stays in the Index stratum: `(+ 1 2)`
        // decodes as `I32(3)`.
1
        let program = Program::new(vec![Expr::List(vec![
1
            Expr::Symbol("+".into()),
1
            Expr::Number(Fraction::from_integer(1)),
1
            Expr::Number(Fraction::from_integer(2)),
1
        ])]);
1
        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(3)));
1
    }
    #[test]
1
    fn nomiscript_eval_captures_fractional_result() {
        use nomiscript::{Expr, Fraction, Program};
        // A Scalar operand keeps rational division: `(/ 1/2 2) → 1/4`, decoding
        // as `Ratio`. (All-integer `(/ 1 4)` would be Index `0`.)
1
        let program = Program::new(vec![Expr::List(vec![
1
            Expr::Symbol("/".into()),
1
            Expr::Number(Fraction::new(1, 2)),
1
            Expr::Number(Fraction::from_integer(2)),
1
        ])]);
1
        assert_eq!(
1
            run_nomiscript_eval(&program),
            Some(EvalValue::Ratio { numer: 1, denom: 4 })
        );
1
    }
    #[test]
1
    fn nomiscript_eval_captures_nil_for_empty_program() {
1
        let program = nomiscript::Program::default();
1
        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::Nil));
1
    }
    #[test]
1
    fn nomiscript_eval_decodes_bool_as_bool() {
        use nomiscript::{Expr, Program};
1
        let program = Program::new(vec![Expr::Bool(true)]);
        // `#t` carries `WasmType::Bool` (i31-boxed); the decoder surfaces a
        // truthy bool as `Bool(true)` (a falsy one would be `Nil`), not the
        // raw integer the old i32-conflated path produced.
1
        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::Bool(true)));
1
    }
    /// Drift-detector: compile-eval each script source, run it, and
    /// verify the static `result_ty` hint reported by
    /// `compile_eval_with_type` matches the decoded `EvalValue`'s
    /// variant. Catches eval-vs-codegen drift across the compiler —
    /// the exact bug class that produced the tag-sync test regressions.
    /// Add a row whenever a new WasmType or Expr-shape is supported.
    #[test]
1
    fn nomiscript_eval_type_hint_matches_value_variant() {
        use nomiscript::{Compiler, Program, Reader, SymbolTable, WasmType};
1
        let cases: &[(&str, Option<WasmType>)] = &[
1
            // ADR-0028: integer literals + all-integer arithmetic are Index
1
            // (I32); a fractional literal is Scalar (Ratio).
1
            ("42", Some(WasmType::I32)),
1
            ("(+ 1 2)", Some(WasmType::I32)),
1
            ("(/ 1 4)", Some(WasmType::I32)),
1
            ("(/ 1/2 2)", Some(WasmType::Ratio)),
1
            ("(= 1 1)", Some(WasmType::Bool)),
1
            ("(< 1 2)", Some(WasmType::Bool)),
1
            ("#t", Some(WasmType::Bool)),
1
            ("\"hello\"", Some(WasmType::StringRef)),
1
            ("(let ((x 1)) (+ x 1))", Some(WasmType::I32)),
1
            ("(let ((x 1)) \"tail\")", Some(WasmType::StringRef)),
1
            ("(if (= 1 1) 2 3)", Some(WasmType::I32)),
1
        ];
11
        for (src, expected_ty) in cases {
11
            let program: Program = Reader::parse(src).expect("parse");
11
            let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
11
            let mut compiler = Compiler::new();
11
            let mut symbols = SymbolTable::with_builtins();
11
            let (bytes, result_ty) = compiler
11
                .compile_eval_with_type(&program, &mut symbols)
11
                .unwrap_or_else(|e| panic!("compile {src:?}: {e}"));
11
            assert_eq!(
11
                &result_ty, expected_ty,
                "compile_eval_with_type reported wrong static type for {src:?}",
            );
11
            let module = compile_module(&engine, &bytes).expect("module");
11
            let mut linker: Linker<()> = Linker::new(&engine);
11
            link_nomi_raise_stub(&mut linker, &engine);
11
            let mut store: Store<()> = Store::new(&engine, ());
11
            store.set_fuel(10_000_000).unwrap();
11
            store.set_epoch_deadline(1);
11
            let instance = linker.instantiate(&mut store, &module).unwrap();
11
            let func = instance.get_func(&mut store, "nomi-eval").unwrap();
11
            let mut results = [Val::AnyRef(None)];
11
            func.call(&mut store, &[], &mut results)
11
                .unwrap_or_else(|e| panic!("run {src:?}: {e}"));
11
            let any = match &results[0] {
11
                Val::AnyRef(a) => *a,
                _ => panic!("nomi-eval returned non-anyref for {src:?}"),
            };
11
            let decoded = decode_eval_result(&mut store, any, result_ty)
11
                .unwrap_or_else(|e| panic!("decode {src:?}: {e}"));
            // The mapping below is the canonical EvalValue ↔ WasmType
            // contract. Any drift fails here.
11
            let ok = matches!(
11
                (&result_ty, &decoded),
                (None, EvalValue::Nil)
                    | (Some(WasmType::I32), EvalValue::I32(_))
                    // A Bool decodes to Bool(true) when truthy or Nil when falsy.
                    | (Some(WasmType::Bool), EvalValue::Bool(_) | EvalValue::Nil)
                    | (Some(WasmType::Ratio), EvalValue::Ratio { .. })
                    | (Some(WasmType::Commodity), EvalValue::Commodity { .. })
                    | (
                        Some(WasmType::StringRef),
                        EvalValue::String(_) | EvalValue::Bytes(_)
                    ),
            );
11
            assert!(
11
                ok,
                "type/value drift for {src:?}: hint={result_ty:?}, decoded={decoded:?}",
            );
        }
1
    }
    #[tokio::test(flavor = "current_thread")]
1
    async fn render_entity_emits_named_field_plist() {
        // Build a $commodity-shaped struct (3 string fields, matching the
        // Commodity layout's id/symbol/name slots) via WAT, then decode it with
        // `render_entity` — the same call the host eval path makes for a returned
        // entity. Proves the spec-driven decoder reads each slot by name.
1
        let wat = r#"
1
        (module
1
          (type $i8 (array (mut i8)))
1
          (type $commodity (struct
1
            (field (ref null $i8))
1
            (field (ref null $i8))
1
            (field (ref null $i8))))
1
          (data $id "uuid-123")
1
          (data $sym "USD")
1
          (data $name "US Dollar")
1
          (func (export "go") (result (ref null struct))
1
            (struct.new $commodity
1
              (array.new_data $i8 $id  (i32.const 0) (i32.const 8))
1
              (array.new_data $i8 $sym (i32.const 0) (i32.const 3))
1
              (array.new_data $i8 $name (i32.const 0) (i32.const 9)))))
1
        "#;
1
        let engine = build_engine(EngineOpts::baseline()).unwrap();
1
        let module = compile_wat(&engine, wat).unwrap();
1
        let linker: Linker<()> = Linker::new(&engine);
1
        let mut store: Store<()> = Store::new(&engine, ());
1
        store.set_epoch_deadline(1_000);
1
        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1
        let go = instance.get_func(&mut store, "go").unwrap();
1
        let mut results = [Val::AnyRef(None)];
1
        go.call_async(&mut store, &[], &mut results).await.unwrap();
1
        let entity = match results[0] {
1
            Val::AnyRef(Some(a)) => a.unwrap_struct(&mut store).unwrap(),
            other => panic!("go did not return a struct: {other:?}"),
        };
1
        let rendered =
1
            render_entity(&mut store, entity, nomiscript::EntityKind::Commodity).unwrap();
1
        assert_eq!(
1
            rendered,
1
            "(:commodity :id \"uuid-123\" :symbol \"USD\" :name \"US Dollar\")"
1
        );
1
    }
    #[tokio::test(flavor = "current_thread")]
1
    async fn read_entity_string_field_reads_named_slot() {
        // Same Commodity-shaped struct; prove the field reader resolves a named
        // slot from the layout (id = slot 0, name = slot 2) — the call
        // `draft-split` makes to turn an entity ref into a stable uuid string.
1
        let wat = r#"
1
        (module
1
          (type $i8 (array (mut i8)))
1
          (type $commodity (struct
1
            (field (ref null $i8))
1
            (field (ref null $i8))
1
            (field (ref null $i8))))
1
          (data $id "uuid-123")
1
          (data $sym "USD")
1
          (data $name "US Dollar")
1
          (func (export "go") (result (ref null struct))
1
            (struct.new $commodity
1
              (array.new_data $i8 $id  (i32.const 0) (i32.const 8))
1
              (array.new_data $i8 $sym (i32.const 0) (i32.const 3))
1
              (array.new_data $i8 $name (i32.const 0) (i32.const 9)))))
1
        "#;
1
        let engine = build_engine(EngineOpts::baseline()).unwrap();
1
        let module = compile_wat(&engine, wat).unwrap();
1
        let linker: Linker<()> = Linker::new(&engine);
1
        let mut store: Store<()> = Store::new(&engine, ());
1
        store.set_epoch_deadline(1_000);
1
        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1
        let go = instance.get_func(&mut store, "go").unwrap();
1
        let mut results = [Val::AnyRef(None)];
1
        go.call_async(&mut store, &[], &mut results).await.unwrap();
1
        let entity = match results[0] {
1
            Val::AnyRef(Some(a)) => a.unwrap_struct(&mut store).unwrap(),
            other => panic!("go did not return a struct: {other:?}"),
        };
1
        let id = read_entity_string_field_ctx(
1
            &mut store,
1
            entity,
1
            nomiscript::EntityKind::Commodity,
1
            "id",
        )
1
        .unwrap();
1
        assert_eq!(id, "uuid-123");
1
        let name = read_entity_string_field_ctx(
1
            &mut store,
1
            entity,
1
            nomiscript::EntityKind::Commodity,
1
            "name",
        )
1
        .unwrap();
1
        assert_eq!(name, "US Dollar");
        // A field that isn't a String slot in the layout is rejected.
1
        let bad = read_entity_string_field_ctx(
1
            &mut store,
1
            entity,
1
            nomiscript::EntityKind::Commodity,
1
            "nonexistent",
        );
1
        assert!(bad.is_err());
1
    }
}