diff --git a/SPEC.md b/SPEC.md
index d3736da8..66d0018b 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -63,7 +63,7 @@ Sum types are compatible with `t` — a sum value can be passed to any `t` param
### Map type (`M k v`)
-Dynamic key-value collection. Keys are always text at runtime.
+Dynamic key-value collection. Keys are typed: text (`t`) or integer (`n`). `Int(1)` and `Text("1")` are distinct keys.
```
mmap -- empty map
@@ -76,6 +76,18 @@ mdel m k -- return new map with key k removed
len m -- number of entries
```
+Numeric keys work directly — no `str` conversion needed. Float keys floor to `i64` at the builtin boundary (matching `at xs i`); NaN/Infinity raise at runtime.
+
+```
+idx=mmap
+idx=mset idx 7 "seven" -- M n t, integer key
+mget idx 7 -- "seven"
+mhas idx 7 -- true
+mhas idx "7" -- false (Int and Text are distinct)
+```
+
+`jdmp` stringifies numeric keys for JSON output (JSON object keys are always strings). The round-trip via `jpar` is lossy — numeric keys come back as text.
+
Example:
```
@@ -97,6 +109,28 @@ apply f:F a a x:a>a;f x
Type variables provide weak generics — the verifier accepts any type for `a` without consistency checking across call sites.
+### Inline lambdas
+
+Pass a function literal directly to a HOF instead of defining a one-off top-level helper:
+
+```
+by-dist xs:L n>L n;srt (x:n>n;abs x) xs
+nonempty ws:L t>L t;flt (s:t>b;>(len s) 0) ws
+sumsq xs:L n>n;fld (a:n x:n>n;+a *x x) xs 0
+```
+
+Syntax: `(: ...>;)`. Same shape as a top-level function declaration, wrapped in parens, no name.
+
+**Phase 1 (no captures)** lifts the literal to a synthetic top-level decl and works across every engine (tree, VM, Cranelift JIT, AOT). The body's free variables must all be params, locals defined inside the lambda body, or known top-level fns.
+
+**Phase 2 (closure capture)** lets the body reference variables from the enclosing scope:
+
+```
+f xs:L n thr:n>L n;flt (x:n>b;>x thr) xs -- captures `thr`
+```
+
+Phase 2 is **tree-only**. The VM and Cranelift engines surface `ILO-R012` (unsupported closure capture) and the default runner falls through to the tree interpreter automatically. The ctx-arg form (`srt fn ctx xs`) is the cross-engine alternative for capturing state.
+
---
## Naming
@@ -318,7 +352,7 @@ Called like functions, compiled to dedicated opcodes.
| `srt xs` | sort list (all-number or all-text) or text chars | same type |
| `srt fn xs` | sort list by key function (returns number or text key) | `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 | 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 path lookup (dot-separated keys, array indices) | `R t t` |
| `jdmp value` | serialise ilo value to JSON text | `t` |
| `prnt value` | print value to stdout, return it unchanged (passthrough) | same type |
@@ -337,13 +371,16 @@ Called like functions, compiled to dedicated opcodes.
| `mdel m k` | new map with key k removed | `M k v` |
| `at xs i` | i-th element of list or text (0-indexed; negative counts from end) | element |
| `lst xs i v` | new list with index `i` set to `v` (list update; alias: `lset`) | `L a` |
-| `take n xs` | first `n` elements/chars of list or text (truncates if n > len) | same type |
-| `drop n xs` | skip first `n` elements/chars; return the rest | same type |
+| `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 |
| `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` |
+| `fld fn xs init` | left fold: `fn (fn (fn init x0) x1) ...` | accumulator |
| `flatmap fn xs` | map then flatten one level | `L b` |
| `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)` |
@@ -1179,7 +1216,7 @@ In `--json` mode the value is always wrapped (`{"ok": v}` / `{"error": {...}}`)
`Display` on `Value::Ok` / `Value::Err` still renders `~v` / `^e` in every other context (nested values, `prnt`, REPL prompts, error messages, debug output) — only the top-level program-return print path is split.
-The contract above applies to the in-process runners (`ilo prog.ilo`, `--run-tree`, `--run-vm`, `--run-cranelift`). AOT-compiled standalone binaries from `ilo compile` emit their final value through the same runtime helper as the `prnt` builtin and therefore still print `~v` / `^e`; the split is tracked as a follow-up.
+The contract applies uniformly to in-process runners (`ilo prog.ilo`, `--run-tree`, `--run-vm`, `--run-cranelift`) and to AOT-compiled standalone binaries from `ilo compile`. Both strip the top-level `~`/`^` wrapper on stdout, route `^e` to stderr, and use the same exit codes — output is byte-for-byte identical across every backend.
### Idiomatic hints
diff --git a/ai.txt b/ai.txt
index 47a50cfd..ada674c1 100644
--- a/ai.txt
+++ b/ai.txt
@@ -1,11 +1,11 @@
INTRO: ilo is a token-optimised programming language for AI agents. Every design choice is evaluated against total token cost: generation + retries + context loading.
FUNCTIONS: : ...>; No parens around params — `>` separates params from return type `;` separates statements — no newlines required Last expression is the return value (no `return` keyword) Zero-arg call: `make-id()` tot p:n q:n r:n>n;s=*p q;t=*s r;+s t
-TYPES: `n`=number (f64) `t`=text (string) `b`=bool `_`=any/unknown (wildcard type) `L n`=list of number `R n t`=result: ok=number, err=text `O n`=optional number (nil or n) `M t n`=map from text keys to numbers `S red green blue`=sum type — one of named text variants `F n t`=function type: takes n, returns t (used in HOF params) `order`=named type `a`=type variable — any single lowercase letter except n, t, b [Optional (`O T`)] `O T` accepts either `nil` or a value of type `T`. f x:O n>n;??x 0 -- unwrap optional or default to 0 g>O n;nil -- returns nil (valid O n) h>O n;42 -- returns 42 (valid O n) `??x default` — nil-coalesce: returns `x` if non-nil, else `default`. Unwraps `O T` to `T`. [Sum types (`S a b c`)] Closed set of named text variants. Verifier-enforced; runtime value is always `t`. color x:S red green blue > t ?x{red:"ff0000";green:"00ff00";blue:"0000ff"} Sum types are compatible with `t` — a sum value can be passed to any `t` parameter. [Map type (`M k v`)] Dynamic key-value collection. Keys are always text at runtime. mmap -- empty map mset m k v -- return new map with key k set to v mget m k -- value at key k, or nil mhas m k -- b: true if key exists mkeys m -- L t: sorted list of keys mvals m -- L v: values sorted by key mdel m k -- return new map with key k removed len m -- number of entries Example: scores>M t n m=mmap m=mset m "alice" 99 m=mset m "bob" 87 mget m "alice" -- 99 [Type variables] A single lowercase letter (other than `n`, `t`, `b`) in type position is a type variable, treated as `unknown` during verification. Used for higher-order function signatures: identity x:a>a;x apply f:F a a x:a>a;f x Type variables provide weak generics — the verifier accepts any type for `a` without consistency checking across call sites.
+TYPES: `n`=number (f64) `t`=text (string) `b`=bool `_`=any/unknown (wildcard type) `L n`=list of number `R n t`=result: ok=number, err=text `O n`=optional number (nil or n) `M t n`=map from text keys to numbers `S red green blue`=sum type — one of named text variants `F n t`=function type: takes n, returns t (used in HOF params) `order`=named type `a`=type variable — any single lowercase letter except n, t, b [Optional (`O T`)] `O T` accepts either `nil` or a value of type `T`. f x:O n>n;??x 0 -- unwrap optional or default to 0 g>O n;nil -- returns nil (valid O n) h>O n;42 -- returns 42 (valid O n) `??x default` — nil-coalesce: returns `x` if non-nil, else `default`. Unwraps `O T` to `T`. [Sum types (`S a b c`)] Closed set of named text variants. Verifier-enforced; runtime value is always `t`. color x:S red green blue > t ?x{red:"ff0000";green:"00ff00";blue:"0000ff"} Sum types are compatible with `t` — a sum value can be passed to any `t` parameter. [Map type (`M k v`)] Dynamic key-value collection. Keys are typed: text (`t`) or integer (`n`). `Int(1)` and `Text("1")` are distinct keys. mmap -- empty map mset m k v -- return new map with key k set to v mget m k -- value at key k, or nil mhas m k -- b: true if key exists mkeys m -- L t: sorted list of keys mvals m -- L v: values sorted by key mdel m k -- return new map with key k removed len m -- number of entries Numeric keys work directly — no `str` conversion needed. Float keys floor to `i64` at the builtin boundary (matching `at xs i`); NaN/Infinity raise at runtime. idx=mmap idx=mset idx 7 "seven" -- M n t, integer key mget idx 7 -- "seven" mhas idx 7 -- true mhas idx "7" -- false (Int and Text are distinct) `jdmp` stringifies numeric keys for JSON output (JSON object keys are always strings). The round-trip via `jpar` is lossy — numeric keys come back as text. Example: scores>M t n m=mmap m=mset m "alice" 99 m=mset m "bob" 87 mget m "alice" -- 99 [Type variables] A single lowercase letter (other than `n`, `t`, `b`) in type position is a type variable, treated as `unknown` during verification. Used for higher-order function signatures: identity x:a>a;x apply f:F a a x:a>a;f x Type variables provide weak generics — the verifier accepts any type for `a` without consistency checking across call sites. [Inline lambdas] Pass a function literal directly to a HOF instead of defining a one-off top-level helper: by-dist xs:L n>L n;srt (x:n>n;abs x) xs nonempty ws:L t>L t;flt (s:t>b;>(len s) 0) ws sumsq xs:L n>n;fld (a:n x:n>n;+a *x x) xs 0 Syntax: `(: ...>;)`. Same shape as a top-level function declaration, wrapped in parens, no name. **Phase 1 (no captures)** lifts the literal to a synthetic top-level decl and works across every engine (tree, VM, Cranelift JIT, AOT). The body's free variables must all be params, locals defined inside the lambda body, or known top-level fns. **Phase 2 (closure capture)** lets the body reference variables from the enclosing scope: f xs:L n thr:n>L n;flt (x:n>b;>x thr) xs -- captures `thr` Phase 2 is **tree-only**. The VM and Cranelift engines surface `ILO-R012` (unsupported closure capture) and the default runner falls through to the tree interpreter automatically. The ctx-arg form (`srt fn ctx xs`) is the cross-engine alternative for capturing state.
NAMING: Short names everywhere. 1–3 chars. `order`=`ord`=truncate `customers`=`cs`=consonants `data`=`d`=single letter `level`=`lv`=drop vowels `discount`=`dc`=initials `final`=`fin`=first 3 `items`=`its`=first 3 Function names follow the same rules. Field names in constructors and external tool names keep their full form — they define the public interface. [Identifier syntax] Identifiers are lowercase ASCII only, optionally with hyphenated segments. Formally: `[a-z][a-z0-9]*(-[a-z0-9]+)*`. Capital letters and underscores are rejected at the binding and call site. run -- OK run-d -- OK (hyphen separates segments) r2 -- OK (digit after first letter) runD -- ERROR (capital letter) RunD -- ERROR (leading capital) run_d -- ERROR (underscore not allowed in bindings) -run -- ERROR (must start with a letter) `runD` in the interactive CLI surfaces as `ILO-L003 unexpected token` with a suggestion to use `run-d` or `rund`. The constraint is intentional: a single lexical shape per identifier keeps the token stream predictable for agents and avoids style debates over camelCase vs snake_case vs kebab-case. The only place capital letters and underscores are accepted is **after `.` or `.?`** at field-access position, so heterogeneous JSON keys from real APIs work without rewriting. See [Field names at dot-access](#field-names-at-dot-access) for the full list of post-dot relaxations (`r.URL`, `r.AccessKey`, `r.user_name`, etc.). Binding names (`AccessKey = ...`) and function names (`AccessKey x:n>n;...`) still error. [Reserved words] The following identifiers are reserved and cannot be used as names: `if`, `return`, `let`, `fn`, `def`, `var`, `const`. Using them produces a friendly error with the ilo equivalent: -- ERROR: `if` is a reserved word. Use: ?cond{true:... false:...} -- ERROR: `return` is a reserved word. Last expression is the return value. -- ERROR: `let` is a reserved word. Use: name = expr -- ERROR: `fn`/`def` is a reserved word. Use: name param:type > rettype; body
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=`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) [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) [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) or **nested prefix operators**. Function calls are NOT operands — bind call results to a variable first: -- DON'T: *n fac p → parses as Multiply(n, fac) with p dangling -- DO: r=fac p;*n r **Negative literals vs binary minus**: the lexer greedily includes a leading `-` into number tokens. `-1`, `-7`, `-0` are all number literals. To subtract from zero, 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
STRING LITERALS: Text values are written in double quotes. Escape sequences: `\n`=newline `\t`=tab `\r`=carriage return `\"`=literal double quote `\\`=literal backslash "hello\nworld" -- two-line string "col1\tcol2" -- tab-separated spl "\n" text -- split file content into lines
-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 t`=text to number (Err if unparseable)=`R n t` `abs n`=absolute value=`n` `min a b`=minimum of two numbers=`n` `max a b`=maximum of two numbers=`n` `mod a b`=remainder (modulo); errors on zero divisor=`n` `flr n`=floor (round toward negative infinity)=`n` `cel n`=ceiling (round toward positive infinity)=`n` `rnd`=random float in [0, 1)=`n` `rnd a b`=random integer in [a, b] (inclusive)=`n` `now`=current Unix timestamp (seconds)=`n` `get url`=HTTP GET=`R t t` `get url headers`=HTTP GET with custom headers (`M t t` map)=`R t t` `post url body`=HTTP POST with text body=`R t t` `post url body headers`=HTTP POST with body and custom headers (`M t t` map)=`R t t` `env key`=read environment variable=`R 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` `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` `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 — `{}` placeholders filled left-to-right=`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=same type `srt fn xs`=sort list by key function (returns number or text key)=`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=same type `jpth json path`=JSON path lookup (dot-separated keys, array indices)=`R 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` `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` `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` `mdel m k`=new map with key k removed=`M k v` `at xs i`=i-th element of list or text (0-indexed; negative counts from end)=element `lst xs i v`=new list with index `i` set to `v` (list update; alias: `lset`)=`L a` `take n xs`=first `n` elements/chars of list or text (truncates if n > len)=same type `drop n xs`=skip first `n` elements/chars; return the rest=same type `rsrt xs`=sort descending (list or text chars)=same type `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` `flatmap fn xs`=map then flatten one level=`L b` `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` `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` `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` `rgxall pat s`=every regex match as `L (L t)` (no-group: each match in a 1-elem list)=`L (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` `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` `atan2 y x`=two-argument arctangent (y, x order; radians)=`n` `transpose m`=transpose row-major matrix=`L (L n)` `matmul a b`=matrix product=`L (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` `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` [Datetime (`dtfmt` / `dtparse`)] 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 [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`, `dot`, `solve`, `inv`, `det` 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. 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 long-form names that resolve to the canonical short form after parsing. Using a long form triggers a hint suggesting the short form. This lets newcomers write readable code while learning the canonical names. `floor`=→=`flr` `ceil`=→=`cel` `round`=→=`rou` `random`=→=`rnd` `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 short form) len xs -- canonical — no hint `get` and `post` return `Ok(body)` on success, `Err(message)` on failure (connection error, timeout, DNS failure, etc). `$` is a terse alias for `get`: get url -- R t t: Ok=response body, Err=error message $url -- same as get url get! url -- auto-unwrap: Ok→body, Err→propagate to caller $!url -- same as get! url post url body -- R t t: HTTP POST with text body post 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=post url body h -- POST with x-api-key header Behind the `http` feature flag (on by default). Without the feature, `get`/`post` return `Err("http feature not enabled")`. `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 [JSON builtins] `jpth` extracts a value from a JSON string by dot-separated path. Array elements are accessed by numeric index: jpth json "name" -- R t t: Ok=extracted value as text, Err=error jpth json "user.name" -- nested path lookup jpth json "items.0.name" -- array index access jpth! json "name" -- auto-unwrap `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
+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 t`=text to number (Err if unparseable)=`R n t` `abs n`=absolute value=`n` `min a b`=minimum of two numbers=`n` `max a b`=maximum of two numbers=`n` `mod a b`=remainder (modulo); errors on zero divisor=`n` `flr n`=floor (round toward negative infinity)=`n` `cel n`=ceiling (round toward positive infinity)=`n` `rnd`=random float in [0, 1)=`n` `rnd a b`=random integer in [a, b] (inclusive)=`n` `now`=current Unix timestamp (seconds)=`n` `get url`=HTTP GET=`R t t` `get url headers`=HTTP GET with custom headers (`M t t` map)=`R t t` `post url body`=HTTP POST with text body=`R t t` `post url body headers`=HTTP POST with body and custom headers (`M t t` map)=`R t t` `env key`=read environment variable=`R 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` `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` `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 — `{}` placeholders filled left-to-right=`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=same type `srt fn xs`=sort list by key function (returns number or text key)=`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 path lookup (dot-separated keys, array indices)=`R 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` `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` `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` `mdel m k`=new map with key k removed=`M k v` `at xs i`=i-th element of list or text (0-indexed; negative counts from end)=element `lst xs i v`=new list with index `i` set to `v` (list update; alias: `lset`)=`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 `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` `fld fn xs init`=left fold: `fn (fn (fn init x0) x1) ...`=accumulator `flatmap fn xs`=map then flatten one level=`L b` `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` `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` `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` `rgxall pat s`=every regex match as `L (L t)` (no-group: each match in a 1-elem list)=`L (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` `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` `atan2 y x`=two-argument arctangent (y, x order; radians)=`n` `transpose m`=transpose row-major matrix=`L (L n)` `matmul a b`=matrix product=`L (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` `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` [Datetime (`dtfmt` / `dtparse`)] 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 [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`, `dot`, `solve`, `inv`, `det` 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. 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 long-form names that resolve to the canonical short form after parsing. Using a long form triggers a hint suggesting the short form. This lets newcomers write readable code while learning the canonical names. `floor`=→=`flr` `ceil`=→=`cel` `round`=→=`rou` `random`=→=`rnd` `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 short form) len xs -- canonical — no hint `get` and `post` return `Ok(body)` on success, `Err(message)` on failure (connection error, timeout, DNS failure, etc). `$` is a terse alias for `get`: get url -- R t t: Ok=response body, Err=error message $url -- same as get url get! url -- auto-unwrap: Ok→body, Err→propagate to caller $!url -- same as get! url post url body -- R t t: HTTP POST with text body post 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=post url body h -- POST with x-api-key header Behind the `http` feature flag (on by default). Without the feature, `get`/`post` return `Err("http feature not enabled")`. `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 [JSON builtins] `jpth` extracts a value from a JSON string by dot-separated path. Array elements are accessed by numeric index: jpth json "name" -- R t t: Ok=extracted value as text, Err=error jpth json "user.name" -- nested path lookup jpth json "items.0.name" -- array index access jpth! json "name" -- auto-unwrap `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
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 (dot notation): xs.0 # first element xs.2 # third element **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) `?cond then else`=prefix ternary: `?=x 0 10 20` (no early return) `!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. 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"}`. **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 (`=`, `>`, `<`, `>=`, `<=`, `!=`). [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}
@@ -15,6 +15,6 @@ TOOLS (EXTERNAL CALLS): tool "" > timeou
IMPORTS: Split programs across files with `use`: use "path/to/file.ilo" -- import all declarations use "path/to/file.ilo" [name1 name2] -- import only named declarations All imported declarations merge into a flat shared namespace — no qualification, no `mod::fn` syntax. The verifier catches name collisions. -- math.ilo dbl n:n>n; *n 2 half n:n>n; /n 2 -- main.ilo use "math.ilo" run n:n>n; dbl! half n [Rules] Path is relative to the importing file's directory Transitive: if `a.ilo` uses `b.ilo`, `b.ilo`'s declarations are visible to `main.ilo` when it uses `a.ilo` Circular imports are an error (`ILO-P018`) Scoped import with unknown name: `ILO-P019` `use` in inline code (no file context): `ILO-P017` [Error codes] `ILO-P017`=File not found or `use` in inline mode `ILO-P018`=Circular import detected `ILO-P019`=Name in `[...]` list not declared in the imported file
ERROR HANDLING: `R ok err` return type. Call then match: get-user uid;?{^e:^+"Lookup failed: "e;~d:use d} Compensate/rollback inline: charge pid amt;?{^e:release rid;^+"Payment failed: "e;~cid:continue} [Auto-Unwrap `!`] `func! args` calls `func` and auto-unwraps the Result: if `~v` (Ok), returns `v`; if `^e` (Err), immediately returns `^e` from the enclosing function. inner x:n>R n t;~x outer x:n>R n t;d=inner! x;~d Equivalent to `r=inner x;?r{~v:v;^e:^e}` but in 1 token instead of 12. Rules: The called function must return `R` or `O` (else verifier error ILO-T025) The enclosing function must return `R` (or `O` for Optional callees) (else verifier error ILO-T026) `!` goes after the function name, before args: `get! url` not `get url!` Zero-arg: `fetch!()` [Panic-Unwrap `!!`] `func!! args` is symmetric in shape with `!`, but on the failure path it aborts the program with a runtime diagnostic and exit code 1 instead of propagating. There is no enclosing-return-type constraint, so persona code can use it from `main>t`, `main>n`, or any non-Result / non-Optional context. main>t;rdl!! "input.txt" -- read file, abort with diagnostic if missing main>n;v=num!! "42";v -- parse number, abort on parse error main>n;m=mset mmap "k" 7;mget!! m "k" -- get value or abort if key missing On `^e` (Err) the program writes `panic-unwrap: ` to stderr and exits 1. On `O nil` the program writes `panic-unwrap: expected value, got nil`. On `~v` (Ok) or non-nil Optional, the inner value is extracted, identical to `!`. Rules: The called function must return `R` or `O` (else verifier error ILO-T025) **No constraint on the enclosing function's return type** — this is the difference from `!` `!!` goes after the function name, before args: `rdl!! path` not `rdl path!!` Zero-arg: `fetch!!()` Use `!` when the caller wants to react to the Err (compensate, retry, log). Use `!!` when the failure is a programming or environmental error the caller has no way to recover from — typical in short scripts, glue code, and main entry points.
PATTERNS (FOR LLM GENERATORS): [Bind-first pattern] Always bind complex expressions to variables before using them in operators. Operators only accept atoms and nested operators as operands — not function calls. -- DON'T: *n fac -n 1 (fac is an operand of *, not a call) -- DO: r=fac -n 1;*n r (bind call result, then use in operator) [Recursion template] >;;...;;combine 1. **Guard**: base case returns early — `<=n 1 1` (or `<=n 1{1}`) 2. **Bind**: bind recursive call results — `r=fac -n 1` 3. **Combine**: use bound results in final expression — `*n r` [Factorial] fac n:n>n;<=n 1 1;r=fac -n 1;*n r `<=n 1 1` — braceless guard: if n <= 1, return 1 `r=fac -n 1` — recursive call with prefix subtract as argument `*n r` — multiply n by result [Fibonacci] fib n:n>n;<=n 1 n;a=fib -n 1;b=fib -n 2;+a b `<=n 1 n` — braceless guard: return n for 0 and 1 `a=fib -n 1;b=fib -n 2` — two recursive calls, each with prefix arg `+a b` — add results [Multi-statement bodies] Semicolons separate statements. Last expression is the return value. f x:n>n;a=*x 2;b=+a 1;*b b -- (x*2 + 1)^2 Bodies may also be written across multiple newline-separated lines, indented under the signature. The parser stays inside the same function body while it sees an open bracket (`[`, `(`, `{`) or a pipe operator continuation. This makes long literals and multi-line conditional pipelines readable without semicolons: f x:n>n a=*x 2 b=+a 1 *b b g>L n [10, 20, 30, 40, 50, 60, 70, 80] Statement separation reverts to standard rules once brackets close. A blank line ends the current declaration. [Multi-function files] Functions in a file are separated by **newlines**. The parser strips all newlines, so the token stream is flat. After parsing each function body, the parser uses the next newline-delimited boundary to start the next declaration. A non-last function body's **final expression must not be a bare variable reference (`Ref`) or a function call**, because the parser greedily reads following tokens as additional call arguments. Safe endings prevent this: Binary operator=`+n 0`, `*x 1`=✓=fixed arity — no greedy loop Index access=`xs.0`, `rec.field`=✓=returns `Expr::Index`, not `Ref` Match block=`?v{…}`=✓=ends with `}` ForEach block=`@x xs{…}`=✓=ends with `}` Parenthesised expr=`(x>>f>>g)`=✓=ends with `)` Text/number literal=`"ok"`, `42`=✓=literal, not `Ref` Bare variable (`Ref`)=`n`, `result`=✗=greedy loop fires Bare function call=`len xs`, `f a`=✗=greedy loop fires The **last function in a file** can end with anything — greedy parsing stops at EOF. -- Non-last functions: end with a binary expression digs n:n>n;t=str n;l=len t;+l 0 -- +l 0 = l (binary, safe) clmp n:n lo:n hi:n>n;n hi hi;+n 0 -- +n 0 = n (binary, safe; `clamp` is a builtin) -- Last function: bare call is fine sz xs:L n>n;len xs -- EOF — greedy loop stops naturally To use a pipe chain in a non-last function, wrap it in parentheses: dbl-inc x:n>n;(x>>dbl>>inc) -- parens prevent >> from consuming next function's name inc-sq x:n>n;x>>inc>>sq -- last function — no parens needed [DO / DON'T] -- DON'T: fac n:n>n;<=n 1 1;*n fac -n 1 -- ↑ *n sees fac as an atom operand, not a call -- DO: fac n:n>n;<=n 1 1;r=fac -n 1;*n r -- ↑ bind-first: call result goes into r, then *n r works -- DON'T: +fac -n 1 fac -n 2 -- ↑ + takes two operands; fac is just an atom ref -- DO: a=fac -n 1;b=fac -n 2;+a b -- ↑ bind both calls, then combine
-ERROR DIAGNOSTICS: ilo verifies programs before execution and reports errors with stable codes, source context, and suggestions. [Error codes] Every error has a stable code: `ILO-L___`=lexer (tokenisation) `ILO-P___`=parser (syntax) `ILO-T___`=type verifier (static analysis) `ILO-R___`=runtime (execution) Use `--explain` to see a detailed explanation: ilo --explain ILO-T004 [Source context] Errors point at the relevant source location with a caret: error[ILO-T005]: undefined function 'foo' (called with 1 args) --> 1:9 1 | f x:n>n;foo x = note: in function 'f' = suggestion: did you mean 'f'? Parser, verifier, and runtime errors all show source spans. The verifier uses the enclosing statement span as the best available location for expression-level errors. [Suggestions] The verifier provides context-aware hints: **Did you mean?** — Levenshtein-based suggestions for undefined variables, functions, fields, and types **Type conversion** — suggests `str` for n→t, `num` for t→n **Missing arms** — lists uncovered match patterns with types **Arity** — shows expected parameter signature [Error output formats] --ansi / -a ANSI colour (default for TTY) --text / -t Plain text (no colour) --json / -j JSON (default for piped output) --no-hints / -nh Suppress idiomatic hints NO_COLOR=1 Disable colour (same as --text) JSON error output follows a structured schema with `severity`, `code`, `message`, `labels` (with spans), `notes`, and `suggestion` fields. [Top-level program output] For a program whose entry function returns a Result, the `~`/`^` wrapper is split across streams and exit codes so shell callers do not have to strip a prefix: `~v` (Ok)=`v` (bare)=—=0 `^e` (Err)=—=`^e`=1 any non-Result=`v`=—=0 In `--json` mode the value is always wrapped (`{"ok": v}` / `{"error": {...}}`) and emitted to stdout; exit codes match the plain-mode table. `Display` on `Value::Ok` / `Value::Err` still renders `~v` / `^e` in every other context (nested values, `prnt`, REPL prompts, error messages, debug output) — only the top-level program-return print path is split. The contract above applies to the in-process runners (`ilo prog.ilo`, `--run-tree`, `--run-vm`, `--run-cranelift`). AOT-compiled standalone binaries from `ilo compile` emit their final value through the same runtime helper as the `prnt` builtin and therefore still print `~v` / `^e`; the split is tracked as a follow-up. [Idiomatic hints] After successful execution, ilo scans the source for non-canonical forms and emits hints to stderr: hint: `==` → `=` saves 1 char (both mean equality in ilo) hint: `length` → `len` (canonical short form) Builtin alias hints appear at most once per program (the first long-form name found). In JSON mode, hints appear as `{"hints":["..."]}` on stderr. Suppress with `--no-hints` / `-nh`. [CLI invocation] ilo 'code' [args...] -- inline program; default-runs the entry function ilo program.ilo [func] [args] -- if `func` is omitted and the file declares exactly one function, that function runs automatically ilo program.ilo --ast -- print parsed AST as JSON and exit ilo --explain ILO-T004 -- print error explanation and exit ilo help ai -- compact AI spec to stdout (= contents of ai.txt) ilo serv -- long-lived JSON request/response loop **Default-run.** Inline programs (`ilo 'code'`) and single-function files run their entry function with the remaining CLI args; no explicit function name needed. Multi-function files require either a function name argument or a function called `main`. **Text-typed params.** When the entry function declares a parameter of type `t`, the CLI passes the raw arg through without numeric coercion. `ilo 'f x:t>t;x' 42` returns the string `"42"`, not the number 42. **Exit codes.** A program returning `Value::Err` (or `^reason` from the entry function) exits with code 1 and prints the err payload on stderr. `~v` (Ok) and any non-Result return value exit 0. Verifier and parser errors exit 2. **List args from the CLI.** Comma-separated args become `L n` or `L t` automatically: `ilo 'f xs:L n>n;sum xs' 1,2,3`.
+ERROR DIAGNOSTICS: ilo verifies programs before execution and reports errors with stable codes, source context, and suggestions. [Error codes] Every error has a stable code: `ILO-L___`=lexer (tokenisation) `ILO-P___`=parser (syntax) `ILO-T___`=type verifier (static analysis) `ILO-R___`=runtime (execution) Use `--explain` to see a detailed explanation: ilo --explain ILO-T004 [Source context] Errors point at the relevant source location with a caret: error[ILO-T005]: undefined function 'foo' (called with 1 args) --> 1:9 1 | f x:n>n;foo x = note: in function 'f' = suggestion: did you mean 'f'? Parser, verifier, and runtime errors all show source spans. The verifier uses the enclosing statement span as the best available location for expression-level errors. [Suggestions] The verifier provides context-aware hints: **Did you mean?** — Levenshtein-based suggestions for undefined variables, functions, fields, and types **Type conversion** — suggests `str` for n→t, `num` for t→n **Missing arms** — lists uncovered match patterns with types **Arity** — shows expected parameter signature [Error output formats] --ansi / -a ANSI colour (default for TTY) --text / -t Plain text (no colour) --json / -j JSON (default for piped output) --no-hints / -nh Suppress idiomatic hints NO_COLOR=1 Disable colour (same as --text) JSON error output follows a structured schema with `severity`, `code`, `message`, `labels` (with spans), `notes`, and `suggestion` fields. [Top-level program output] For a program whose entry function returns a Result, the `~`/`^` wrapper is split across streams and exit codes so shell callers do not have to strip a prefix: `~v` (Ok)=`v` (bare)=—=0 `^e` (Err)=—=`^e`=1 any non-Result=`v`=—=0 In `--json` mode the value is always wrapped (`{"ok": v}` / `{"error": {...}}`) and emitted to stdout; exit codes match the plain-mode table. `Display` on `Value::Ok` / `Value::Err` still renders `~v` / `^e` in every other context (nested values, `prnt`, REPL prompts, error messages, debug output) — only the top-level program-return print path is split. The contract applies uniformly to in-process runners (`ilo prog.ilo`, `--run-tree`, `--run-vm`, `--run-cranelift`) and to AOT-compiled standalone binaries from `ilo compile`. Both strip the top-level `~`/`^` wrapper on stdout, route `^e` to stderr, and use the same exit codes — output is byte-for-byte identical across every backend. [Idiomatic hints] After successful execution, ilo scans the source for non-canonical forms and emits hints to stderr: hint: `==` → `=` saves 1 char (both mean equality in ilo) hint: `length` → `len` (canonical short form) Builtin alias hints appear at most once per program (the first long-form name found). In JSON mode, hints appear as `{"hints":["..."]}` on stderr. Suppress with `--no-hints` / `-nh`. [CLI invocation] ilo 'code' [args...] -- inline program; default-runs the entry function ilo program.ilo [func] [args] -- if `func` is omitted and the file declares exactly one function, that function runs automatically ilo program.ilo --ast -- print parsed AST as JSON and exit ilo --explain ILO-T004 -- print error explanation and exit ilo help ai -- compact AI spec to stdout (= contents of ai.txt) ilo serv -- long-lived JSON request/response loop **Default-run.** Inline programs (`ilo 'code'`) and single-function files run their entry function with the remaining CLI args; no explicit function name needed. Multi-function files require either a function name argument or a function called `main`. **Text-typed params.** When the entry function declares a parameter of type `t`, the CLI passes the raw arg through without numeric coercion. `ilo 'f x:t>t;x' 42` returns the string `"42"`, not the number 42. **Exit codes.** A program returning `Value::Err` (or `^reason` from the entry function) exits with code 1 and prints the err payload on stderr. `~v` (Ok) and any non-Result return value exit 0. Verifier and parser errors exit 2. **List args from the CLI.** Comma-separated args become `L n` or `L t` automatically: `ilo 'f xs:L n>n;sum xs' 1,2,3`.
FORMATTER: Dense output is the default — newlines are for humans, not agents. No flag needed for dense format: ilo 'code' Dense wire format (default) ilo 'code' --dense / -d Same, explicit ilo 'code' --expanded / -e Expanded human format (for code review) [Dense format] Single line per declaration, minimal whitespace. Operators glue to first operand: cls sp:n>t;>=sp 1000{"gold"};>=sp 500{"silver"};"bronze" [Expanded format] Multi-line with 2-space indentation. Operators spaced from operands: cls sp:n > t >= sp 1000 { "gold" } >= sp 500 { "silver" } "bronze" Dense format is canonical — `dense(parse(dense(parse(src)))) == dense(parse(src))`.
COMPLETE EXAMPLE: tool get-user"Retrieve user by ID" uid:t>R profile t timeout:5,retry:2 tool send-email"Send an email" to:t subject:t body:t>R _ t timeout:10,retry:1 type profile{id:t;name:t;email:t;verified:b} ntf uid:t msg:t>R _ t;get-user uid;?{^e:^+"Lookup failed: "e;~d:!d.verified{^"Email not verified"};send-email d.email "Notification" msg;?{^e:^+"Send failed: "e;~_:~_}} [Recursive Example] Factorial and Fibonacci as standalone functions: fac n:n>n;<=n 1 1;r=fac -n 1;*n r fib n:n>n;<=n 1 n;a=fib -n 1;b=fib -n 2;+a b
diff --git a/build.rs b/build.rs
index 9e357f27..e98f1693 100644
--- a/build.rs
+++ b/build.rs
@@ -8,6 +8,7 @@
fn main() {
println!("cargo:rerun-if-changed=SPEC.md");
+ println!("cargo:rerun-if-changed=skills/ilo/SKILL.md");
let spec = std::fs::read_to_string("SPEC.md").expect("SPEC.md not found");
let compact = compact_spec(&spec);
@@ -20,6 +21,24 @@ fn main() {
if needs_write {
std::fs::write(tracked_path, &compact).expect("failed to write ai.txt");
}
+
+ // Mirror the compact spec into skills/ilo/SKILL.md between marker comments so the
+ // Claude Code skill surface always carries the canonical reference. Without this,
+ // SKILL.md drifts behind SPEC.md whenever a new builtin or section lands and agents
+ // that load the skill never see the update unless they additionally run `ilo help ai`.
+ let skill_path = std::path::Path::new("skills/ilo/SKILL.md");
+ if let Ok(existing) = std::fs::read_to_string(skill_path) {
+ const BEGIN: &str = "";
+ const END: &str = "";
+ if let (Some(b), Some(e)) = (existing.find(BEGIN), existing.find(END)) {
+ let head = &existing[..b + BEGIN.len()];
+ let tail = &existing[e..];
+ let injected = format!("{head}\n\n```\n{compact}```\n\n{tail}");
+ if injected != existing {
+ std::fs::write(skill_path, injected).expect("failed to write SKILL.md");
+ }
+ }
+ }
}
/// Compress the spec into one line per `## Section`.
diff --git a/skills/ilo/SKILL.md b/skills/ilo/SKILL.md
index 04e5d58e..8eb06d8a 100644
--- a/skills/ilo/SKILL.md
+++ b/skills/ilo/SKILL.md
@@ -30,13 +30,36 @@ Three ways to execute ilo, in preference order:
## Load the Full Spec
-After ensuring ilo is installed, load the compact AI spec for complete language reference:
+The canonical compact spec is embedded inline below (between the `BEGIN AI-SPEC` markers). It is regenerated from `SPEC.md` on every `cargo build` so it cannot drift. Agents that load this skill see the full builtin reference without needing to shell out.
+
+If the binary is available, `ilo help ai` prints the same content — useful for verification or when this file's copy is missing.
+
+
-```bash
-ilo help ai
+```
+INTRO: ilo is a token-optimised programming language for AI agents. Every design choice is evaluated against total token cost: generation + retries + context loading.
+FUNCTIONS: : ...>; No parens around params — `>` separates params from return type `;` separates statements — no newlines required Last expression is the return value (no `return` keyword) Zero-arg call: `make-id()` tot p:n q:n r:n>n;s=*p q;t=*s r;+s t
+TYPES: `n`=number (f64) `t`=text (string) `b`=bool `_`=any/unknown (wildcard type) `L n`=list of number `R n t`=result: ok=number, err=text `O n`=optional number (nil or n) `M t n`=map from text keys to numbers `S red green blue`=sum type — one of named text variants `F n t`=function type: takes n, returns t (used in HOF params) `order`=named type `a`=type variable — any single lowercase letter except n, t, b [Optional (`O T`)] `O T` accepts either `nil` or a value of type `T`. f x:O n>n;??x 0 -- unwrap optional or default to 0 g>O n;nil -- returns nil (valid O n) h>O n;42 -- returns 42 (valid O n) `??x default` — nil-coalesce: returns `x` if non-nil, else `default`. Unwraps `O T` to `T`. [Sum types (`S a b c`)] Closed set of named text variants. Verifier-enforced; runtime value is always `t`. color x:S red green blue > t ?x{red:"ff0000";green:"00ff00";blue:"0000ff"} Sum types are compatible with `t` — a sum value can be passed to any `t` parameter. [Map type (`M k v`)] Dynamic key-value collection. Keys are typed: text (`t`) or integer (`n`). `Int(1)` and `Text("1")` are distinct keys. mmap -- empty map mset m k v -- return new map with key k set to v mget m k -- value at key k, or nil mhas m k -- b: true if key exists mkeys m -- L t: sorted list of keys mvals m -- L v: values sorted by key mdel m k -- return new map with key k removed len m -- number of entries Numeric keys work directly — no `str` conversion needed. Float keys floor to `i64` at the builtin boundary (matching `at xs i`); NaN/Infinity raise at runtime. idx=mmap idx=mset idx 7 "seven" -- M n t, integer key mget idx 7 -- "seven" mhas idx 7 -- true mhas idx "7" -- false (Int and Text are distinct) `jdmp` stringifies numeric keys for JSON output (JSON object keys are always strings). The round-trip via `jpar` is lossy — numeric keys come back as text. Example: scores>M t n m=mmap m=mset m "alice" 99 m=mset m "bob" 87 mget m "alice" -- 99 [Type variables] A single lowercase letter (other than `n`, `t`, `b`) in type position is a type variable, treated as `unknown` during verification. Used for higher-order function signatures: identity x:a>a;x apply f:F a a x:a>a;f x Type variables provide weak generics — the verifier accepts any type for `a` without consistency checking across call sites. [Inline lambdas] Pass a function literal directly to a HOF instead of defining a one-off top-level helper: by-dist xs:L n>L n;srt (x:n>n;abs x) xs nonempty ws:L t>L t;flt (s:t>b;>(len s) 0) ws sumsq xs:L n>n;fld (a:n x:n>n;+a *x x) xs 0 Syntax: `(: ...>;)`. Same shape as a top-level function declaration, wrapped in parens, no name. **Phase 1 (no captures)** lifts the literal to a synthetic top-level decl and works across every engine (tree, VM, Cranelift JIT, AOT). The body's free variables must all be params, locals defined inside the lambda body, or known top-level fns. **Phase 2 (closure capture)** lets the body reference variables from the enclosing scope: f xs:L n thr:n>L n;flt (x:n>b;>x thr) xs -- captures `thr` Phase 2 is **tree-only**. The VM and Cranelift engines surface `ILO-R012` (unsupported closure capture) and the default runner falls through to the tree interpreter automatically. The ctx-arg form (`srt fn ctx xs`) is the cross-engine alternative for capturing state.
+NAMING: Short names everywhere. 1–3 chars. `order`=`ord`=truncate `customers`=`cs`=consonants `data`=`d`=single letter `level`=`lv`=drop vowels `discount`=`dc`=initials `final`=`fin`=first 3 `items`=`its`=first 3 Function names follow the same rules. Field names in constructors and external tool names keep their full form — they define the public interface. [Identifier syntax] Identifiers are lowercase ASCII only, optionally with hyphenated segments. Formally: `[a-z][a-z0-9]*(-[a-z0-9]+)*`. Capital letters and underscores are rejected at the binding and call site. run -- OK run-d -- OK (hyphen separates segments) r2 -- OK (digit after first letter) runD -- ERROR (capital letter) RunD -- ERROR (leading capital) run_d -- ERROR (underscore not allowed in bindings) -run -- ERROR (must start with a letter) `runD` in the interactive CLI surfaces as `ILO-L003 unexpected token` with a suggestion to use `run-d` or `rund`. The constraint is intentional: a single lexical shape per identifier keeps the token stream predictable for agents and avoids style debates over camelCase vs snake_case vs kebab-case. The only place capital letters and underscores are accepted is **after `.` or `.?`** at field-access position, so heterogeneous JSON keys from real APIs work without rewriting. See [Field names at dot-access](#field-names-at-dot-access) for the full list of post-dot relaxations (`r.URL`, `r.AccessKey`, `r.user_name`, etc.). Binding names (`AccessKey = ...`) and function names (`AccessKey x:n>n;...`) still error. [Reserved words] The following identifiers are reserved and cannot be used as names: `if`, `return`, `let`, `fn`, `def`, `var`, `const`. Using them produces a friendly error with the ilo equivalent: -- ERROR: `if` is a reserved word. Use: ?cond{true:... false:...} -- ERROR: `return` is a reserved word. Last expression is the return value. -- ERROR: `let` is a reserved word. Use: name = expr -- ERROR: `fn`/`def` is a reserved word. Use: name param:type > rettype; body
+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=`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) [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) [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) or **nested prefix operators**. Function calls are NOT operands — bind call results to a variable first: -- DON'T: *n fac p → parses as Multiply(n, fac) with p dangling -- DO: r=fac p;*n r **Negative literals vs binary minus**: the lexer greedily includes a leading `-` into number tokens. `-1`, `-7`, `-0` are all number literals. To subtract from zero, 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
+STRING LITERALS: Text values are written in double quotes. Escape sequences: `\n`=newline `\t`=tab `\r`=carriage return `\"`=literal double quote `\\`=literal backslash "hello\nworld" -- two-line string "col1\tcol2" -- tab-separated spl "\n" text -- split file content into lines
+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 t`=text to number (Err if unparseable)=`R n t` `abs n`=absolute value=`n` `min a b`=minimum of two numbers=`n` `max a b`=maximum of two numbers=`n` `mod a b`=remainder (modulo); errors on zero divisor=`n` `flr n`=floor (round toward negative infinity)=`n` `cel n`=ceiling (round toward positive infinity)=`n` `rnd`=random float in [0, 1)=`n` `rnd a b`=random integer in [a, b] (inclusive)=`n` `now`=current Unix timestamp (seconds)=`n` `get url`=HTTP GET=`R t t` `get url headers`=HTTP GET with custom headers (`M t t` map)=`R t t` `post url body`=HTTP POST with text body=`R t t` `post url body headers`=HTTP POST with body and custom headers (`M t t` map)=`R t t` `env key`=read environment variable=`R 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` `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` `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 — `{}` placeholders filled left-to-right=`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=same type `srt fn xs`=sort list by key function (returns number or text key)=`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 path lookup (dot-separated keys, array indices)=`R 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` `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` `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` `mdel m k`=new map with key k removed=`M k v` `at xs i`=i-th element of list or text (0-indexed; negative counts from end)=element `lst xs i v`=new list with index `i` set to `v` (list update; alias: `lset`)=`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 `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` `fld fn xs init`=left fold: `fn (fn (fn init x0) x1) ...`=accumulator `flatmap fn xs`=map then flatten one level=`L b` `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` `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` `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` `rgxall pat s`=every regex match as `L (L t)` (no-group: each match in a 1-elem list)=`L (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` `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` `atan2 y x`=two-argument arctangent (y, x order; radians)=`n` `transpose m`=transpose row-major matrix=`L (L n)` `matmul a b`=matrix product=`L (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` `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` [Datetime (`dtfmt` / `dtparse`)] 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 [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`, `dot`, `solve`, `inv`, `det` 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. 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 long-form names that resolve to the canonical short form after parsing. Using a long form triggers a hint suggesting the short form. This lets newcomers write readable code while learning the canonical names. `floor`=→=`flr` `ceil`=→=`cel` `round`=→=`rou` `random`=→=`rnd` `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 short form) len xs -- canonical — no hint `get` and `post` return `Ok(body)` on success, `Err(message)` on failure (connection error, timeout, DNS failure, etc). `$` is a terse alias for `get`: get url -- R t t: Ok=response body, Err=error message $url -- same as get url get! url -- auto-unwrap: Ok→body, Err→propagate to caller $!url -- same as get! url post url body -- R t t: HTTP POST with text body post 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=post url body h -- POST with x-api-key header Behind the `http` feature flag (on by default). Without the feature, `get`/`post` return `Err("http feature not enabled")`. `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 [JSON builtins] `jpth` extracts a value from a JSON string by dot-separated path. Array elements are accessed by numeric index: jpth json "name" -- R t t: Ok=extracted value as text, Err=error jpth json "user.name" -- nested path lookup jpth json "items.0.name" -- array index access jpth! json "name" -- auto-unwrap `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
+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 (dot notation): xs.0 # first element xs.2 # third element **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) `?cond then else`=prefix ternary: `?=x 0 10 20` (no early return) `!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. 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"}`. **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 (`=`, `>`, `<`, `>=`, `<=`, `!=`). [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}
+CALLS: Positional args, space-separated, no parens: get-user uid send-email d.email "Notification" msg charge pid amt [Call Arguments] Call arguments can be atoms or prefix expressions: fac -n 1 -- Call(fac, [Subtract(n, 1)]) fac +a b -- Call(fac, [Add(a, b)]) g +a b c -- Call(g, [Add(a,b), c]) — 2 args fac p -- Call(fac, [Ref(p)]) Use parentheses when you need a full expression (including another call) as an argument: f (g x) -- Call(f, [Call(g, [x])])
+RECORDS: Define: type point{x:n;y:n} Construct (type name as constructor): p=point x:10 y:20 Access: p.x ord.addr.country Destructure: {x;y}=p Binds `x` to `p.x` and `y` to `p.y`. All named fields must exist on the record. Update: ord with total:fin cost:sh [Field names at dot-access] After `.` or `.?`, the parser accepts any identifier-shaped token as a field name, including: **Reserved keywords** — `r.type`, `r.if`, `r.use`, `r.true`, `r.nil`. JSON keys commonly mirror language keywords and dot-access must just work. **camelCase** — `r.cvssMetricV31`, `r.userId`. Real-world JSON from APIs is rarely snake_case. **Leading uppercase** — `r.Items`, `r.UserName`. PascalCase keys from .NET / Java backends are first-class. **snake_case** — `r.type_id`, `r.user_name`. **kebab-case** — `r.x-request-id` (requires the leading segment to be an identifier). These relaxations are scoped to post-dot position only — top-level identifiers still follow the standard naming rules.
+TOOLS (EXTERNAL CALLS): tool "" > timeout:,retry: tool get-user"Retrieve user by ID" uid:t>R profile t timeout:5,retry:2 Tool declarations are verified statically like functions — call sites are type-checked and arity-checked. At runtime, tool calls dispatch through a provider configured via `--tools `: { "tools": { "get-user": { "url": "https://api.example.com/get-user", "method": "POST", "timeout_secs": 5, "retries": 2, "headers": { "Authorization": "Bearer token" } } } } ilo serialises call arguments as `{"args": [...]}` (JSON array), sends them to the endpoint, and deserialises the response body back to an ilo value. HTTP 2xx → `Ok(response)`, non-2xx → `Err("HTTP : ...")`. Without `--tools`, tool calls return `Ok(_)` (stub behaviour). **Value ↔ JSON mapping:** `n`=number `t`=string `b`=boolean `_`=null `L n`=array `R ok err`=`{"ok": ...}` or `{"err": ...}` record=object Tool return type `>t` is the escape hatch — any JSON response is coerced to a text string without parsing.
+IMPORTS: Split programs across files with `use`: use "path/to/file.ilo" -- import all declarations use "path/to/file.ilo" [name1 name2] -- import only named declarations All imported declarations merge into a flat shared namespace — no qualification, no `mod::fn` syntax. The verifier catches name collisions. -- math.ilo dbl n:n>n; *n 2 half n:n>n; /n 2 -- main.ilo use "math.ilo" run n:n>n; dbl! half n [Rules] Path is relative to the importing file's directory Transitive: if `a.ilo` uses `b.ilo`, `b.ilo`'s declarations are visible to `main.ilo` when it uses `a.ilo` Circular imports are an error (`ILO-P018`) Scoped import with unknown name: `ILO-P019` `use` in inline code (no file context): `ILO-P017` [Error codes] `ILO-P017`=File not found or `use` in inline mode `ILO-P018`=Circular import detected `ILO-P019`=Name in `[...]` list not declared in the imported file
+ERROR HANDLING: `R ok err` return type. Call then match: get-user uid;?{^e:^+"Lookup failed: "e;~d:use d} Compensate/rollback inline: charge pid amt;?{^e:release rid;^+"Payment failed: "e;~cid:continue} [Auto-Unwrap `!`] `func! args` calls `func` and auto-unwraps the Result: if `~v` (Ok), returns `v`; if `^e` (Err), immediately returns `^e` from the enclosing function. inner x:n>R n t;~x outer x:n>R n t;d=inner! x;~d Equivalent to `r=inner x;?r{~v:v;^e:^e}` but in 1 token instead of 12. Rules: The called function must return `R` or `O` (else verifier error ILO-T025) The enclosing function must return `R` (or `O` for Optional callees) (else verifier error ILO-T026) `!` goes after the function name, before args: `get! url` not `get url!` Zero-arg: `fetch!()` [Panic-Unwrap `!!`] `func!! args` is symmetric in shape with `!`, but on the failure path it aborts the program with a runtime diagnostic and exit code 1 instead of propagating. There is no enclosing-return-type constraint, so persona code can use it from `main>t`, `main>n`, or any non-Result / non-Optional context. main>t;rdl!! "input.txt" -- read file, abort with diagnostic if missing main>n;v=num!! "42";v -- parse number, abort on parse error main>n;m=mset mmap "k" 7;mget!! m "k" -- get value or abort if key missing On `^e` (Err) the program writes `panic-unwrap: ` to stderr and exits 1. On `O nil` the program writes `panic-unwrap: expected value, got nil`. On `~v` (Ok) or non-nil Optional, the inner value is extracted, identical to `!`. Rules: The called function must return `R` or `O` (else verifier error ILO-T025) **No constraint on the enclosing function's return type** — this is the difference from `!` `!!` goes after the function name, before args: `rdl!! path` not `rdl path!!` Zero-arg: `fetch!!()` Use `!` when the caller wants to react to the Err (compensate, retry, log). Use `!!` when the failure is a programming or environmental error the caller has no way to recover from — typical in short scripts, glue code, and main entry points.
+PATTERNS (FOR LLM GENERATORS): [Bind-first pattern] Always bind complex expressions to variables before using them in operators. Operators only accept atoms and nested operators as operands — not function calls. -- DON'T: *n fac -n 1 (fac is an operand of *, not a call) -- DO: r=fac -n 1;*n r (bind call result, then use in operator) [Recursion template] >;;...;;combine 1. **Guard**: base case returns early — `<=n 1 1` (or `<=n 1{1}`) 2. **Bind**: bind recursive call results — `r=fac -n 1` 3. **Combine**: use bound results in final expression — `*n r` [Factorial] fac n:n>n;<=n 1 1;r=fac -n 1;*n r `<=n 1 1` — braceless guard: if n <= 1, return 1 `r=fac -n 1` — recursive call with prefix subtract as argument `*n r` — multiply n by result [Fibonacci] fib n:n>n;<=n 1 n;a=fib -n 1;b=fib -n 2;+a b `<=n 1 n` — braceless guard: return n for 0 and 1 `a=fib -n 1;b=fib -n 2` — two recursive calls, each with prefix arg `+a b` — add results [Multi-statement bodies] Semicolons separate statements. Last expression is the return value. f x:n>n;a=*x 2;b=+a 1;*b b -- (x*2 + 1)^2 Bodies may also be written across multiple newline-separated lines, indented under the signature. The parser stays inside the same function body while it sees an open bracket (`[`, `(`, `{`) or a pipe operator continuation. This makes long literals and multi-line conditional pipelines readable without semicolons: f x:n>n a=*x 2 b=+a 1 *b b g>L n [10, 20, 30, 40, 50, 60, 70, 80] Statement separation reverts to standard rules once brackets close. A blank line ends the current declaration. [Multi-function files] Functions in a file are separated by **newlines**. The parser strips all newlines, so the token stream is flat. After parsing each function body, the parser uses the next newline-delimited boundary to start the next declaration. A non-last function body's **final expression must not be a bare variable reference (`Ref`) or a function call**, because the parser greedily reads following tokens as additional call arguments. Safe endings prevent this: Binary operator=`+n 0`, `*x 1`=✓=fixed arity — no greedy loop Index access=`xs.0`, `rec.field`=✓=returns `Expr::Index`, not `Ref` Match block=`?v{…}`=✓=ends with `}` ForEach block=`@x xs{…}`=✓=ends with `}` Parenthesised expr=`(x>>f>>g)`=✓=ends with `)` Text/number literal=`"ok"`, `42`=✓=literal, not `Ref` Bare variable (`Ref`)=`n`, `result`=✗=greedy loop fires Bare function call=`len xs`, `f a`=✗=greedy loop fires The **last function in a file** can end with anything — greedy parsing stops at EOF. -- Non-last functions: end with a binary expression digs n:n>n;t=str n;l=len t;+l 0 -- +l 0 = l (binary, safe) clmp n:n lo:n hi:n>n;n hi hi;+n 0 -- +n 0 = n (binary, safe; `clamp` is a builtin) -- Last function: bare call is fine sz xs:L n>n;len xs -- EOF — greedy loop stops naturally To use a pipe chain in a non-last function, wrap it in parentheses: dbl-inc x:n>n;(x>>dbl>>inc) -- parens prevent >> from consuming next function's name inc-sq x:n>n;x>>inc>>sq -- last function — no parens needed [DO / DON'T] -- DON'T: fac n:n>n;<=n 1 1;*n fac -n 1 -- ↑ *n sees fac as an atom operand, not a call -- DO: fac n:n>n;<=n 1 1;r=fac -n 1;*n r -- ↑ bind-first: call result goes into r, then *n r works -- DON'T: +fac -n 1 fac -n 2 -- ↑ + takes two operands; fac is just an atom ref -- DO: a=fac -n 1;b=fac -n 2;+a b -- ↑ bind both calls, then combine
+ERROR DIAGNOSTICS: ilo verifies programs before execution and reports errors with stable codes, source context, and suggestions. [Error codes] Every error has a stable code: `ILO-L___`=lexer (tokenisation) `ILO-P___`=parser (syntax) `ILO-T___`=type verifier (static analysis) `ILO-R___`=runtime (execution) Use `--explain` to see a detailed explanation: ilo --explain ILO-T004 [Source context] Errors point at the relevant source location with a caret: error[ILO-T005]: undefined function 'foo' (called with 1 args) --> 1:9 1 | f x:n>n;foo x = note: in function 'f' = suggestion: did you mean 'f'? Parser, verifier, and runtime errors all show source spans. The verifier uses the enclosing statement span as the best available location for expression-level errors. [Suggestions] The verifier provides context-aware hints: **Did you mean?** — Levenshtein-based suggestions for undefined variables, functions, fields, and types **Type conversion** — suggests `str` for n→t, `num` for t→n **Missing arms** — lists uncovered match patterns with types **Arity** — shows expected parameter signature [Error output formats] --ansi / -a ANSI colour (default for TTY) --text / -t Plain text (no colour) --json / -j JSON (default for piped output) --no-hints / -nh Suppress idiomatic hints NO_COLOR=1 Disable colour (same as --text) JSON error output follows a structured schema with `severity`, `code`, `message`, `labels` (with spans), `notes`, and `suggestion` fields. [Top-level program output] For a program whose entry function returns a Result, the `~`/`^` wrapper is split across streams and exit codes so shell callers do not have to strip a prefix: `~v` (Ok)=`v` (bare)=—=0 `^e` (Err)=—=`^e`=1 any non-Result=`v`=—=0 In `--json` mode the value is always wrapped (`{"ok": v}` / `{"error": {...}}`) and emitted to stdout; exit codes match the plain-mode table. `Display` on `Value::Ok` / `Value::Err` still renders `~v` / `^e` in every other context (nested values, `prnt`, REPL prompts, error messages, debug output) — only the top-level program-return print path is split. The contract applies uniformly to in-process runners (`ilo prog.ilo`, `--run-tree`, `--run-vm`, `--run-cranelift`) and to AOT-compiled standalone binaries from `ilo compile`. Both strip the top-level `~`/`^` wrapper on stdout, route `^e` to stderr, and use the same exit codes — output is byte-for-byte identical across every backend. [Idiomatic hints] After successful execution, ilo scans the source for non-canonical forms and emits hints to stderr: hint: `==` → `=` saves 1 char (both mean equality in ilo) hint: `length` → `len` (canonical short form) Builtin alias hints appear at most once per program (the first long-form name found). In JSON mode, hints appear as `{"hints":["..."]}` on stderr. Suppress with `--no-hints` / `-nh`. [CLI invocation] ilo 'code' [args...] -- inline program; default-runs the entry function ilo program.ilo [func] [args] -- if `func` is omitted and the file declares exactly one function, that function runs automatically ilo program.ilo --ast -- print parsed AST as JSON and exit ilo --explain ILO-T004 -- print error explanation and exit ilo help ai -- compact AI spec to stdout (= contents of ai.txt) ilo serv -- long-lived JSON request/response loop **Default-run.** Inline programs (`ilo 'code'`) and single-function files run their entry function with the remaining CLI args; no explicit function name needed. Multi-function files require either a function name argument or a function called `main`. **Text-typed params.** When the entry function declares a parameter of type `t`, the CLI passes the raw arg through without numeric coercion. `ilo 'f x:t>t;x' 42` returns the string `"42"`, not the number 42. **Exit codes.** A program returning `Value::Err` (or `^reason` from the entry function) exits with code 1 and prints the err payload on stderr. `~v` (Ok) and any non-Result return value exit 0. Verifier and parser errors exit 2. **List args from the CLI.** Comma-separated args become `L n` or `L t` automatically: `ilo 'f xs:L n>n;sum xs' 1,2,3`.
+FORMATTER: Dense output is the default — newlines are for humans, not agents. No flag needed for dense format: ilo 'code' Dense wire format (default) ilo 'code' --dense / -d Same, explicit ilo 'code' --expanded / -e Expanded human format (for code review) [Dense format] Single line per declaration, minimal whitespace. Operators glue to first operand: cls sp:n>t;>=sp 1000{"gold"};>=sp 500{"silver"};"bronze" [Expanded format] Multi-line with 2-space indentation. Operators spaced from operands: cls sp:n > t >= sp 1000 { "gold" } >= sp 500 { "silver" } "bronze" Dense format is canonical — `dense(parse(dense(parse(src)))) == dense(parse(src))`.
+COMPLETE EXAMPLE: tool get-user"Retrieve user by ID" uid:t>R profile t timeout:5,retry:2 tool send-email"Send an email" to:t subject:t body:t>R _ t timeout:10,retry:1 type profile{id:t;name:t;email:t;verified:b} ntf uid:t msg:t>R _ t;get-user uid;?{^e:^+"Lookup failed: "e;~d:!d.verified{^"Email not verified"};send-email d.email "Notification" msg;?{^e:^+"Send failed: "e;~_:~_}} [Recursive Example] Factorial and Fibonacci as standalone functions: fac n:n>n;<=n 1 1;r=fac -n 1;*n r fib n:n>n;<=n 1 n;a=fib -n 1;b=fib -n 2;+a b
```
-This outputs the full spec optimised for LLM consumption. Read it before writing ilo code if you need details beyond the summary below.
+
## Overview
@@ -110,6 +133,15 @@ Auto-unwrap with `!` — propagates errors automatically:
d=get! url -- Ok->value, Err->propagate to caller
```
+Panic-unwrap with `!!` — aborts the program with a runtime diagnostic and exit 1 on `^e` / `nil`. No constraint on the enclosing function's return type, so it works from `main>t`, `main>n`, scripts, etc:
+
+```
+main>t;xs=rdl!! "input.txt";cat xs "\n"
+main>n;num!! "42"
+```
+
+Both apply to `R` and `O` callees.
+
## Loops
```
@@ -129,6 +161,20 @@ main xs:L n>L n;flt pos xs -- filter by predicate
main xs:L n>n;fld add xs 0 -- fold/reduce
```
+All HOFs (`map`, `flt`, `fld`, `srt`, `grp`, `uniqby`, `partition`, `flatmap`) work cross-engine (tree, VM, Cranelift JIT, AOT).
+
+### Inline lambdas
+
+Pass a function literal directly to a HOF — no helper decl required:
+
+```
+srt (x:n>n;abs x) xs -- sort by distance from zero
+flt (s:t>b;>(len s) 0) ws -- non-empty strings
+fld (a:n x:n>n;+a *x x) xs 0 -- sum of squares
+```
+
+Phase 1 (no captures) is cross-engine. Phase 2 (capture from enclosing scope, e.g. `flt (x:n>b;>x thr) xs`) is tree-only; VM/Cranelift fall through to tree automatically with `ILO-R012`.
+
## Pipe Operator
```
@@ -157,6 +203,8 @@ mhas m "key" -- bool: exists?
mkeys m -- sorted key list
```
+Keys are typed: text (`t`) or integer (`n`). Numeric keys work directly — `mset m 7 v`, `mget m 7`, no `str` conversion. `Int(1)` and `Text("1")` are distinct. `jdmp` stringifies numeric keys for JSON output.
+
## Builtins Reference
**Math**: `abs` `min` `max` `mod` `flr` `cel` `rou` `rnd` `rndn` `clamp` `sum` `avg`
@@ -197,6 +245,22 @@ ilo program.ilo funcname args # from file
ilo 'f xs:L n>n;len xs' 1,2,3 # list args
ilo --explain ILO-T004 # explain error
ilo help ai # compact spec
+ilo compile program.ilo # AOT-compile to native binary
+```
+
+### Top-level output
+
+A program returning `~v` (Ok) prints just the inner value to stdout (no `~` prefix), exit 0. A program returning `^e` (Err) prints `^e` to stderr, exit 1. Non-Result returns print plain on stdout, exit 0. `--json` mode wraps in `{"ok": v}` / `{"error": ...}`. AOT-compiled binaries from `ilo compile` follow the same contract byte-for-byte.
+
+### Negative indices
+
+`at`, `slc`, `take`, `drop` all accept negative indices counting from the end (Python-style). Bounds clamp; never wrap:
+
+```
+at xs -1 -- last element
+slc xs 0 -1 -- drop the last element
+take -1 xs -- all but the last
+drop -1 xs -- only the last
```
## Multi-Function File Rules