Skip to main content

lex_runtime/
builtins.rs

1//! Pure stdlib builtins — string, numeric, list, option, result, json
2//! ops dispatched via the same `EffectHandler` interface as effects, but
3//! without policy gates (they have no observable side effects).
4
5use lex_bytecode::{MapKey, Value};
6use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::sync::{Mutex, OnceLock};
8
9/// Returns `true` if `(kind, op)` will be handled by the pure-builtin
10/// path (no side effects, no policy gate needed). Used by the effect
11/// handler to decide whether to consume `args` by value.
12pub fn is_pure_call(kind: &str, op: &str) -> bool {
13    if !is_pure_module(kind) { return false; }
14    !matches!(
15        (kind, op),
16        ("crypto", "random")
17        | ("crypto", "random_str_hex")
18        // p256_generate mints key material from the OS RNG → [random]
19        // effect, handled on the effect path (#651).
20        | ("crypto", "p256_generate")
21        // secp256k1_generate likewise mints from the OS RNG → [random] (#655).
22        | ("crypto", "secp256k1_generate")
23        | ("datetime", "now")
24        | ("http", "send")
25        | ("http", "get")
26        | ("http", "post")
27        | ("http", "stream_lines")
28        // arrow.read_csv reads from disk → effect-handler path (#426 I/O slice).
29        | ("arrow", "read_csv")
30        // arrow.{read,write}_parquet + arrow.write_csv — effect-gated I/O (#432).
31        | ("arrow", "read_parquet")
32        | ("arrow", "read_parquet_cols")
33        | ("arrow", "write_parquet")
34        | ("arrow", "write_csv")
35    )
36}
37
38/// Dispatch a pure-builtin call with owned args (no clone of arg values).
39/// Callers must first verify `is_pure_call(kind, op)` to ensure args
40/// ownership is only transferred for known-pure ops.
41///
42/// `list.cons` is handled here with move semantics so the tail `Vec<Value>`
43/// is extended without cloning each element (#405).
44pub fn call_pure_builtin(kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String> {
45    if (kind, op) == ("list", "cons") {
46        let mut it = args.into_iter();
47        let head = it.next().unwrap_or(Value::Unit);
48        let mut tail = match it.next() {
49            Some(Value::List(v)) => v,
50            Some(other) => return Err(format!("list.cons: expected List, got {other:?}")),
51            None => std::collections::VecDeque::new(),
52        };
53        tail.push_front(head);
54        return Ok(Value::List(tail));
55    }
56    dispatch(kind, op, &args)
57}
58
59/// Returns Some(...) if `(kind, op)` names a known pure builtin.
60/// `None` means "not handled here; fall through to effect dispatch".
61///
62/// Prefer `is_pure_call` + `call_pure_builtin` in hot paths — this
63/// variant takes `&[Value]` and must clone args for operations like
64/// `list.cons`; kept for external callers that already hold a slice.
65pub fn try_pure_builtin(kind: &str, op: &str, args: &[Value]) -> Option<Result<Value, String>> {
66    if !is_pure_call(kind, op) { return None; }
67    Some(dispatch(kind, op, args))
68}
69
70/// `kind` is one of the known pure module aliases — used by the policy
71/// walk to skip pure builtins that programs reference via imports.
72pub fn is_pure_module(kind: &str) -> bool {
73    matches!(kind, "str" | "int" | "float" | "bool" | "list" | "iter"
74        | "option" | "result" | "tuple" | "json" | "bytes" | "flow" | "math"
75        | "map" | "set" | "crypto" | "regex" | "deque" | "datetime" | "duration" | "http"
76        | "toml" | "yaml" | "dotenv" | "csv" | "test" | "random" | "parser"
77        | "cli" | "arrow" | "df" | "decimal")
78}
79
80fn dispatch(kind: &str, op: &str, args: &[Value]) -> Result<Value, String> {
81    match (kind, op) {
82        // -- str --
83        ("str", "is_empty") => Ok(Value::Bool(expect_str(args.first())?.is_empty())),
84        ("str", "len") => Ok(Value::Int(expect_str(args.first())?.len() as i64)),
85        ("str", "concat") => {
86            let a = expect_str(args.first())?;
87            let b = expect_str(args.get(1))?;
88            Ok(Value::Str(format!("{a}{b}").into()))
89        }
90        ("str", "to_int") => {
91            let s = expect_str(args.first())?;
92            match s.parse::<i64>() {
93                Ok(n) => Ok(some(Value::Int(n))),
94                Err(_) => Ok(none()),
95            }
96        }
97        ("str", "split") => {
98            let s = expect_str(args.first())?;
99            let sep = expect_str(args.get(1))?;
100            let items: std::collections::VecDeque<Value> = if sep.is_empty() {
101                s.chars().map(|c| Value::Str(c.to_string().into())).collect()
102            } else {
103                s.split(sep.as_str()).map(|p| Value::Str(p.into())).collect()
104            };
105            Ok(Value::List(items))
106        }
107        ("str", "join") => {
108            let parts = expect_list(args.first())?;
109            let sep = expect_str(args.get(1))?;
110            let mut out = String::new();
111            for (i, p) in parts.iter().enumerate() {
112                if i > 0 { out.push_str(&sep); }
113                match p {
114                    Value::Str(s) => out.push_str(s),
115                    other => return Err(format!("str.join element must be Str, got {other:?}")),
116                }
117            }
118            Ok(Value::Str(out.into()))
119        }
120        ("str", "starts_with") => {
121            let s = expect_str(args.first())?;
122            let prefix = expect_str(args.get(1))?;
123            Ok(Value::Bool(s.starts_with(prefix.as_str())))
124        }
125        ("str", "ends_with") => {
126            let s = expect_str(args.first())?;
127            let suffix = expect_str(args.get(1))?;
128            Ok(Value::Bool(s.ends_with(suffix.as_str())))
129        }
130        ("str", "contains") => {
131            let s = expect_str(args.first())?;
132            let needle = expect_str(args.get(1))?;
133            Ok(Value::Bool(s.contains(needle.as_str())))
134        }
135        ("str", "cmp") => {
136            let a = expect_str(args.first())?;
137            let b = expect_str(args.get(1))?;
138            Ok(Value::Int(match a.as_str().cmp(b.as_str()) {
139                std::cmp::Ordering::Less => -1,
140                std::cmp::Ordering::Equal => 0,
141                std::cmp::Ordering::Greater => 1,
142            }))
143        }
144        ("str", "replace") => {
145            let s = expect_str(args.first())?;
146            let from = expect_str(args.get(1))?;
147            let to = expect_str(args.get(2))?;
148            Ok(Value::Str(s.replace(from.as_str(), to.as_str()).into()))
149        }
150        ("str", "trim") => Ok(Value::Str(expect_str(args.first())?.trim().into())),
151        ("str", "to_upper") => Ok(Value::Str(expect_str(args.first())?.to_uppercase().into())),
152        ("str", "to_lower") => Ok(Value::Str(expect_str(args.first())?.to_lowercase().into())),
153        ("str", "strip_prefix") => {
154            let s = expect_str(args.first())?;
155            let prefix = expect_str(args.get(1))?;
156            Ok(match s.strip_prefix(prefix.as_str()) {
157                Some(rest) => some(Value::Str(rest.into())),
158                None => none(),
159            })
160        }
161        ("str", "strip_suffix") => {
162            let s = expect_str(args.first())?;
163            let suffix = expect_str(args.get(1))?;
164            Ok(match s.strip_suffix(suffix.as_str()) {
165                Some(rest) => some(Value::Str(rest.into())),
166                None => none(),
167            })
168        }
169        ("str", "slice") => {
170            // Half-open codepoint-index slice. `lo` and `hi` are Unicode
171            // scalar value (codepoint) indices, not byte offsets. Out-of-range
172            // indices clamp to the codepoint count, mirroring Python's `s[lo:hi]`
173            // semantics. Reversed ranges error as a caller logic bug. (#620)
174            let s = expect_str(args.first())?;
175            let lo_i = expect_int(args.get(1))?;
176            let hi_i = expect_int(args.get(2))?;
177            let lo_cp = lo_i.max(0) as usize;
178            let hi_cp = hi_i.max(0) as usize;
179            if lo_cp > hi_cp {
180                return Err(format!(
181                    "str.slice: reversed range [{lo_cp}..{hi_cp}]"));
182            }
183            // Resolve codepoint indices to byte offsets in a single pass.
184            // Indices past the end clamp to s.len(), yielding an empty slice.
185            let lo_byte = s.char_indices().nth(lo_cp).map(|(b, _)| b).unwrap_or(s.len());
186            let hi_byte = s.char_indices().nth(hi_cp).map(|(b, _)| b).unwrap_or(s.len());
187            Ok(Value::Str(s[lo_byte..hi_byte].into()))
188        }
189
190        // -- int / float --
191        ("int", "to_str") => Ok(Value::Str(expect_int(args.first())?.to_string().into())),
192        ("int", "to_float") => Ok(Value::Float(expect_int(args.first())? as f64)),
193        ("float", "to_int") => Ok(Value::Int(expect_float(args.first())? as i64)),
194        ("float", "to_str") => Ok(Value::Str(expect_float(args.first())?.to_string().into())),
195        ("str", "to_float") => {
196            let s = expect_str(args.first())?;
197            match s.parse::<f64>() {
198                Ok(f) => Ok(some(Value::Float(f))),
199                Err(_) => Ok(none()),
200            }
201        }
202
203        // -- list --
204        ("list", "len") => Ok(Value::Int(expect_list(args.first())?.len() as i64)),
205        ("list", "is_empty") => Ok(Value::Bool(expect_list(args.first())?.is_empty())),
206        ("list", "head") => {
207            let xs = expect_list(args.first())?;
208            match xs.front() {
209                Some(v) => Ok(some(v.clone())),
210                None => Ok(none()),
211            }
212        }
213        ("list", "tail") => {
214            let xs = expect_list(args.first())?;
215            if xs.is_empty() { Ok(Value::List(std::collections::VecDeque::new())) }
216            else { Ok(Value::List(xs.iter().skip(1).cloned().collect::<std::collections::VecDeque<_>>())) }
217        }
218        ("list", "range") => {
219            let lo = expect_int(args.first())?;
220            let hi = expect_int(args.get(1))?;
221            Ok(Value::List((lo..hi).map(Value::Int).collect::<std::collections::VecDeque<_>>()))
222        }
223        ("list", "concat") => {
224            let mut out = expect_list(args.first())?.clone();
225            out.extend(expect_list(args.get(1))?.iter().cloned());
226            Ok(Value::List(out))
227        }
228        ("list", "reverse") => {
229            let out = expect_list(args.first())?.clone();
230            let rev: std::collections::VecDeque<Value> = out.into_iter().rev().collect();
231            Ok(Value::List(rev))
232        }
233        // #334: cons — prepend a single element to a list.
234        // (fast path via call_pure_builtin; this branch handles the
235        // borrow-based dispatch path which must clone)
236        ("list", "cons") => {
237            let head = args.first().cloned().unwrap_or(Value::Unit);
238            let mut out: std::collections::VecDeque<Value> =
239                expect_list(args.get(1))?.iter().cloned().collect();
240            out.push_front(head);
241            Ok(Value::List(out))
242        }
243        ("list", "enumerate") => {
244            let xs = expect_list(args.first())?;
245            let pairs = xs.iter().cloned().enumerate()
246                .map(|(i, v)| Value::Tuple(vec![Value::Int(i as i64), v]))
247                .collect::<std::collections::VecDeque<_>>();
248            Ok(Value::List(pairs))
249        }
250
251        // -- tuple --
252        // Per §11.1: fst, snd, third for 2- and 3-tuples. Index out of
253        // range is an error rather than a panic so calling `tuple.third`
254        // on a 2-tuple is a clean failure instead of a host crash.
255        ("tuple", "fst")   => tuple_index(first_arg(args)?, 0),
256        ("tuple", "snd")   => tuple_index(first_arg(args)?, 1),
257        ("tuple", "third") => tuple_index(first_arg(args)?, 2),
258        ("tuple", "len") => match first_arg(args)? {
259            Value::Tuple(items) => Ok(Value::Int(items.len() as i64)),
260            other => Err(format!("tuple.len: expected Tuple, got {other:?}")),
261        },
262
263        // -- option --
264        ("option", "unwrap_or") => {
265            let opt = first_arg(args)?;
266            let default = args.get(1).cloned().unwrap_or(Value::Unit);
267            match opt {
268                Value::Variant { name, args } if name == "Some" && !args.is_empty() => Ok(args[0].clone()),
269                Value::Variant { name, .. } if name == "None" => Ok(default),
270                other => Err(format!("option.unwrap_or expected Option, got {other:?}")),
271            }
272        }
273        // option.unwrap_or_else: lazy default via thunk — only called when None.
274        // Handled inline by the bytecode compiler; this arm is the interpreter
275        // fallback path (thunk is pre-applied as a Value::Unit default since the
276        // runtime cannot call closures itself — the compiler path is canonical).
277        ("option", "unwrap_or_else") => {
278            let opt = first_arg(args)?;
279            match opt {
280                Value::Variant { name, args } if name == "Some" && !args.is_empty() => Ok(args[0].clone()),
281                Value::Variant { name, .. } if name == "None" => {
282                    // The closure argument cannot be invoked from pure-builtin
283                    // context; callers that reach this path have already
284                    // evaluated the thunk and passed its result as args[1].
285                    Ok(args.get(1).cloned().unwrap_or(Value::Unit))
286                }
287                other => Err(format!("option.unwrap_or_else expected Option, got {other:?}")),
288            }
289        }
290        ("option", "is_some") => match first_arg(args)? {
291            Value::Variant { name, .. } => Ok(Value::Bool(name == "Some")),
292            other => Err(format!("option.is_some expected Option, got {other:?}")),
293        },
294        ("option", "is_none") => match first_arg(args)? {
295            Value::Variant { name, .. } => Ok(Value::Bool(name == "None")),
296            other => Err(format!("option.is_none expected Option, got {other:?}")),
297        },
298
299        // -- result --
300        ("result", "is_ok") => match first_arg(args)? {
301            Value::Variant { name, .. } => Ok(Value::Bool(name == "Ok")),
302            other => Err(format!("result.is_ok expected Result, got {other:?}")),
303        },
304        ("result", "is_err") => match first_arg(args)? {
305            Value::Variant { name, .. } => Ok(Value::Bool(name == "Err")),
306            other => Err(format!("result.is_err expected Result, got {other:?}")),
307        },
308        ("result", "unwrap_or") => {
309            let res = first_arg(args)?;
310            let default = args.get(1).cloned().unwrap_or(Value::Unit);
311            match res {
312                Value::Variant { name, args } if name == "Ok" && !args.is_empty() => Ok(args[0].clone()),
313                Value::Variant { name, .. } if name == "Err" => Ok(default),
314                other => Err(format!("result.unwrap_or expected Result, got {other:?}")),
315            }
316        }
317
318        // -- json --
319        ("json", "stringify") => {
320            let v = first_arg(args)?;
321            Ok(Value::Str(serde_json::to_string(&value_to_json(v)).unwrap_or_default().into()))
322        }
323        ("json", "parse") => {
324            let s = expect_str(args.first())?;
325            match serde_json::from_str::<serde_json::Value>(&s) {
326                Ok(v) => Ok(ok_v(json_to_value(&v))),
327                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
328            }
329        }
330        // Tactical fix for #168: validate required fields before
331        // returning Ok. #322: also validate field types via schema.
332        ("json", "parse_strict") => {
333            let s = expect_str(args.first())?;
334            let required = required_field_names(args.get(1))?;
335            let schema = extract_type_schema(args.get(2));
336            match serde_json::from_str::<serde_json::Value>(&s) {
337                Ok(v) => {
338                    if let Err(e) = check_required_fields(&v, &required) {
339                        return Ok(err_v(Value::Str(e.into())));
340                    }
341                    if let Err(e) = validate_field_types(&v, &schema) {
342                        return Ok(err_v(Value::Str(e.into())));
343                    }
344                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
345                }
346                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
347            }
348        }
349
350        // -- toml (config parser; routes through serde_json::Value
351        // so the parsed shape composes with the existing json
352        // tooling. Datetimes become RFC 3339 strings — the only
353        // info-losing step) --
354        ("toml", "parse") => {
355            let s = expect_str(args.first())?;
356            match toml::from_str::<serde_json::Value>(&s) {
357                Ok(mut v) => {
358                    unwrap_toml_datetime_markers(&mut v);
359                    Ok(ok_v(json_to_value(&v)))
360                }
361                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
362            }
363        }
364        // Compiler-emitted variant of parse_strict that carries the type
365        // schema injected by the type-checker rewrite pass (#322).
366        // Identical to parse_strict but the 3rd arg (schema) is always present.
367        ("json", "parse_strict_typed") => {
368            let s = expect_str(args.first())?;
369            let required = required_field_names(args.get(1))?;
370            let schema = extract_type_schema(args.get(2));
371            match serde_json::from_str::<serde_json::Value>(&s) {
372                Ok(v) => {
373                    if let Err(e) = check_required_fields(&v, &required) {
374                        return Ok(err_v(Value::Str(e.into())));
375                    }
376                    if let Err(e) = validate_field_types(&v, &schema) {
377                        return Ok(err_v(Value::Str(e.into())));
378                    }
379                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
380                }
381                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
382            }
383        }
384
385        // Tactical fix for #168: validate required fields before
386        // returning Ok. #322: also validate field types via schema.
387        ("toml", "parse_strict") => {
388            let s = expect_str(args.first())?;
389            let required = required_field_names(args.get(1))?;
390            let schema = extract_type_schema(args.get(2));
391            match toml::from_str::<serde_json::Value>(&s) {
392                Ok(mut v) => {
393                    unwrap_toml_datetime_markers(&mut v);
394                    if let Err(e) = check_required_fields(&v, &required) {
395                        return Ok(err_v(Value::Str(e.into())));
396                    }
397                    if let Err(e) = validate_field_types(&v, &schema) {
398                        return Ok(err_v(Value::Str(e.into())));
399                    }
400                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
401                }
402                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
403            }
404        }
405        ("toml", "parse_strict_typed") => {
406            let s = expect_str(args.first())?;
407            let required = required_field_names(args.get(1))?;
408            let schema = extract_type_schema(args.get(2));
409            match toml::from_str::<serde_json::Value>(&s) {
410                Ok(mut v) => {
411                    unwrap_toml_datetime_markers(&mut v);
412                    if let Err(e) = check_required_fields(&v, &required) {
413                        return Ok(err_v(Value::Str(e.into())));
414                    }
415                    if let Err(e) = validate_field_types(&v, &schema) {
416                        return Ok(err_v(Value::Str(e.into())));
417                    }
418                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
419                }
420                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
421            }
422        }
423        ("toml", "stringify") => {
424            let v = first_arg(args)?;
425            // serde_json::Value → toml::Value via its serde impls.
426            // TOML's grammar is stricter than JSON's (top-level
427            // must be a table; no `null`; no mixed-type arrays),
428            // so the conversion can fail — surface as Result::Err
429            // rather than panic.
430            let json = value_to_json(v);
431            match toml::to_string(&json) {
432                Ok(s)  => Ok(ok_v(Value::Str(s.into()))),
433                Err(e) => Ok(err_v(Value::Str(format!("toml.stringify: {e}").into()))),
434            }
435        }
436
437        // -- yaml -- mirrors std.toml. Wraps serde_yaml so values
438        // map to the same Lex shape as JSON. YAML's Tag/Anchor
439        // features are folded out by serde_yaml's deserialize-to-
440        // Value path; non-representable shapes (e.g. non-string
441        // map keys when stringifying) surface as Result::Err.
442        ("yaml", "parse") => {
443            let s = expect_str(args.first())?;
444            match serde_yaml::from_str::<serde_json::Value>(&s) {
445                Ok(v)  => Ok(ok_v(json_to_value(&v))),
446                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
447            }
448        }
449        // Tactical fix for #168 — same shape as toml.parse_strict.
450        // #322: also validate field types via schema.
451        ("yaml", "parse_strict") => {
452            let s = expect_str(args.first())?;
453            let required = required_field_names(args.get(1))?;
454            let schema = extract_type_schema(args.get(2));
455            match serde_yaml::from_str::<serde_json::Value>(&s) {
456                Ok(v) => {
457                    if let Err(e) = check_required_fields(&v, &required) {
458                        return Ok(err_v(Value::Str(e.into())));
459                    }
460                    if let Err(e) = validate_field_types(&v, &schema) {
461                        return Ok(err_v(Value::Str(e.into())));
462                    }
463                    Ok(ok_v(json_to_value(&v)))
464                }
465                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
466            }
467        }
468        ("yaml", "parse_strict_typed") => {
469            let s = expect_str(args.first())?;
470            let required = required_field_names(args.get(1))?;
471            let schema = extract_type_schema(args.get(2));
472            match serde_yaml::from_str::<serde_json::Value>(&s) {
473                Ok(v) => {
474                    if let Err(e) = check_required_fields(&v, &required) {
475                        return Ok(err_v(Value::Str(e.into())));
476                    }
477                    if let Err(e) = validate_field_types(&v, &schema) {
478                        return Ok(err_v(Value::Str(e.into())));
479                    }
480                    Ok(ok_v(json_to_value(&v)))
481                }
482                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
483            }
484        }
485        ("yaml", "stringify") => {
486            let v = first_arg(args)?;
487            let json = value_to_json(v);
488            match serde_yaml::to_string(&json) {
489                Ok(s)  => Ok(ok_v(Value::Str(s.into()))),
490                Err(e) => Ok(err_v(Value::Str(format!("yaml.stringify: {e}").into()))),
491            }
492        }
493
494        // -- dotenv -- KEY=VALUE pair files. Hand-rolled parser
495        // because the dotenvy crate's API is geared at loading
496        // into the process env, not parsing-to-data. The grammar
497        // we accept: blank lines, `# comment` lines, and
498        // `KEY=VALUE` (optional surrounding `"..."` or `'...'`,
499        // unescaped). Simple but covers the .env files in the
500        // wild that aren't trying to be shell.
501        ("dotenv", "parse") => {
502            use std::collections::BTreeMap;
503            use lex_bytecode::MapKey;
504            let s = expect_str(args.first())?;
505            match parse_dotenv(&s) {
506                Ok(map) => {
507                    let mut bt: BTreeMap<MapKey, Value> = BTreeMap::new();
508                    for (k, v) in map {
509                        bt.insert(MapKey::Str(k), Value::Str(v.into()));
510                    }
511                    Ok(ok_v(Value::Map(bt)))
512                }
513                Err(e) => Ok(err_v(Value::Str(e.into()))),
514            }
515        }
516
517        // -- csv -- rows-as-lists; first row is whatever the file
518        // has. The caller decides whether row 0 is a header. We
519        // could ship a `parse_with_headers` later that returns a
520        // List[Map[Str, Str]]; v1 keeps the surface tight.
521        ("csv", "parse") => {
522            let s = expect_str(args.first())?;
523            let mut rdr = csv::ReaderBuilder::new()
524                .has_headers(false)
525                .flexible(true)
526                .from_reader(s.as_bytes());
527            let mut rows: std::collections::VecDeque<Value> = std::collections::VecDeque::new();
528            for r in rdr.records() {
529                match r {
530                    Ok(rec) => {
531                        let row: std::collections::VecDeque<Value> = rec.iter()
532                            .map(|f| Value::Str(f.into()))
533                            .collect();
534                        rows.push_back(Value::List(row));
535                    }
536                    Err(e) => return Ok(err_v(Value::Str(format!("csv.parse: {e}").into()))),
537                }
538            }
539            Ok(ok_v(Value::List(rows)))
540        }
541        ("csv", "stringify") => {
542            // List[List[Str]] → CSV string. Mixed-type rows are
543            // not allowed (CSV is text-only); non-Str cells get
544            // stringified via to_json since that's already the
545            // convention for `json.stringify` etc.
546            let v = first_arg(args)?;
547            let rows = match v {
548                Value::List(rs) => rs,
549                _ => return Ok(err_v(Value::Str("csv.stringify expects List[List[Str]]".into()))),
550            };
551            let mut out = Vec::new();
552            {
553                let mut wtr = csv::WriterBuilder::new()
554                    .has_headers(false)
555                    .from_writer(&mut out);
556                for row in rows {
557                    let cells = match row {
558                        Value::List(cs) => cs,
559                        _ => return Ok(err_v(Value::Str("csv.stringify row must be List[Str]".into()))),
560                    };
561                    let strs: Vec<String> = cells.iter().map(|c| match c {
562                        Value::Str(s) => s.to_string(),
563                        other => serde_json::to_string(&other.to_json())
564                            .unwrap_or_else(|_| String::new()),
565                    }).collect();
566                    if let Err(e) = wtr.write_record(&strs) {
567                        return Ok(err_v(Value::Str(format!("csv.stringify: {e}").into())));
568                    }
569                }
570                if let Err(e) = wtr.flush() {
571                    return Ok(err_v(Value::Str(format!("csv.stringify flush: {e}").into())));
572                }
573            }
574            match String::from_utf8(out) {
575                Ok(s) => Ok(ok_v(Value::Str(s.into()))),
576                Err(e) => Ok(err_v(Value::Str(format!("csv.stringify utf8: {e}").into()))),
577            }
578        }
579
580        // -- test -- tiny assertion library. Each helper is pure
581        // and returns `Result[Unit, Str]` so tests are themselves
582        // functions returning a Result. A suite is a List the user
583        // iterates with `list.fold`; no Rust-side Suite/Runner
584        // types in v1, so the whole thing is 4 builtins + a few
585        // Lex-source helpers callers can copy into their tests/.
586        ("test", "assert_eq") => {
587            let a = first_arg(args)?;
588            let b = args.get(1).ok_or("test.assert_eq: missing second arg")?;
589            if a == b {
590                Ok(ok_v(Value::Unit))
591            } else {
592                Ok(err_v(Value::Str(format!("assert_eq: lhs {} != rhs {}",
593                    value_to_json(a), value_to_json(b)).into())))
594            }
595        }
596        ("test", "assert_ne") => {
597            let a = first_arg(args)?;
598            let b = args.get(1).ok_or("test.assert_ne: missing second arg")?;
599            if a != b {
600                Ok(ok_v(Value::Unit))
601            } else {
602                Ok(err_v(Value::Str(format!("assert_ne: both sides are {}",
603                    value_to_json(a)).into())))
604            }
605        }
606        ("test", "assert_true") => {
607            match first_arg(args)? {
608                Value::Bool(true) => Ok(ok_v(Value::Unit)),
609                Value::Bool(false) => Ok(err_v(Value::Str("assert_true: was false".into()))),
610                other => Err(format!("test.assert_true expects Bool, got {other:?}")),
611            }
612        }
613        ("test", "assert_false") => {
614            match first_arg(args)? {
615                Value::Bool(false) => Ok(ok_v(Value::Unit)),
616                Value::Bool(true)  => Ok(err_v(Value::Str("assert_false: was true".into()))),
617                other => Err(format!("test.assert_false expects Bool, got {other:?}")),
618            }
619        }
620
621        // -- bytes --
622        ("bytes", "len") => {
623            let b = expect_bytes(args.first())?;
624            Ok(Value::Int(b.len() as i64))
625        }
626        ("bytes", "eq") => {
627            let a = expect_bytes(args.first())?;
628            let b = expect_bytes(args.get(1))?;
629            Ok(Value::Bool(a == b))
630        }
631        ("bytes", "from_str") => {
632            let s = expect_str(args.first())?;
633            Ok(Value::Bytes(s.into_bytes()))
634        }
635        ("bytes", "to_str") => {
636            let b = expect_bytes(args.first())?;
637            match String::from_utf8(b.to_vec()) {
638                Ok(s) => Ok(ok_v(Value::Str(s.into()))),
639                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
640            }
641        }
642        ("bytes", "slice") => {
643            let b = expect_bytes(args.first())?;
644            let lo = expect_int(args.get(1))? as usize;
645            let hi = expect_int(args.get(2))? as usize;
646            if lo > hi || hi > b.len() {
647                return Err(format!("bytes.slice: out of range [{lo}..{hi}] of {}", b.len()));
648            }
649            Ok(Value::Bytes(b[lo..hi].to_vec()))
650        }
651        ("bytes", "is_empty") => {
652            let b = expect_bytes(args.first())?;
653            Ok(Value::Bool(b.is_empty()))
654        }
655
656        // -- math --
657        // Matrices are stored as the F64Array fast-lane variant (a flat
658        // row-major Vec<f64> with shape). Lex code treats them as the
659        // type alias `Matrix = { rows :: Int, cols :: Int, data ::
660        // List[Float] }`; field access is unsupported, so all
661        // introspection happens through these helpers.
662        ("math", "exp")   => Ok(Value::Float(expect_float(args.first())?.exp())),
663        ("math", "log")   => Ok(Value::Float(expect_float(args.first())?.ln())),
664        ("math", "log2")  => Ok(Value::Float(expect_float(args.first())?.log2())),
665        ("math", "log10") => Ok(Value::Float(expect_float(args.first())?.log10())),
666        ("math", "sqrt")  => Ok(Value::Float(expect_float(args.first())?.sqrt())),
667        ("math", "abs")   => Ok(Value::Float(expect_float(args.first())?.abs())),
668        ("math", "sin")   => Ok(Value::Float(expect_float(args.first())?.sin())),
669        ("math", "cos")   => Ok(Value::Float(expect_float(args.first())?.cos())),
670        ("math", "tan")   => Ok(Value::Float(expect_float(args.first())?.tan())),
671        ("math", "asin")  => Ok(Value::Float(expect_float(args.first())?.asin())),
672        ("math", "acos")  => Ok(Value::Float(expect_float(args.first())?.acos())),
673        ("math", "atan")  => Ok(Value::Float(expect_float(args.first())?.atan())),
674        ("math", "floor") => Ok(Value::Float(expect_float(args.first())?.floor())),
675        ("math", "ceil")  => Ok(Value::Float(expect_float(args.first())?.ceil())),
676        ("math", "round") => Ok(Value::Float(expect_float(args.first())?.round())),
677        ("math", "trunc") => Ok(Value::Float(expect_float(args.first())?.trunc())),
678        ("math", "pow") => {
679            let a = expect_float(args.first())?;
680            let b = expect_float(args.get(1))?;
681            Ok(Value::Float(a.powf(b)))
682        }
683        ("math", "atan2") => {
684            let y = expect_float(args.first())?;
685            let x = expect_float(args.get(1))?;
686            Ok(Value::Float(y.atan2(x)))
687        }
688        ("math", "min") => {
689            let a = expect_float(args.first())?;
690            let b = expect_float(args.get(1))?;
691            Ok(Value::Float(a.min(b)))
692        }
693        ("math", "max") => {
694            let a = expect_float(args.first())?;
695            let b = expect_float(args.get(1))?;
696            Ok(Value::Float(a.max(b)))
697        }
698        ("math", "zeros") => {
699            let r = expect_int(args.first())?;
700            let c = expect_int(args.get(1))?;
701            if r < 0 || c < 0 {
702                return Err(format!("math.zeros: negative dim {r}x{c}"));
703            }
704            let r = r as usize; let c = c as usize;
705            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data: vec![0.0; r * c] })
706        }
707        ("math", "ones") => {
708            let r = expect_int(args.first())?;
709            let c = expect_int(args.get(1))?;
710            if r < 0 || c < 0 {
711                return Err(format!("math.ones: negative dim {r}x{c}"));
712            }
713            let r = r as usize; let c = c as usize;
714            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data: vec![1.0; r * c] })
715        }
716        ("math", "from_lists") => {
717            let rows = expect_list(args.first())?;
718            let r = rows.len();
719            if r == 0 {
720                return Ok(Value::F64Array { rows: 0, cols: 0, data: Vec::new() });
721            }
722            let first_row = match &rows[0] {
723                Value::List(xs) => xs,
724                other => return Err(format!("math.from_lists: row 0 not List, got {other:?}")),
725            };
726            let c = first_row.len();
727            let mut data = Vec::with_capacity(r * c);
728            for (i, row) in rows.iter().enumerate() {
729                let row = match row {
730                    Value::List(xs) => xs,
731                    other => return Err(format!("math.from_lists: row {i} not List, got {other:?}")),
732                };
733                if row.len() != c {
734                    return Err(format!("math.from_lists: row {i} has {} cols, expected {c}", row.len()));
735                }
736                for (j, v) in row.iter().enumerate() {
737                    let f = match v {
738                        Value::Float(f) => *f,
739                        Value::Int(n) => *n as f64,
740                        other => return Err(format!("math.from_lists: ({i},{j}) not numeric, got {other:?}")),
741                    };
742                    data.push(f);
743                }
744            }
745            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
746        }
747        ("math", "from_flat") => {
748            let r = expect_int(args.first())?;
749            let c = expect_int(args.get(1))?;
750            let xs = expect_list(args.get(2))?;
751            if r < 0 || c < 0 {
752                return Err(format!("math.from_flat: negative dim {r}x{c}"));
753            }
754            let r = r as usize; let c = c as usize;
755            if xs.len() != r * c {
756                return Err(format!("math.from_flat: list len {} != {}*{}", xs.len(), r, c));
757            }
758            let mut data = Vec::with_capacity(r * c);
759            for v in xs {
760                data.push(match v {
761                    Value::Float(f) => *f,
762                    Value::Int(n)   => *n as f64,
763                    other => return Err(format!("math.from_flat: non-numeric element {other:?}")),
764                });
765            }
766            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
767        }
768        ("math", "rows") => {
769            let (r, _, _) = unpack_matrix(first_arg(args)?)?;
770            Ok(Value::Int(r as i64))
771        }
772        ("math", "cols") => {
773            let (_, c, _) = unpack_matrix(first_arg(args)?)?;
774            Ok(Value::Int(c as i64))
775        }
776        ("math", "get") => {
777            let (r, c, data) = unpack_matrix(first_arg(args)?)?;
778            let i = expect_int(args.get(1))? as usize;
779            let j = expect_int(args.get(2))? as usize;
780            if i >= r || j >= c {
781                return Err(format!("math.get: ({i},{j}) out of {r}x{c}"));
782            }
783            Ok(Value::Float(data[i * c + j]))
784        }
785        ("math", "to_flat") => {
786            let (_, _, data) = unpack_matrix(first_arg(args)?)?;
787            Ok(Value::List(data.into_iter().map(Value::Float).collect()))
788        }
789        ("math", "transpose") => {
790            let (r, c, data) = unpack_matrix(first_arg(args)?)?;
791            let mut out = vec![0.0; r * c];
792            for i in 0..r {
793                for j in 0..c {
794                    out[j * r + i] = data[i * c + j];
795                }
796            }
797            Ok(Value::F64Array { rows: c as u32, cols: r as u32, data: out })
798        }
799        ("math", "matmul") => {
800            let (m, k1, a) = unpack_matrix(first_arg(args)?)?;
801            let (k2, n, b) = unpack_matrix(args.get(1).ok_or("math.matmul: missing arg 1")?)?;
802            if k1 != k2 {
803                return Err(format!("math.matmul: dim mismatch {m}x{k1} · {k2}x{n}"));
804            }
805            // Plain triple loop. For the small matrices used in the ML
806            // demo (n<200, k<10) this is well under a millisecond and
807            // avoids pulling in matrixmultiply for the runtime crate.
808            let mut c = vec![0.0; m * n];
809            for i in 0..m {
810                for kk in 0..k1 {
811                    let aik = a[i * k1 + kk];
812                    for j in 0..n {
813                        c[i * n + j] += aik * b[kk * n + j];
814                    }
815                }
816            }
817            Ok(Value::F64Array { rows: m as u32, cols: n as u32, data: c })
818        }
819        ("math", "scale") => {
820            let s = expect_float(args.first())?;
821            let (r, c, mut data) = unpack_matrix(args.get(1).ok_or("math.scale: missing arg 1")?)?;
822            for x in &mut data { *x *= s; }
823            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
824        }
825        ("math", "add") | ("math", "sub") => {
826            let (ar, ac, a) = unpack_matrix(first_arg(args)?)?;
827            let (br, bc, b) = unpack_matrix(args.get(1).ok_or("math.add/sub: missing arg 1")?)?;
828            if ar != br || ac != bc {
829                return Err(format!("math.{op}: shape mismatch {ar}x{ac} vs {br}x{bc}"));
830            }
831            let neg = op == "sub";
832            let mut out = a;
833            for (i, x) in out.iter_mut().enumerate() {
834                if neg { *x -= b[i] } else { *x += b[i] }
835            }
836            Ok(Value::F64Array { rows: ar as u32, cols: ac as u32, data: out })
837        }
838        ("math", "sigmoid") => {
839            let (r, c, mut data) = unpack_matrix(first_arg(args)?)?;
840            for x in &mut data { *x = 1.0 / (1.0 + (-*x).exp()); }
841            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
842        }
843
844        // -- map --
845        ("map", "new") => Ok(Value::Map(BTreeMap::new())),
846        ("map", "size") => Ok(Value::Int(expect_map(args.first())?.len() as i64)),
847        ("map", "has") => {
848            let m = expect_map(args.first())?;
849            let k = MapKey::from_value(args.get(1).ok_or("map.has: missing key")?)?;
850            Ok(Value::Bool(m.contains_key(&k)))
851        }
852        ("map", "get") => {
853            let m = expect_map(args.first())?;
854            let k = MapKey::from_value(args.get(1).ok_or("map.get: missing key")?)?;
855            Ok(match m.get(&k) {
856                Some(v) => some(v.clone()),
857                None    => none(),
858            })
859        }
860        ("map", "set") => {
861            let mut m = expect_map(args.first())?.clone();
862            let k = MapKey::from_value(args.get(1).ok_or("map.set: missing key")?)?;
863            let v = args.get(2).ok_or("map.set: missing value")?.clone();
864            m.insert(k, v);
865            Ok(Value::Map(m))
866        }
867        ("map", "delete") => {
868            let mut m = expect_map(args.first())?.clone();
869            let k = MapKey::from_value(args.get(1).ok_or("map.delete: missing key")?)?;
870            m.remove(&k);
871            Ok(Value::Map(m))
872        }
873        ("map", "keys") => {
874            let m = expect_map(args.first())?;
875            Ok(Value::List(m.keys().cloned().map(MapKey::into_value).collect()))
876        }
877        ("map", "values") => {
878            let m = expect_map(args.first())?;
879            Ok(Value::List(m.values().cloned().collect()))
880        }
881        ("map", "entries") => {
882            let m = expect_map(args.first())?;
883            Ok(Value::List(m.iter()
884                .map(|(k, v)| Value::Tuple(vec![k.as_value(), v.clone()]))
885                .collect()))
886        }
887        ("map", "from_list") => {
888            let pairs = expect_list(args.first())?;
889            let mut m = BTreeMap::new();
890            for p in pairs {
891                let items = match p {
892                    Value::Tuple(items) if items.len() == 2 => items,
893                    other => return Err(format!(
894                        "map.from_list element must be a 2-tuple, got {other:?}")),
895                };
896                let k = MapKey::from_value(&items[0])?;
897                m.insert(k, items[1].clone());
898            }
899            Ok(Value::Map(m))
900        }
901
902        // -- set --
903        ("set", "new") => Ok(Value::Set(BTreeSet::new())),
904        ("set", "size") => Ok(Value::Int(expect_set(args.first())?.len() as i64)),
905        ("set", "has") => {
906            let s = expect_set(args.first())?;
907            let k = MapKey::from_value(args.get(1).ok_or("set.has: missing element")?)?;
908            Ok(Value::Bool(s.contains(&k)))
909        }
910        ("set", "add") => {
911            let mut s = expect_set(args.first())?.clone();
912            let k = MapKey::from_value(args.get(1).ok_or("set.add: missing element")?)?;
913            s.insert(k);
914            Ok(Value::Set(s))
915        }
916        ("set", "delete") => {
917            let mut s = expect_set(args.first())?.clone();
918            let k = MapKey::from_value(args.get(1).ok_or("set.delete: missing element")?)?;
919            s.remove(&k);
920            Ok(Value::Set(s))
921        }
922        ("set", "to_list") => {
923            let s = expect_set(args.first())?;
924            Ok(Value::List(s.iter().cloned().map(MapKey::into_value).collect()))
925        }
926        ("set", "from_list") => {
927            let xs = expect_list(args.first())?;
928            let mut s = BTreeSet::new();
929            for x in xs {
930                s.insert(MapKey::from_value(x)?);
931            }
932            Ok(Value::Set(s))
933        }
934        ("set", "union") => {
935            let a = expect_set(args.first())?;
936            let b = expect_set(args.get(1))?;
937            Ok(Value::Set(a.union(b).cloned().collect()))
938        }
939        ("set", "intersect") => {
940            let a = expect_set(args.first())?;
941            let b = expect_set(args.get(1))?;
942            Ok(Value::Set(a.intersection(b).cloned().collect()))
943        }
944        ("set", "diff") => {
945            let a = expect_set(args.first())?;
946            let b = expect_set(args.get(1))?;
947            Ok(Value::Set(a.difference(b).cloned().collect()))
948        }
949        ("set", "is_empty") => Ok(Value::Bool(expect_set(args.first())?.is_empty())),
950        ("set", "is_subset") => {
951            let a = expect_set(args.first())?;
952            let b = expect_set(args.get(1))?;
953            Ok(Value::Bool(a.is_subset(b)))
954        }
955
956        // -- map helpers --
957        ("map", "merge") => {
958            // b's entries override a's. We construct a new BTreeMap
959            // by extending a with b's pairs.
960            let a = expect_map(args.first())?.clone();
961            let b = expect_map(args.get(1))?;
962            let mut out = a;
963            for (k, v) in b {
964                out.insert(k.clone(), v.clone());
965            }
966            Ok(Value::Map(out))
967        }
968        ("map", "is_empty") => Ok(Value::Bool(expect_map(args.first())?.is_empty())),
969
970        // -- deque --
971        ("deque", "new") => Ok(Value::Deque(std::collections::VecDeque::new())),
972        ("deque", "size") => Ok(Value::Int(expect_deque(args.first())?.len() as i64)),
973        ("deque", "is_empty") => Ok(Value::Bool(expect_deque(args.first())?.is_empty())),
974        ("deque", "push_back") => {
975            let mut d = expect_deque(args.first())?.clone();
976            let x = args.get(1).ok_or("deque.push_back: missing value")?.clone();
977            d.push_back(x);
978            Ok(Value::Deque(d))
979        }
980        ("deque", "push_front") => {
981            let mut d = expect_deque(args.first())?.clone();
982            let x = args.get(1).ok_or("deque.push_front: missing value")?.clone();
983            d.push_front(x);
984            Ok(Value::Deque(d))
985        }
986        ("deque", "pop_back") => {
987            let mut d = expect_deque(args.first())?.clone();
988            match d.pop_back() {
989                Some(x) => Ok(Value::Variant {
990                    name: "Some".into(),
991                    args: vec![Value::Tuple(vec![x, Value::Deque(d)])],
992                }),
993                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
994            }
995        }
996        ("deque", "pop_front") => {
997            let mut d = expect_deque(args.first())?.clone();
998            match d.pop_front() {
999                Some(x) => Ok(Value::Variant {
1000                    name: "Some".into(),
1001                    args: vec![Value::Tuple(vec![x, Value::Deque(d)])],
1002                }),
1003                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1004            }
1005        }
1006        ("deque", "peek_back") => {
1007            let d = expect_deque(args.first())?;
1008            match d.back() {
1009                Some(x) => Ok(Value::Variant {
1010                    name: "Some".into(),
1011                    args: vec![x.clone()],
1012                }),
1013                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1014            }
1015        }
1016        ("deque", "peek_front") => {
1017            let d = expect_deque(args.first())?;
1018            match d.front() {
1019                Some(x) => Ok(Value::Variant {
1020                    name: "Some".into(),
1021                    args: vec![x.clone()],
1022                }),
1023                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1024            }
1025        }
1026        ("deque", "from_list") => {
1027            let xs = expect_list(args.first())?;
1028            Ok(Value::Deque(xs.iter().cloned().collect()))
1029        }
1030        ("deque", "to_list") => {
1031            let d = expect_deque(args.first())?;
1032            Ok(Value::List(d.iter().cloned().collect()))
1033        }
1034
1035        // -- crypto (pure ops; crypto.random is effectful and routes
1036        // through the handler under [random], see try_pure_builtin) --
1037        ("crypto", "sha256") => {
1038            use sha2::{Digest, Sha256};
1039            let data = expect_bytes(args.first())?;
1040            let mut h = Sha256::new();
1041            h.update(data);
1042            Ok(Value::Bytes(h.finalize().to_vec()))
1043        }
1044        ("crypto", "sha512") => {
1045            use sha2::{Digest, Sha512};
1046            let data = expect_bytes(args.first())?;
1047            let mut h = Sha512::new();
1048            h.update(data);
1049            Ok(Value::Bytes(h.finalize().to_vec()))
1050        }
1051        ("crypto", "md5") => {
1052            use md5::{Digest, Md5};
1053            let data = expect_bytes(args.first())?;
1054            let mut h = Md5::new();
1055            h.update(data);
1056            Ok(Value::Bytes(h.finalize().to_vec()))
1057        }
1058        // BLAKE2b (#382) — 64-byte digest, faster than SHA-512 on most
1059        // CPUs with the same security level. Backed by the `blake2`
1060        // crate; uses `Blake2b512` (the standard 512-bit variant).
1061        ("crypto", "blake2b") => {
1062            use blake2::{Blake2b512, Digest};
1063            let data = expect_bytes(args.first())?;
1064            let mut h = Blake2b512::new();
1065            h.update(data);
1066            Ok(Value::Bytes(h.finalize().to_vec()))
1067        }
1068        // Keccak-256 (#655) — Ethereum's hash. This is the original
1069        // Keccak padding (0x01), NOT NIST SHA3-256 (0x06); they produce
1070        // different digests for the same input. Used for EIP-712 struct
1071        // hashing, the final signing digest, and address derivation.
1072        ("crypto", "keccak256") => {
1073            use sha3::{Digest, Keccak256};
1074            let data = expect_bytes(args.first())?;
1075            let mut h = Keccak256::new();
1076            h.update(data);
1077            Ok(Value::Bytes(h.finalize().to_vec()))
1078        }
1079        // Hex-string convenience hashers (#382). Equivalent to
1080        // `hex_encode(shaN(bytes_of_str(s)))` for the common case
1081        // where the caller has a Str and wants a hex Str digest.
1082        ("crypto", "sha256_str") => {
1083            use sha2::{Digest, Sha256};
1084            let s = expect_str(args.first())?;
1085            let mut h = Sha256::new();
1086            h.update(s.as_bytes());
1087            Ok(Value::Str(hex::encode(h.finalize()).into()))
1088        }
1089        ("crypto", "sha512_str") => {
1090            use sha2::{Digest, Sha512};
1091            let s = expect_str(args.first())?;
1092            let mut h = Sha512::new();
1093            h.update(s.as_bytes());
1094            Ok(Value::Str(hex::encode(h.finalize()).into()))
1095        }
1096        ("crypto", "hmac_sha256") => {
1097            use hmac::{Hmac, KeyInit, Mac};
1098            type HmacSha256 = Hmac<sha2::Sha256>;
1099            let key = expect_bytes(args.first())?;
1100            let data = expect_bytes(args.get(1))?;
1101            let mut mac = HmacSha256::new_from_slice(key)
1102                .map_err(|e| format!("hmac_sha256 key: {e}"))?;
1103            mac.update(data);
1104            Ok(Value::Bytes(mac.finalize().into_bytes().to_vec()))
1105        }
1106        ("crypto", "hmac_sha512") => {
1107            use hmac::{Hmac, KeyInit, Mac};
1108            type HmacSha512 = Hmac<sha2::Sha512>;
1109            let key = expect_bytes(args.first())?;
1110            let data = expect_bytes(args.get(1))?;
1111            let mut mac = HmacSha512::new_from_slice(key)
1112                .map_err(|e| format!("hmac_sha512 key: {e}"))?;
1113            mac.update(data);
1114            Ok(Value::Bytes(mac.finalize().into_bytes().to_vec()))
1115        }
1116        // ed25519 asymmetric signatures (#643). A secret key is its 32-byte
1117        // seed — generate one with the effectful `crypto.random(32)`. These three
1118        // ops are pure (deterministic given their inputs).
1119        ("crypto", "ed25519_public_key") => {
1120            use ed25519_dalek::SigningKey;
1121            let secret = expect_bytes(args.first())?;
1122            let seed: [u8; 32] = match secret.as_slice().try_into() {
1123                Ok(s)  => s,
1124                Err(_) => return Ok(err_v(Value::Str("ed25519_public_key: secret must be 32 bytes".into()))),
1125            };
1126            let sk = SigningKey::from_bytes(&seed);
1127            Ok(ok_v(Value::Bytes(sk.verifying_key().to_bytes().to_vec())))
1128        }
1129        ("crypto", "ed25519_sign") => {
1130            use ed25519_dalek::{Signer, SigningKey};
1131            let secret = expect_bytes(args.first())?;
1132            let message = expect_bytes(args.get(1))?;
1133            let seed: [u8; 32] = match secret.as_slice().try_into() {
1134                Ok(s)  => s,
1135                Err(_) => return Ok(err_v(Value::Str("ed25519_sign: secret must be 32 bytes".into()))),
1136            };
1137            let sk = SigningKey::from_bytes(&seed);
1138            Ok(ok_v(Value::Bytes(sk.sign(message).to_bytes().to_vec())))
1139        }
1140        ("crypto", "ed25519_verify") => {
1141            use ed25519_dalek::{Signature, Verifier, VerifyingKey};
1142            let public = expect_bytes(args.first())?;
1143            let message = expect_bytes(args.get(1))?;
1144            let sig_bytes = expect_bytes(args.get(2))?;
1145            let pk_arr: [u8; 32] = match public.as_slice().try_into() {
1146                Ok(p)  => p,
1147                Err(_) => return Ok(Value::Bool(false)),
1148            };
1149            let sig_arr: [u8; 64] = match sig_bytes.as_slice().try_into() {
1150                Ok(s)  => s,
1151                Err(_) => return Ok(Value::Bool(false)),
1152            };
1153            let vk = match VerifyingKey::from_bytes(&pk_arr) {
1154                Ok(v)  => v,
1155                Err(_) => return Ok(Value::Bool(false)),
1156            };
1157            let sig = Signature::from_bytes(&sig_arr);
1158            Ok(Value::Bool(vk.verify(message, &sig).is_ok()))
1159        }
1160        // P-256 ECDSA / ES256 (#651). Backs the JWT/SD-JWT signing
1161        // primitives `lex-jose` needs for AP2 mandates. Key minting
1162        // (`p256_generate`) is effectful (`[random]`) and lives in the
1163        // handler; these three ops are deterministic given their inputs.
1164        //
1165        // - Secret key: 32-byte scalar (`SigningKey::to_bytes`).
1166        // - Public key: 33-byte SEC1 *compressed* point.
1167        // - Signature: ASN.1 DER-encoded (standard for ES256/JOSE
1168        //   producers that emit DER; JWK/raw-r||s conversion is a
1169        //   `lex-jose` concern).
1170        // Signing hashes `msg` with SHA-256 internally (ES256).
1171        ("crypto", "p256_public_key") => {
1172            use p256::ecdsa::SigningKey;
1173            let secret = expect_bytes(args.first())?;
1174            let sk = match SigningKey::from_slice(secret) {
1175                Ok(k)  => k,
1176                Err(_) => return Ok(err_v(Value::Str(
1177                    "p256_public_key: secret must be a 32-byte P-256 scalar".into()))),
1178            };
1179            let point = sk.verifying_key().to_encoded_point(true);
1180            Ok(ok_v(Value::Bytes(point.as_bytes().to_vec())))
1181        }
1182        ("crypto", "p256_sign") => {
1183            use p256::ecdsa::{signature::Signer, Signature, SigningKey};
1184            let secret = expect_bytes(args.first())?;
1185            let message = expect_bytes(args.get(1))?;
1186            let sk = match SigningKey::from_slice(secret) {
1187                Ok(k)  => k,
1188                Err(_) => return Ok(err_v(Value::Str(
1189                    "p256_sign: secret must be a 32-byte P-256 scalar".into()))),
1190            };
1191            let sig: Signature = sk.sign(message);
1192            Ok(ok_v(Value::Bytes(sig.to_der().as_bytes().to_vec())))
1193        }
1194        ("crypto", "p256_verify") => {
1195            use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
1196            let public = expect_bytes(args.first())?;
1197            let message = expect_bytes(args.get(1))?;
1198            let sig_bytes = expect_bytes(args.get(2))?;
1199            let vk = match VerifyingKey::from_sec1_bytes(public) {
1200                Ok(v)  => v,
1201                Err(_) => return Ok(Value::Bool(false)),
1202            };
1203            let sig = match Signature::from_der(sig_bytes) {
1204                Ok(s)  => s,
1205                Err(_) => return Ok(Value::Bool(false)),
1206            };
1207            Ok(Value::Bool(vk.verify(message, &sig).is_ok()))
1208        }
1209        // secp256k1 ECDSA + recovery (#655) — the EVM curve, for EIP-712
1210        // typed-data signing (EIP-3009 / x402 `exact`). Key minting
1211        // (`secp256k1_generate`) is effectful (`[random]`) and lives in
1212        // the handler; these ops are deterministic given their inputs.
1213        //
1214        // Unlike `p256_*`, sign/verify take a PRE-HASHED 32-byte digest
1215        // (EIP-712 already hashed) and do not hash again.
1216        // - Secret key: 32-byte scalar.
1217        // - Public key: 65-byte uncompressed SEC1 point (0x04‖X‖Y).
1218        // - Signature: 65 bytes `r‖s‖v`, v ∈ {27,28}, low-S (EIP-2).
1219        ("crypto", "secp256k1_public_key") => {
1220            use k256::ecdsa::SigningKey;
1221            let secret = expect_bytes(args.first())?;
1222            let sk = match SigningKey::from_slice(secret) {
1223                Ok(k)  => k,
1224                Err(_) => return Ok(err_v(Value::Str(
1225                    "secp256k1_public_key: secret must be a 32-byte secp256k1 scalar".into()))),
1226            };
1227            // Uncompressed SEC1 so callers can derive an Ethereum address
1228            // as keccak256(point[1..])[12..] without decompressing.
1229            let point = sk.verifying_key().to_encoded_point(false);
1230            Ok(ok_v(Value::Bytes(point.as_bytes().to_vec())))
1231        }
1232        ("crypto", "secp256k1_sign_digest") => {
1233            use k256::ecdsa::SigningKey;
1234            let secret = expect_bytes(args.first())?;
1235            let digest = expect_bytes(args.get(1))?;
1236            if digest.len() != 32 {
1237                return Ok(err_v(Value::Str(
1238                    "secp256k1_sign_digest: digest must be exactly 32 bytes".into())));
1239            }
1240            let sk = match SigningKey::from_slice(secret) {
1241                Ok(k)  => k,
1242                Err(_) => return Ok(err_v(Value::Str(
1243                    "secp256k1_sign_digest: secret must be a 32-byte secp256k1 scalar".into()))),
1244            };
1245            // RustCrypto normalizes to low-S (EIP-2) and returns the
1246            // recovery id. Ethereum's `v` is 27 + recid.
1247            match sk.sign_prehash_recoverable(digest) {
1248                Ok((sig, recid)) => {
1249                    let mut out = sig.to_bytes().to_vec(); // 64 bytes: r‖s
1250                    out.push(27u8 + recid.to_byte());
1251                    Ok(ok_v(Value::Bytes(out)))
1252                }
1253                Err(e) => Ok(err_v(Value::Str(
1254                    format!("secp256k1_sign_digest: {e}").into()))),
1255            }
1256        }
1257        ("crypto", "secp256k1_recover") => {
1258            use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
1259            let digest = expect_bytes(args.first())?;
1260            let sig_bytes = expect_bytes(args.get(1))?;
1261            if digest.len() != 32 {
1262                return Ok(err_v(Value::Str(
1263                    "secp256k1_recover: digest must be exactly 32 bytes".into())));
1264            }
1265            if sig_bytes.len() != 65 {
1266                return Ok(err_v(Value::Str(
1267                    "secp256k1_recover: signature must be 65 bytes (r‖s‖v)".into())));
1268            }
1269            let sig = match Signature::from_slice(&sig_bytes[..64]) {
1270                Ok(s)  => s,
1271                Err(_) => return Ok(err_v(Value::Str(
1272                    "secp256k1_recover: malformed r‖s".into()))),
1273            };
1274            // Accept both Ethereum {27,28} and raw {0,1} encodings of v.
1275            let v = sig_bytes[64];
1276            let recid_byte = if v >= 27 { v - 27 } else { v };
1277            let recid = match RecoveryId::from_byte(recid_byte) {
1278                Some(r) => r,
1279                None    => return Ok(err_v(Value::Str(
1280                    "secp256k1_recover: invalid recovery id".into()))),
1281            };
1282            match VerifyingKey::recover_from_prehash(digest, &sig, recid) {
1283                Ok(vk) => Ok(ok_v(Value::Bytes(
1284                    vk.to_encoded_point(false).as_bytes().to_vec()))),
1285                Err(e) => Ok(err_v(Value::Str(
1286                    format!("secp256k1_recover: {e}").into()))),
1287            }
1288        }
1289        ("crypto", "secp256k1_verify") => {
1290            use k256::ecdsa::{signature::hazmat::PrehashVerifier, Signature, VerifyingKey};
1291            let public = expect_bytes(args.first())?;
1292            let digest = expect_bytes(args.get(1))?;
1293            let sig_bytes = expect_bytes(args.get(2))?;
1294            if digest.len() != 32 {
1295                return Ok(Value::Bool(false));
1296            }
1297            let vk = match VerifyingKey::from_sec1_bytes(public) {
1298                Ok(v)  => v,
1299                Err(_) => return Ok(Value::Bool(false)),
1300            };
1301            // Accept a 65-byte recoverable sig (drop v) or a bare 64-byte r‖s.
1302            let rs = if sig_bytes.len() == 65 { &sig_bytes[..64] } else { sig_bytes.as_slice() };
1303            let sig = match Signature::from_slice(rs) {
1304                Ok(s)  => s,
1305                Err(_) => return Ok(Value::Bool(false)),
1306            };
1307            Ok(Value::Bool(vk.verify_prehash(digest, &sig).is_ok()))
1308        }
1309        ("crypto", "base64_encode") => {
1310            use base64::{Engine, engine::general_purpose::STANDARD};
1311            let data = expect_bytes(args.first())?;
1312            Ok(Value::Str(STANDARD.encode(data).into()))
1313        }
1314        ("crypto", "base64_decode") => {
1315            use base64::{Engine, engine::general_purpose::STANDARD};
1316            let s = expect_str(args.first())?;
1317            match STANDARD.decode(s) {
1318                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1319                Err(e) => Ok(err_v(Value::Str(format!("base64: {e}").into()))),
1320            }
1321        }
1322        // URL-safe base64 (#382). Alphabet `-_` instead of `+/`,
1323        // padding stripped. Use for JWT segments, signed cookies, any
1324        // token that travels in a URL or path component.
1325        ("crypto", "base64url_encode") => {
1326            use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1327            let data = expect_bytes(args.first())?;
1328            Ok(Value::Str(URL_SAFE_NO_PAD.encode(data).into()))
1329        }
1330        ("crypto", "base64url_decode") => {
1331            use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1332            let s = expect_str(args.first())?;
1333            match URL_SAFE_NO_PAD.decode(s) {
1334                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1335                Err(e) => Ok(err_v(Value::Str(format!("base64url: {e}").into()))),
1336            }
1337        }
1338        ("crypto", "hex_encode") => {
1339            let data = expect_bytes(args.first())?;
1340            Ok(Value::Str(hex::encode(data).into()))
1341        }
1342        ("crypto", "hex_decode") => {
1343            let s = expect_str(args.first())?;
1344            match hex::decode(s) {
1345                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1346                Err(e) => Ok(err_v(Value::Str(format!("hex: {e}").into()))),
1347            }
1348        }
1349        // base58 (#658). Bitcoin/Solana alphabet, no Base58Check checksum —
1350        // the encoding Solana uses for pubkeys, signatures, and the x402
1351        // `exact` payload. Pure, like base64/hex.
1352        ("crypto", "base58_encode") => {
1353            let data = expect_bytes(args.first())?;
1354            Ok(Value::Str(bs58::encode(data).into_string().into()))
1355        }
1356        ("crypto", "base58_decode") => {
1357            let s = expect_str(args.first())?;
1358            match bs58::decode(s).into_vec() {
1359                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1360                Err(e) => Ok(err_v(Value::Str(format!("base58: {e}").into()))),
1361            }
1362        }
1363        ("crypto", "constant_time_eq") | ("crypto", "eq") => {
1364            use subtle::ConstantTimeEq;
1365            let a = expect_bytes(args.first())?;
1366            let b = expect_bytes(args.get(1))?;
1367            // `subtle` returns Choice; comparison only meaningful when
1368            // lengths match. For mismatched lengths return false in
1369            // constant time (length itself isn't secret, but we want
1370            // a single comparison shape).
1371            //
1372            // `eq` (#382) is the recommended spelling — same semantics,
1373            // shorter name. `constant_time_eq` stays as an alias for
1374            // existing callers.
1375            let eq = if a.len() == b.len() {
1376                a.ct_eq(b).into()
1377            } else {
1378                false
1379            };
1380            Ok(Value::Bool(eq))
1381        }
1382        // Constant-time string equality (#382). Compares the bytes of
1383        // both strings; semantics identical to `eq` after `.as_bytes()`.
1384        ("crypto", "eq_str") => {
1385            use subtle::ConstantTimeEq;
1386            let a = expect_str(args.first())?;
1387            let b = expect_str(args.get(1))?;
1388            let eq = if a.len() == b.len() {
1389                a.as_bytes().ct_eq(b.as_bytes()).into()
1390            } else {
1391                false
1392            };
1393            Ok(Value::Bool(eq))
1394        }
1395
1396        // -- AEAD (#382 AEAD slice). Pure: same key + nonce + aad +
1397        // plaintext always produce the same ciphertext + tag. The
1398        // `[random]` effect lives one level up at the caller, where the
1399        // nonce is generated; AEAD ops themselves are deterministic and
1400        // therefore pure.
1401        //
1402        // AES-GCM key length is 128 / 192 / 256 bits; we pick the
1403        // variant from the key size at runtime so callers don't have
1404        // to choose between three near-identical wrappers.
1405        ("crypto", "aes_gcm_seal") => Ok(aes_gcm_seal_impl(args)),
1406        ("crypto", "aes_gcm_open") => Ok(aes_gcm_open_impl(args)),
1407        ("crypto", "chacha20_poly1305_seal") => Ok(chacha20_seal_impl(args)),
1408        ("crypto", "chacha20_poly1305_open") => Ok(chacha20_open_impl(args)),
1409        ("crypto", "pbkdf2_sha256") => Ok(pbkdf2_sha256_impl(args)),
1410        ("crypto", "hkdf_sha256")   => Ok(hkdf_sha256_impl(args)),
1411        ("crypto", "argon2id")      => Ok(argon2id_impl(args)),
1412
1413        // -- random (#219): pure, seeded RNG. Backed by SplitMix64;
1414        // state is the u64 mixer state stored as a single i64 in
1415        // `Rng = { state :: Int }`. Threading the Rng through the
1416        // call site is the user's responsibility — there is no
1417        // global RNG and therefore no `[random]` effect tag for
1418        // pure-seeded usage. --
1419        ("random", "seed") => {
1420            let s = args.first().ok_or("random.seed: missing arg")?.as_int();
1421            // Hash the user-supplied seed once before installing it.
1422            // SplitMix64 is fine when seeded with any u64, but
1423            // hashing first protects against pathological seeds
1424            // (e.g., 0) that would make the very first draw zero.
1425            let mixed = splitmix64(s as u64).0;
1426            Ok(rng_value(mixed))
1427        }
1428        ("random", "int") => {
1429            let state = rng_decode(args.first())?;
1430            let lo = args.get(1).ok_or("random.int: missing lo")?.as_int();
1431            let hi = args.get(2).ok_or("random.int: missing hi")?.as_int();
1432            if hi < lo {
1433                return Err(format!(
1434                    "random.int: hi ({hi}) must be >= lo ({lo})"));
1435            }
1436            let span = (hi as i128) - (lo as i128) + 1;
1437            let (raw, next_state) = splitmix64(state);
1438            // Reduce uniformly to [lo, hi]. The bias from a plain
1439            // modulo is at most `(u64::MAX % span) / u64::MAX`,
1440            // which for any practical span is invisible. Crypto
1441            // applications should use `crypto.random` instead.
1442            let drawn = lo as i128 + (raw as u128 % span as u128) as i128;
1443            Ok(Value::Tuple(vec![
1444                Value::Int(drawn as i64),
1445                rng_value(next_state),
1446            ]))
1447        }
1448        ("random", "float") => {
1449            let state = rng_decode(args.first())?;
1450            let (raw, next_state) = splitmix64(state);
1451            // Take the top 53 bits and divide by 2^53 to land in
1452            // [0.0, 1.0); this is the standard f64 uniform draw.
1453            let f = ((raw >> 11) as f64) / ((1u64 << 53) as f64);
1454            Ok(Value::Tuple(vec![Value::Float(f), rng_value(next_state)]))
1455        }
1456        ("random", "choose") => {
1457            let state = rng_decode(args.first())?;
1458            let xs = match args.get(1) {
1459                Some(Value::List(xs)) => xs,
1460                _ => return Err("random.choose: expected List".into()),
1461            };
1462            if xs.is_empty() {
1463                return Ok(Value::Variant {
1464                    name: "None".into(), args: vec![],
1465                });
1466            }
1467            let (raw, next_state) = splitmix64(state);
1468            let idx = (raw as usize) % xs.len();
1469            let pick = xs[idx].clone();
1470            Ok(Value::Variant {
1471                name: "Some".into(),
1472                args: vec![Value::Tuple(vec![pick, rng_value(next_state)])],
1473            })
1474        }
1475
1476        // -- parser (#217): parser combinators. Parser values are
1477        // tagged Records — `{ kind: "Char", ch: "x" }` etc. — so
1478        // canonical equality follows from the canonical Record
1479        // encoding. The interpreter is `parser_run_impl`. --
1480        ("parser", "char") => {
1481            let s = expect_str(args.first())?;
1482            if s.chars().count() != 1 {
1483                return Err(format!(
1484                    "parser.char: expected 1-character string, got {s:?}"));
1485            }
1486            Ok(parser_node("Char", &[("ch", Value::Str(s.into()))]))
1487        }
1488        ("parser", "string") => {
1489            let s = expect_str(args.first())?;
1490            Ok(parser_node("String", &[("s", Value::Str(s.into()))]))
1491        }
1492        ("parser", "digit") => Ok(parser_node("Digit", &[])),
1493        ("parser", "alpha") => Ok(parser_node("Alpha", &[])),
1494        ("parser", "whitespace") => Ok(parser_node("Whitespace", &[])),
1495        ("parser", "eof") => Ok(parser_node("Eof", &[])),
1496        ("parser", "seq") => {
1497            let a = args.first().cloned()
1498                .ok_or_else(|| "parser.seq: missing first parser".to_string())?;
1499            let b = args.get(1).cloned()
1500                .ok_or_else(|| "parser.seq: missing second parser".to_string())?;
1501            Ok(parser_node("Seq", &[("a", a), ("b", b)]))
1502        }
1503        ("parser", "alt") => {
1504            let a = args.first().cloned()
1505                .ok_or_else(|| "parser.alt: missing first parser".to_string())?;
1506            let b = args.get(1).cloned()
1507                .ok_or_else(|| "parser.alt: missing second parser".to_string())?;
1508            Ok(parser_node("Alt", &[("a", a), ("b", b)]))
1509        }
1510        ("parser", "many") => {
1511            let p = args.first().cloned()
1512                .ok_or_else(|| "parser.many: missing inner parser".to_string())?;
1513            Ok(parser_node("Many", &[("p", p)]))
1514        }
1515        ("parser", "optional") => {
1516            let p = args.first().cloned()
1517                .ok_or_else(|| "parser.optional: missing inner parser".to_string())?;
1518            Ok(parser_node("Optional", &[("p", p)]))
1519        }
1520        // `parser.map` and `parser.and_then` (#221): closure-bearing
1521        // combinators. Constructors only — actual closure invocation
1522        // happens at parser.run time via the Vm-level interpreter.
1523        ("parser", "map") => {
1524            let p = args.first().cloned()
1525                .ok_or_else(|| "parser.map: missing parser".to_string())?;
1526            let f = args.get(1).cloned()
1527                .ok_or_else(|| "parser.map: missing closure".to_string())?;
1528            Ok(parser_node("Map", &[("p", p), ("f", f)]))
1529        }
1530        ("parser", "and_then") => {
1531            let p = args.first().cloned()
1532                .ok_or_else(|| "parser.and_then: missing parser".to_string())?;
1533            let f = args.get(1).cloned()
1534                .ok_or_else(|| "parser.and_then: missing closure".to_string())?;
1535            Ok(parser_node("AndThen", &[("p", p), ("f", f)]))
1536        }
1537        // `parser.run` is handled at the Vm level (lex-bytecode's
1538        // `Op::EffectCall` intercept) — it needs reentrant Vm access
1539        // to invoke the closures inside `Map` / `AndThen` nodes. The
1540        // pure-builtin path doesn't have that, so we deliberately do
1541        // *not* have a `("parser", "run")` arm here.
1542
1543        // -- regex (the compiled `Regex` is stored as the pattern
1544        // string; the runtime caches the actual `regex::Regex` so
1545        // ops don't re-compile on every call) --
1546        ("regex", "compile") => {
1547            let pat = expect_str(args.first())?;
1548            match get_or_compile_regex(&pat) {
1549                Ok(_) => Ok(ok_v(Value::Str(pat.into()))),
1550                Err(e) => Ok(err_v(Value::Str(e.into()))),
1551            }
1552        }
1553        ("regex", "is_match") => {
1554            let pat = expect_str(args.first())?;
1555            let s = expect_str(args.get(1))?;
1556            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.is_match: {e}"))?;
1557            Ok(Value::Bool(re.is_match(&s)))
1558        }
1559        // is_match_str :: Str, Str -> Bool
1560        // Compiles the first argument as a pattern on the fly (uses the shared
1561        // cache) and matches against the second.  Returns false on invalid
1562        // pattern rather than propagating an error, keeping the pure signature.
1563        ("regex", "is_match_str") => {
1564            let pat = expect_str(args.first())?;
1565            let s = expect_str(args.get(1))?;
1566            match get_or_compile_regex(&pat) {
1567                Ok(re) => Ok(Value::Bool(re.is_match(&s))),
1568                Err(_) => Ok(Value::Bool(false)),
1569            }
1570        }
1571        ("regex", "find") => {
1572            let pat = expect_str(args.first())?;
1573            let s = expect_str(args.get(1))?;
1574            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.find: {e}"))?;
1575            match re.captures(&s) {
1576                Some(caps) => Ok(Value::Variant {
1577                    name: "Some".into(),
1578                    args: vec![match_value(&caps)],
1579                }),
1580                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1581            }
1582        }
1583        ("regex", "find_all") => {
1584            let pat = expect_str(args.first())?;
1585            let s = expect_str(args.get(1))?;
1586            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.find_all: {e}"))?;
1587            let items: std::collections::VecDeque<Value> = re.captures_iter(&s).map(|caps| match_value(&caps)).collect();
1588            Ok(Value::List(items))
1589        }
1590        ("regex", "replace") => {
1591            let pat = expect_str(args.first())?;
1592            let s = expect_str(args.get(1))?;
1593            let rep = expect_str(args.get(2))?;
1594            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.replace: {e}"))?;
1595            Ok(Value::Str(re.replace(&s, rep.as_str()).into_owned().into()))
1596        }
1597        ("regex", "replace_all") => {
1598            let pat = expect_str(args.first())?;
1599            let s = expect_str(args.get(1))?;
1600            let rep = expect_str(args.get(2))?;
1601            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.replace_all: {e}"))?;
1602            Ok(Value::Str(re.replace_all(&s, rep.as_str()).into_owned().into()))
1603        }
1604        // -- datetime (pure ops; datetime.now is effectful and routes
1605        // through the handler under [time]) --
1606        ("datetime", "parse_iso") => {
1607            let s = expect_str(args.first())?;
1608            match chrono::DateTime::parse_from_rfc3339(&s) {
1609                Ok(dt) => Ok(ok_v(Value::Int(instant_from_chrono(dt)))),
1610                Err(e) => Ok(err_v(Value::Str(format!("parse_iso: {e}").into()))),
1611            }
1612        }
1613        ("datetime", "format_iso") => {
1614            let n = expect_int(args.first())?;
1615            Ok(Value::Str(format_iso(n).into()))
1616        }
1617        ("datetime", "parse") => {
1618            let s = expect_str(args.first())?;
1619            let fmt = expect_str(args.get(1))?;
1620            match chrono::NaiveDateTime::parse_from_str(&s, &fmt) {
1621                Ok(naive) => {
1622                    use chrono::TimeZone;
1623                    match chrono::Utc.from_local_datetime(&naive).single() {
1624                        Some(dt) => Ok(ok_v(Value::Int(instant_from_chrono(dt)))),
1625                        None => Ok(err_v(Value::Str("parse: ambiguous local time".into()))),
1626                    }
1627                }
1628                Err(e) => Ok(err_v(Value::Str(format!("parse: {e}").into()))),
1629            }
1630        }
1631        ("datetime", "format") => {
1632            let n = expect_int(args.first())?;
1633            let fmt = expect_str(args.get(1))?;
1634            let dt = chrono_from_instant(n);
1635            Ok(Value::Str(dt.format(&fmt).to_string().into()))
1636        }
1637        ("datetime", "to_components") => {
1638            let n = expect_int(args.first())?;
1639            let tz = match parse_tz_arg(args.get(1)) {
1640                Ok(t) => t,
1641                Err(e) => return Ok(err_v(Value::Str(e.into()))),
1642            };
1643            match resolve_tz_to_components(n, &tz) {
1644                Ok(rec) => Ok(ok_v(rec)),
1645                Err(e) => Ok(err_v(Value::Str(e.into()))),
1646            }
1647        }
1648        ("datetime", "from_components") => {
1649            let rec = match args.first() {
1650                Some(Value::Record { fields: r, .. }) => r.clone(),
1651                _ => return Err("from_components: expected DateTime record".into()),
1652            };
1653            match instant_from_components(&rec) {
1654                Ok(n) => Ok(ok_v(Value::Int(n))),
1655                Err(e) => Ok(err_v(Value::Str(e.into()))),
1656            }
1657        }
1658        ("datetime", "add") => {
1659            let a = expect_int(args.first())?;
1660            let d = expect_int(args.get(1))?;
1661            Ok(Value::Int(a.saturating_add(d)))
1662        }
1663        ("datetime", "diff") => {
1664            let a = expect_int(args.first())?;
1665            let b = expect_int(args.get(1))?;
1666            Ok(Value::Int(a.saturating_sub(b)))
1667        }
1668        ("datetime", "duration_seconds") => {
1669            let s = expect_float(args.first())?;
1670            let nanos = (s * 1_000_000_000.0) as i64;
1671            Ok(Value::Int(nanos))
1672        }
1673        ("datetime", "duration_minutes") => {
1674            let m = expect_int(args.first())?;
1675            Ok(Value::Int(m.saturating_mul(60_000_000_000)))
1676        }
1677        ("datetime", "duration_days") => {
1678            let d = expect_int(args.first())?;
1679            Ok(Value::Int(d.saturating_mul(86_400_000_000_000)))
1680        }
1681        // #331: Instant comparison ops.
1682        ("datetime", "before") => {
1683            let a = expect_int(args.first())?;
1684            let b = expect_int(args.get(1))?;
1685            Ok(Value::Bool(a < b))
1686        }
1687        ("datetime", "after") => {
1688            let a = expect_int(args.first())?;
1689            let b = expect_int(args.get(1))?;
1690            Ok(Value::Bool(a > b))
1691        }
1692        ("datetime", "compare") => {
1693            let a = expect_int(args.first())?;
1694            let b = expect_int(args.get(1))?;
1695            Ok(Value::Int(a.cmp(&b) as i64))
1696        }
1697        // #331: Duration scalar extraction.
1698        ("duration", "seconds") => {
1699            let nanos = expect_int(args.first())?;
1700            Ok(Value::Int(nanos / 1_000_000_000))
1701        }
1702
1703        ("regex", "split") => {
1704            let pat = expect_str(args.first())?;
1705            let s = expect_str(args.get(1))?;
1706            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.split: {e}"))?;
1707            let parts: std::collections::VecDeque<Value> = re.split(&s).map(|p| Value::Str(p.into())).collect();
1708            Ok(Value::List(parts))
1709        }
1710
1711        // -- http (builders + decoders; wire ops live in the
1712        // effect handler under `[net]`) --
1713        ("http", "with_header") => {
1714            let req = expect_record_pure(args.first())?.clone();
1715            let k = expect_str(args.get(1))?;
1716            let v = expect_str(args.get(2))?;
1717            Ok(Value::record_interned(http_set_header(req, &k, &v)))
1718        }
1719        ("http", "with_auth") => {
1720            let req = expect_record_pure(args.first())?.clone();
1721            let scheme = expect_str(args.get(1))?;
1722            let token = expect_str(args.get(2))?;
1723            let value = format!("{scheme} {token}");
1724            Ok(Value::record_interned(http_set_header(req, "Authorization", &value)))
1725        }
1726        ("http", "with_query") => {
1727            let req = expect_record_pure(args.first())?.clone();
1728            let params = match args.get(1) {
1729                Some(Value::Map(m)) => m.clone(),
1730                Some(other) => return Err(format!(
1731                    "http.with_query: params must be Map[Str, Str], got {other:?}")),
1732                None => return Err("http.with_query: missing params argument".into()),
1733            };
1734            Ok(Value::record_interned(http_append_query(req, &params)))
1735        }
1736        ("http", "with_timeout_ms") => {
1737            let req = expect_record_pure(args.first())?.clone();
1738            let ms = expect_int(args.get(1))?;
1739            let mut out = req;
1740            out.insert("timeout_ms".into(), Value::Variant {
1741                name: "Some".into(),
1742                args: vec![Value::Int(ms)],
1743            });
1744            Ok(Value::record_interned(out))
1745        }
1746        ("http", "json_body") => {
1747            let resp = expect_record_pure(args.first())?;
1748            let body = match resp.get("body") {
1749                Some(Value::Bytes(b)) => b.clone(),
1750                _ => return Err("http.json_body: HttpResponse.body must be Bytes".into()),
1751            };
1752            let s = match std::str::from_utf8(&body) {
1753                Ok(s) => s,
1754                Err(e) => return Ok(http_decode_err_pure(format!("body not UTF-8: {e}"))),
1755            };
1756            match serde_json::from_str::<serde_json::Value>(s) {
1757                Ok(j) => Ok(ok_v(Value::from_json(&j))),
1758                Err(e) => Ok(http_decode_err_pure(format!("json parse: {e}"))),
1759            }
1760        }
1761        ("http", "text_body") => {
1762            let resp = expect_record_pure(args.first())?;
1763            let body = match resp.get("body") {
1764                Some(Value::Bytes(b)) => b.clone(),
1765                _ => return Err("http.text_body: HttpResponse.body must be Bytes".into()),
1766            };
1767            match String::from_utf8(body) {
1768                Ok(s) => Ok(ok_v(Value::Str(s.into()))),
1769                Err(e) => Ok(http_decode_err_pure(format!("body not UTF-8: {e}"))),
1770            }
1771        }
1772
1773        // -- std.cli (Rubric port): argparse-equivalent for end-user
1774        // programs. Specs are tagged Json values; the parser walks
1775        // argv against the spec and returns a CliParsed Json record.
1776        ("cli", "flag") => {
1777            let name = expect_str(args.first())?;
1778            let short = opt_str(args.get(1));
1779            let help = expect_str(args.get(2))?;
1780            Ok(value_from_json(crate::cli::flag_spec(&name, short.as_deref(), &help)))
1781        }
1782        ("cli", "option") => {
1783            let name = expect_str(args.first())?;
1784            let short = opt_str(args.get(1));
1785            let help = expect_str(args.get(2))?;
1786            let default = opt_str(args.get(3));
1787            Ok(value_from_json(crate::cli::option_spec(&name, short.as_deref(), &help, default.as_deref())))
1788        }
1789        ("cli", "positional") => {
1790            let name = expect_str(args.first())?;
1791            let help = expect_str(args.get(1))?;
1792            let required = expect_bool(args.get(2))?;
1793            Ok(value_from_json(crate::cli::positional_spec(&name, &help, required)))
1794        }
1795        ("cli", "spec") => {
1796            let name = expect_str(args.first())?;
1797            let help = expect_str(args.get(1))?;
1798            let arg_specs: Vec<serde_json::Value> = expect_list(args.get(2))?
1799                .iter().map(value_to_json).collect();
1800            let subs: Vec<serde_json::Value> = expect_list(args.get(3))?
1801                .iter().map(value_to_json).collect();
1802            Ok(value_from_json(crate::cli::build_spec(&name, &help, arg_specs, subs)))
1803        }
1804        ("cli", "parse") => {
1805            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1806            let argv: Vec<String> = expect_list(args.get(1))?
1807                .iter().map(|v| match v {
1808                    Value::Str(s) => Ok(s.to_string()),
1809                    other => Err(format!("cli.parse: argv must be List[Str], got {other:?}")),
1810                }).collect::<Result<_, _>>()?;
1811            match crate::cli::parse(&spec, &argv) {
1812                Ok(parsed) => Ok(ok_v(value_from_json(parsed))),
1813                Err(msg) => Ok(err_v(Value::Str(msg.into()))),
1814            }
1815        }
1816        ("cli", "envelope") => {
1817            let ok = expect_bool(args.first())?;
1818            let cmd = expect_str(args.get(1))?;
1819            let data = value_to_json(args.get(2).unwrap_or(&Value::Unit));
1820            Ok(value_from_json(crate::cli::envelope(ok, &cmd, data)))
1821        }
1822        ("cli", "describe") => {
1823            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1824            Ok(value_from_json(crate::cli::describe(&spec)))
1825        }
1826        ("cli", "help") => {
1827            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1828            Ok(Value::Str(crate::cli::help_text(&spec).into()))
1829        }
1830
1831        // -- arrow -- delegated to a dedicated module (#426)
1832        ("arrow", op) => match crate::arrow::dispatch(op, args) {
1833            Some(r) => r,
1834            None => Err(format!("unknown pure builtin: arrow.{op}")),
1835        },
1836        // -- df -- Polars-backed query ops (#427), gated behind the
1837        // `df` feature so embedders that don't need dataframes avoid
1838        // the polars dep tree.
1839        #[cfg(feature = "df")]
1840        ("df", op) => match crate::df::dispatch(op, args) {
1841            Some(r) => r,
1842            None => Err(format!("unknown pure builtin: df.{op}")),
1843        },
1844        #[cfg(not(feature = "df"))]
1845        ("df", op) => Err(format!(
1846            "df.{op}: this build was compiled without the `df` feature; \
1847             Polars-backed dataframe query ops are unavailable"
1848        )),
1849
1850        // -- std.decimal (#574): exact scaled-integer decimal arithmetic.
1851        // Decimal values are `{ coefficient :: Int, exponent :: Int }` records
1852        // representing `coefficient × 10^exponent`. All arithmetic is exact
1853        // (no IEEE 754 rounding); precision loss happens only at `round_to`,
1854        // which requires an explicit rounding mode string.
1855
1856        ("decimal", "decimal") => {
1857            let coef = expect_int(args.first())?;
1858            let exp  = expect_int(args.get(1))?;
1859            Ok(make_decimal(coef, exp))
1860        }
1861        ("decimal", "zero") => Ok(make_decimal(0, 0)),
1862        ("decimal", "one")  => Ok(make_decimal(1, 0)),
1863        ("decimal", "from_int") => {
1864            Ok(make_decimal(expect_int(args.first())?, 0))
1865        }
1866        ("decimal", "pow10") => {
1867            Ok(Value::Int(decimal_pow10(expect_int(args.first())?)?))
1868        }
1869        ("decimal", "add") => {
1870            let (ca, ea) = expect_decimal(args.first())?;
1871            let (cb, eb) = expect_decimal(args.get(1))?;
1872            let (a2, b2, e) = decimal_align(ca, ea, cb, eb)?;
1873            Ok(make_decimal(
1874                a2.checked_add(b2).ok_or("decimal.add: overflow")?, e))
1875        }
1876        ("decimal", "sub") => {
1877            let (ca, ea) = expect_decimal(args.first())?;
1878            let (cb, eb) = expect_decimal(args.get(1))?;
1879            let (a2, b2, e) = decimal_align(ca, ea, cb, eb)?;
1880            Ok(make_decimal(
1881                a2.checked_sub(b2).ok_or("decimal.sub: overflow")?, e))
1882        }
1883        ("decimal", "mul") => {
1884            let (ca, ea) = expect_decimal(args.first())?;
1885            let (cb, eb) = expect_decimal(args.get(1))?;
1886            Ok(make_decimal(
1887                ca.checked_mul(cb).ok_or("decimal.mul: overflow")?,
1888                ea.checked_add(eb).ok_or("decimal.mul: exponent overflow")?,
1889            ))
1890        }
1891        ("decimal", "compare") => {
1892            let (ca, ea) = expect_decimal(args.first())?;
1893            let (cb, eb) = expect_decimal(args.get(1))?;
1894            let (a2, b2, _) = decimal_align(ca, ea, cb, eb)?;
1895            Ok(Value::Int(if a2 < b2 { -1 } else if a2 > b2 { 1 } else { 0 }))
1896        }
1897        ("decimal", "is_zero")     => {
1898            let (c, _) = expect_decimal(args.first())?;
1899            Ok(Value::Bool(c == 0))
1900        }
1901        ("decimal", "is_positive") => {
1902            let (c, _) = expect_decimal(args.first())?;
1903            Ok(Value::Bool(c > 0))
1904        }
1905        ("decimal", "is_negative") => {
1906            let (c, _) = expect_decimal(args.first())?;
1907            Ok(Value::Bool(c < 0))
1908        }
1909        ("decimal", "negate") => {
1910            let (c, e) = expect_decimal(args.first())?;
1911            Ok(make_decimal(-c, e))
1912        }
1913        ("decimal", "abs") => {
1914            let (c, e) = expect_decimal(args.first())?;
1915            Ok(make_decimal(c.abs(), e))
1916        }
1917        ("decimal", "normalize") => {
1918            let (mut c, mut e) = expect_decimal(args.first())?;
1919            if c == 0 { return Ok(make_decimal(0, 0)); }
1920            while c % 10 == 0 { c /= 10; e += 1; }
1921            Ok(make_decimal(c, e))
1922        }
1923        ("decimal", "round_to") => {
1924            let (c, e)   = expect_decimal(args.first())?;
1925            let target_e = expect_int(args.get(1))?;
1926            let mode     = expect_str(args.get(2))?;
1927            Ok(make_decimal(decimal_round(c, e, target_e, &mode)?, target_e))
1928        }
1929        ("decimal", "to_str") => {
1930            let (c, e) = expect_decimal(args.first())?;
1931            Ok(Value::Str(decimal_to_str(c, e)?.into()))
1932        }
1933
1934        _ => Err(format!("unknown pure builtin: {kind}.{op}")),
1935    }
1936}
1937
1938// -- std.decimal helpers (#574) ------------------------------------------
1939
1940/// Extract `(coefficient, exponent)` from a `Decimal` record value.
1941fn expect_decimal(v: Option<&Value>) -> Result<(i64, i64), String> {
1942    match v {
1943        Some(Value::Record { fields, .. }) => {
1944            let coef = match fields.get("coefficient") {
1945                Some(Value::Int(n)) => *n,
1946                _ => return Err("decimal: missing or invalid 'coefficient' field".into()),
1947            };
1948            let exp = match fields.get("exponent") {
1949                Some(Value::Int(n)) => *n,
1950                _ => return Err("decimal: missing or invalid 'exponent' field".into()),
1951            };
1952            Ok((coef, exp))
1953        }
1954        Some(other) => Err(format!("decimal: expected {{ coefficient, exponent }} record, got {other:?}")),
1955        None => Err("decimal: missing argument".into()),
1956    }
1957}
1958
1959/// Build a `Decimal` `Value::Record`.
1960fn make_decimal(coefficient: i64, exponent: i64) -> Value {
1961    let mut fields = indexmap::IndexMap::new();
1962    fields.insert("coefficient".into(), Value::Int(coefficient));
1963    fields.insert("exponent".into(), Value::Int(exponent));
1964    Value::record_interned(fields)
1965}
1966
1967/// 10^n for n in [0, 18]. Returns an error outside that range.
1968fn decimal_pow10(n: i64) -> Result<i64, String> {
1969    if n < 0  { return Err(format!("decimal.pow10: negative exponent {n}")); }
1970    if n > 18 { return Err(format!("decimal.pow10: exponent {n} exceeds max (18)")); }
1971    Ok(10i64.pow(n as u32))
1972}
1973
1974/// Bring two Decimals to the same exponent.
1975/// Returns `(coef_a_aligned, coef_b_aligned, common_exponent)`.
1976fn decimal_align(ca: i64, ea: i64, cb: i64, eb: i64) -> Result<(i64, i64, i64), String> {
1977    if ea == eb { return Ok((ca, cb, ea)); }
1978    if ea > eb {
1979        let scale = decimal_pow10(ea - eb)?;
1980        let ca2 = ca.checked_mul(scale)
1981            .ok_or_else(|| format!("decimal: overflow aligning (shift {})", ea - eb))?;
1982        Ok((ca2, cb, eb))
1983    } else {
1984        let scale = decimal_pow10(eb - ea)?;
1985        let cb2 = cb.checked_mul(scale)
1986            .ok_or_else(|| format!("decimal: overflow aligning (shift {})", eb - ea))?;
1987        Ok((ca, cb2, ea))
1988    }
1989}
1990
1991/// Compute the rounded coefficient when scaling `c × 10^e` to `target_e`.
1992/// `target_e > e` (we're reducing precision): divides by `10^(target_e - e)`
1993/// and applies `mode`. When `target_e <= e` (gaining precision) multiplies
1994/// exactly — no rounding needed.
1995fn decimal_round(c: i64, e: i64, target_e: i64, mode: &str) -> Result<i64, String> {
1996    if e >= target_e {
1997        // Gaining precision (or staying equal) — exact, no rounding.
1998        let shift = e - target_e;
1999        let scale = decimal_pow10(shift)?;
2000        return c.checked_mul(scale)
2001            .ok_or_else(|| format!("decimal.round_to: overflow scaling (shift {shift})"));
2002    }
2003    // Losing precision — divide and round.
2004    let shift   = target_e - e;
2005    let divisor = decimal_pow10(shift)?;
2006    let q = c / divisor;
2007    let r = c % divisor;  // same sign as c (Rust truncation toward zero)
2008
2009    if r == 0 { return Ok(q); }
2010
2011    let abs_r   = r.abs();
2012    let positive = r > 0; // sign of the original value when q is near zero
2013
2014    let rounded = match mode {
2015        "Down"     => q,
2016        "Up"       => if positive { q + 1 } else { q - 1 },
2017        "Floor"    => if positive { q }     else { q - 1 },
2018        "Ceiling"  => if positive { q + 1 } else { q },
2019        "HalfUp"   => {
2020            if abs_r * 2 >= divisor {
2021                if positive { q + 1 } else { q - 1 }
2022            } else { q }
2023        }
2024        "HalfDown" => {
2025            if abs_r * 2 > divisor {
2026                if positive { q + 1 } else { q - 1 }
2027            } else { q }
2028        }
2029        "HalfEven" => {
2030            if abs_r * 2 > divisor {
2031                if positive { q + 1 } else { q - 1 }
2032            } else if abs_r * 2 == divisor {
2033                // Round to nearest even (banker's rounding)
2034                if q % 2 == 0 { q } else { if positive { q + 1 } else { q - 1 } }
2035            } else { q }
2036        }
2037        other => return Err(format!(
2038            "decimal.round_to: unknown rounding mode {other:?}; \
2039             valid modes: HalfUp HalfDown HalfEven Down Up Ceiling Floor")),
2040    };
2041    Ok(rounded)
2042}
2043
2044/// Format a Decimal as a decimal-notation string.
2045/// e.g. `(12345, -2)` → `"123.45"`, `(7, 2)` → `"700"`, `(-63, -2)` → `"-0.63"`.
2046fn decimal_to_str(c: i64, e: i64) -> Result<String, String> {
2047    if e == 0 { return Ok(c.to_string()); }
2048    if e > 0 {
2049        let scale = decimal_pow10(e)?;
2050        let val   = c.checked_mul(scale)
2051            .ok_or("decimal.to_str: overflow")?;
2052        return Ok(val.to_string());
2053    }
2054    // e < 0: render fractional digits
2055    let scale          = decimal_pow10(-e)?;
2056    let sign           = if c < 0 { "-" } else { "" };
2057    let abs_c          = c.abs();
2058    let int_part       = abs_c / scale;
2059    let frac_part      = abs_c % scale;
2060    let decimal_places = (-e) as usize;
2061    Ok(format!("{sign}{int_part}.{frac_part:0>decimal_places$}"))
2062}
2063
2064/// Extract `Option[Str]` arg as `Option<String>`. None and missing
2065/// arg both map to `None`. Used by the `cli` builders so callers can
2066/// pass `option.none()` or `Some("v")` interchangeably.
2067fn opt_str(arg: Option<&Value>) -> Option<String> {
2068    match arg {
2069        Some(Value::Variant { name, args }) if name == "Some" => {
2070            args.first().and_then(|v| match v {
2071                Value::Str(s) => Some(s.to_string()),
2072                _ => None,
2073            })
2074        }
2075        _ => None,
2076    }
2077}
2078
2079fn value_from_json(v: serde_json::Value) -> Value { Value::from_json(&v) }
2080
2081/// Process-wide cache of compiled regexes, keyed by the pattern
2082/// string. Compilation is the only cost we want to amortize; matching
2083/// the same `Regex` from multiple threads is safe (`regex::Regex` is
2084/// `Send + Sync`).
2085fn regex_cache() -> &'static Mutex<HashMap<String, regex::Regex>> {
2086    static CACHE: OnceLock<Mutex<HashMap<String, regex::Regex>>> = OnceLock::new();
2087    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
2088}
2089
2090fn get_or_compile_regex(pattern: &str) -> Result<regex::Regex, String> {
2091    let cache = regex_cache();
2092    {
2093        let guard = cache.lock().unwrap();
2094        if let Some(re) = guard.get(pattern) {
2095            return Ok(re.clone());
2096        }
2097    }
2098    let re = regex::Regex::new(pattern).map_err(|e| format!("invalid regex: {e}"))?;
2099    let mut guard = cache.lock().unwrap();
2100    guard.insert(pattern.to_string(), re.clone());
2101    Ok(re)
2102}
2103
2104/// Build a `Match` record value: `{ text, start, end, groups }` where
2105/// `groups` is the captured groups in order (group 0 is the full match).
2106/// Missing optional groups become empty strings.
2107fn match_value(caps: &regex::Captures) -> Value {
2108    let m0 = caps.get(0).expect("regex match always has group 0");
2109    let mut rec = indexmap::IndexMap::new();
2110    rec.insert("text".into(), Value::Str(m0.as_str().into()));
2111    rec.insert("start".into(), Value::Int(m0.start() as i64));
2112    rec.insert("end".into(), Value::Int(m0.end() as i64));
2113    let groups: std::collections::VecDeque<Value> = (1..caps.len())
2114        .map(|i| {
2115            Value::Str(
2116                caps.get(i)
2117                    .map(|m| m.as_str())
2118                    .unwrap_or_default()
2119                    .into(),
2120            )
2121        })
2122        .collect();
2123    rec.insert("groups".into(), Value::List(groups));
2124    Value::record_dynamic(rec)
2125}
2126
2127fn expect_map(v: Option<&Value>) -> Result<&BTreeMap<MapKey, Value>, String> {
2128    match v {
2129        Some(Value::Map(m)) => Ok(m),
2130        other => Err(format!("expected Map, got {other:?}")),
2131    }
2132}
2133
2134fn expect_set(v: Option<&Value>) -> Result<&BTreeSet<MapKey>, String> {
2135    match v {
2136        Some(Value::Set(s)) => Ok(s),
2137        other => Err(format!("expected Set, got {other:?}")),
2138    }
2139}
2140
2141/// Unpack any matrix-shaped Value into (rows, cols, flat row-major data).
2142/// Accepts the F64Array fast lane and the legacy `Record { rows, cols,
2143/// data: List[Float] }` shape for compatibility with hand-built matrices.
2144fn unpack_matrix(v: &Value) -> Result<(usize, usize, Vec<f64>), String> {
2145    if let Value::F64Array { rows, cols, data } = v {
2146        return Ok((*rows as usize, *cols as usize, data.clone()));
2147    }
2148    let rec = match v {
2149        Value::Record { fields: r, .. } => r,
2150        other => return Err(format!("expected matrix, got {other:?}")),
2151    };
2152    let rows = match rec.get("rows") {
2153        Some(Value::Int(n)) => *n as usize,
2154        _ => return Err("matrix: missing/invalid `rows`".into()),
2155    };
2156    let cols = match rec.get("cols") {
2157        Some(Value::Int(n)) => *n as usize,
2158        _ => return Err("matrix: missing/invalid `cols`".into()),
2159    };
2160    let data = match rec.get("data") {
2161        Some(Value::List(items)) => {
2162            let mut out = Vec::with_capacity(items.len());
2163            for it in items {
2164                out.push(match it {
2165                    Value::Float(f) => *f,
2166                    Value::Int(n) => *n as f64,
2167                    other => return Err(format!("matrix data: not numeric, got {other:?}")),
2168                });
2169            }
2170            out
2171        }
2172        _ => return Err("matrix: missing/invalid `data`".into()),
2173    };
2174    if data.len() != rows * cols {
2175        return Err(format!("matrix: data len {} != {rows}*{cols}", data.len()));
2176    }
2177    Ok((rows, cols, data))
2178}
2179
2180fn expect_bytes(v: Option<&Value>) -> Result<&Vec<u8>, String> {
2181    match v {
2182        Some(Value::Bytes(b)) => Ok(b),
2183        Some(other) => Err(format!("expected Bytes, got {other:?}")),
2184        None => Err("missing argument".into()),
2185    }
2186}
2187
2188fn first_arg(args: &[Value]) -> Result<&Value, String> {
2189    args.first().ok_or_else(|| "missing argument".into())
2190}
2191
2192fn tuple_index(v: &Value, i: usize) -> Result<Value, String> {
2193    match v {
2194        Value::Tuple(items) => items.get(i).cloned()
2195            .ok_or_else(|| format!("tuple index {i} out of range (len={})", items.len())),
2196        other => Err(format!("expected Tuple, got {other:?}")),
2197    }
2198}
2199
2200fn expect_str(v: Option<&Value>) -> Result<String, String> {
2201    match v {
2202        Some(Value::Str(s)) => Ok(s.to_string()),
2203        Some(other) => Err(format!("expected Str, got {other:?}")),
2204        None => Err("missing argument".into()),
2205    }
2206}
2207
2208fn expect_int(v: Option<&Value>) -> Result<i64, String> {
2209    match v {
2210        Some(Value::Int(n)) => Ok(*n),
2211        Some(other) => Err(format!("expected Int, got {other:?}")),
2212        None => Err("missing argument".into()),
2213    }
2214}
2215
2216fn expect_float(v: Option<&Value>) -> Result<f64, String> {
2217    match v {
2218        Some(Value::Float(f)) => Ok(*f),
2219        Some(other) => Err(format!("expected Float, got {other:?}")),
2220        None => Err("missing argument".into()),
2221    }
2222}
2223
2224fn expect_list(v: Option<&Value>) -> Result<&std::collections::VecDeque<Value>, String> {
2225    match v {
2226        Some(Value::List(xs)) => Ok(xs),
2227        Some(other) => Err(format!("expected List, got {other:?}")),
2228        None => Err("missing argument".into()),
2229    }
2230}
2231
2232fn expect_bool(v: Option<&Value>) -> Result<bool, String> {
2233    match v {
2234        Some(Value::Bool(b)) => Ok(*b),
2235        Some(other) => Err(format!("expected Bool, got {other:?}")),
2236        None => Err("missing argument".into()),
2237    }
2238}
2239
2240fn expect_deque(v: Option<&Value>) -> Result<&std::collections::VecDeque<Value>, String> {
2241    match v {
2242        Some(Value::Deque(d)) => Ok(d),
2243        Some(other) => Err(format!("expected Deque, got {other:?}")),
2244        None => Err("missing argument".into()),
2245    }
2246}
2247
2248fn some(v: Value) -> Value { Value::Variant { name: "Some".into(), args: vec![v] } }
2249fn none() -> Value { Value::Variant { name: "None".into(), args: Vec::new() } }
2250fn ok_v(v: Value) -> Value { Value::Variant { name: "Ok".into(), args: vec![v] } }
2251fn err_v(v: Value) -> Value { Value::Variant { name: "Err".into(), args: vec![v] } }
2252
2253// -- std.parser helpers (#217) ----------------------------------------
2254
2255/// Construct a tagged parser-AST node. The runtime representation is
2256/// `{ kind: "Char" | "Seq" | ..., ...children }`; the type system
2257/// treats these as opaque `Parser[T]` so user code can't poke at the
2258/// fields. Encoding is canonical because `IndexMap` insertion order
2259/// is stable and we always insert `kind` first.
2260fn parser_node(kind: &str, fields: &[(&str, Value)]) -> Value {
2261    let mut r = indexmap::IndexMap::new();
2262    r.insert("kind".into(), Value::Str(kind.into()));
2263    for (k, v) in fields {
2264        r.insert((*k).into(), v.clone());
2265    }
2266    Value::record_dynamic(r)
2267}
2268
2269// `parser.run` interpretation lives in `lex-bytecode::parser_runtime`
2270// (#221) — it needs reentrant Vm access to invoke closures inside
2271// `Map` / `AndThen` nodes, which the pure-builtin path doesn't have.
2272
2273// -- std.random helpers (#219) ----------------------------------------
2274
2275/// SplitMix64 — single-`u64` state PRNG that is byte-identical
2276/// across platforms (no float math, no platform-dependent reductions).
2277/// Returns `(drawn, next_state)`. Constants are the canonical
2278/// SplitMix64 mixer from the original 2014 paper.
2279fn splitmix64(state: u64) -> (u64, u64) {
2280    let next = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
2281    let mut z = next;
2282    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
2283    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
2284    let z = z ^ (z >> 31);
2285    (z, next)
2286}
2287
2288/// Encode a SplitMix64 state as the user-facing `Rng` value.
2289/// `Rng = { state :: Int }`; the type-checker treats `Rng` as
2290/// opaque so users can't poke at the field.
2291fn rng_value(state: u64) -> Value {
2292    let mut fields = indexmap::IndexMap::new();
2293    fields.insert("state".into(), Value::Int(state as i64));
2294    Value::record_dynamic(fields)
2295}
2296
2297/// Pull the SplitMix64 state out of a `Value::Record { state }`.
2298fn rng_decode(v: Option<&Value>) -> Result<u64, String> {
2299    let rec = match v {
2300        Some(Value::Record { fields: r, .. }) => r,
2301        Some(other) => return Err(format!("expected Rng, got {other:?}")),
2302        None => return Err("missing Rng arg".into()),
2303    };
2304    match rec.get("state") {
2305        Some(Value::Int(n)) => Ok(*n as u64),
2306        _ => Err("malformed Rng: missing `state :: Int`".into()),
2307    }
2308}
2309
2310// -- helpers for `std.http` builders / decoders --
2311
2312fn expect_record_pure(v: Option<&Value>) -> Result<&indexmap::IndexMap<smol_str::SmolStr, Value>, String> {
2313    match v {
2314        Some(Value::Record { fields: r, .. }) => Ok(r),
2315        Some(other) => Err(format!("expected Record, got {other:?}")),
2316        None => Err("missing Record argument".into()),
2317    }
2318}
2319
2320fn http_decode_err_pure(msg: String) -> Value {
2321    let inner = Value::Variant {
2322        name: "DecodeError".into(),
2323        args: vec![Value::Str(msg.into())],
2324    };
2325    err_v(inner)
2326}
2327
2328/// Apply or replace a header in an `HttpRequest` record's `headers`
2329/// field. Header names are normalized to lowercase to match HTTP/1.1
2330/// case-insensitivity; an existing entry under any casing is
2331/// overwritten by the new value.
2332fn http_set_header(
2333    mut req: indexmap::IndexMap<smol_str::SmolStr, Value>,
2334    name: &str,
2335    value: &str,
2336) -> indexmap::IndexMap<smol_str::SmolStr, Value> {
2337    use lex_bytecode::MapKey;
2338    let mut headers = match req.shift_remove("headers") {
2339        Some(Value::Map(m)) => m,
2340        _ => std::collections::BTreeMap::new(),
2341    };
2342    let key = MapKey::Str(name.to_lowercase());
2343    // Drop any case variant of the same header name first so casing
2344    // flips don't accumulate duplicates.
2345    let lowered = name.to_lowercase();
2346    headers.retain(|k, _| match k {
2347        MapKey::Str(s) => s.to_lowercase() != lowered,
2348        _ => true,
2349    });
2350    headers.insert(key, Value::Str(value.into()));
2351    req.insert("headers".into(), Value::Map(headers));
2352    req
2353}
2354
2355/// Append `?k=v&...` (URL-encoded) to the `url` field of an
2356/// `HttpRequest` record. Existing query string is preserved and
2357/// extended with `&`. Iteration order is the input map's natural
2358/// order (`BTreeMap` → sorted by key) so the produced URL is
2359/// deterministic.
2360fn http_append_query(
2361    mut req: indexmap::IndexMap<smol_str::SmolStr, Value>,
2362    params: &std::collections::BTreeMap<lex_bytecode::MapKey, Value>,
2363) -> indexmap::IndexMap<smol_str::SmolStr, Value> {
2364    use lex_bytecode::MapKey;
2365    let url = match req.get("url") {
2366        Some(Value::Str(s)) => s.clone(),
2367        _ => return req,
2368    };
2369    let mut pieces = Vec::new();
2370    for (k, v) in params {
2371        let kk = match k { MapKey::Str(s) => s.to_string(), _ => continue };
2372        let vv = match v { Value::Str(s) => s.to_string(), _ => continue };
2373        pieces.push(format!("{}={}", url_encode(&kk), url_encode(&vv)));
2374    }
2375    if pieces.is_empty() { return req; }
2376    let sep = if url.contains('?') { '&' } else { '?' };
2377    let new_url = format!("{url}{sep}{}", pieces.join("&"));
2378    req.insert("url".into(), Value::Str(new_url.into()));
2379    req
2380}
2381
2382/// Minimal RFC-3986 percent-encode for `application/x-www-form-
2383/// urlencoded` query values. Pulling in `urlencoding` for one
2384/// callsite would drag a dep into the runtime; the inline version is
2385/// short and easy to audit.
2386fn url_encode(s: &str) -> String {
2387    let mut out = String::with_capacity(s.len());
2388    for b in s.bytes() {
2389        match b {
2390            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2391                out.push(b as char);
2392            }
2393            _ => out.push_str(&format!("%{:02X}", b)),
2394        }
2395    }
2396    out
2397}
2398
2399fn value_to_json(v: &Value) -> serde_json::Value { v.to_json() }
2400
2401/// The `toml` crate's serde adapter wraps datetimes in a sentinel
2402/// object `{"$__toml_private_datetime": "<rfc3339>"}` so that the
2403/// `Datetime` type round-trips through `serde::Value`. For Lex's
2404/// purposes a plain RFC-3339 string is what we want — callers can
2405/// then pipe through `datetime.parse_iso` if they need an
2406/// `Instant`. Walk the tree and replace each wrapper with its
2407/// inner string, in-place.
2408fn unwrap_toml_datetime_markers(v: &mut serde_json::Value) {
2409    use serde_json::Value as J;
2410    match v {
2411        J::Object(map) => {
2412            // Detect single-key marker objects and replace them
2413            // with their inner string. We have to take care to
2414            // avoid borrow conflicts.
2415            if map.len() == 1 {
2416                if let Some(J::String(s)) = map.get("$__toml_private_datetime") {
2417                    let s = s.clone();
2418                    *v = J::String(s);
2419                    return;
2420                }
2421            }
2422            for (_, child) in map.iter_mut() {
2423                unwrap_toml_datetime_markers(child);
2424            }
2425        }
2426        J::Array(items) => {
2427            for item in items.iter_mut() {
2428                unwrap_toml_datetime_markers(item);
2429            }
2430        }
2431        _ => {}
2432    }
2433}
2434
2435fn json_to_value(v: &serde_json::Value) -> Value { Value::from_json(v) }
2436
2437/// Extract the `List[Str]` of required field names from the second
2438/// argument of `*.parse_strict`. The list is allowed to be empty
2439/// (the parse degenerates to plain `parse`); other shapes are a
2440/// caller bug rather than a parse error.
2441fn required_field_names(arg: Option<&Value>) -> Result<Vec<String>, String> {
2442    let list = expect_list(arg)?;
2443    let mut out = Vec::with_capacity(list.len());
2444    for v in list {
2445        match v {
2446            Value::Str(s) => out.push(s.to_string()),
2447            other => return Err(format!(
2448                "parse_strict: required-fields list must contain Str, got {other:?}"
2449            )),
2450        }
2451    }
2452    Ok(out)
2453}
2454
2455/// Verify that `value` is an object containing every entry in
2456/// `required`. A required entry may be a plain field name (must
2457/// exist at the top level) or a dotted path (`"project.license"`)
2458/// which descends through nested objects. Returns a stable,
2459/// human-readable error listing every missing path so the agent's
2460/// verifier can surface it directly.
2461///
2462/// Tactical fix for #168 — gives users a way to make `parse[T]`
2463/// errors propagate as `Result::Err` instead of as runtime
2464/// `GetField` errors at access time. The full type-driven fix
2465/// (deriving `required` from `T` at type-check time so plain
2466/// `parse[T]` works, including auto-wrapping `Option[F]` fields
2467/// as not-required) is the cleaner endgame; see #168.
2468///
2469/// Path semantics:
2470/// * `"name"` → top-level `name` must be present (any value).
2471/// * `"a.b.c"` → walk `a`, then `b`, then check `c` exists. Each
2472///   intermediate value must itself be an object.
2473/// * `\\.` is the literal-dot escape (e.g. `"weird\\.key"` for a
2474///   field that genuinely contains a dot in its name).
2475fn check_required_fields(
2476    value: &serde_json::Value,
2477    required: &[String],
2478) -> Result<(), String> {
2479    if required.is_empty() {
2480        return Ok(());
2481    }
2482    if !matches!(value, serde_json::Value::Object(_)) {
2483        return Err(format!(
2484            "parse_strict: expected top-level object with fields {:?}, got {value}",
2485            required
2486        ));
2487    }
2488    let mut missing: Vec<String> = Vec::new();
2489    for path in required {
2490        if !path_exists(value, path) {
2491            missing.push(path.clone());
2492        }
2493    }
2494    if missing.is_empty() {
2495        Ok(())
2496    } else {
2497        Err(format!("missing required field(s): {}", missing.join(", ")))
2498    }
2499}
2500
2501/// Walk `value` along the dotted `path` and report whether the
2502/// terminal segment exists. Intermediate non-object stops surface
2503/// as "missing" — a path can't traverse through a string, list, or
2504/// scalar.
2505fn path_exists(value: &serde_json::Value, path: &str) -> bool {
2506    let mut cursor = value;
2507    let segments = split_dotted_path(path);
2508    for seg in &segments {
2509        match cursor {
2510            serde_json::Value::Object(o) => match o.get(seg.as_str()) {
2511                Some(next) => cursor = next,
2512                None => return false,
2513            },
2514            _ => return false,
2515        }
2516    }
2517    true
2518}
2519
2520/// Split `"a.b.c"` into `["a", "b", "c"]`, with `\.` recognised
2521/// as a literal-dot escape so legitimate dotted field names
2522/// (e.g. `"package\.json"`) don't accidentally start a descent.
2523fn split_dotted_path(path: &str) -> Vec<String> {
2524    let mut out: Vec<String> = Vec::new();
2525    let mut cur = String::new();
2526    let mut iter = path.chars().peekable();
2527    while let Some(c) = iter.next() {
2528        if c == '\\' {
2529            // Backslash at end is preserved; only `\.` is special.
2530            if let Some(&'.') = iter.peek() {
2531                cur.push('.');
2532                iter.next();
2533                continue;
2534            }
2535            cur.push(c);
2536        } else if c == '.' {
2537            out.push(std::mem::take(&mut cur));
2538        } else {
2539            cur.push(c);
2540        }
2541    }
2542    out.push(cur);
2543    out
2544}
2545
2546/// Extract the `List[(Str, Str)]` type schema from the third argument
2547/// of `*.parse_strict` (#322). If the argument is absent or malformed,
2548/// returns an empty vec — callers treat that as "skip type validation".
2549fn extract_type_schema(v: Option<&Value>) -> Vec<(String, String)> {
2550    match v {
2551        Some(Value::List(pairs)) => pairs.iter().filter_map(|p| {
2552            if let Value::Tuple(items) = p {
2553                if items.len() == 2 {
2554                    if let (Value::Str(name), Value::Str(tag)) = (&items[0], &items[1]) {
2555                        return Some((name.to_string(), tag.to_string()));
2556                    }
2557                }
2558            }
2559            None
2560        }).collect(),
2561        _ => vec![],
2562    }
2563}
2564
2565/// Validate each field in `json` against its declared type tag from
2566/// the schema. Returns `Err` for the first field whose JSON value
2567/// doesn't match its tag. Fields not present in the JSON object are
2568/// silently skipped (presence is enforced separately by
2569/// `check_required_fields`).
2570fn validate_field_types(
2571    json: &serde_json::Value,
2572    schema: &[(String, String)],
2573) -> Result<(), String> {
2574    if schema.is_empty() {
2575        return Ok(());
2576    }
2577    let obj = match json.as_object() {
2578        Some(o) => o,
2579        None => return Ok(()), // not an object — let other validation handle it
2580    };
2581    for (field, tag) in schema {
2582        if let Some(val) = obj.get(field) {
2583            if let Err(e) = check_json_type(val, tag) {
2584                return Err(format!("field `{field}`: {e}"));
2585            }
2586        }
2587    }
2588    Ok(())
2589}
2590
2591/// Post-process a Record produced by `json_to_value` to correctly wrap
2592/// `Option[X]` fields. `json_to_value` is schema-blind: it converts JSON null
2593/// to `Value::Unit` and never wraps non-null values in `some(...)`. This pass
2594/// fixes that for every field declared as `Option[X]` in the type schema.
2595fn apply_option_wrapping(v: Value, json: &serde_json::Value, schema: &[(String, String)]) -> Value {
2596    if schema.is_empty() {
2597        return v;
2598    }
2599    let fields = match v {
2600        Value::Record { fields, .. } => *fields,
2601        other => return other,
2602    };
2603    let json_obj = match json.as_object() {
2604        Some(o) => o,
2605        None => return Value::record_interned(fields),
2606    };
2607    let mut new_fields = fields;
2608    for (field_name, tag) in schema {
2609        if tag.starts_with("Option[") && tag.ends_with(']') {
2610            let json_val = json_obj.get(field_name.as_str());
2611            let wrapped = match json_val {
2612                None | Some(serde_json::Value::Null) => none(),
2613                Some(_) => {
2614                    let inner = new_fields
2615                        .get(field_name.as_str())
2616                        .cloned()
2617                        .unwrap_or(Value::Unit);
2618                    some(inner)
2619                }
2620            };
2621            new_fields.insert(smol_str::SmolStr::from(field_name.as_str()), wrapped);
2622        }
2623    }
2624    Value::record_interned(new_fields)
2625}
2626
2627/// Recursively check that `val` conforms to the compact type `tag`.
2628fn check_json_type(val: &serde_json::Value, tag: &str) -> Result<(), String> {
2629    use serde_json::Value as J;
2630    match (tag, val) {
2631        ("Int", J::Number(n)) if n.is_i64() || n.is_u64() => Ok(()),
2632        ("Int", other) => Err(format!("expected Int, got {}", json_type_name(other))),
2633        ("Float", J::Number(_)) => Ok(()),
2634        ("Float", other) => Err(format!("expected Float, got {}", json_type_name(other))),
2635        ("Bool", J::Bool(_)) => Ok(()),
2636        ("Bool", other) => Err(format!("expected Bool, got {}", json_type_name(other))),
2637        ("Str", J::String(_)) => Ok(()),
2638        ("Str", other) => Err(format!("expected Str, got {}", json_type_name(other))),
2639        // Option[X]: null maps to None — any null is acceptable
2640        (tag, J::Null) if tag.starts_with("Option[") => Ok(()),
2641        (tag, val) if tag.starts_with("Option[") && tag.ends_with(']') => {
2642            let inner = &tag[7..tag.len() - 1]; // strip "Option[" and "]"
2643            check_json_type(val, inner)
2644        }
2645        // List[X]: validate each element
2646        (tag, J::Array(items)) if tag.starts_with("List[") && tag.ends_with(']') => {
2647            let inner = &tag[5..tag.len() - 1]; // strip "List[" and "]"
2648            for (i, item) in items.iter().enumerate() {
2649                if let Err(e) = check_json_type(item, inner) {
2650                    return Err(format!("[{i}]: {e}"));
2651                }
2652            }
2653            Ok(())
2654        }
2655        ("Record", _) => Ok(()), // opaque nested record — skip deep check
2656        ("Any", _) => Ok(()),    // unknown type — skip
2657        _ => Ok(()),             // unrecognized tag — skip
2658    }
2659}
2660
2661fn json_type_name(v: &serde_json::Value) -> &'static str {
2662    match v {
2663        serde_json::Value::Null => "null",
2664        serde_json::Value::Bool(_) => "Bool",
2665        serde_json::Value::Number(_) => "Number",
2666        serde_json::Value::String(_) => "Str",
2667        serde_json::Value::Array(_) => "Array",
2668        serde_json::Value::Object(_) => "Object",
2669    }
2670}
2671
2672/// Parse a `.env`-style file into key→value pairs. Accepts:
2673///
2674/// * Blank lines and `# comment` lines (ignored).
2675/// * `KEY=VALUE` with no spaces around `=`. Optional surrounding
2676///   `"..."` or `'...'` quotes on the value. No escape sequences,
2677///   no shell expansion — by design; we want this to be a *data*
2678///   parser, not a shell snippet evaluator.
2679///
2680/// Errors carry the offending line number (1-indexed) so the
2681/// agent's verifier can point a human at the right place.
2682fn parse_dotenv(src: &str) -> Result<indexmap::IndexMap<String, String>, String> {
2683    let mut out = indexmap::IndexMap::new();
2684    for (idx, raw) in src.lines().enumerate() {
2685        let line = raw.trim();
2686        if line.is_empty() || line.starts_with('#') {
2687            continue;
2688        }
2689        // Optional `export KEY=VALUE` shell form — accepted for
2690        // compat with files that grew out of `set -a` workflows.
2691        let after_export = line.strip_prefix("export ").unwrap_or(line);
2692        let (k, v) = match after_export.split_once('=') {
2693            Some(kv) => kv,
2694            None => return Err(format!("dotenv.parse line {}: missing `=`", idx + 1)),
2695        };
2696        let key = k.trim();
2697        if key.is_empty() {
2698            return Err(format!("dotenv.parse line {}: empty key", idx + 1));
2699        }
2700        let v_trim = v.trim();
2701        let value = if let Some(q) = v_trim.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
2702            q.to_string()
2703        } else if let Some(q) = v_trim.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
2704            q.to_string()
2705        } else {
2706            v_trim.to_string()
2707        };
2708        out.insert(key.to_string(), value);
2709    }
2710    Ok(out)
2711}
2712
2713// -- datetime helpers (Instant ↔ chrono::DateTime<Utc>) --
2714
2715/// Convert a `chrono::DateTime` (any `TimeZone`) into a Lex `Instant`,
2716/// represented as nanoseconds since the UTC unix epoch. Saturates on
2717/// out-of-range timestamps so the runtime never panics.
2718fn instant_from_chrono<Tz: chrono::TimeZone>(dt: chrono::DateTime<Tz>) -> i64 {
2719    dt.timestamp_nanos_opt().unwrap_or(i64::MAX)
2720}
2721
2722fn chrono_from_instant(n: i64) -> chrono::DateTime<chrono::Utc> {
2723    let secs = n.div_euclid(1_000_000_000);
2724    let nanos = n.rem_euclid(1_000_000_000) as u32;
2725    use chrono::TimeZone;
2726    chrono::Utc
2727        .timestamp_opt(secs, nanos)
2728        .single()
2729        .unwrap_or_else(chrono::Utc::now)
2730}
2731
2732fn format_iso(n: i64) -> String {
2733    chrono_from_instant(n).to_rfc3339()
2734}
2735
2736/// Parsed form of the user-side `Tz` variant. Mirrors the type
2737/// registered in `TypeEnv::new_with_builtins`.
2738enum TzArg {
2739    Utc,
2740    Local,
2741    /// Fixed offset in minutes east of UTC.
2742    Offset(i32),
2743    /// IANA name like `"America/New_York"`.
2744    Iana(String),
2745}
2746
2747fn parse_tz_arg(v: Option<&Value>) -> Result<TzArg, String> {
2748    match v {
2749        Some(Value::Variant { name, args }) => match (name.as_str(), args.as_slice()) {
2750            ("Utc", []) => Ok(TzArg::Utc),
2751            ("Local", []) => Ok(TzArg::Local),
2752            ("Offset", [Value::Int(m)]) => {
2753                let m = i32::try_from(*m).map_err(|_| {
2754                    format!("Tz::Offset: minutes out of range: {m}")
2755                })?;
2756                Ok(TzArg::Offset(m))
2757            }
2758            ("Iana", [Value::Str(s)]) => Ok(TzArg::Iana(s.to_string())),
2759            (other, _) => Err(format!(
2760                "expected Tz variant (Utc | Local | Offset(Int) | Iana(Str)), got `{other}` with {} arg(s)",
2761                args.len()
2762            )),
2763        },
2764        Some(other) => Err(format!("expected Tz variant, got {other:?}")),
2765        None => Err("missing Tz argument".into()),
2766    }
2767}
2768
2769fn resolve_tz_to_components(n: i64, tz: &TzArg) -> Result<Value, String> {
2770    use chrono::{TimeZone, Datelike, Timelike, Offset};
2771    let utc_dt = chrono_from_instant(n);
2772    let (y, m, d, hh, mm, ss, ns, off_min) = match tz {
2773        TzArg::Utc => {
2774            let d = utc_dt;
2775            (d.year(), d.month() as i32, d.day() as i32,
2776             d.hour() as i32, d.minute() as i32, d.second() as i32,
2777             d.nanosecond() as i32, 0)
2778        }
2779        TzArg::Local => {
2780            let d = utc_dt.with_timezone(&chrono::Local);
2781            let off = d.offset().fix().local_minus_utc() / 60;
2782            (d.year(), d.month() as i32, d.day() as i32,
2783             d.hour() as i32, d.minute() as i32, d.second() as i32,
2784             d.nanosecond() as i32, off)
2785        }
2786        TzArg::Offset(off_min) => {
2787            let off_secs = off_min.saturating_mul(60);
2788            let fixed = chrono::FixedOffset::east_opt(off_secs)
2789                .ok_or("to_components: offset out of range")?;
2790            let d = utc_dt.with_timezone(&fixed);
2791            (d.year(), d.month() as i32, d.day() as i32,
2792             d.hour() as i32, d.minute() as i32, d.second() as i32,
2793             d.nanosecond() as i32, *off_min)
2794        }
2795        TzArg::Iana(name) => {
2796            let tz: chrono_tz::Tz = name.parse()
2797                .map_err(|e| format!("to_components: unknown timezone `{name}`: {e}"))?;
2798            let d = utc_dt.with_timezone(&tz);
2799            let off = d.offset().fix().local_minus_utc() / 60;
2800            (d.year(), d.month() as i32, d.day() as i32,
2801             d.hour() as i32, d.minute() as i32, d.second() as i32,
2802             d.nanosecond() as i32, off)
2803        }
2804    };
2805    let mut rec = indexmap::IndexMap::new();
2806    rec.insert("year".into(),    Value::Int(y as i64));
2807    rec.insert("month".into(),   Value::Int(m as i64));
2808    rec.insert("day".into(),     Value::Int(d as i64));
2809    rec.insert("hour".into(),    Value::Int(hh as i64));
2810    rec.insert("minute".into(),  Value::Int(mm as i64));
2811    rec.insert("second".into(),  Value::Int(ss as i64));
2812    rec.insert("nano".into(),    Value::Int(ns as i64));
2813    rec.insert("tz_offset_minutes".into(), Value::Int(off_min as i64));
2814    let _ = chrono::Utc.timestamp_opt(0, 0); // touch TimeZone to suppress unused-import lint paths
2815    Ok(Value::record_dynamic(rec))
2816}
2817
2818
2819fn instant_from_components(rec: &indexmap::IndexMap<smol_str::SmolStr, Value>) -> Result<i64, String> {
2820    use chrono::TimeZone;
2821    fn get_int(rec: &indexmap::IndexMap<smol_str::SmolStr, Value>, k: &str) -> Result<i64, String> {
2822        match rec.get(k) {
2823            Some(Value::Int(n)) => Ok(*n),
2824            other => Err(format!("from_components: missing or non-int field `{k}`: {other:?}")),
2825        }
2826    }
2827    let y = get_int(rec, "year")? as i32;
2828    let m = get_int(rec, "month")? as u32;
2829    let d = get_int(rec, "day")? as u32;
2830    let hh = get_int(rec, "hour")? as u32;
2831    let mm = get_int(rec, "minute")? as u32;
2832    let ss = get_int(rec, "second")? as u32;
2833    let ns = get_int(rec, "nano")? as u32;
2834    let off_min = get_int(rec, "tz_offset_minutes")? as i32;
2835    let off = chrono::FixedOffset::east_opt(off_min * 60)
2836        .ok_or("from_components: offset out of range")?;
2837    let dt = off
2838        .with_ymd_and_hms(y, m, d, hh, mm, ss)
2839        .single()
2840        .ok_or("from_components: invalid or ambiguous date/time")?;
2841    let dt = dt + chrono::Duration::nanoseconds(ns as i64);
2842    Ok(instant_from_chrono(dt))
2843}
2844
2845// ── AEAD helpers (#382 AEAD slice) ────────────────────────────────────
2846//
2847// Each `*_seal_impl` returns a `Result[AeadResult, Str]` Lex Variant:
2848// `Ok(AeadResult { ciphertext, tag })` on success, `Err(msg)` on input
2849// validation failure (wrong key/nonce length). Each `*_open_impl`
2850// returns `Result[Bytes, Str]` — authentication failure (bad tag /
2851// modified ciphertext) surfaces as `Err`, not a panic.
2852//
2853// Pure ops: every output is a deterministic function of the inputs;
2854// no syscalls, no clock reads, no entropy. Live in the pure-builtin
2855// dispatch table so callers don't need an effect grant beyond
2856// whatever they used to obtain key + nonce in the first place.
2857
2858/// `(key, nonce, aad, plaintext)` references unpacked from a 4-arg
2859/// AEAD seal call. Aliased so the `type_complexity` clippy lint stays
2860/// quiet on the tuple of four borrows.
2861type Aead4<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>);
2862
2863/// `(key, nonce, aad, ciphertext, tag)` references unpacked from a
2864/// 5-arg AEAD open call.
2865type Aead5<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>);
2866
2867fn unpack4_bytes<'a>(
2868    args: &'a [Value],
2869    op: &str,
2870) -> Result<Aead4<'a>, String> {
2871    let pick = |i: usize, name: &str| -> Result<&'a Vec<u8>, String> {
2872        match args.get(i) {
2873            Some(Value::Bytes(b)) => Ok(b),
2874            Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2875            None => Err(format!("{op}: missing {name} argument")),
2876        }
2877    };
2878    Ok((pick(0, "key")?, pick(1, "nonce")?, pick(2, "aad")?, pick(3, "plaintext")?))
2879}
2880
2881fn unpack5_bytes<'a>(
2882    args: &'a [Value],
2883    op: &str,
2884) -> Result<Aead5<'a>, String> {
2885    let pick = |i: usize, name: &str| -> Result<&'a Vec<u8>, String> {
2886        match args.get(i) {
2887            Some(Value::Bytes(b)) => Ok(b),
2888            Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2889            None => Err(format!("{op}: missing {name} argument")),
2890        }
2891    };
2892    Ok((
2893        pick(0, "key")?,
2894        pick(1, "nonce")?,
2895        pick(2, "aad")?,
2896        pick(3, "ciphertext")?,
2897        pick(4, "tag")?,
2898    ))
2899}
2900
2901fn aead_result(ciphertext: Vec<u8>, tag: Vec<u8>) -> Value {
2902    let mut rec = indexmap::IndexMap::new();
2903    rec.insert("ciphertext".into(), Value::Bytes(ciphertext));
2904    rec.insert("tag".into(), Value::Bytes(tag));
2905    Value::record_dynamic(rec)
2906}
2907
2908fn aead_err(msg: impl Into<String>) -> Value {
2909    let s: String = msg.into();
2910    err_v(Value::Str(s.into()))
2911}
2912
2913fn aes_gcm_seal_impl(args: &[Value]) -> Value {
2914    use aes_gcm::aead::{Aead, KeyInit, Payload};
2915    use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
2916    let (key, nonce, aad, plaintext) = match unpack4_bytes(args, "aes_gcm_seal") {
2917        Ok(t) => t,
2918        Err(e) => return aead_err(e),
2919    };
2920    if nonce.len() != 12 {
2921        return aead_err(format!(
2922            "aes_gcm_seal: nonce must be exactly 12 bytes, got {}", nonce.len()
2923        ));
2924    }
2925    let n = Nonce::from_slice(nonce);
2926    let payload = Payload { msg: plaintext, aad };
2927    // Encrypts and appends the 16-byte tag. We split the tag back out so
2928    // the caller sees the structured AeadResult shape.
2929    let combined = match key.len() {
2930        16 => {
2931            let cipher = Aes128Gcm::new_from_slice(key)
2932                .map_err(|e| e.to_string());
2933            match cipher {
2934                Ok(c) => c.encrypt(n, payload).map_err(|e| format!("aes_gcm_seal: {e}")),
2935                Err(e) => Err(format!("aes_gcm_seal: {e}")),
2936            }
2937        }
2938        32 => {
2939            let cipher = Aes256Gcm::new_from_slice(key)
2940                .map_err(|e| e.to_string());
2941            match cipher {
2942                Ok(c) => c.encrypt(n, payload).map_err(|e| format!("aes_gcm_seal: {e}")),
2943                Err(e) => Err(format!("aes_gcm_seal: {e}")),
2944            }
2945        }
2946        // AES-192 is rarely used; the aes-gcm crate doesn't expose
2947        // Aes192Gcm in its default API. Reject other sizes explicitly.
2948        other => return aead_err(format!(
2949            "aes_gcm_seal: key must be 16 or 32 bytes, got {other}"
2950        )),
2951    };
2952    match combined {
2953        Ok(mut buf) => {
2954            // tag is the last 16 bytes.
2955            let tag_start = buf.len() - 16;
2956            let tag = buf.split_off(tag_start);
2957            ok_v(aead_result(buf, tag))
2958        }
2959        Err(e) => aead_err(e),
2960    }
2961}
2962
2963fn aes_gcm_open_impl(args: &[Value]) -> Value {
2964    use aes_gcm::aead::{Aead, KeyInit, Payload};
2965    use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
2966    let (key, nonce, aad, ciphertext, tag) = match unpack5_bytes(args, "aes_gcm_open") {
2967        Ok(t) => t,
2968        Err(e) => return err_v(Value::Str(e.into())),
2969    };
2970    if nonce.len() != 12 {
2971        return err_v(Value::Str(format!(
2972            "aes_gcm_open: nonce must be exactly 12 bytes, got {}", nonce.len()
2973        ).into()));
2974    }
2975    if tag.len() != 16 {
2976        return err_v(Value::Str(format!(
2977            "aes_gcm_open: tag must be exactly 16 bytes, got {}", tag.len()
2978        ).into()));
2979    }
2980    // Rebuild the "ciphertext || tag" buffer the aes-gcm crate expects.
2981    let mut combined = Vec::with_capacity(ciphertext.len() + tag.len());
2982    combined.extend_from_slice(ciphertext);
2983    combined.extend_from_slice(tag);
2984    let n = Nonce::from_slice(nonce);
2985    let payload = Payload { msg: &combined, aad };
2986    let plaintext = match key.len() {
2987        16 => Aes128Gcm::new_from_slice(key)
2988            .map_err(|e| format!("aes_gcm_open: {e}"))
2989            .and_then(|c| c.decrypt(n, payload).map_err(|e| format!("aes_gcm_open: {e}"))),
2990        32 => Aes256Gcm::new_from_slice(key)
2991            .map_err(|e| format!("aes_gcm_open: {e}"))
2992            .and_then(|c| c.decrypt(n, payload).map_err(|e| format!("aes_gcm_open: {e}"))),
2993        other => return err_v(Value::Str(format!(
2994            "aes_gcm_open: key must be 16 or 32 bytes, got {other}"
2995        ).into())),
2996    };
2997    match plaintext {
2998        Ok(p) => ok_v(Value::Bytes(p)),
2999        Err(e) => err_v(Value::Str(e.into())),
3000    }
3001}
3002
3003fn chacha20_seal_impl(args: &[Value]) -> Value {
3004    use chacha20poly1305::aead::{Aead, KeyInit, Payload};
3005    use chacha20poly1305::{ChaCha20Poly1305, Nonce};
3006    let (key, nonce, aad, plaintext) = match unpack4_bytes(args, "chacha20_poly1305_seal") {
3007        Ok(t) => t,
3008        Err(e) => return aead_err(e),
3009    };
3010    if key.len() != 32 {
3011        return aead_err(format!(
3012            "chacha20_poly1305_seal: key must be exactly 32 bytes, got {}", key.len()
3013        ));
3014    }
3015    if nonce.len() != 12 {
3016        return aead_err(format!(
3017            "chacha20_poly1305_seal: nonce must be exactly 12 bytes, got {}", nonce.len()
3018        ));
3019    }
3020    let cipher = ChaCha20Poly1305::new_from_slice(key)
3021        .map_err(|e| format!("chacha20_poly1305_seal: {e}"));
3022    let n = Nonce::from_slice(nonce);
3023    let payload = Payload { msg: plaintext, aad };
3024    let combined = match cipher {
3025        Ok(c) => c.encrypt(n, payload).map_err(|e| format!("chacha20_poly1305_seal: {e}")),
3026        Err(e) => Err(e),
3027    };
3028    match combined {
3029        Ok(mut buf) => {
3030            let tag_start = buf.len() - 16;
3031            let tag = buf.split_off(tag_start);
3032            ok_v(aead_result(buf, tag))
3033        }
3034        Err(e) => aead_err(e),
3035    }
3036}
3037
3038fn chacha20_open_impl(args: &[Value]) -> Value {
3039    use chacha20poly1305::aead::{Aead, KeyInit, Payload};
3040    use chacha20poly1305::{ChaCha20Poly1305, Nonce};
3041    let (key, nonce, aad, ciphertext, tag) = match unpack5_bytes(args, "chacha20_poly1305_open") {
3042        Ok(t) => t,
3043        Err(e) => return err_v(Value::Str(e.into())),
3044    };
3045    if key.len() != 32 {
3046        return err_v(Value::Str(format!(
3047            "chacha20_poly1305_open: key must be exactly 32 bytes, got {}", key.len()
3048        ).into()));
3049    }
3050    if nonce.len() != 12 {
3051        return err_v(Value::Str(format!(
3052            "chacha20_poly1305_open: nonce must be exactly 12 bytes, got {}", nonce.len()
3053        ).into()));
3054    }
3055    if tag.len() != 16 {
3056        return err_v(Value::Str(format!(
3057            "chacha20_poly1305_open: tag must be exactly 16 bytes, got {}", tag.len()
3058        ).into()));
3059    }
3060    let mut combined = Vec::with_capacity(ciphertext.len() + tag.len());
3061    combined.extend_from_slice(ciphertext);
3062    combined.extend_from_slice(tag);
3063    let cipher = ChaCha20Poly1305::new_from_slice(key)
3064        .map_err(|e| format!("chacha20_poly1305_open: {e}"));
3065    let n = Nonce::from_slice(nonce);
3066    let payload = Payload { msg: &combined, aad };
3067    match cipher.and_then(|c| c.decrypt(n, payload).map_err(|e| format!("chacha20_poly1305_open: {e}"))) {
3068        Ok(p) => ok_v(Value::Bytes(p)),
3069        Err(e) => err_v(Value::Str(e.into())),
3070    }
3071}
3072
3073// ── KDFs (#382 KDF slice) ──────────────────────────────────────────────────
3074//
3075// All three primitives return Result[Bytes, Str] so caller-controlled
3076// inputs (iteration count, output length, argon2id work factors) that
3077// violate the underlying crate's contract surface as Err, never as a
3078// VM panic.
3079
3080/// `(password :: Bytes, salt :: Bytes, iterations :: Int, len :: Int)`
3081/// references unpacked from a 4-arg KDF call.
3082type Kdf4<'a> = (&'a Vec<u8>, &'a Vec<u8>, i64, i64);
3083
3084/// `(ikm :: Bytes, salt :: Bytes, info :: Bytes, len :: Int)`
3085/// references unpacked from a 4-arg HKDF call.
3086type Hkdf4<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, i64);
3087
3088/// `(password :: Bytes, salt :: Bytes, t_cost :: Int, m_cost :: Int, len :: Int)`
3089/// for argon2id.
3090type Argon5<'a> = (&'a Vec<u8>, &'a Vec<u8>, i64, i64, i64);
3091
3092fn pick_bytes<'a>(args: &'a [Value], i: usize, op: &str, name: &str)
3093    -> Result<&'a Vec<u8>, String>
3094{
3095    match args.get(i) {
3096        Some(Value::Bytes(b)) => Ok(b),
3097        Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
3098        None => Err(format!("{op}: missing {name} argument")),
3099    }
3100}
3101
3102fn pick_int(args: &[Value], i: usize, op: &str, name: &str) -> Result<i64, String> {
3103    match args.get(i) {
3104        Some(Value::Int(n)) => Ok(*n),
3105        Some(other) => Err(format!("{op}: {name} must be Int, got {other:?}")),
3106        None => Err(format!("{op}: missing {name} argument")),
3107    }
3108}
3109
3110fn unpack_kdf4<'a>(args: &'a [Value], op: &str) -> Result<Kdf4<'a>, String> {
3111    Ok((
3112        pick_bytes(args, 0, op, "password")?,
3113        pick_bytes(args, 1, op, "salt")?,
3114        pick_int(args, 2, op, "iterations")?,
3115        pick_int(args, 3, op, "len")?,
3116    ))
3117}
3118
3119fn unpack_hkdf4<'a>(args: &'a [Value], op: &str) -> Result<Hkdf4<'a>, String> {
3120    Ok((
3121        pick_bytes(args, 0, op, "ikm")?,
3122        pick_bytes(args, 1, op, "salt")?,
3123        pick_bytes(args, 2, op, "info")?,
3124        pick_int(args, 3, op, "len")?,
3125    ))
3126}
3127
3128fn unpack_argon5<'a>(args: &'a [Value], op: &str) -> Result<Argon5<'a>, String> {
3129    Ok((
3130        pick_bytes(args, 0, op, "password")?,
3131        pick_bytes(args, 1, op, "salt")?,
3132        pick_int(args, 2, op, "t_cost")?,
3133        pick_int(args, 3, op, "m_cost")?,
3134        pick_int(args, 4, op, "len")?,
3135    ))
3136}
3137
3138/// Output-length sanity check shared by all three KDFs. A negative or
3139/// absurdly large `len` is a programmer error, not a runtime concern;
3140/// we cap at 1 MiB to keep accidental `i64::MAX` calls from OOMing the
3141/// process.
3142const KDF_MAX_LEN: usize = 1024 * 1024;
3143
3144fn check_len(op: &str, len: i64) -> Result<usize, String> {
3145    if len <= 0 {
3146        return Err(format!("{op}: len must be > 0, got {len}"));
3147    }
3148    if (len as u64) > KDF_MAX_LEN as u64 {
3149        return Err(format!(
3150            "{op}: len must be <= {KDF_MAX_LEN}, got {len}"
3151        ));
3152    }
3153    Ok(len as usize)
3154}
3155
3156fn pbkdf2_sha256_impl(args: &[Value]) -> Value {
3157    use hmac::Hmac;
3158    use sha2::Sha256;
3159    let op = "pbkdf2_sha256";
3160    let (password, salt, iterations, len) = match unpack_kdf4(args, op) {
3161        Ok(t) => t,
3162        Err(e) => return err_v(Value::Str(e.into())),
3163    };
3164    if iterations <= 0 {
3165        return err_v(Value::Str(format!(
3166            "{op}: iterations must be > 0, got {iterations}"
3167        ).into()));
3168    }
3169    let out_len = match check_len(op, len) {
3170        Ok(n) => n,
3171        Err(e) => return err_v(Value::Str(e.into())),
3172    };
3173    let rounds = match u32::try_from(iterations) {
3174        Ok(r) => r,
3175        Err(_) => {
3176            return err_v(Value::Str(format!(
3177                "{op}: iterations must fit in u32, got {iterations}"
3178            ).into()))
3179        }
3180    };
3181    let mut out = vec![0u8; out_len];
3182    if let Err(e) = pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, rounds, &mut out) {
3183        return err_v(Value::Str(format!("{op}: {e}").into()));
3184    }
3185    ok_v(Value::Bytes(out))
3186}
3187
3188fn hkdf_sha256_impl(args: &[Value]) -> Value {
3189    use hkdf::Hkdf;
3190    use sha2::Sha256;
3191    let op = "hkdf_sha256";
3192    let (ikm, salt, info, len) = match unpack_hkdf4(args, op) {
3193        Ok(t) => t,
3194        Err(e) => return err_v(Value::Str(e.into())),
3195    };
3196    let out_len = match check_len(op, len) {
3197        Ok(n) => n,
3198        Err(e) => return err_v(Value::Str(e.into())),
3199    };
3200    // RFC 5869 caps output at 255 * HashLen; the `expand` call below
3201    // returns InvalidLength when exceeded — surface that as Err.
3202    let salt_opt: Option<&[u8]> = if salt.is_empty() { None } else { Some(salt) };
3203    let hk = Hkdf::<Sha256>::new(salt_opt, ikm);
3204    let mut out = vec![0u8; out_len];
3205    match hk.expand(info, &mut out) {
3206        Ok(()) => ok_v(Value::Bytes(out)),
3207        Err(e) => err_v(Value::Str(format!("{op}: {e}").into())),
3208    }
3209}
3210
3211fn argon2id_impl(args: &[Value]) -> Value {
3212    use argon2::{Algorithm, Argon2, Params, Version};
3213    let op = "argon2id";
3214    let (password, salt, t_cost, m_cost, len) = match unpack_argon5(args, op) {
3215        Ok(t) => t,
3216        Err(e) => return err_v(Value::Str(e.into())),
3217    };
3218    let out_len = match check_len(op, len) {
3219        Ok(n) => n,
3220        Err(e) => return err_v(Value::Str(e.into())),
3221    };
3222    let t = match u32::try_from(t_cost) {
3223        Ok(n) if n >= 1 => n,
3224        _ => return err_v(Value::Str(format!(
3225            "{op}: t_cost must be a u32 >= 1, got {t_cost}"
3226        ).into())),
3227    };
3228    let m = match u32::try_from(m_cost) {
3229        Ok(n) if n >= Params::MIN_M_COST => n,
3230        _ => return err_v(Value::Str(format!(
3231            "{op}: m_cost must be a u32 >= {}, got {m_cost}",
3232            Params::MIN_M_COST
3233        ).into())),
3234    };
3235    // p=1 is the default and what every interop spec assumes (PHC
3236    // string, libsodium's argon2id_str). We don't expose parallelism
3237    // as a knob for now to keep callers from picking a value that
3238    // makes hashes uncomparable across machines.
3239    let params = match Params::new(m, t, 1, Some(out_len)) {
3240        Ok(p) => p,
3241        Err(e) => return err_v(Value::Str(format!("{op}: {e}").into())),
3242    };
3243    let hasher = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
3244    let mut out = vec![0u8; out_len];
3245    if let Err(e) = hasher.hash_password_into(password, salt, &mut out) {
3246        return err_v(Value::Str(format!("{op}: {e}").into()));
3247    }
3248    ok_v(Value::Bytes(out))
3249}