From 7d48512a3c364950e7ecf188ca83061b6d43d201 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Thu, 21 May 2026 13:09:36 +0100 Subject: [PATCH 1/4] slc: accept end=-1 as 'to end' sugar when start is non-negative Add resolve_slc_end helper that special-cases end_raw == -1 with a non-negative start as len(xs), so 'slc xs 0 -1' returns the whole list instead of dropping the last element. Other shapes keep the existing Python-style relative-offset semantics (slc xs -3 -1 is still the penultimate window, slc xs 0 -2 still drops the last two). The sugar matches the Python/JS mental model agents reach for first when they want 'from index N to the end'. Drop-last continues to live on take -1 xs. Tree-walker, VM (OP_SLC), and Cranelift JIT helper all route through the same helper. --- src/builtins.rs | 22 ++++++++++++++++++++++ src/interpreter/mod.rs | 15 ++++++++++----- src/vm/mod.rs | 17 +++++++++++------ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/builtins.rs b/src/builtins.rs index 0f61e10e..c592f51e 100644 --- a/src/builtins.rs +++ b/src/builtins.rs @@ -949,6 +949,28 @@ pub(crate) fn resolve_slice_bound(raw: i64, len: usize) -> usize { adjusted.clamp(0, len_i) as usize } +/// Resolve `slc`'s `end` bound with the `-1 = to end` sugar. +/// +/// `slc` historically treated negative end indices as Python-style relative +/// offsets, so `slc s 0 -1` dropped the last element. Agents trained on +/// Python/JS keep reaching for `-1` to mean "to end of string/list" instead, +/// so we add a narrow ergonomic exception: when `start_raw >= 0` and +/// `end_raw == -1`, treat the end as `len`. All other shapes (negative +/// start, or end < -1) keep the Python-style relative-offset behaviour via +/// [`resolve_slice_bound`]. +/// +/// This is intentionally `-1` only, not "any negative end". Treating every +/// negative end as "to end" would silently break the existing +/// `slc xs -3 -1` / `slc "hello" -99 -1` shapes that already rely on the +/// Python semantics. +#[inline] +pub(crate) fn resolve_slc_end(start_raw: i64, end_raw: i64, len: usize) -> usize { + if start_raw >= 0 && end_raw == -1 { + return len; + } + resolve_slice_bound(end_raw, len) +} + /// Resolve `take n xs` against `len`, returning the prefix length to retain. /// /// - `n >= 0`: take the first `min(n, len)` elements. diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 0c36a8a1..7d4b8728 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -3317,9 +3317,14 @@ fn call_function(env: &mut Env, name: &str, args: Vec) -> Result { ))); } if builtin == Some(Builtin::Slc) && args.len() == 3 { - // Both bounds accept negative integers Python-style: - // `slc xs -1 0` is empty, `slc xs 0 -1` drops the last element, - // `slc xs -2 (len xs)` returns the last two elements. + // Bounds accept negative integers Python-style, with one ergonomic + // exception on the end bound: + // `slc xs -1 (len xs)` returns the last element + // `slc xs -2 (len xs)` returns the last two elements + // `slc xs -3 -1` returns the penultimate window (Python-style) + // `slc xs 0 -1` returns the WHOLE list (-1 = "to end" sugar + // when start is non-negative; see + // `resolve_slc_end` in builtins.rs) let start_raw = match &args[1] { Value::Number(n) => { if n.fract() != 0.0 { @@ -3357,14 +3362,14 @@ fn call_function(env: &mut Env, name: &str, args: Vec) -> Result { return match &args[0] { Value::List(items) => { let len = items.len(); - let end = crate::builtins::resolve_slice_bound(end_raw, len); + let end = crate::builtins::resolve_slc_end(start_raw, end_raw, len); let start = crate::builtins::resolve_slice_bound(start_raw, len).min(end); Ok(Value::List(Arc::new(items[start..end].to_vec()))) } Value::Text(s) => { let chars: Vec = s.chars().collect(); let len = chars.len(); - let end = crate::builtins::resolve_slice_bound(end_raw, len); + let end = crate::builtins::resolve_slc_end(start_raw, end_raw, len); let start = crate::builtins::resolve_slice_bound(start_raw, len).min(end); Ok(Value::Text(Arc::new(chars[start..end].iter().collect()))) } diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 9476fae6..c2890cc4 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -12418,7 +12418,10 @@ impl<'a> VM<'a> { } OP_SLC => { // Two-instruction sequence: OP_SLC A=result B=list C=start; data word A=end_reg - // Bounds accept negative integers Python-style (`-1` = last element). + // Bounds accept negative integers Python-style (`-1` = last element), + // with one ergonomic exception: when start is non-negative and end is + // exactly `-1`, end is treated as `len` (to-end sugar). See + // `crate::builtins::resolve_slc_end` for the full rule. let a = ((inst >> 16) & 0xFF) as usize + base; let b = ((inst >> 8) & 0xFF) as usize + base; let c = (inst & 0xFF) as usize + base; @@ -12448,7 +12451,7 @@ impl<'a> VM<'a> { }; let chars: Vec = s.chars().collect(); let len = chars.len(); - let end = crate::builtins::resolve_slice_bound(end_raw, len); + let end = crate::builtins::resolve_slc_end(start_raw, end_raw, len); let start = crate::builtins::resolve_slice_bound(start_raw, len).min(end); let result: String = chars[start..end].iter().collect(); reg_set!(a, NanVal::heap_string(result)); @@ -12457,7 +12460,7 @@ impl<'a> VM<'a> { h @ (HeapObj::List(_) | HeapObj::ListView { .. }) => { let items = slice_of(h); let len = items.len(); - let end = crate::builtins::resolve_slice_bound(end_raw, len); + let end = crate::builtins::resolve_slc_end(start_raw, end_raw, len); let start = crate::builtins::resolve_slice_bound(start_raw, len).min(end); let mut sliced = Vec::with_capacity(end - start); @@ -16528,7 +16531,9 @@ pub(crate) extern "C" fn jit_rsrt(a: u64, span_bits: u64) -> u64 { #[unsafe(no_mangle)] pub(crate) extern "C" fn jit_slc(a: u64, start: u64, end: u64, span_bits: u64) -> u64 { // Bounds accept negative integers Python-style; kept in lockstep with the - // tree-walker and OP_SLC by delegating to `resolve_slice_bound`. + // tree-walker and OP_SLC by delegating to `resolve_slice_bound`. End uses + // `resolve_slc_end` so the `-1 = to end` sugar fires for non-negative + // starts (e.g. `slc xs 0 -1` is the full list). let vb = NanVal(a); let vc = NanVal(start); let vd = NanVal(end); @@ -16552,7 +16557,7 @@ pub(crate) extern "C" fn jit_slc(a: u64, start: u64, end: u64, span_bits: u64) - }; let chars: Vec = s.chars().collect(); let len = chars.len(); - let e = crate::builtins::resolve_slice_bound(e_raw, len); + let e = crate::builtins::resolve_slc_end(s_raw, e_raw, len); let s = crate::builtins::resolve_slice_bound(s_raw, len).min(e); return NanVal::heap_string(chars[s..e].iter().collect()).0; } @@ -16560,7 +16565,7 @@ pub(crate) extern "C" fn jit_slc(a: u64, start: u64, end: u64, span_bits: u64) - && let HeapObj::List(items) = unsafe { vb.as_heap_ref() } { let len = items.len(); - let e = crate::builtins::resolve_slice_bound(e_raw, len); + let e = crate::builtins::resolve_slc_end(s_raw, e_raw, len); let s = crate::builtins::resolve_slice_bound(s_raw, len).min(e); let mut sliced = Vec::with_capacity(e - s); for v in &items[s..e] { From c20f849fb7839a6d83033d5795bf0d8baf8fef71 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Thu, 21 May 2026 13:09:43 +0100 Subject: [PATCH 2/4] tests: pin slc end=-1 sugar and adjacent Python-style shapes Update the cross-engine slc regression suite to match the new sugar: the slc xs 0 -1 / slc "hello" 0 -1 / quant-trader cases now assert 'to end' behaviour. Add new pins for slc xs 2 -1 (mid-start sugar), slc xs -1 -1 (negative start keeps Python style), slc xs 0 -2 (other negative ends untouched), and slc xs -3 -1 (penultimate window). Adds take -1 xs as the canonical 'drop last' shape. --- tests/regression_neg_index_slice.rs | 75 ++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/tests/regression_neg_index_slice.rs b/tests/regression_neg_index_slice.rs index c3e1a4af..29d4432b 100644 --- a/tests/regression_neg_index_slice.rs +++ b/tests/regression_neg_index_slice.rs @@ -68,22 +68,45 @@ fn slc_list_last_two() { check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs -2 5", "f", "[40, 50]"); } -// `slc xs 0 -1` drops the last element. +// `slc xs 0 -1` is the `-1 = "to end"` sugar shape (start non-negative, +// end exactly `-1`): returns the whole list. See `resolve_slc_end` for the +// rule and the negative-start tests below for the Python-style behaviour +// that's preserved when start is also negative. #[test] -fn slc_list_neg_end_drops_last() { +fn slc_list_pos_start_neg_one_end_is_full_list() { check_all_engines( "f>L n;xs=[10,20,30,40,50];slc xs 0 -1", "f", - "[10, 20, 30, 40]", + "[10, 20, 30, 40, 50]", ); } -// `slc xs -3 -1` middle slice from negatives. +// `slc xs 2 -1` with non-negative start and end == -1: still "to end". +#[test] +fn slc_list_mid_start_neg_one_end_is_to_end() { + check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs 2 -1", "f", "[30, 40, 50]"); +} + +// `slc xs -3 -1` keeps Python-style semantics because start is negative: +// end resolves to `len - 1 = 4`, so the result is the penultimate window. #[test] -fn slc_list_neg_both_bounds() { +fn slc_list_neg_both_bounds_keeps_python_style() { check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs -3 -1", "f", "[30, 40]"); } +// Boundary: `slc xs -1 -1` keeps Python-style (start is negative): empty. +#[test] +fn slc_list_neg_one_neg_one_python_style() { + check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs -1 -1", "f", "[]"); +} + +// `slc xs 0 -2` is NOT sugar (end is -2, not -1): keeps Python-style, +// drops the last two elements. Pin so the sugar stays narrow. +#[test] +fn slc_list_neg_two_end_keeps_python_style() { + check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs 0 -2", "f", "[10, 20, 30]"); +} + // `slc xs -len 0` is empty (start clamps to 0, end clamps to 0). #[test] fn slc_list_neg_len_to_zero_empty() { @@ -118,22 +141,49 @@ fn slc_list_pos_end_past_len_clamps() { check_all_engines("f>L n;xs=[10,20,30];slc xs 0 99", "f", "[10, 20, 30]"); } -// Quant-trader fencepost: previously needed `npm=- np 1;at eq npm`. Now -// `slc eq -1 np` drops the last element cleanly. +// Quant-trader fencepost: `slc eq 0 -1` no longer "drops the last element" +// (post #15: `-1` end with non-negative start is "to end" sugar — see +// `resolve_slc_end`). For the drop-last shape, use `take -1 eq` instead; +// pinning both here keeps agents on the happy path. #[test] -fn slc_quant_trader_fencepost() { +fn slc_quant_trader_sugar_is_full_list() { check_all_engines( "f>L n;eq=[100,101,102,103,99];slc eq 0 -1", "f", + "[100, 101, 102, 103, 99]", + ); +} + +#[test] +fn take_neg_one_is_drop_last() { + // Drop-last shape now goes through `take -1`, which keeps Python-style + // negative semantics (see `resolve_take_count`). + check_all_engines( + "f>L n;eq=[100,101,102,103,99];take -1 eq", + "f", "[100, 101, 102, 103]", ); } // ── slc: negative bounds on text ────────────────────────────────────────── +// Text mirror of `slc_list_pos_start_neg_one_end_is_full_list`: with a +// non-negative start, end == -1 is "to end of string" sugar. +#[test] +fn slc_text_pos_start_neg_one_end_is_full_string() { + check_all_engines("f>t;slc \"hello\" 0 -1", "f", "hello"); +} + +#[test] +fn slc_text_pos_start_neg_one_end_mid() { + check_all_engines("f>t;slc \"hello\" 2 -1", "f", "llo"); +} + +// `slc s 0 -2` keeps Python-style (drops the last two chars). Pin so the +// sugar stays narrow on text too. #[test] -fn slc_text_neg_end_drops_last_char() { - check_all_engines("f>t;slc \"hello\" 0 -1", "f", "hell"); +fn slc_text_neg_two_end_keeps_python_style() { + check_all_engines("f>t;slc \"hello\" 0 -2", "f", "hel"); } #[test] @@ -141,8 +191,11 @@ fn slc_text_last_three() { check_all_engines("f>t;slc \"hello\" -3 5", "f", "llo"); } +// Negative start with end == -1: sugar does NOT fire (start is negative), +// so Python-style applies. start clamps to 0, end resolves to 4, so we get +// `hell`. #[test] -fn slc_text_neg_beyond_len_clamps() { +fn slc_text_neg_beyond_len_clamps_python_style() { check_all_engines("f>t;slc \"hello\" -99 -1", "f", "hell"); } From 393a222187d3e85c74a35b04ea91e4e8c343d1e5 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Thu, 21 May 2026 13:09:49 +0100 Subject: [PATCH 3/4] examples: slc-to-end demo + refresh negative-indices for the new sugar Add examples/slc-to-end.ilo so the engines harness pins the new agent-facing shape (slc xs s -1 with s>=0 means 'to end'). Update examples/negative-indices.ilo: the drop-last shape moves to 'take -1 xs', the trim-edges example uses explicit (- (len s) 1), and a penultimate example is added so the file still covers the negative-start path. --- examples/negative-indices.ilo | 25 ++++++++++++++----------- examples/slc-to-end.ilo | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 examples/slc-to-end.ilo diff --git a/examples/negative-indices.ilo b/examples/negative-indices.ilo index 8695eb58..7770c843 100644 --- a/examples/negative-indices.ilo +++ b/examples/negative-indices.ilo @@ -1,25 +1,26 @@ -- Negative indices on slice-shaped builtins count from the end of the --- list or text, matching Python semantics already in place for `at xs i`. +-- list or text, matching Python semantics already in place for `at xs i`, +-- with one ergonomic exception on `slc`'s end bound (see below). -- -- slc xs s e accepts negative s and e. `slc xs -1 (len xs)` is the --- last element as a 1-element list; `slc xs 0 -1` drops --- the last element; bounds clamp, never wrap. +-- last element as a 1-element list. `slc xs -3 -1` keeps +-- Python-style semantics when start is negative (the +-- penultimate window). When start is non-negative and +-- end is exactly -1, the end is read as "to end of +-- list/text" (see `slc-to-end.ilo`). -- take n xs with n<0 keeps all but the last |n|. Equivalent to --- Python's xs[:n]. `take -1 [10,20,30]` is [10,20]. +-- Python's xs[:n]. `take -1 [10,20,30]` is [10,20]. Use +-- this when you want to "drop the last element". -- drop n xs with n<0 keeps only the last |n|. Equivalent to -- Python's xs[n:]. `drop -1 [10,20,30]` is [30]. --- --- Closes the quant-trader fencepost (`@i 1..np` loops produce np-1 results, --- so the equity curve ends at length np; `at eq np` errored, and the --- workaround was `npm=- np 1`). With negative indices everywhere, the same --- pattern is `slc eq 0 -1` — no separate binding, no off-by-one risk. last-element xs:L n>L n;slc xs -1 (len xs) -drop-last xs:L n>L n;slc xs 0 -1 +drop-last xs:L n>L n;take -1 xs keep-last-two xs:L n>L n;slc xs -2 (len xs) all-but-last xs:L n>L n;take -1 xs only-last-two xs:L n>L n;drop -2 xs -trim-edges s:t>t;slc s 1 -1 +trim-edges s:t>t;slc s 1 (- (len s) 1) +penultimate xs:L n>L n;slc xs -3 -1 -- run: last-element [10,20,30,40,50] -- out: [50] @@ -33,3 +34,5 @@ trim-edges s:t>t;slc s 1 -1 -- out: [40, 50] -- run: trim-edges "(quoted)" -- out: quoted +-- run: penultimate [10,20,30,40,50] +-- out: [30, 40] diff --git a/examples/slc-to-end.ilo b/examples/slc-to-end.ilo new file mode 100644 index 00000000..ec40cc4c --- /dev/null +++ b/examples/slc-to-end.ilo @@ -0,0 +1,24 @@ +-- `slc xs s -1` with non-negative `s` reads the end as "to end of +-- list/text". This is an ergonomic exception to ilo's otherwise +-- Python-style negative-index handling: if you carry a Python/JS mental +-- model where `-1` means "the end", it just works without computing +-- `(len xs)` at the call site. +-- +-- The sugar is intentionally narrow: +-- - sugar fires only when `s >= 0` AND `e == -1` +-- - any other negative end (e.g. `-2`) keeps Python-style semantics +-- - negative starts also keep Python-style (so `slc xs -3 -1` is the +-- penultimate window, not "from the third-from-last to the end") +-- +-- If you want the "drop last" Python shape, use `take -1 xs` instead. + +drop-prefix xs:L n>L n;slc xs 2 -1 +suffix-from i:n xs:L n>L n;slc xs i -1 +tail-of-string s:t>t;slc s 1 -1 + +-- run: drop-prefix [10,20,30,40,50] +-- out: [30, 40, 50] +-- run: suffix-from 3 [10,20,30,40,50] +-- out: [40, 50] +-- run: tail-of-string "hello" +-- out: ello From e7db91cd3f5f28d28ad60b416d05deede5bb96c8 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Thu, 21 May 2026 13:09:55 +0100 Subject: [PATCH 4/4] docs: document slc end=-1 'to end' sugar in SPEC, ai.txt, and skill Note the sugar (a>=0 + b==-1 means 'to end of list/text') in the slc row of SPEC.md, the canonical ai.txt builtins line, and the list section of the ilo-builtins-core skill. Each pointer also nudges agents toward take -1 xs for the 'drop last' shape so the sugar stays ergonomic without losing the Python-style negative-offset path. --- SPEC.md | 2 +- ai.txt | 2 +- skills/ilo/ilo-builtins-core.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index 7817e0c9..dd29638d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -532,7 +532,7 @@ Called like functions, compiled to dedicated opcodes. | `srt xs` | sort list (all-number or all-text) or text chars (stable: equal elements keep their input order) | same type | | `srt fn xs` | sort list by key function (returns number or text key); stable: items with equal keys keep their input order | `L` | | `unq xs` | remove duplicates, preserve order (list or text chars) | same type | -| `slc xs a b` | slice list or text from index a to b (a, b accept negative indices counting from end; bounds clamp) | same type | +| `slc xs a b` | slice list or text from index a to b (a, b accept negative indices counting from end; bounds clamp). Sugar: when `a >= 0` and `b == -1`, `b` reads as `len xs` (Python/JS "to end" shape). Other negative `b` values (e.g. `-2`) keep the relative-offset semantics; use `take -1 xs` if you want "drop last". | same type | | `jpth json path` | JSON dot-path lookup, dot-separated keys + numeric array indices (e.g. `"a.b.0.c"`), not JSONPath - leading `$`, `*`, or `[...]` rejected with a diagnostic. Result is typed: arrays → list, objects → record, scalars → matching primitive. | `R _ t` | | `jkeys json path` | sorted top-level keys of the JSON object at `path` (empty path = root). Err if the value at the path is not an object. | `R (L t) t` | | `jdmp value` | serialise ilo value to JSON text | `t` | diff --git a/ai.txt b/ai.txt index 39e191d5..4a8be132 100644 --- a/ai.txt +++ b/ai.txt @@ -6,7 +6,7 @@ NAMING: Short names everywhere. 1–3 chars. `order`=`ord`=truncate `customers`= COMMENTS: -- full line comment +a b -- end of line comment -- no multi-line comments; use consecutive -- lines -- like this Single-line only. `--` to end of line. No multi-line comment syntax - newlines are a human display concern, not a language concern. An entire ilo program can be one line. Use consecutive `--` lines when humans need multi-line comments. Stripped at the lexer level before parsing - comments produce no AST nodes and cost zero runtime tokens. Generating `--` costs 1 LLM token, so comments are essentially free. **Gotcha:** `--x 1` is a comment, not "negate (x minus 1)". The lexer matches `--` greedily as a comment and eats the rest of the line. To negate a subtraction, use a space or bind first: -- DON'T: --x 1 (comment, not negate-subtract) -- DO: - -x 1 (space separates the two minus operators) -- DO: r=-x 1;-r (bind first) OPERATORS: Both prefix and infix notation are supported. **Prefix is preferred** - it is the token-optimal form that eliminates parentheses and produces denser code. Infix is available for readability when needed. [Binary] `+a b`=`a + b`=add / concat / list concat=`n`, `t`, `L` `+=a v`=append to list (returns new list, see [Append semantics](#append-semantics-+=))=`L` `-a b`=`a - b`=subtract=`n` `*a b`=`a * b`=multiply=`n` `/a b`=`a / b`=divide=`n` `=a b`=`a == b`=equal (prefix `=` is preferred; `==a b` also accepted)=any `!=a b`=`a != b`=not equal=any `>a b`=`a > b`=greater than=`n`, `t` `=a b`=`a >= b`=greater or equal=`n`, `t` `<=a b`=`a <= b`=less or equal=`n`, `t` `&a b`=`a & b`=logical AND (short-circuit)=any (truthy) `|a b`=`a | b`=logical OR (short-circuit)=any (truthy) [Append semantics (`+=`)] `+=xs v` is **pure-shaped**, despite the imperative-looking syntax. It returns a new list with `v` appended and does **not** mutate `xs` in the caller's scope. It works in every position a value-producing expression works: -- 1. Rebind (canonical accumulator pattern) xs=[];@i 0..3{xs=+=xs i};xs -- [0, 1, 2] -- 2. Non-rebind assignment (xs preserved) xs=[1, 2, 3];ys=+=xs 99 -- xs is still [1, 2, 3]; ys is [1, 2, 3, 99] -- 3. Pipeline / argument position len +=xs 99 -- length of [xs..., 99] sum +=xs 99 -- sum of [xs..., 99] The rebind shape `xs = +=xs v` is the standard foreach-build accumulator. When the binding is RC=1 the engines mutate the underlying buffer in place (amortised O(1) per push) - but this is a behind-the-scenes optimisation. To any observer the operation is still functional: nothing outside the rebind sees the old `xs`. The non-rebind shape `ys = +=xs v` always allocates a fresh list and leaves `xs` untouched, so source aliases are safe. There is no separate `push` builtin. `+=` covers every use case and is shorter; adding an alias would mean two ways to spell the same operation, costing reasoning tokens and surface area. [Unary] `-x`=negate=`n` `!x`=logical NOT=any (truthy) [Special infix] `a??b`=nil-coalesce (if a is nil, return b)=any `a>>f`=pipe (desugar to `f(a)`)=any [Prefix nesting (no parens needed)] +*a b c -- (a * b) + c *a +b c -- a * (b + c) >=+x y 100 -- (x + y) >= 100 -*a b *c d -- (a * b) - (c * d) The outer prefix op binds the inner prefix subexpression as its **left** operand, regardless of operator precedence. With two same-precedence ops side by side this is easy to misread: */a b c -- (a/b) * c ← NOT (a*b)/c /*a b c -- (a*b) / c ← NOT (a/b)*c +-a b c -- (a-b) + c ← NOT (a+b)-c -+a b c -- (a+b) - c ← NOT (a-b)+c The runtime emits a `hint:` diagnostic when one of these four pairs appears at a prefix position, since the parse order disagrees with the natural left-to-right reading. To force the other grouping, swap the ops or bind the inner result first: -- Want (a*b)/c with a=6, b=2, c=3: r=*a b;/r c -- bind, then divide → 4 /*a b c -- equivalent, swapping the prefix-pair order [Infix precedence] Standard mathematical precedence (higher binds tighter): 6=`*` `/` 5=`+` `-` `+=` 4=`>` `<` `>=` `<=` 3=`=` `!=` 2=`&` 1=`|` Function application binds tighter than all infix operators: f a + b -- (f a) + b, NOT f(a + b) x * y + 1 -- (x * y) + 1 (x + y) * 2 -- parens override precedence Each nested prefix operator saves 2 tokens (no `(` `)` needed). Flat prefix like `+a b` saves 1 char vs `a + b`. Across 25 expression patterns, prefix notation saves **22% tokens** and **42% characters** vs infix. See [research/explorations/prefix-vs-infix/](research/explorations/prefix-vs-infix/) for the full benchmark. Disambiguation: `-` followed by one atom is unary negate, followed by two atoms is binary subtract. [Operands] Operator operands are **atoms** (literals, refs, field access), **nested prefix operators**, or **known-arity function calls**. The prefix-binop operand parser dispatches to call parsing when the ident at the cursor is a known-arity user fn or builtin AND the next token can start another operand: wh >len q 0{body} -- parses as wh > (len q) 0 { body } +f g h -- if f is 1-arity: BinOp(+, Call(f, [g]), h) -lnx 5 lnx 3 -- BinOp(-, Call(lnx, [5]), Call(lnx, [3])) dbl 5 -- Negate(Call(dbl, [5])) - unary on a call This parallels the `??` precedent: `??x default` accepts a call expression on the value side. Applies to every prefix-binop family member - `+`, `-`, `*`, `/`, comparisons, `&`, `|`, `+=` - and to unary negate when the call consumes the only operand. The same expansion also applies to the then/else slots of the prefix-ternary family (`?=cond a b`, `?>cond a b`, …) and the `?h cond a b` keyword form, so `?h =a b sev sc "NONE"` parses `sev sc` as a nested call without parens or a bind-first. Bare locals that shadow a user fn name still resolve via `Ref` rather than expanding into a zero-arg call, so `&e f{...}` where `f` is a local still parses as the bool operator with two refs. When the call expansion isn't available (the ident is a local that shadows a fn name, or the call's arity doesn't fit the remaining tokens), bind the call result first: r=fac p;*n r -- bind, then operate - always unambiguous **Negative literals vs binary minus**: the lexer greedily includes a leading `-` into number tokens. `-1`, `-7`, `-0` are all number literals at fresh-expression positions. To subtract from zero at the start of a statement, use a space: `- 0 v` (Minus token, then `0`, then `v`). f v:n>n;-0 v -- WRONG: -0 is Number(-0.0); v is a stray token f v:n>n;- 0 v -- OK: binary subtract: 0 - v = -v The lexer splits a glued negative literal back into `Minus + Number` when the previous token is one of `;`, `\n`, `=`, `{`, `(`, or `-`. The `-` context covers the operand slot of an outer prefix-minus, so `- -0 a b` lexes as `-, -, 0, a, b` and parses as `Subtract(Subtract(0, a), b)` = `-a - b` rather than tripping `ILO-P020`. Negative literals after an Ident, `[`, or another prefix binop (`+`, `*`, `/`) stay glued so call args (`at xs -1`), list literals (`[-2 1 3]`), and binary operands (`+a -3`) read naturally. **Subtraction spacing convention**: for general subtraction at statement position, write `a - b` with spaces on **both** sides. `a -b` (glued, no space before the `-`) is not a binary subtract: the lexer packs `-b` into a negative-literal token because the previous token (`a`, an Ident) is one of the keep-glued contexts above. That's deliberate so call args and list elements read naturally, but it means `0 -1.5` is a parse error (`ILO-P001: expected declaration, got number `-1.5`` with a tailored hint pointing at this rule). For a bare negative value as an expression, wrap in parens: `(-1.5)`. STRING LITERALS: Text values are written in double quotes. Escape sequences: `\n`=newline (0x0A) `\t`=tab (0x09) `\r`=carriage return (0x0D) `\f`=form feed (0x0C, PDF page separator) `\b`=backspace (0x08) `\v`=vertical tab (0x0B) `\a`=bell (0x07) `\0`=null (0x00) `\"`=literal double quote `\\`=literal backslash `\/`=literal forward slash (JSON passthrough) Unknown escapes (e.g. `\z`) preserve the backslash + char verbatim. "hello\nworld" -- two-line string "col1\tcol2" -- tab-separated spl text "\n" -- split file content into lines spl pdf "\f" -- split pdftotext output into pages -BUILTINS: Called like functions, compiled to dedicated opcodes. `len x`=length of string (bytes) or list (elements)=`n` `str n`=number to text (integers format without `.0`)=`t` `num x`=polymorphic: text → parsed number (trims leading/trailing ASCII whitespace; Err if unparseable). number → identity-wrapped Ok. Accepting both saves the `num (str x)` roundtrip when `x` already came back numeric (e.g. from `jpar!` on a JSON number).=`R n t` `abs n`=absolute value=`n` `min a b`=minimum of two numbers=`n` `min xs`=minimum element of a numeric list (error if empty)=`n` `max a b`=maximum of two numbers=`n` `max xs`=maximum element of a numeric list (error if empty)=`n` `mod a b`=C-style signed remainder; result sign matches dividend. Errors on zero divisor. For negative inputs use `fmod`.=`n` `fmod a b`=Floor-mod: always non-negative when `b > 0`. Equivalent to Python `a % b`. Errors on zero divisor. NaN/Inf inputs propagate via IEEE 754 (same policy as every other math builtin). Use instead of `(a % b + b) % b` workarounds for weekday/timezone arithmetic.=`n` `flr n`=floor (round toward negative infinity)=`n` `cel n`=ceiling (round toward positive infinity)=`n` `rnd`=random float in [0, 1). NOT round - for round use `rou` (alias: `round`). Aliases: `rand`, `random`.=`n` `rnd a b`=random integer in [a, b] (inclusive)=`n` `rand-bytes n`=cryptographically random bytes from the platform CSPRNG (via `getrandom`), encoded as base64url-no-pad text. Distinct from `rnd` (seedable uniform float for simulations): this is the path for JWT `jti`, CSRF tokens, session IDs, nonces. Output is URL-safe so it drops straight into headers / cookies / query strings. Capped at 1 MiB; non-negative `n` only.=`t` `now`=current Unix timestamp (seconds)=`n` `now-ms`=current Unix timestamp (milliseconds)=`n` `get url`=HTTP GET=`R t t` `get url headers`=HTTP GET with custom headers (`M t t` map)=`R t t` `get-to url timeout-ms`=HTTP GET with explicit timeout (milliseconds); Err if deadline exceeded=`R t t` `pst url body`=HTTP POST with text body (renamed from `post` in 0.12.0)=`R t t` `pst url body headers`=HTTP POST with body and custom headers (`M t t` map)=`R t t` `pst-to url body timeout-ms`=HTTP POST with explicit timeout (milliseconds); Err if deadline exceeded=`R t t` `urlenc s`=RFC 3986 percent-encode; unreserved chars (ALPHA/DIGIT/`-._~`) pass through, everything else as `%HH`. Total.=`t` `urldec s`=inverse of `urlenc`; Err on invalid percent escape or non-UTF-8 decoded bytes=`R t t` `b64u s`=base64url-encode UTF-8 bytes of `s` (RFC 4648 §5, no padding, `-`/`_` alphabet). Total.=`t` `b64u-dec s`=inverse of `b64u`; Err on invalid base64url or non-UTF-8 decoded bytes=`R t t` `run cmd argv`=spawn `cmd` with argv list — see [Process spawn](#process-spawn) for the no-shell-no-glob security model=`R (M t t) t` `env key`=read environment variable=`R t t` `env-all`=snapshot the full process environment as `M t t`=`R (M t t) t` `rd path`=read file; format auto-detected from extension (`.csv`/`.tsv`→grid, `.json`→graph, else text)=`R _ t` `rd path fmt`=read file with explicit format override (`"csv"`, `"tsv"`, `"json"`, `"raw"`)=`R _ t` `rdl path`=read file as list of lines=`R (L t) t` `rdin`=read all of stdin as text; Err on I/O failure or WASM=`R t t` `rdinl`=read stdin as list of lines (newlines stripped); Err on I/O failure or WASM=`R (L t) t` `lsd dir`=list directory entries (filenames only, not full paths; sorted lexicographically; includes both files and subdirs; empty dirs return `[]`, not Err). Renamed from `ls` in 0.12.1 so the natural `ls=rdl! p` binding for "lines" stays free.=`R (L t) t` `walk dir`=recursive depth-first traversal; paths returned relative to `dir`, sorted; includes both file and directory entries; symlinks not followed. Unreadable subdirectories (e.g. permission denied) are silently skipped so one locked sibling does not poison the whole walk; an unreadable root still returns `Err`=`R (L t) t` `glob dir pat`=shell-style filter under `dir`: `*`/`?`/`[abc]` within a path segment, `**` across segments; relative-path output, sorted; no matches returns `[]` (not Err). Shares `walk`'s traversal so unreadable subdirectories are skipped silently=`R (L t) t` `dirname path`=POSIX-style parent directory. `dirname "/a/b/c.txt"` → `"/a/b"`, `dirname "/"` → `"/"`, `dirname "foo.txt"` → `""` (POSIX returns `"."` here; ilo returns `""` so `pathjoin [dirname p basename p]` round-trips a plain filename without a phantom `./` prefix), `dirname "foo/"` → `""` (trailing slash stripped, then no directory component remains), `dirname "/a"` → `"/"`. Pure text op, no I/O, no Result. Unix forward-slash semantics; Windows separator handling is a 0.13.0 concern=`t` `basename path`=POSIX-style final path segment. `basename "/a/b/c.txt"` → `"c.txt"`, `basename "/"` → `"/"`, `basename "foo/"` → `"foo"` (trailing slash stripped), `basename ""` → `""`. Pure text op, total=`t` `pathjoin parts`=join a list of path segments with `/`, collapsing duplicate separators at joints and dropping empty segments. `pathjoin ["a" "b" "c.txt"]` → `"a/b/c.txt"`, `pathjoin ["a/" "/b/" "c.txt"]` → `"a/b/c.txt"`, `pathjoin []` → `""`, `pathjoin ["/" "a"]` → `"/a"` (leading absolute root preserved). List form (not variadic) so arity inference stays predictable; matches `cat xs sep`'s shape=`t` `fsize path`=file size in bytes (follows symlinks); `Err` on missing, permission-denied, or path-is-directory. Paired predicate `isfile` collapses the error tier into `false` for one-token branches=`R n t` `mtime path`=last modification time as Unix epoch seconds (`f64`, fractional preserved; follows symlinks); `Err` on missing or permission-denied. Pairs with `now` for "is this file older than N seconds" checks=`R n t` `isfile path`=`true` iff `path` resolves to a regular file (follows symlinks). Missing, permission-denied, or non-file all collapse to `false` — natural shape for `?isfile p{…}`. Asymmetric vs `fsize`/`mtime` (which return `R n t`) by design: predicates want a one-token branch, size/mtime callers want to distinguish missing from perm-denied=`b` `isdir path`=`true` iff `path` resolves to a directory (follows symlinks). Same `false`-on-failure collapse as `isfile`=`b` `rdb s fmt`=parse string/buffer in given format - for data from HTTP, env vars, etc.=`R _ t` `wr path s`=write text to file (overwrite)=`R t t` `wr path data "csv"`=write list-of-lists as CSV (with proper quoting)=`R t t` `wr path data "tsv"`=write list-of-lists as TSV=`R t t` `wr path data "json"`=write any value as pretty JSON=`R t t` `wra path s`=append text to file (create if missing)=`R t t` `wrl path xs`=write list of lines to file (joins with `\n`)=`R t t` `trm s`=trim leading and trailing whitespace=`t` `spl t sep`=split text by separator=`L t` `fmt tmpl args…`=format string - bare `{}` placeholders only, filled left-to-right. Printf-style specs (`{:06d}`, `{:.3f}`) are rejected; compose `fmt2` for decimal precision and `padl` for width/padding. Literal templates require `{}`-count == arg-count (verifier rejects mismatches with `ILO-T013`). Lists are formatted as a single value, not splatted: `fmt "{} {}" [a, b]` is an error - use `fmt "{} {}" a b` instead=`t` `cat xs sep`=join list of text with separator=`t` `has xs v`=membership test (list: element, text: substring)=`b` `hd xs`=head (first element/char) of list or text=element / `t` `tl xs`=tail (all but first) of list or text=`L` / `t` `rev xs`=reverse list or text=same type `srt xs`=sort list (all-number or all-text) or text chars (stable: equal elements keep their input order)=same type `srt fn xs`=sort list by key function (returns number or text key); stable: items with equal keys keep their input order=`L` `unq xs`=remove duplicates, preserve order (list or text chars)=same type `slc xs a b`=slice list or text from index a to b (a, b accept negative indices counting from end; bounds clamp)=same type `jpth json path`=JSON dot-path lookup, dot-separated keys + numeric array indices (e.g. `"a.b.0.c"`), not JSONPath - leading `$`, `*`, or `[...]` rejected with a diagnostic. Result is typed: arrays → list, objects → record, scalars → matching primitive.=`R _ t` `jkeys json path`=sorted top-level keys of the JSON object at `path` (empty path = root). Err if the value at the path is not an object.=`R (L t) t` `jdmp value`=serialise ilo value to JSON text=`t` `prnt value`=print value to stdout, return it unchanged (passthrough)=same type `jpar text`=parse JSON text into ilo values=`R _ t` `jpar-list text`=parse JSON text, assert top-level is array, return typed list=`R (L _) t` `grp fn xs`=group list by key function=`M t (L a)` `flat xs`=flatten one level of nesting=`L a` `sum xs`=sum of numeric list (0 for empty)=`n` `prod xs`=product of numeric list (1 for empty)=`n` `avg xs`=mean of numeric list (error if empty)=`n` `rgx pat s`=regex: no groups→all matches; groups→first match captures=`L t` `mmap`=create empty map=`M t _` `mget m k`=value at key k (nil if missing)=element or nil `mset m k v`=new map with key k set to v=`M k v` `mhas m k`=true if key exists=`b` `mkeys m`=sorted list of keys=`L t` `mvals m`=values sorted by key=`L v` `mpairs m`=sorted [k, v] pairs; `mpairs m == zip (mkeys m) (mvals m)`=`L (L _)` `mdel m k`=new map with key k removed=`M k v` `mget-or m k default`=value at key k, or `default` if missing (never nil; default type must match value type)=`v` `at xs i`=i-th element of list or text (0-indexed; negative counts from end; float `i` auto-floors)=element `lget-or xs i default`=element at index `i`, or `default` if OOB (negative indices like `at`; never errors on OOB)=`a` `lst xs i v`=list-set: returns a new list with index `i` replaced by `v` (the canonical list-update builtin; same role as `lset`/`setat`/`set-at` in other languages — `lset` is the long-form alias)=`L a` `take n xs`=first `n` elements/chars of list or text (n>=0 truncates if n>len; n<0 keeps all but the last `abs n`, Python `xs[:n]`)=same type `drop n xs`=skip first `n` elements/chars (n>=0 returns the rest; n<0 keeps only the last `abs n`, Python `xs[n:]`)=same type `rsrt xs`=sort descending (list or text chars)=same type `rsrt fn xs`=sort descending by key function (returns number or text key)=`L` `rsrt fn ctx xs`=sort descending by key function with explicit ctx arg (closure-bind alternative; `fn` takes `(elem, ctx)`)=`L` `uniqby fn xs`=dedupe by key function (first occurrence wins)=`L a` `zip xs ys`=pairwise pairs of two lists; truncates to shorter input=`L (L _)` `enumerate xs`=pair each element with its index → `[[i, v], ...]`=`L (L _)` `range a b`=half-open numeric range `[a, a+1, ..., b-1]`; empty when `a >= b`=`L n` `map fn xs`=apply `fn` to each element=`L b` `flt fn xs`=keep elements where `fn x` is true=`L a` `ct fn xs`=count elements where `fn x` is true (avoids `len (flt fn xs)`'s intermediate list alloc)=`n` `fld fn xs init`=left fold: `fn (fn (fn init x0) x1) ...`=accumulator `flatmap fn xs`=map then flatten one level=`L b` `mapr fn xs`=map with short-circuit Result propagation: collects Ok values, returns first Err=`R (L b) e` `default-on-err r d`=unwrap `R T E` to `T`, returning `d` if Err; verifier requires `d` matches Ok type. Mirror of `??` for Result (`??` is nil-coalesce for `O T` only - use `default-on-err` for Result). Prefer over `?r{~v:v;^_:d}` when no error payload is needed. ILO-T040 when first arg is not `R T E` (hint steers at `??` only when first arg is Optional); ILO-T042 when the default's type doesn't match the Ok type; ILO-T041 when `??` is used on a Result. T041 is suppressed when the lhs type is `Unknown` (e.g. type-variable params) to avoid false positives on generic code=`T` `partition fn xs`=split list into `[passing, failing]` by predicate=`L (L a)` `chunks n xs`=non-overlapping chunks of size `n` (final chunk may be shorter)=`L (L a)` `window n xs`=sliding windows of size `n` (drops trailing partial; empty if n > len)=`L (L a)` `clamp x lo hi`=restrict `x` to `[lo, hi]` (lower bound wins when `lo > hi`)=`n` `cumsum xs`=running sum; output length matches input=`L n` `cprod xs`=running product; output length matches input=`L n` `ewm xs a`=exponential moving average: `ewm[0] = xs[0]`, `ewm[i] = a*xs[i] + (1-a)*ewm[i-1]`; `a` in `[0, 1]`, out-of-range errors `ILO-R009`=`L n` `frq xs`=frequency map of elements (keys are bare stringified values)=`M t n` `median xs`=median of numeric list=`n` `quantile xs p`=sample quantile (linear interp; `p` clamped to `[0, 1]`)=`n` `stdev xs`=sample standard deviation (divides by N-1)=`n` `variance xs`=sample variance (divides by N-1)=`n` `argmax xs`=index of the maximum element (first occurrence wins on ties; errors on empty list)=`n` `argmin xs`=index of the minimum element (first occurrence wins on ties; errors on empty list)=`n` `argsort xs`=sorted-index permutation ascending - stable sort, indices of smallest to largest (empty list returns `[]`)=`L n` `setunion a b`=set union of two lists (deduped, sorted output)=`L a` `setinter a b`=set intersection (deduped, sorted)=`L a` `setdiff a b`=set difference `a - b` (deduped, sorted)=`L a` `chars s`=explode a string into single-char strings (one per Unicode scalar)=`L t` `ord s`=Unicode codepoint of the first character of `s`=`n` `chr n`=single-character string for codepoint `n`=`t` `upr s`=uppercase (ASCII)=`t` `lwr s`=lowercase (ASCII)=`t` `cap s`=capitalise first char (ASCII)=`t` `padl s w`=left-pad to width `w` with spaces (no-op if already wider)=`t` `padr s w`=right-pad to width `w` with spaces (no-op if already wider)=`t` `padl s w pc`=left-pad to width `w` with 1-character string `pc` (e.g. `"0"` for sortable zero-padded keys)=`t` `padr s w pc`=right-pad to width `w` with 1-character string `pc` (e.g. `"."` for dot-leader alignment). Idiom: `padr "" w pc` repeats `pc` w times (histogram bars, divider lines)=`t` `rgxall pat s`=every regex match as `L (L t)` (no-group: each match in a 1-elem list)=`L (L t)` `rgxall1 pat s`=flat first-capture-group convenience: 0 groups → `L t` of whole matches; 1 group → `L t` of capture-1 strings; 2+ groups errors=`L t` `rgxall-multi pats s`=multi-pattern flat-match: apply each pattern in `pats:L t` to `s`, concat all hits in pattern order; per-pattern semantics follow `rgxall1` (0 groups → whole matches; 1 group → capture-1 strings; 2+ groups errors)=`L t` `rgxsub pat repl s`=regex substitute all matches; `$1`, `$2`, ... reference capture groups=`t` `dtfmt epoch fmt`=format Unix epoch as text (strftime, UTC)=`R t t` `dtparse s fmt`=parse text to Unix epoch (strftime, UTC)=`R n t` `dtparse-rel s now`=parse relative-date phrase to epoch; `now` is the anchor epoch=`R n t` `dur-parse s`=parse human duration string ("3h 30m", "1 week 2 days", "1.5 hours", "90s") into seconds. Lenient: accepts abbreviations `s`/`m`/`h`/`d`/`w`, full names (singular + plural), decimal quantities, mixed sequences. Err if empty or no unit found=`R n t` `dur-fmt n`=format seconds as human-readable duration ("2h 42m", "1 day", "30s"). Drops zero parts; uses largest applicable units. Zero returns "0s". Negative values format with a leading "-"=`t` `rdjl path`=read JSONL file as `L (R _ t)`: one parse result per non-empty line=`L (R _ t)` `get-many urls`=concurrent HTTP GET fan-out (max 10 parallel), preserves order=`L (R t t)` `sleep ms`=pause current engine for `ms` milliseconds; returns nil=`_` `rou n`=round to nearest integer (banker's rounding)=`n` `rndn mu sigma`=one sample from normal distribution `N(mu, sigma)` (Box-Muller)=`n` `pow b e`=`b` raised to power `e`=`n` `sqrt n`=square root=`n` `exp n`=natural exponent `e^n`=`n` `log n`=natural logarithm=`n` `log10 n`=base-10 logarithm=`n` `log2 n`=base-2 logarithm=`n` `sin n`=sine (radians)=`n` `cos n`=cosine (radians)=`n` `tan n`=tangent (radians)=`n` `asin n`=arcsine, returns radians in `[-pi/2, pi/2]`; NaN outside `[-1, 1]`=`n` `acos n`=arccosine, returns radians in `[0, pi]`; NaN outside `[-1, 1]`=`n` `atan n`=arctangent, returns radians in `[-pi/2, pi/2]`=`n` `atan2 y x`=two-argument arctangent (y, x order; radians)=`n` `pi`=3.141592653589793 (IEEE-754 f64, `f64::consts::PI`)=`n` `tau`=6.283185307179586 (== 2\*pi; one full turn in radians)=`n` `e`=2.718281828459045 (Euler's number, `f64::consts::E`)=`n` `transpose m`=transpose row-major matrix=`L (L n)` `matmul a b`=matrix product=`L (L n)` `matvec xm ys`=matrix-vector product (`r[i] = sum_j xm[i][j] * ys[j]`); skips the wrap-as-column + flatten ceremony around `matmul`=`L n` `dot a b`=vector dot product=`n` `solve a b`=solve `Ax = b` via LU with partial pivoting; errors on singular/non-square=`L n` `inv a`=matrix inverse; errors on singular/non-square=`L (L n)` `det a`=determinant; errors on non-square=`n` `lstsq xm ys`=ordinary least squares: returns coefficients `b` minimising `|xm·b - ys|²` via the normal equations (`solve (Xᵀ X) (Xᵀ y)`). Errors on rank-deficient design, underdetermined system (cols > rows), or row/length mismatch=`L n` `fft xs`=discrete FFT: real samples → `L [re, im]`; zero-padded to next power of 2=`L (L n)` `ifft pairs`=inverse FFT; imaginary part dropped on return=`L n` `fmt2 x digits`=format number `x` to `digits` decimal places (half-to-even rounding; `digits` clamped to `0..=20`). Compose with `fmt` for template + precision: `fmt "x={}" (fmt2 v 2)`=`t` > **`fmt` does not print.** `fmt` and `fmt2` are pure-functional string builders, not `println!`. A bare `fmt "..." v` statement evaluates and discards the resulting text on every engine - nothing reaches stdout. Print with `prnt fmt "..." v` or capture with `line = fmt "..." v`. The verifier emits **ILO-T032** when `fmt`/`fmt2` is a non-tail statement with no binding. Tail position is fine: `say-x v:n>t;fmt "x={}" v` returns the string to the caller as documented. > **`+=`, `mset`, and `mdel` return a new value, they do not mutate in place.** `+=xs v` returns a new list; `mset m k v` and `mdel m k` return a new map. As a bare statement (`@i 0..3{+=out i}`, `mset m "a" 1;m`) the result is silently discarded and the source binding is unchanged. The verifier emits **ILO-T033** when these calls appear at a discarded position - any non-tail statement, or anywhere inside a loop body. Fix is the assignment form: `out=+=out i`, `m=mset m k v`, `m=mdel m k`. Tail position in a function/`?{}` arm is fine - the value flows out as the return. > **`wr` and `wrl` return the written path, not a status.** Both succeed with `~path` (the file path you passed in), not `~"ok"` or nil. A `save` helper that ends with a bare `wrl "tasks.txt" xs` therefore returns `~"tasks.txt"`, and every successful mutation echoes the state-file path to stdout - noise for any caller piping output. Discard the path and return a clean status string instead: `save xs:L t>R t t;r=wrl "tasks.txt" xs;?r{~_:~"ok";^e:^e}`. The error arm still propagates `wrl`'s message. See [`examples/cli-tasks-save-ok.ilo`](examples/cli-tasks-save-ok.ilo) for the full shape. [Datetime (`dtfmt` / `dtparse` / `dtparse-rel`)] UTC only. Format strings follow strftime conventions (`%Y-%m-%d %H:%M:%S`, `%s`, etc). dtfmt 1700000000 "%Y-%m-%d" -- R t t: Ok="2023-11-14", Err if out of range dtparse "2024-01-15" "%Y-%m-%d" -- R n t: Ok=epoch seconds, Err if unparseable dtfmt! e "%H:%M:%S" -- auto-unwrap inside R-returning fn `dtparse-rel s now` resolves a natural-language relative-date phrase to a Unix epoch anchored at `now`. Phrases supported: `today`, `yesterday`, `tomorrow` `N days ago`, `in N days` (also `N day ago`, `in N day`) `N weeks ago`, `in N weeks` `N months ago`, `in N months` (end-of-month clamping: `Jan 31 + 1 month = Feb 28/29`) `last `, `next `, `this ` — weekdays as `monday`–`sunday` or short `mon`–`sun`; `last`/`next` never return today ISO-8601 date literal `YYYY-MM-DD` — passthrough to `dtparse` (ignores `now`) -- now = 1705276800 (2024-01-15, Monday) dtparse-rel!! "yesterday" (now) -- 2024-01-14 00:00 UTC dtparse-rel!! "3 days ago" (now) -- 2024-01-12 00:00 UTC dtparse-rel!! "in 2 weeks" (now) -- 2024-01-29 00:00 UTC dtparse-rel!! "last friday" (now) -- 2024-01-12 00:00 UTC dtparse-rel!! "next wednesday" (now) -- 2024-01-17 00:00 UTC dtparse-rel!! "2023-12-25" (now) -- 1703462400 (ignores now) Unrecognised phrases return `Err` with a message listing valid forms. All times are midnight UTC. [Duration (`dur-parse` / `dur-fmt`)] `dur-parse s > R n t` — parse a human-readable duration string into total seconds as a float. `dur-fmt n > t` — format seconds as a human-readable duration string. Both are tree-bridge eligible: VM and Cranelift dispatch through the same interpreter arm. Accepted units for `dur-parse`: `w`=week, weeks `d`=day, days `h`=hour, hours, hr, hrs `m`=min, mins, minute, minutes `s`=sec, secs, second, seconds dur-parse "3h 30m" -- R n t: Ok=12600, Err if no unit found dur-parse "1 week 2 days" -- R n t: Ok=777600 dur-parse "1.5 hours" -- R n t: Ok=5400 dur-parse "4h32m" -- no space between number and unit: Ok=16320 dur-parse! s -- auto-unwrap inside R-returning fn dur-fmt 9720 -- "2h 42m" dur-fmt 86400 -- "1 day" dur-fmt 90 -- "1m 30s" dur-fmt 90.5 -- "1m 30.5s" (fractional seconds preserved) dur-fmt 0 -- "0s" dur-fmt -90 -- "-1m 30s" (single leading minus) -- Round-trip: parse -> seconds -> format n = dur-parse! "2 days 3 hours" dur-fmt n -- "2 days 3h" **Months are not supported.** `mo`, `month`, `months`, `M` are deliberately omitted because a month is not a fixed number of seconds. Strings like `"3mo"` or `"3 months"` produce a `no recognised unit` error. Use explicit day counts (e.g. `"30 days"`, `"90 days"`). **Sticky sign.** A leading `-` in `dur-parse` is sticky: it applies to every following token until an explicit `+` resets it. So `"-1m 30s"` parses to `-90`, and `"-1h +10m"` parses to `-3000`. This makes the round-trip `dur-fmt -> dur-parse` symmetric for negative durations, where `dur-fmt` emits a single leading minus rather than signing each part. **Fractional seconds.** `dur-fmt` renders sub-second fractions with up to 3 decimal places (trailing zeros stripped), both for sub-second inputs (`0.5 -> "0.5s"`) and for mixed values where the seconds component carries a fraction (`90.5 -> "1m 30.5s"`). Fractional minutes / hours / days / weeks are decomposed into smaller units before formatting. [Set operations] `setunion`, `setinter`, `setdiff` operate on lists of `t`, `n`, or `b` (same constraint as `uniqby`). Output is deduped and sorted by a type-prefixed string key, so results are deterministic across runs and engines. Sort is lexicographic on the key, not numeric - re-sort with `srt` afterwards if you need numeric order. [Linear algebra] `transpose`, `matmul`, `matvec`, `dot`, `solve`, `inv`, `det`, `lstsq` operate on row-major matrices (`L (L n)`) and flat vectors (`L n`). `solve`, `inv`, `det` use LU decomposition with partial pivoting and raise on singular or non-square inputs. `matvec xm ys` is matrix-vector product as a flat vector; it skips the `flatten matmul xm (map (y:n>L n;[y]) ys)` ceremony needed to coerce a vector into a column matrix. `lstsq` is a thin wrapper around the normal equations (`solve (Xᵀ X) (Xᵀ y)`) — closed-form OLS at the same precision tier as `solve`; numerically inferior to QR/SVD for ill-conditioned designs. These ship as host-vetted builtins because hand-rolled implementations risk silent precision loss. [FFT] `fft xs` runs an iterative Cooley-Tukey radix-2 transform on real samples, zero-padding to the next power of two. Output is `L [re, im]` with one inner pair per frequency bin. `ifft pairs` is the inverse, dropping the imaginary part on return. [Builtin aliases] All builtins accept one or more alias names that resolve to the canonical name after parsing. Using an alias triggers a hint suggesting the canonical form. Most aliases go from a familiar long form (e.g. `length`) to the canonical short (`len`), letting newcomers write readable code while learning the canonical names. A small number go the other direction: where the canonical name is already 4+ characters and there is a natural short form with no plausible-user-binding collision, the short form is carved out as a permanent ergonomic alias. `floor`=→=`flr` `ceil`=→=`cel` `round`=→=`rou` `rand`=→=`rnd` `random`=→=`rnd` `rng`=→=`range` `lset`=→=`lst` `regex_all`=→=`rgxall` `regex_sub`=→=`rgxsub` `string`=→=`str` `number`=→=`num` `length`=→=`len` `head`=→=`hd` `tail`=→=`tl` `reverse`=→=`rev` `sort`=→=`srt` `slice`=→=`slc` `unique`=→=`unq` `filter`=→=`flt` `fold`=→=`fld` `flatten`=→=`flat` `concat`=→=`cat` `contains`=→=`has` `group`=→=`grp` `average`=→=`avg` `print`=→=`prnt` `trim`=→=`trm` `split`=→=`spl` `format`=→=`fmt` `regex`=→=`rgx` `read`=→=`rd` `readlines`=→=`rdl` `readbuf`=→=`rdb` `write`=→=`wr` `writelines`=→=`wrl` length xs -- works, but emits: hint: `length` → `len` (canonical form) len xs -- canonical - no hint rng 0 10 -- works, but emits: hint: `rng` → `range` (canonical form) range 0 10 -- canonical - no hint Every alias - both short-form (`rng`, `rand`) and long-form (`head`, `length`, `filter`, `concat`, ...) - follows the same shadow-prevention rule as canonical builtins: using an alias name as a binding LHS or user-function name is rejected at parse time with `ILO-P011`. The alias resolver rewrites call-position uses to the canonical builtin, so if the bind were allowed the user variable would be silently bypassed and the builtin called instead. For example, `head=fmt "### {}" t` then `cat [head body] "\n"` would rewrite `head` in call position to `hd`, emitting empty output with no error. The parser intercepts every alias in all three positions (top-level binding, local binding inside a function, user function declaration) with a rename hint. The full alias table is listed above; every entry triggers `ILO-P011` in all three contexts. `get` and `pst` return `Ok(body)` on success, `Err(message)` on failure (connection error, timeout, DNS failure, etc). In 0.12.0 the `$` sigil was rebound from `get` (parochial — `$` for HTTP is unique to ilo) to the new `run` builtin (argv-list process spawn). `$` for shell-exec reads cross-language — bash, Perl, Ruby, Python, PowerShell, and Zx all use `$` for command substitution. HTTP `get` is still called by name; the `$` shortcut is for process exec only. `post` was renamed to `pst` to bring it into line with the I/O compression family (`rd`, `wr`, `srt`, `flt`, `fld`, `fmt`). get url -- R t t: Ok=response body, Err=error message get! url -- auto-unwrap: Ok→body, Err→propagate to caller pst url body -- R t t: HTTP POST with text body pst url body headers -- R t t: HTTP POST with body and custom headers -- Custom headers: build an M t t map with mmap/mset h=mmap h=mset h "x-api-key" "secret" r=get url h -- GET with x-api-key header r=pst url body h -- POST with x-api-key header -- Explicit timeouts (milliseconds; rounds up to nearest second internally) r=get-to url 5000 -- GET with 5 s timeout; Err if exceeded r=pst-to url body 3000 -- POST with 3 s timeout Behind the `http` feature flag (on by default). Without the feature, `get`/`pst`/`get-to`/`pst-to` return `Err("http feature not enabled")`. [Process spawn] ilo provides one process-spawn primitive: `run cmd argv > R (M t t) t`. The signature is deliberately narrow: the first argument is the program (text), the second is the argv list (`L t`), and the result is a `Result` whose `Ok` carries a three-key Map of stdout / stderr / code as text. **Output map schema.** On `Ok`, the map has exactly these three keys, all `t`-valued: `stdout`=`t`=captured stdout bytes decoded as UTF-8 (lossy), as written `stderr`=`t`=captured stderr bytes decoded as UTF-8 (lossy), as written `code`=`t`=exit code as decimal text (`"0"`, `"1"`, …); on unix a signal-terminated child reports `"signal:"` (e.g. `"signal:9"`), and an unknown status reports `"unknown"` The map is always shaped this way on success: callers can rely on `mget m "stdout"`, `mget m "stderr"`, and `mget m "code"` all being non-nil text. Trailing newlines from the child are preserved verbatim, so use `trm` if you want to compare against a stripped value. r=run "echo" ["hi"] -- Ok({"stdout":"hi\n","stderr":"","code":"0"}) out=mget r.! "stdout" -- "hi\n" code=mget r.! "code" -- "0" err=mget r.! "stderr" -- "" $"git" ["status", "--short"] -- equivalent: $ is the sigil shortcut for run **No shell, no interpolation, no glob.** The argv list is passed directly to `std::process::Command::args`. There is no `sh -c`, no string concatenation between `cmd` and `argv`, and no glob expansion. This is the principled defence against shell injection: ilo refuses to provide an injection vector while still providing controlled exec. Compared to bash + `jq`, the argv-list discipline and the typed Result + Map handle make `run` materially safer for agent orchestration. **Non-zero exit is NOT an error.** `Err` is reserved for spawn failures (command not found, permission denied, kernel-level pipe failure, output cap exceeded). A child that returns a non-zero exit code surfaces as `Ok({"stdout":..., "stderr":..., "code":""})`; the caller inspects `code` and branches as needed. This matches Python's `subprocess.run` semantics. **Inherits parent env + cwd.** The first version provides no env or cwd override. Set the parent env / cwd before invoking ilo if you need a different shape. **Captured output is capped at 10 MiB per stream.** Either stream exceeding the cap returns an `Err` rather than partial capture so downstream JSON pipelines never see a truncated payload. **Stdin for child processes.** `run` spawns children with stdin closed (previously `/dev/null`). Use `rdin` / `rdinl` to read the **parent** program's own stdin from the shell pipeline. `rdin` reads all of stdin as text; `rdinl` reads it line by line. Behind the same default build profile as `get`/`pst`; on `wasm32` targets, `run` returns `Err("run: process spawn not available on wasm")`. `env` reads an environment variable by name, returning `Ok(value)` or `Err("env var 'KEY' not set")`: env key -- R t t: Ok=value, Err=not set message env! key -- auto-unwrap: Ok→value, Err→propagate to caller `env-all` returns the full process environment as a `M t t` map wrapped in `R`, mirroring the `env` shape so `env-all!` auto-unwraps inside a Result-returning function. Use it for "merge env over config" patterns where the agent does not know which keys to read up-front: env-all -- R (M t t) t: Ok=map of every env var, Err reserved for future failures env-all! -- auto-unwrap to M t t Non-UTF-8 environment variables are silently skipped (same policy as Rust's `std::env::vars`); the snapshot is always `Ok` today. [JSON builtins] `jpth` extracts a value from a JSON string by dot-separated path. Array elements are accessed by numeric index. **Note: `jpth` is dot-path only, not JSONPath.** A leading `$`, `*` wildcard, or `[...]` bracket selector triggers a diagnostic error pointing at the dot-path form; iterate arrays yourself with `@i` or `map` if you need wildcard behaviour. Since 0.12.1 the Ok variant is **typed**: a JSON array comes back as a list (`@`-iterable, `len`-able), a JSON object comes back as a record (`jdmp`-roundtrippable, `jkeys`-enumerable), and scalars come back as the matching ilo primitive (number, text, bool, nil). Pre-0.12.1 every non-string leaf was stringified, forcing a re-parse via `jpar` to iterate. The signature is now `R _ t`. jpth json "name" -- R _ t: Ok=typed value, Err=error message jpth json "user.name" -- nested path lookup jpth json "items.0.name" -- array index access (dot before index, not [0]) jpth json "spans" -- Ok=L _ when the leaf is a JSON array (iterable!) jpth json "deps" -- Ok=record when the leaf is a JSON object jpth json "n" -- Ok=Number 42 (not Text "42") on a numeric leaf jpth! json "name" -- auto-unwrap jpth json "$.a.b" -- ^"jpth is dot-path only ..." (JSONPath rejected) jpth json "items.*.name" -- ^"jpth is dot-path only ..." (no wildcards) `jkeys json path` returns the **sorted** top-level keys of the JSON object at the dot-path as `L t`. Empty path means root. Errs if the value at the path is not an object. Pairs with `mkeys` (which works on ilo `M` maps) so an agent can enumerate JSON object keys without re-parsing through `jpar`. jkeys json "" -- R (L t) t: Ok=sorted root keys jkeys json "deps" -- sorted keys of the "deps" object jkeys! json "deps" -- auto-unwrap jkeys json "items" -- ^"jkeys: value at path is not a JSON object" `jdmp` serialises any ilo value to a JSON string: jdmp 42 -- "42" jdmp "hello" -- "\"hello\"" jdmp [1 2 3] -- "[1,2,3]" jdmp (pt x:1 y:2) -- "{\"x\":1,\"y\":2}" `jpar` parses a JSON string into ilo values. JSON objects become records with type name `json`, arrays become lists, strings/numbers/bools/null map directly: jpar text -- R _ t: Ok=parsed value, Err=parse error r=jpar! "{\"x\":1}" -- r is a json record, access with r.x `jpar-list` is a typed companion: it parses the JSON string and **asserts the top-level value is an array**. The result is `R (L _) t`, so `jpar-list! body` unwraps directly to a list that `@` can iterate — no intermediate binding or type annotation needed: jpar-list text -- R (L _) t: Ok=list of parsed values, Err=parse or type error -- iterate a JSON array response body: @x (jpar-list! body){prnt x} -- or bind first: xs=jpar-list! body;@i 0..len xs{prnt (at xs i)} Use `jpar` when the JSON top-level shape is unknown (object, array, scalar). Use `jpar-list` when you know the response is an array and want to iterate it immediately. [URL and base64url encoding] Token-cheap primitives for OAuth, JWT, and webhook-signature workflows. All four are tree-bridge eligible: pure text-in / text-out with no I/O and no FnRef args, so the VM and Cranelift backends inherit them automatically. `urlenc s > t` percent-encodes per RFC 3986. The unreserved set (`ALPHA` / `DIGIT` / `-` / `.` / `_` / `~`) passes through literally; every other byte is emitted as `%HH`. Multi-byte UTF-8 is encoded byte-by-byte. `urldec s > R t t` is the inverse. It returns `Err` on a stray `%` not followed by two hex digits, or on decoded bytes that aren't valid UTF-8. `b64u s > t` base64url-encodes the UTF-8 bytes of `s` using the URL-safe alphabet (RFC 4648 §5: `-` and `_` substituted for `+` and `/`) with padding stripped. `b64u-dec s > R t t` is the inverse. It returns `Err` on input that contains characters outside the base64url alphabet, on `=` padding (the encode side strips padding, so the decode side rejects it for a strict round-trip), or on decoded bytes that aren't valid UTF-8. urlenc "a b&c=d" -- "a%20b%26c%3Dd" urldec! "a%20b%26c%3Dd" -- "a b&c=d" b64u "{\"alg\":\"HS256\",\"typ\":\"JWT\"}" -- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" b64u-dec! (b64u "hello, world!") -- "hello, world!" Both decoders return `Result` so malformed input surfaces typed at the boundary; both encoders are total. Use `!` to auto-unwrap inside an `R`-returning function, or pattern-match on the Result to handle the Err arm explicitly. +BUILTINS: Called like functions, compiled to dedicated opcodes. `len x`=length of string (bytes) or list (elements)=`n` `str n`=number to text (integers format without `.0`)=`t` `num x`=polymorphic: text → parsed number (trims leading/trailing ASCII whitespace; Err if unparseable). number → identity-wrapped Ok. Accepting both saves the `num (str x)` roundtrip when `x` already came back numeric (e.g. from `jpar!` on a JSON number).=`R n t` `abs n`=absolute value=`n` `min a b`=minimum of two numbers=`n` `min xs`=minimum element of a numeric list (error if empty)=`n` `max a b`=maximum of two numbers=`n` `max xs`=maximum element of a numeric list (error if empty)=`n` `mod a b`=C-style signed remainder; result sign matches dividend. Errors on zero divisor. For negative inputs use `fmod`.=`n` `fmod a b`=Floor-mod: always non-negative when `b > 0`. Equivalent to Python `a % b`. Errors on zero divisor. NaN/Inf inputs propagate via IEEE 754 (same policy as every other math builtin). Use instead of `(a % b + b) % b` workarounds for weekday/timezone arithmetic.=`n` `flr n`=floor (round toward negative infinity)=`n` `cel n`=ceiling (round toward positive infinity)=`n` `rnd`=random float in [0, 1). NOT round - for round use `rou` (alias: `round`). Aliases: `rand`, `random`.=`n` `rnd a b`=random integer in [a, b] (inclusive)=`n` `rand-bytes n`=cryptographically random bytes from the platform CSPRNG (via `getrandom`), encoded as base64url-no-pad text. Distinct from `rnd` (seedable uniform float for simulations): this is the path for JWT `jti`, CSRF tokens, session IDs, nonces. Output is URL-safe so it drops straight into headers / cookies / query strings. Capped at 1 MiB; non-negative `n` only.=`t` `now`=current Unix timestamp (seconds)=`n` `now-ms`=current Unix timestamp (milliseconds)=`n` `get url`=HTTP GET=`R t t` `get url headers`=HTTP GET with custom headers (`M t t` map)=`R t t` `get-to url timeout-ms`=HTTP GET with explicit timeout (milliseconds); Err if deadline exceeded=`R t t` `pst url body`=HTTP POST with text body (renamed from `post` in 0.12.0)=`R t t` `pst url body headers`=HTTP POST with body and custom headers (`M t t` map)=`R t t` `pst-to url body timeout-ms`=HTTP POST with explicit timeout (milliseconds); Err if deadline exceeded=`R t t` `urlenc s`=RFC 3986 percent-encode; unreserved chars (ALPHA/DIGIT/`-._~`) pass through, everything else as `%HH`. Total.=`t` `urldec s`=inverse of `urlenc`; Err on invalid percent escape or non-UTF-8 decoded bytes=`R t t` `b64u s`=base64url-encode UTF-8 bytes of `s` (RFC 4648 §5, no padding, `-`/`_` alphabet). Total.=`t` `b64u-dec s`=inverse of `b64u`; Err on invalid base64url or non-UTF-8 decoded bytes=`R t t` `run cmd argv`=spawn `cmd` with argv list — see [Process spawn](#process-spawn) for the no-shell-no-glob security model=`R (M t t) t` `env key`=read environment variable=`R t t` `env-all`=snapshot the full process environment as `M t t`=`R (M t t) t` `rd path`=read file; format auto-detected from extension (`.csv`/`.tsv`→grid, `.json`→graph, else text)=`R _ t` `rd path fmt`=read file with explicit format override (`"csv"`, `"tsv"`, `"json"`, `"raw"`)=`R _ t` `rdl path`=read file as list of lines=`R (L t) t` `rdin`=read all of stdin as text; Err on I/O failure or WASM=`R t t` `rdinl`=read stdin as list of lines (newlines stripped); Err on I/O failure or WASM=`R (L t) t` `lsd dir`=list directory entries (filenames only, not full paths; sorted lexicographically; includes both files and subdirs; empty dirs return `[]`, not Err). Renamed from `ls` in 0.12.1 so the natural `ls=rdl! p` binding for "lines" stays free.=`R (L t) t` `walk dir`=recursive depth-first traversal; paths returned relative to `dir`, sorted; includes both file and directory entries; symlinks not followed. Unreadable subdirectories (e.g. permission denied) are silently skipped so one locked sibling does not poison the whole walk; an unreadable root still returns `Err`=`R (L t) t` `glob dir pat`=shell-style filter under `dir`: `*`/`?`/`[abc]` within a path segment, `**` across segments; relative-path output, sorted; no matches returns `[]` (not Err). Shares `walk`'s traversal so unreadable subdirectories are skipped silently=`R (L t) t` `dirname path`=POSIX-style parent directory. `dirname "/a/b/c.txt"` → `"/a/b"`, `dirname "/"` → `"/"`, `dirname "foo.txt"` → `""` (POSIX returns `"."` here; ilo returns `""` so `pathjoin [dirname p basename p]` round-trips a plain filename without a phantom `./` prefix), `dirname "foo/"` → `""` (trailing slash stripped, then no directory component remains), `dirname "/a"` → `"/"`. Pure text op, no I/O, no Result. Unix forward-slash semantics; Windows separator handling is a 0.13.0 concern=`t` `basename path`=POSIX-style final path segment. `basename "/a/b/c.txt"` → `"c.txt"`, `basename "/"` → `"/"`, `basename "foo/"` → `"foo"` (trailing slash stripped), `basename ""` → `""`. Pure text op, total=`t` `pathjoin parts`=join a list of path segments with `/`, collapsing duplicate separators at joints and dropping empty segments. `pathjoin ["a" "b" "c.txt"]` → `"a/b/c.txt"`, `pathjoin ["a/" "/b/" "c.txt"]` → `"a/b/c.txt"`, `pathjoin []` → `""`, `pathjoin ["/" "a"]` → `"/a"` (leading absolute root preserved). List form (not variadic) so arity inference stays predictable; matches `cat xs sep`'s shape=`t` `fsize path`=file size in bytes (follows symlinks); `Err` on missing, permission-denied, or path-is-directory. Paired predicate `isfile` collapses the error tier into `false` for one-token branches=`R n t` `mtime path`=last modification time as Unix epoch seconds (`f64`, fractional preserved; follows symlinks); `Err` on missing or permission-denied. Pairs with `now` for "is this file older than N seconds" checks=`R n t` `isfile path`=`true` iff `path` resolves to a regular file (follows symlinks). Missing, permission-denied, or non-file all collapse to `false` — natural shape for `?isfile p{…}`. Asymmetric vs `fsize`/`mtime` (which return `R n t`) by design: predicates want a one-token branch, size/mtime callers want to distinguish missing from perm-denied=`b` `isdir path`=`true` iff `path` resolves to a directory (follows symlinks). Same `false`-on-failure collapse as `isfile`=`b` `rdb s fmt`=parse string/buffer in given format - for data from HTTP, env vars, etc.=`R _ t` `wr path s`=write text to file (overwrite)=`R t t` `wr path data "csv"`=write list-of-lists as CSV (with proper quoting)=`R t t` `wr path data "tsv"`=write list-of-lists as TSV=`R t t` `wr path data "json"`=write any value as pretty JSON=`R t t` `wra path s`=append text to file (create if missing)=`R t t` `wrl path xs`=write list of lines to file (joins with `\n`)=`R t t` `trm s`=trim leading and trailing whitespace=`t` `spl t sep`=split text by separator=`L t` `fmt tmpl args…`=format string - bare `{}` placeholders only, filled left-to-right. Printf-style specs (`{:06d}`, `{:.3f}`) are rejected; compose `fmt2` for decimal precision and `padl` for width/padding. Literal templates require `{}`-count == arg-count (verifier rejects mismatches with `ILO-T013`). Lists are formatted as a single value, not splatted: `fmt "{} {}" [a, b]` is an error - use `fmt "{} {}" a b` instead=`t` `cat xs sep`=join list of text with separator=`t` `has xs v`=membership test (list: element, text: substring)=`b` `hd xs`=head (first element/char) of list or text=element / `t` `tl xs`=tail (all but first) of list or text=`L` / `t` `rev xs`=reverse list or text=same type `srt xs`=sort list (all-number or all-text) or text chars (stable: equal elements keep their input order)=same type `srt fn xs`=sort list by key function (returns number or text key); stable: items with equal keys keep their input order=`L` `unq xs`=remove duplicates, preserve order (list or text chars)=same type `slc xs a b`=slice list or text from index a to b (a, b accept negative indices counting from end; bounds clamp). Sugar: when `a >= 0` and `b == -1`, `b` reads as `len xs` (Python/JS "to end" shape). Other negative `b` values (e.g. `-2`) keep the relative-offset semantics; use `take -1 xs` if you want "drop last".=same type `jpth json path`=JSON dot-path lookup, dot-separated keys + numeric array indices (e.g. `"a.b.0.c"`), not JSONPath - leading `$`, `*`, or `[...]` rejected with a diagnostic. Result is typed: arrays → list, objects → record, scalars → matching primitive.=`R _ t` `jkeys json path`=sorted top-level keys of the JSON object at `path` (empty path = root). Err if the value at the path is not an object.=`R (L t) t` `jdmp value`=serialise ilo value to JSON text=`t` `prnt value`=print value to stdout, return it unchanged (passthrough)=same type `jpar text`=parse JSON text into ilo values=`R _ t` `jpar-list text`=parse JSON text, assert top-level is array, return typed list=`R (L _) t` `grp fn xs`=group list by key function=`M t (L a)` `flat xs`=flatten one level of nesting=`L a` `sum xs`=sum of numeric list (0 for empty)=`n` `prod xs`=product of numeric list (1 for empty)=`n` `avg xs`=mean of numeric list (error if empty)=`n` `rgx pat s`=regex: no groups→all matches; groups→first match captures=`L t` `mmap`=create empty map=`M t _` `mget m k`=value at key k (nil if missing)=element or nil `mset m k v`=new map with key k set to v=`M k v` `mhas m k`=true if key exists=`b` `mkeys m`=sorted list of keys=`L t` `mvals m`=values sorted by key=`L v` `mpairs m`=sorted [k, v] pairs; `mpairs m == zip (mkeys m) (mvals m)`=`L (L _)` `mdel m k`=new map with key k removed=`M k v` `mget-or m k default`=value at key k, or `default` if missing (never nil; default type must match value type)=`v` `at xs i`=i-th element of list or text (0-indexed; negative counts from end; float `i` auto-floors)=element `lget-or xs i default`=element at index `i`, or `default` if OOB (negative indices like `at`; never errors on OOB)=`a` `lst xs i v`=list-set: returns a new list with index `i` replaced by `v` (the canonical list-update builtin; same role as `lset`/`setat`/`set-at` in other languages — `lset` is the long-form alias)=`L a` `take n xs`=first `n` elements/chars of list or text (n>=0 truncates if n>len; n<0 keeps all but the last `abs n`, Python `xs[:n]`)=same type `drop n xs`=skip first `n` elements/chars (n>=0 returns the rest; n<0 keeps only the last `abs n`, Python `xs[n:]`)=same type `rsrt xs`=sort descending (list or text chars)=same type `rsrt fn xs`=sort descending by key function (returns number or text key)=`L` `rsrt fn ctx xs`=sort descending by key function with explicit ctx arg (closure-bind alternative; `fn` takes `(elem, ctx)`)=`L` `uniqby fn xs`=dedupe by key function (first occurrence wins)=`L a` `zip xs ys`=pairwise pairs of two lists; truncates to shorter input=`L (L _)` `enumerate xs`=pair each element with its index → `[[i, v], ...]`=`L (L _)` `range a b`=half-open numeric range `[a, a+1, ..., b-1]`; empty when `a >= b`=`L n` `map fn xs`=apply `fn` to each element=`L b` `flt fn xs`=keep elements where `fn x` is true=`L a` `ct fn xs`=count elements where `fn x` is true (avoids `len (flt fn xs)`'s intermediate list alloc)=`n` `fld fn xs init`=left fold: `fn (fn (fn init x0) x1) ...`=accumulator `flatmap fn xs`=map then flatten one level=`L b` `mapr fn xs`=map with short-circuit Result propagation: collects Ok values, returns first Err=`R (L b) e` `default-on-err r d`=unwrap `R T E` to `T`, returning `d` if Err; verifier requires `d` matches Ok type. Mirror of `??` for Result (`??` is nil-coalesce for `O T` only - use `default-on-err` for Result). Prefer over `?r{~v:v;^_:d}` when no error payload is needed. ILO-T040 when first arg is not `R T E` (hint steers at `??` only when first arg is Optional); ILO-T042 when the default's type doesn't match the Ok type; ILO-T041 when `??` is used on a Result. T041 is suppressed when the lhs type is `Unknown` (e.g. type-variable params) to avoid false positives on generic code=`T` `partition fn xs`=split list into `[passing, failing]` by predicate=`L (L a)` `chunks n xs`=non-overlapping chunks of size `n` (final chunk may be shorter)=`L (L a)` `window n xs`=sliding windows of size `n` (drops trailing partial; empty if n > len)=`L (L a)` `clamp x lo hi`=restrict `x` to `[lo, hi]` (lower bound wins when `lo > hi`)=`n` `cumsum xs`=running sum; output length matches input=`L n` `cprod xs`=running product; output length matches input=`L n` `ewm xs a`=exponential moving average: `ewm[0] = xs[0]`, `ewm[i] = a*xs[i] + (1-a)*ewm[i-1]`; `a` in `[0, 1]`, out-of-range errors `ILO-R009`=`L n` `frq xs`=frequency map of elements (keys are bare stringified values)=`M t n` `median xs`=median of numeric list=`n` `quantile xs p`=sample quantile (linear interp; `p` clamped to `[0, 1]`)=`n` `stdev xs`=sample standard deviation (divides by N-1)=`n` `variance xs`=sample variance (divides by N-1)=`n` `argmax xs`=index of the maximum element (first occurrence wins on ties; errors on empty list)=`n` `argmin xs`=index of the minimum element (first occurrence wins on ties; errors on empty list)=`n` `argsort xs`=sorted-index permutation ascending - stable sort, indices of smallest to largest (empty list returns `[]`)=`L n` `setunion a b`=set union of two lists (deduped, sorted output)=`L a` `setinter a b`=set intersection (deduped, sorted)=`L a` `setdiff a b`=set difference `a - b` (deduped, sorted)=`L a` `chars s`=explode a string into single-char strings (one per Unicode scalar)=`L t` `ord s`=Unicode codepoint of the first character of `s`=`n` `chr n`=single-character string for codepoint `n`=`t` `upr s`=uppercase (ASCII)=`t` `lwr s`=lowercase (ASCII)=`t` `cap s`=capitalise first char (ASCII)=`t` `padl s w`=left-pad to width `w` with spaces (no-op if already wider)=`t` `padr s w`=right-pad to width `w` with spaces (no-op if already wider)=`t` `padl s w pc`=left-pad to width `w` with 1-character string `pc` (e.g. `"0"` for sortable zero-padded keys)=`t` `padr s w pc`=right-pad to width `w` with 1-character string `pc` (e.g. `"."` for dot-leader alignment). Idiom: `padr "" w pc` repeats `pc` w times (histogram bars, divider lines)=`t` `rgxall pat s`=every regex match as `L (L t)` (no-group: each match in a 1-elem list)=`L (L t)` `rgxall1 pat s`=flat first-capture-group convenience: 0 groups → `L t` of whole matches; 1 group → `L t` of capture-1 strings; 2+ groups errors=`L t` `rgxall-multi pats s`=multi-pattern flat-match: apply each pattern in `pats:L t` to `s`, concat all hits in pattern order; per-pattern semantics follow `rgxall1` (0 groups → whole matches; 1 group → capture-1 strings; 2+ groups errors)=`L t` `rgxsub pat repl s`=regex substitute all matches; `$1`, `$2`, ... reference capture groups=`t` `dtfmt epoch fmt`=format Unix epoch as text (strftime, UTC)=`R t t` `dtparse s fmt`=parse text to Unix epoch (strftime, UTC)=`R n t` `dtparse-rel s now`=parse relative-date phrase to epoch; `now` is the anchor epoch=`R n t` `dur-parse s`=parse human duration string ("3h 30m", "1 week 2 days", "1.5 hours", "90s") into seconds. Lenient: accepts abbreviations `s`/`m`/`h`/`d`/`w`, full names (singular + plural), decimal quantities, mixed sequences. Err if empty or no unit found=`R n t` `dur-fmt n`=format seconds as human-readable duration ("2h 42m", "1 day", "30s"). Drops zero parts; uses largest applicable units. Zero returns "0s". Negative values format with a leading "-"=`t` `rdjl path`=read JSONL file as `L (R _ t)`: one parse result per non-empty line=`L (R _ t)` `get-many urls`=concurrent HTTP GET fan-out (max 10 parallel), preserves order=`L (R t t)` `sleep ms`=pause current engine for `ms` milliseconds; returns nil=`_` `rou n`=round to nearest integer (banker's rounding)=`n` `rndn mu sigma`=one sample from normal distribution `N(mu, sigma)` (Box-Muller)=`n` `pow b e`=`b` raised to power `e`=`n` `sqrt n`=square root=`n` `exp n`=natural exponent `e^n`=`n` `log n`=natural logarithm=`n` `log10 n`=base-10 logarithm=`n` `log2 n`=base-2 logarithm=`n` `sin n`=sine (radians)=`n` `cos n`=cosine (radians)=`n` `tan n`=tangent (radians)=`n` `asin n`=arcsine, returns radians in `[-pi/2, pi/2]`; NaN outside `[-1, 1]`=`n` `acos n`=arccosine, returns radians in `[0, pi]`; NaN outside `[-1, 1]`=`n` `atan n`=arctangent, returns radians in `[-pi/2, pi/2]`=`n` `atan2 y x`=two-argument arctangent (y, x order; radians)=`n` `pi`=3.141592653589793 (IEEE-754 f64, `f64::consts::PI`)=`n` `tau`=6.283185307179586 (== 2\*pi; one full turn in radians)=`n` `e`=2.718281828459045 (Euler's number, `f64::consts::E`)=`n` `transpose m`=transpose row-major matrix=`L (L n)` `matmul a b`=matrix product=`L (L n)` `matvec xm ys`=matrix-vector product (`r[i] = sum_j xm[i][j] * ys[j]`); skips the wrap-as-column + flatten ceremony around `matmul`=`L n` `dot a b`=vector dot product=`n` `solve a b`=solve `Ax = b` via LU with partial pivoting; errors on singular/non-square=`L n` `inv a`=matrix inverse; errors on singular/non-square=`L (L n)` `det a`=determinant; errors on non-square=`n` `lstsq xm ys`=ordinary least squares: returns coefficients `b` minimising `|xm·b - ys|²` via the normal equations (`solve (Xᵀ X) (Xᵀ y)`). Errors on rank-deficient design, underdetermined system (cols > rows), or row/length mismatch=`L n` `fft xs`=discrete FFT: real samples → `L [re, im]`; zero-padded to next power of 2=`L (L n)` `ifft pairs`=inverse FFT; imaginary part dropped on return=`L n` `fmt2 x digits`=format number `x` to `digits` decimal places (half-to-even rounding; `digits` clamped to `0..=20`). Compose with `fmt` for template + precision: `fmt "x={}" (fmt2 v 2)`=`t` > **`fmt` does not print.** `fmt` and `fmt2` are pure-functional string builders, not `println!`. A bare `fmt "..." v` statement evaluates and discards the resulting text on every engine - nothing reaches stdout. Print with `prnt fmt "..." v` or capture with `line = fmt "..." v`. The verifier emits **ILO-T032** when `fmt`/`fmt2` is a non-tail statement with no binding. Tail position is fine: `say-x v:n>t;fmt "x={}" v` returns the string to the caller as documented. > **`+=`, `mset`, and `mdel` return a new value, they do not mutate in place.** `+=xs v` returns a new list; `mset m k v` and `mdel m k` return a new map. As a bare statement (`@i 0..3{+=out i}`, `mset m "a" 1;m`) the result is silently discarded and the source binding is unchanged. The verifier emits **ILO-T033** when these calls appear at a discarded position - any non-tail statement, or anywhere inside a loop body. Fix is the assignment form: `out=+=out i`, `m=mset m k v`, `m=mdel m k`. Tail position in a function/`?{}` arm is fine - the value flows out as the return. > **`wr` and `wrl` return the written path, not a status.** Both succeed with `~path` (the file path you passed in), not `~"ok"` or nil. A `save` helper that ends with a bare `wrl "tasks.txt" xs` therefore returns `~"tasks.txt"`, and every successful mutation echoes the state-file path to stdout - noise for any caller piping output. Discard the path and return a clean status string instead: `save xs:L t>R t t;r=wrl "tasks.txt" xs;?r{~_:~"ok";^e:^e}`. The error arm still propagates `wrl`'s message. See [`examples/cli-tasks-save-ok.ilo`](examples/cli-tasks-save-ok.ilo) for the full shape. [Datetime (`dtfmt` / `dtparse` / `dtparse-rel`)] UTC only. Format strings follow strftime conventions (`%Y-%m-%d %H:%M:%S`, `%s`, etc). dtfmt 1700000000 "%Y-%m-%d" -- R t t: Ok="2023-11-14", Err if out of range dtparse "2024-01-15" "%Y-%m-%d" -- R n t: Ok=epoch seconds, Err if unparseable dtfmt! e "%H:%M:%S" -- auto-unwrap inside R-returning fn `dtparse-rel s now` resolves a natural-language relative-date phrase to a Unix epoch anchored at `now`. Phrases supported: `today`, `yesterday`, `tomorrow` `N days ago`, `in N days` (also `N day ago`, `in N day`) `N weeks ago`, `in N weeks` `N months ago`, `in N months` (end-of-month clamping: `Jan 31 + 1 month = Feb 28/29`) `last `, `next `, `this ` — weekdays as `monday`–`sunday` or short `mon`–`sun`; `last`/`next` never return today ISO-8601 date literal `YYYY-MM-DD` — passthrough to `dtparse` (ignores `now`) -- now = 1705276800 (2024-01-15, Monday) dtparse-rel!! "yesterday" (now) -- 2024-01-14 00:00 UTC dtparse-rel!! "3 days ago" (now) -- 2024-01-12 00:00 UTC dtparse-rel!! "in 2 weeks" (now) -- 2024-01-29 00:00 UTC dtparse-rel!! "last friday" (now) -- 2024-01-12 00:00 UTC dtparse-rel!! "next wednesday" (now) -- 2024-01-17 00:00 UTC dtparse-rel!! "2023-12-25" (now) -- 1703462400 (ignores now) Unrecognised phrases return `Err` with a message listing valid forms. All times are midnight UTC. [Duration (`dur-parse` / `dur-fmt`)] `dur-parse s > R n t` — parse a human-readable duration string into total seconds as a float. `dur-fmt n > t` — format seconds as a human-readable duration string. Both are tree-bridge eligible: VM and Cranelift dispatch through the same interpreter arm. Accepted units for `dur-parse`: `w`=week, weeks `d`=day, days `h`=hour, hours, hr, hrs `m`=min, mins, minute, minutes `s`=sec, secs, second, seconds dur-parse "3h 30m" -- R n t: Ok=12600, Err if no unit found dur-parse "1 week 2 days" -- R n t: Ok=777600 dur-parse "1.5 hours" -- R n t: Ok=5400 dur-parse "4h32m" -- no space between number and unit: Ok=16320 dur-parse! s -- auto-unwrap inside R-returning fn dur-fmt 9720 -- "2h 42m" dur-fmt 86400 -- "1 day" dur-fmt 90 -- "1m 30s" dur-fmt 90.5 -- "1m 30.5s" (fractional seconds preserved) dur-fmt 0 -- "0s" dur-fmt -90 -- "-1m 30s" (single leading minus) -- Round-trip: parse -> seconds -> format n = dur-parse! "2 days 3 hours" dur-fmt n -- "2 days 3h" **Months are not supported.** `mo`, `month`, `months`, `M` are deliberately omitted because a month is not a fixed number of seconds. Strings like `"3mo"` or `"3 months"` produce a `no recognised unit` error. Use explicit day counts (e.g. `"30 days"`, `"90 days"`). **Sticky sign.** A leading `-` in `dur-parse` is sticky: it applies to every following token until an explicit `+` resets it. So `"-1m 30s"` parses to `-90`, and `"-1h +10m"` parses to `-3000`. This makes the round-trip `dur-fmt -> dur-parse` symmetric for negative durations, where `dur-fmt` emits a single leading minus rather than signing each part. **Fractional seconds.** `dur-fmt` renders sub-second fractions with up to 3 decimal places (trailing zeros stripped), both for sub-second inputs (`0.5 -> "0.5s"`) and for mixed values where the seconds component carries a fraction (`90.5 -> "1m 30.5s"`). Fractional minutes / hours / days / weeks are decomposed into smaller units before formatting. [Set operations] `setunion`, `setinter`, `setdiff` operate on lists of `t`, `n`, or `b` (same constraint as `uniqby`). Output is deduped and sorted by a type-prefixed string key, so results are deterministic across runs and engines. Sort is lexicographic on the key, not numeric - re-sort with `srt` afterwards if you need numeric order. [Linear algebra] `transpose`, `matmul`, `matvec`, `dot`, `solve`, `inv`, `det`, `lstsq` operate on row-major matrices (`L (L n)`) and flat vectors (`L n`). `solve`, `inv`, `det` use LU decomposition with partial pivoting and raise on singular or non-square inputs. `matvec xm ys` is matrix-vector product as a flat vector; it skips the `flatten matmul xm (map (y:n>L n;[y]) ys)` ceremony needed to coerce a vector into a column matrix. `lstsq` is a thin wrapper around the normal equations (`solve (Xᵀ X) (Xᵀ y)`) — closed-form OLS at the same precision tier as `solve`; numerically inferior to QR/SVD for ill-conditioned designs. These ship as host-vetted builtins because hand-rolled implementations risk silent precision loss. [FFT] `fft xs` runs an iterative Cooley-Tukey radix-2 transform on real samples, zero-padding to the next power of two. Output is `L [re, im]` with one inner pair per frequency bin. `ifft pairs` is the inverse, dropping the imaginary part on return. [Builtin aliases] All builtins accept one or more alias names that resolve to the canonical name after parsing. Using an alias triggers a hint suggesting the canonical form. Most aliases go from a familiar long form (e.g. `length`) to the canonical short (`len`), letting newcomers write readable code while learning the canonical names. A small number go the other direction: where the canonical name is already 4+ characters and there is a natural short form with no plausible-user-binding collision, the short form is carved out as a permanent ergonomic alias. `floor`=→=`flr` `ceil`=→=`cel` `round`=→=`rou` `rand`=→=`rnd` `random`=→=`rnd` `rng`=→=`range` `lset`=→=`lst` `regex_all`=→=`rgxall` `regex_sub`=→=`rgxsub` `string`=→=`str` `number`=→=`num` `length`=→=`len` `head`=→=`hd` `tail`=→=`tl` `reverse`=→=`rev` `sort`=→=`srt` `slice`=→=`slc` `unique`=→=`unq` `filter`=→=`flt` `fold`=→=`fld` `flatten`=→=`flat` `concat`=→=`cat` `contains`=→=`has` `group`=→=`grp` `average`=→=`avg` `print`=→=`prnt` `trim`=→=`trm` `split`=→=`spl` `format`=→=`fmt` `regex`=→=`rgx` `read`=→=`rd` `readlines`=→=`rdl` `readbuf`=→=`rdb` `write`=→=`wr` `writelines`=→=`wrl` length xs -- works, but emits: hint: `length` → `len` (canonical form) len xs -- canonical - no hint rng 0 10 -- works, but emits: hint: `rng` → `range` (canonical form) range 0 10 -- canonical - no hint Every alias - both short-form (`rng`, `rand`) and long-form (`head`, `length`, `filter`, `concat`, ...) - follows the same shadow-prevention rule as canonical builtins: using an alias name as a binding LHS or user-function name is rejected at parse time with `ILO-P011`. The alias resolver rewrites call-position uses to the canonical builtin, so if the bind were allowed the user variable would be silently bypassed and the builtin called instead. For example, `head=fmt "### {}" t` then `cat [head body] "\n"` would rewrite `head` in call position to `hd`, emitting empty output with no error. The parser intercepts every alias in all three positions (top-level binding, local binding inside a function, user function declaration) with a rename hint. The full alias table is listed above; every entry triggers `ILO-P011` in all three contexts. `get` and `pst` return `Ok(body)` on success, `Err(message)` on failure (connection error, timeout, DNS failure, etc). In 0.12.0 the `$` sigil was rebound from `get` (parochial — `$` for HTTP is unique to ilo) to the new `run` builtin (argv-list process spawn). `$` for shell-exec reads cross-language — bash, Perl, Ruby, Python, PowerShell, and Zx all use `$` for command substitution. HTTP `get` is still called by name; the `$` shortcut is for process exec only. `post` was renamed to `pst` to bring it into line with the I/O compression family (`rd`, `wr`, `srt`, `flt`, `fld`, `fmt`). get url -- R t t: Ok=response body, Err=error message get! url -- auto-unwrap: Ok→body, Err→propagate to caller pst url body -- R t t: HTTP POST with text body pst url body headers -- R t t: HTTP POST with body and custom headers -- Custom headers: build an M t t map with mmap/mset h=mmap h=mset h "x-api-key" "secret" r=get url h -- GET with x-api-key header r=pst url body h -- POST with x-api-key header -- Explicit timeouts (milliseconds; rounds up to nearest second internally) r=get-to url 5000 -- GET with 5 s timeout; Err if exceeded r=pst-to url body 3000 -- POST with 3 s timeout Behind the `http` feature flag (on by default). Without the feature, `get`/`pst`/`get-to`/`pst-to` return `Err("http feature not enabled")`. [Process spawn] ilo provides one process-spawn primitive: `run cmd argv > R (M t t) t`. The signature is deliberately narrow: the first argument is the program (text), the second is the argv list (`L t`), and the result is a `Result` whose `Ok` carries a three-key Map of stdout / stderr / code as text. **Output map schema.** On `Ok`, the map has exactly these three keys, all `t`-valued: `stdout`=`t`=captured stdout bytes decoded as UTF-8 (lossy), as written `stderr`=`t`=captured stderr bytes decoded as UTF-8 (lossy), as written `code`=`t`=exit code as decimal text (`"0"`, `"1"`, …); on unix a signal-terminated child reports `"signal:"` (e.g. `"signal:9"`), and an unknown status reports `"unknown"` The map is always shaped this way on success: callers can rely on `mget m "stdout"`, `mget m "stderr"`, and `mget m "code"` all being non-nil text. Trailing newlines from the child are preserved verbatim, so use `trm` if you want to compare against a stripped value. r=run "echo" ["hi"] -- Ok({"stdout":"hi\n","stderr":"","code":"0"}) out=mget r.! "stdout" -- "hi\n" code=mget r.! "code" -- "0" err=mget r.! "stderr" -- "" $"git" ["status", "--short"] -- equivalent: $ is the sigil shortcut for run **No shell, no interpolation, no glob.** The argv list is passed directly to `std::process::Command::args`. There is no `sh -c`, no string concatenation between `cmd` and `argv`, and no glob expansion. This is the principled defence against shell injection: ilo refuses to provide an injection vector while still providing controlled exec. Compared to bash + `jq`, the argv-list discipline and the typed Result + Map handle make `run` materially safer for agent orchestration. **Non-zero exit is NOT an error.** `Err` is reserved for spawn failures (command not found, permission denied, kernel-level pipe failure, output cap exceeded). A child that returns a non-zero exit code surfaces as `Ok({"stdout":..., "stderr":..., "code":""})`; the caller inspects `code` and branches as needed. This matches Python's `subprocess.run` semantics. **Inherits parent env + cwd.** The first version provides no env or cwd override. Set the parent env / cwd before invoking ilo if you need a different shape. **Captured output is capped at 10 MiB per stream.** Either stream exceeding the cap returns an `Err` rather than partial capture so downstream JSON pipelines never see a truncated payload. **Stdin for child processes.** `run` spawns children with stdin closed (previously `/dev/null`). Use `rdin` / `rdinl` to read the **parent** program's own stdin from the shell pipeline. `rdin` reads all of stdin as text; `rdinl` reads it line by line. Behind the same default build profile as `get`/`pst`; on `wasm32` targets, `run` returns `Err("run: process spawn not available on wasm")`. `env` reads an environment variable by name, returning `Ok(value)` or `Err("env var 'KEY' not set")`: env key -- R t t: Ok=value, Err=not set message env! key -- auto-unwrap: Ok→value, Err→propagate to caller `env-all` returns the full process environment as a `M t t` map wrapped in `R`, mirroring the `env` shape so `env-all!` auto-unwraps inside a Result-returning function. Use it for "merge env over config" patterns where the agent does not know which keys to read up-front: env-all -- R (M t t) t: Ok=map of every env var, Err reserved for future failures env-all! -- auto-unwrap to M t t Non-UTF-8 environment variables are silently skipped (same policy as Rust's `std::env::vars`); the snapshot is always `Ok` today. [JSON builtins] `jpth` extracts a value from a JSON string by dot-separated path. Array elements are accessed by numeric index. **Note: `jpth` is dot-path only, not JSONPath.** A leading `$`, `*` wildcard, or `[...]` bracket selector triggers a diagnostic error pointing at the dot-path form; iterate arrays yourself with `@i` or `map` if you need wildcard behaviour. Since 0.12.1 the Ok variant is **typed**: a JSON array comes back as a list (`@`-iterable, `len`-able), a JSON object comes back as a record (`jdmp`-roundtrippable, `jkeys`-enumerable), and scalars come back as the matching ilo primitive (number, text, bool, nil). Pre-0.12.1 every non-string leaf was stringified, forcing a re-parse via `jpar` to iterate. The signature is now `R _ t`. jpth json "name" -- R _ t: Ok=typed value, Err=error message jpth json "user.name" -- nested path lookup jpth json "items.0.name" -- array index access (dot before index, not [0]) jpth json "spans" -- Ok=L _ when the leaf is a JSON array (iterable!) jpth json "deps" -- Ok=record when the leaf is a JSON object jpth json "n" -- Ok=Number 42 (not Text "42") on a numeric leaf jpth! json "name" -- auto-unwrap jpth json "$.a.b" -- ^"jpth is dot-path only ..." (JSONPath rejected) jpth json "items.*.name" -- ^"jpth is dot-path only ..." (no wildcards) `jkeys json path` returns the **sorted** top-level keys of the JSON object at the dot-path as `L t`. Empty path means root. Errs if the value at the path is not an object. Pairs with `mkeys` (which works on ilo `M` maps) so an agent can enumerate JSON object keys without re-parsing through `jpar`. jkeys json "" -- R (L t) t: Ok=sorted root keys jkeys json "deps" -- sorted keys of the "deps" object jkeys! json "deps" -- auto-unwrap jkeys json "items" -- ^"jkeys: value at path is not a JSON object" `jdmp` serialises any ilo value to a JSON string: jdmp 42 -- "42" jdmp "hello" -- "\"hello\"" jdmp [1 2 3] -- "[1,2,3]" jdmp (pt x:1 y:2) -- "{\"x\":1,\"y\":2}" `jpar` parses a JSON string into ilo values. JSON objects become records with type name `json`, arrays become lists, strings/numbers/bools/null map directly: jpar text -- R _ t: Ok=parsed value, Err=parse error r=jpar! "{\"x\":1}" -- r is a json record, access with r.x `jpar-list` is a typed companion: it parses the JSON string and **asserts the top-level value is an array**. The result is `R (L _) t`, so `jpar-list! body` unwraps directly to a list that `@` can iterate — no intermediate binding or type annotation needed: jpar-list text -- R (L _) t: Ok=list of parsed values, Err=parse or type error -- iterate a JSON array response body: @x (jpar-list! body){prnt x} -- or bind first: xs=jpar-list! body;@i 0..len xs{prnt (at xs i)} Use `jpar` when the JSON top-level shape is unknown (object, array, scalar). Use `jpar-list` when you know the response is an array and want to iterate it immediately. [URL and base64url encoding] Token-cheap primitives for OAuth, JWT, and webhook-signature workflows. All four are tree-bridge eligible: pure text-in / text-out with no I/O and no FnRef args, so the VM and Cranelift backends inherit them automatically. `urlenc s > t` percent-encodes per RFC 3986. The unreserved set (`ALPHA` / `DIGIT` / `-` / `.` / `_` / `~`) passes through literally; every other byte is emitted as `%HH`. Multi-byte UTF-8 is encoded byte-by-byte. `urldec s > R t t` is the inverse. It returns `Err` on a stray `%` not followed by two hex digits, or on decoded bytes that aren't valid UTF-8. `b64u s > t` base64url-encodes the UTF-8 bytes of `s` using the URL-safe alphabet (RFC 4648 §5: `-` and `_` substituted for `+` and `/`) with padding stripped. `b64u-dec s > R t t` is the inverse. It returns `Err` on input that contains characters outside the base64url alphabet, on `=` padding (the encode side strips padding, so the decode side rejects it for a strict round-trip), or on decoded bytes that aren't valid UTF-8. urlenc "a b&c=d" -- "a%20b%26c%3Dd" urldec! "a%20b%26c%3Dd" -- "a b&c=d" b64u "{\"alg\":\"HS256\",\"typ\":\"JWT\"}" -- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" b64u-dec! (b64u "hello, world!") -- "hello, world!" Both decoders return `Result` so malformed input surfaces typed at the boundary; both encoders are total. Use `!` to auto-unwrap inside an `R`-returning function, or pattern-match on the Result to handle the Err arm explicitly. LISTS: xs=[1 2 3] -- space-separated (preferred) xs=[1, 2, 3] -- commas also work mixed=["search" 10] -- heterogeneous lists allowed (type: L _) w="world" words=["hi" w] -- variables work in list literals empty=[] Elements are expressions in brackets, separated by spaces or commas. Variables and expressions are allowed as elements. Lists may contain mixed types (inferred as `L _`). Use with `@` to iterate: @x xs{+x 1} Index by integer literal or variable (dot notation): xs.0 # first element (literal index) xs.2 # third element (literal index) xs.i # i-th element when `i` is a bound variable in scope The variable-index form `xs.i` is sugar for `at xs i` - the parser builds a field-access node and a post-parse desugar pass rewrites it whenever the field identifier resolves to a binding in scope (parameter, let, foreach, range, match-arm). Record field access keeps working: if the identifier is also a declared field on any record type in the program, the rewrite is skipped and the strict `.field` semantics apply. **CLI list arguments:** Pass lists from the command line with commas (brackets also accepted): ilo 'f xs:L n>n;len xs' 1,2,3 → 3 ilo 'f xs:L t>t;xs.0' 'a,b,c' → a STATEMENTS: Guards and conditionals replace `if`/`else if`/`else`. They are flat statements - no nesting, no closing braces to match. There are three forms: **Braceless guard** (`cond expr`): early return - if condition is true, returns the expression from the function. **Braced conditional** (`cond{body}`): conditional execution - if condition is true, body runs but execution continues (no early return). Use `ret` inside the body for explicit early return. **Ternary** (`cond{then}{else}`): value expression - evaluates then or else branch, no early return. Multiple braceless guards chain vertically for guard clauses, keeping indentation depth constant. Match replaces `switch`. There is no fall-through - each arm is independent. The `_` arm is the default catch-all. `x=expr`=bind `cond{body}`=conditional execution: run body if cond true (no early return) `cond expr`=braceless guard: early return expr if cond true `cond{then}{else}`=ternary: evaluate then or else (no early return) `?bool{then}{else}`=bare-bool ternary: `?h{1}{0}` (no early return) `?cond then else`=prefix ternary: `?=x 0 10 20` (no early return) `?h cond a b`=general prefix-ternary keyword: `?h cn "y" "n"` (3 operand atoms after literal `?h`) `!cond{body}`=negated conditional execution (no early return) `!cond expr`=braceless negated guard (early return) `!cond{then}{else}`=negated ternary `?x{arms}`=match named value `?{arms}`=match last result `@v list{body}`=iterate list `@i a..b{body}`=range iteration: i from a (inclusive) to b (exclusive) `ret expr`=early return from function `~expr`=return ok `^expr`=return err `func! args`=call + auto-unwrap Result, propagate Err to caller `func!! args`=call + auto-unwrap Result, abort on Err with exit 1 `wh cond{body}`=while loop `brk` / `brk expr`=exit enclosing loop (optional value) `cnt`=skip to next iteration of enclosing loop `expr>>func`=pipe: pass result as last arg to func MATCH ARMS: `"gold":body`=literal text `42:body`=literal number `~v:body`=ok - bind inner value to `v` `^e:body`=err - bind inner value to `e` `n v:body`=number - branch if value is a number, bind to `v` `t v:body`=text - branch if value is text, bind to `v` `b v:body`=bool - branch if value is a bool, bind to `v` `l v:body`=list - branch if value is a list, bind to `v` `_:body`=wildcard, binds matched subject to `_` Arms separated by `;`. First match wins. **Exhaustiveness.** Matches on closed sum-shaped types must cover every variant or include `_:`. For a `R T E` subject, `~v: + ^e:` is exhaustive on its own - no `_:` wildcard required (verifier rule, mirrors `S`-typed matches). For a `b` (bool) subject, `true: + false:` is exhaustive. For numbers and text, `_:` is required. parse>t;r=num "3.14";?r{~v:str v;^e:e} -- canonical two-arm Result match Zero-arg user functions called bare in a value position auto-expand to a call, so `r=mk` where `mk>R t t;...` makes `r` the Result, not a function reference. In any binding position the name `_` is permitted and binds normally - `~_:body`, `^_:body`, `n _:body` etc. expose the matched inner value to `body` under the name `_`. Bodies that don't reference `_` are unaffected. cls sp:n>t;>=sp 1000 "gold";>=sp 500 "silver";"bronze" [Braceless Guards (Early Return)] When the guard condition is a comparison or logical operator (`>=`, `<=`, `>`, `<`, `=`, `!=`, `&`, `|`) and the body is a single expression, braces are optional. **Braceless guards cause early return from the function:** cls sp:n>t;>=sp 1000 "gold";>=sp 500 "silver";"bronze" Negated braceless guards also work: `!<=n 0 ^"must be positive"`. **Comparison operators always start a guard at statement position.** You cannot use `=`, `<`, `>`, `<=`, `>=` etc. as a standalone return expression - the parser treats them as a guard condition and expects a following return value. To return a comparison result, bind it first: -- WRONG: r=has xs v;=r true -- =r true is parsed as a guard, not a return expression -- OK: r=has xs v;r -- return the bool directly (only safe as the last statement) -- OK: has xs v -- bare call is safe as last statement in last function [Braced Conditionals (No Early Return)] A braced guard `cond{body}` is **conditional execution** - the body runs if the condition is true, but execution always continues to the next statement (no early return): f x:n>n;>x 0{99};+x 1 -- {99} runs when x>0 but is discarded; always returns +x 1 This makes braced conditionals natural in loops: f xs:L n>n;m=0;@x xs{>x m{m=x}};m -- find max: update m when x > m Use `ret` inside a braced conditional for explicit early return: f x:n>n;>x 0{ret x};-x -- return x early if positive, else negate > **Common footgun.** `=cond{val}` reads like "if cond, return val" but it isn't. The braces are conditional execution: `val` is evaluated, discarded, and execution falls through to the next statement. If you want early return, use the braceless form `=cond val` (when val is a single expression) or wrap with `ret` inside the braces: `=cond{ret val}`. > > ``` > f x:n>n;=x 1{99};0 -- f 1 → 0 (99 is discarded, falls through) > f x:n>n;=x 1 99;0 -- f 1 → 99 (braceless guard: early return) > f x:n>n;=x 1{ret 99};0 -- f 1 → 99 (explicit ret inside braces) > ``` [Ternary (Guard-Else)] A guard followed by a second brace block becomes a ternary - it produces a value without early return: f x:n>t;=x 1{"yes"}{"no"} Like braced conditionals, ternary does **not** return from the function. Code after the ternary continues executing: f x:n>n;=x 0{10}{20};+x 1 -- always returns x+1, ternary value is discarded Negated ternary: `!=x 1{"not one"}{"one"}`. **Bare-bool ternary** uses `?` with a bool-valued expression as the condition - no comparison operator required: f h:b>n;?h{1}{0} -- if h then 1 else 0 f x:n>t;c=>x 0;?c{"pos"}{"nonpos"} -- bool from comparison, then ternary This is the natural shape when the condition is already a bool (function param, comparison result, predicate call) and saves the explicit `=h true` step that the `=cond{a}{b}` form would otherwise require. Detected purely by shape: `?subj{a}{b}` where both braces contain a single colon-and-semi-free expression. Match-arm forms (`?x{1:a;2:b;_:c}`, `?h{true:a;false:b}`) are unaffected - the colon or semicolon at the outer brace level routes them to match parsing. **Prefix ternary** uses `?` with a comparison operator for a fully prefix-style conditional: f x:n>n;?=x 0 10 20 -- if x==0 then 10 else 20 f x:n>n;v=?>x 100 1 0;v -- assign result to v The condition must start with a comparison operator (`=`, `>`, `<`, `>=`, `<=`, `!=`). **Bare-bool prefix ternary** uses `?` with a bool-valued subject (param, comparison result, predicate call) followed by two operand atoms - the parens-free, brace-free shape: f h:b>n;?h 1 0 -- if h then 1 else 0 f h:b>n;v=?h 1 0;v -- assign result to v This is the cheapest shape when the condition is already a bool - 6 chars for `?h 1 0` vs 8 for the brace form `?h{1}{0}` and 12 for the eq-prefix form `?=h true 1 0`. The match-vs-ternary disambiguator routes `?subj{arms-with-colon-or-semi}` to match parsing, `?subj{a}{b}` to brace bare-bool ternary, and `?subj a b` (two bare operands at the cursor, no leading brace) to bare-bool prefix ternary. `?subj` alone with no following operand still errors the same way as before. **`?h cond a b` general prefix-ternary keyword** uses the literal subject ident `h` plus three operand atoms - the condition is the first operand and `a`/`b` are the arms, analogous to the `?=`/`?>`/`?<` family of comparison-prefix-ternaries but with the condition as an arbitrary bool-valued atom rather than a comparison expression: f x:n>t;cn=>x 0;?h cn "pos" "nonpos" -- comparison-derived bool as condition f t:t>t;ok=has ["a" "b" "c"] t;?h ok "yes" "no" -- predicate result as condition f mn:t>t;cn=(=mn "v40");sc1=?h cn "v4" "v3";sc1 -- in let-RHS The disambiguator is operand count: **two** operand atoms after `?h` keeps the bool-subject reading above (`?h a b` → `if h then a else b`); **three** operand atoms promotes `?h` to the fixed keyword form (`?h cond a b` → `if cond then a else b`). The keyword reading triggers only for the literal ident `h`, so every other bool-named subject (`?ready a b`, `?ok 1 0`, …) keeps the PR #330 semantics regardless of how many operands follow. Use the keyword form when the condition is a more complex bool expression than a single ref and you want the cheapest prefix shape; the brace form `?cond{a}{b}` works too but is two characters longer per occurrence. Each of the three operand slots accepts the same shapes as a prefix-binop operand - atom, nested prefix operator, or known-arity call. `?h =a b sev sc "NONE"` parses `sev sc` as `Call(sev, [sc])` in the then-slot, so `Call` results don't have to be bound first or paren-grouped (paren form `(sev sc)` still works as an explicit alternative). **Condition must be `b`.** The verifier rejects (`ILO-T038`) any ternary whose cond doesn't type-check to `b` - number, text, function-ref, `R T E` without unwrap, etc. This catches the silent-truthy family of bugs where a non-bool cond would otherwise always take the then-branch at runtime. If the cond is more complex than a single ref or comparison, bind it first (`c=;?h c a b`) or use the brace-delimited ternary `?cond{then}{else}`. The original 0.12.0 bug that motivated this check: `?h (> p 0.5) 1 0` parsed the paren-grouped prefix-comparison as a zero-param inline lambda, lifted it into a synthetic decl, and silently always took the then-branch - both layers (parser disambiguator + verifier type-check) are now hardened against the family. [Early Return] `ret expr` explicitly returns from the current function: f x:n>n;>x 0{ret x};0 -- return x early if positive, else 0 f xs:L n>n;@x xs{>=x 10{ret x}};0 -- return first element >= 10 Braceless guards provide early return for simple cases. Use `ret` inside braced conditionals when you need early return with more complex logic or inside loops. [Range Iteration] `@i a..b{body}` iterates `i` from `a` (inclusive) to `b` (exclusive). Both bounds can be atoms, prefix-op expressions, or function calls. The index variable is a fresh binding per iteration; other variables in the body update the enclosing scope: f>n;s=0;@i 0..5{s=+s i};s -- sum 0+1+2+3+4 = 10 f>n;xs=[];@i 0..3{xs=+=xs i};xs -- [0, 1, 2] f n:n>n;s=0;@i 0..n{s=+s i};s -- dynamic end bound g xs:L n>n;s=0;@j 0..len xs{s=+s j};s -- call-form bound h i:n n:n>L n;xs=[];@j +i 2..n{xs=+=xs j};xs -- prefix-op bound [While Loop] `wh cond{body}` loops while condition is truthy: f>n;i=0;s=0;wh n;i=0;wh true{i=+i 1;>=i 3{ret i}};0 -- ret inside braced guard: early return from loop Variable rebinding inside loops updates the existing variable rather than creating a new binding. [Break and Continue] `brk` exits the enclosing `wh` or `@` loop. `cnt` skips to the next iteration: f>n;i=0;wh true{i=+i 1;>=i 3{brk}};i -- i = 3 f>n;i=0;s=0;wh =i 3{cnt};s=+s i};s -- s = 3 (skips i>=3) `brk expr` provides an optional value (currently discarded - the loop result is the last body value before the break). Both `brk` and `cnt` work inside braced conditionals within loops. Using them outside a loop is a compile-time error (no-op in current implementation). [Pipe Operator] `>>` chains calls by passing the left side as the last argument to the right side: str x>>len -- desugars to: len (str x) add x 1>>add 2 -- desugars to: add 2 (add x 1) f x>>g>>h -- desugars to: h (g (f x)) Pipes desugar at parse time - no new AST node. Works with `!` for auto-unwrap: `f x>>g!>>h`. [Safe Field Navigation] `.?` is the tolerant field accessor. It returns nil whenever the access can't yield a real value, instead of erroring: object is nil → nil object is a present record but the field is missing → nil object is not a record at all (list, text, number) → nil user.?name -- nil if user is nil, else user.name (or nil if absent) user.?addr.?city -- chained: nil propagates through chain x.?name??"unknown" -- combine with ?? for defaults r.?optMetric.?v40 -- heterogeneous JSON (jpar): optional fields stay nil Strict `.field` access still errors on missing fields, so typo detection on user-defined record types survives at verify time (ILO-T019) and at runtime (ILO-R005). Use `.field` when you want the strictness, `.?field` when the field is optional or the record shape is dynamic. [Nil-Coalesce Operator] `??` evaluates the left side; if nil, evaluates and returns the right side: x??42 -- if x is nil, returns 42 a??b??99 -- chained: first non-nil wins, else 99 mk 0??"default" -- works with function results Compiled via `OP_JMPNN` (jump if not nil) - right side is only evaluated when left is nil. Use braces when the body has multiple statements: >=sp 1000{a=classify sp;a} ?r{^e:^+"failed: "e;~v:v} diff --git a/skills/ilo/ilo-builtins-core.md b/skills/ilo/ilo-builtins-core.md index 8c61aa9a..a200c0ee 100644 --- a/skills/ilo/ilo-builtins-core.md +++ b/skills/ilo/ilo-builtins-core.md @@ -26,7 +26,7 @@ See `examples/default-on-err.ilo` for the full pattern set. ## List -`len hd tl at lst take drop slc`; `rev srt rsrt unq uniqby flat grp zip enumerate range`; `chunks window flatmap partition`; `setunion setinter setdiff`. `at xs i` floors floats; negative indexes from end (same for `slc take drop`). Bounds clamp. **`lst xs i v` IS the list-set / `lset` / `setat` builtin** — returns a new list with index `i` replaced by `v` (alias `lset`; reach for `lst` whenever you'd write `xs[i] = v` in Python). Last element = `at xs -1`. `srt` and `srt fn xs` are stable: equal elements (or equal keys) keep their input order, so merging parallel records sorted by a shared timestamp keeps the per-source ordering inside each tie group. +`len hd tl at lst take drop slc`; `rev srt rsrt unq uniqby flat grp zip enumerate range`; `chunks window flatmap partition`; `setunion setinter setdiff`. `at xs i` floors floats; negative indexes from end (same for `slc take drop`). Bounds clamp. **`lst xs i v` IS the list-set / `lset` / `setat` builtin** — returns a new list with index `i` replaced by `v` (alias `lset`; reach for `lst` whenever you'd write `xs[i] = v` in Python). Last element = `at xs -1`. **`slc xs s -1` with `s>=0` means "to end of list/text"** (Python/JS sugar): `slc xs 2 -1` is everything from index 2 onward; `slc "hello" 0 -1` is `"hello"`. Other negative ends keep relative-offset semantics (`slc xs 0 -2` drops the last two). To "drop the last element" use `take -1 xs`, not `slc xs 0 -1`. `srt` and `srt fn xs` are stable: equal elements (or equal keys) keep their input order, so merging parallel records sorted by a shared timestamp keeps the per-source ordering inside each tie group. ## HOFs