algal.expr.v1

The bounded pure expression contract. An expr program is manifest data: canonical JSON that a contract-owned evaluator interprets under a fuel meter — never host code, never an effect, never ambient authority. One implementation (crates/algal-expr, Rust) serves every runtime: linked natively into the kernel, compiled to wasm32-unknown-unknown for Bun. Two targets of one evaluator, so identical semantics hold by construction.

Envelope

json
{ "contract": "algal.expr.v1", "program": ["add", 1, 2] }

program is any JSON value. A scalar (null, boolean, number, string) self-evaluates. An array is an operation call: arr[0] must be a string naming an op, arr[1..] are its arguments. There is no other syntax — canonical JSON is the grammar, so a program is digestable and mergeable like any other manifest data.

Literal arrays must be quoted: ["list", 1, 2] evaluates each argument and collects the results ([1,2]), while [1,2] alone is a call to op 1 and fails. ["quote", [1,2]] returns the array verbatim. This is the language's single real footgun — always quote data-shaped arrays.

Evaluation model

Evaluation is a tree walk over the program. Every node either produces a JSON value or fails with a typed error; there is no mutation, no recursion (no op can reference a program), and no I/O. Termination is enforced twice over: the grammar admits no unbounded loop, and the fuel meter bounds total work including intermediate value sizes.

Two name scopes exist. The env scope holds the caller-supplied record — for expr cells, the delivered input-port values. The lexical scope holds binders introduced by let, map, filter, and fold. ["get", "name", ...path] reads a name: the first argument evaluates to a name string, looked up lexical-first then env; subsequent arguments evaluate to path steps (string keys into objects, nonnegative integer indexes into arrays). An unbound name is EXPR_PATH; a missing key, an out-of-range index, or a step into a non-container returns null (the pick.v1 miss precedent — paths degrade gracefully, names do not).

Static checking runs before evaluation: program bounds, op table, arity, binder names, and that every literal get name resolves to a declared name or an enclosing binder — a typo'd port fails admission, not the run. Computed first arguments (["get", ["sconcat", "a", "b"]]) remain legal and fail only at evaluation.

Types

Strict, always. Numbers are the JSON number domain (IEEE-754 double); if, and, or, not, and filter require booleans — no truthiness. Cross-type eq/neq is false/true, never an error. There is no coercion anywhere: a number is not a string is not a boolean.

Operations

min..max is arity; * is variadic. Binders name literal strings.

oparitysemantics
add mul1..*numeric sum / product; non-finite result fails
sub div mod2numeric; div is float division (7/2 → 3.5); mod is truncated remainder (dividend's sign); zero divisor fails
neg1numeric negation
min max1..*numeric extremes
abs floor ceil1numeric transforms; non-finite result fails
round1round half toward +∞ (JS Math.round tie rule: -2.5 → -2)
clamp3[clamp, x, lo, hi]min(max(x,lo),hi); lo > hi fails
lt lte gt gte2two numbers (numeric order) or two strings (UTF-16 code-unit order — the canonical key order)
eq neq2structural equality: key-order-insensitive, 1 ≡ 1.0, -0 ≡ 0; cross-type false
and or1..*strict-boolean, short-circuit
not1strict-boolean negation
if3[if, cond, then, else]; cond must be boolean; only the taken branch evaluates
let3[let, "name", value, body] — binds name in body
get1..*[get, name-expr, ...path] — see scopes; miss → null, unbound name → error
list0..*collect evaluated arguments into an array
len1array length
nth2[nth, list, index] — out of range is an error
concat1..*concatenate arrays
map3[map, list, "name", body] — body per element, binder name
filter3same shape; keeps elements whose body is true (strict bool)
fold5[fold, list, init, "acc", "item", body] — left fold
contains2[contains, list, value]eq membership
reverse1reverse an array
take drop2[take, list, n] / [drop, list, n]n a nonneg integer; past the end clamps
flat1one-level flatten; every element must be a list (strict)
unique1dedupe by eq, first-seen order; O(n²) in fuel
sort1total order: null < bool < number < string < list < map — numbers by value, strings by UTF-16 code units, lists elementwise, maps by canonical bytes; stable on eq
slen1string length in UTF-16 code units (JS .length)
sconcat1..*concatenate strings
upper lower trim1ASCII-only case and whitespace ( \t\n\r\v\f) transforms — deliberately not Unicode-aware
split2[split, string, sep] — nonempty separator; literal split
join2[join, list, sep] — elements must be strings
scontains2substring test
starts ends2prefix / suffix test
has2[has, map, key] — key presence (distinguishes null from absent)
keys values1entries in canonical key order (array-index keys numerically first, then UTF-16)
merge1..*shallow object merge, rightmost wins; result ≤ object-keys bound
toText1canonical JSON render of any value as a string
isText isNum isBool isList isMap isNull1type predicates
quote1return the argument verbatim — data, not a call

Division or modulo by zero is EXPR_DIV_ZERO; a non-finite arithmetic result (overflow, 0/0) is EXPR_NUM.

Bounds and fuel

boundvalue
program canonical bytes16,384
program nodes512
program depth16
env canonical bytes262,144
value depth (env and result)32
array length1,024
object keys256
string bytes65,536
output canonical bytes65,536
intermediate value canonical bytes262,144
variable name length64
fuel ceiling1,000,000

Every intermediate value must obey the value depth, string, array, object, and byte bounds, even when a later operation discards it. Collection builders check cumulative bytes before retaining each child. A fold that duplicates its accumulator therefore fails with EXPR_BOUNDS before exponential expansion; the final output check is not the memory safety boundary.

Fuel is a deterministic work meter: each evaluated node spends its op's base cost (1 for control and predicates, 2 for most, 2 + argc for get) plus the canonical byte length of values it materially produces (sconcat/join/split/upper/trim/concat/toText spend their output size; map/filter/fold/contains/sort/merge/scontains/ starts/ends/flat spend 1 per element or input byte; unique spends 1 per pairwise comparison; eq spends operand node counts). Same program + env ⇒ same value or error and the same burn. run takes a caller fuel budget ≤ the ceiling; exhaustion is EXPR_FUEL. In an organism, an expr cell activation burns 100 + fuel work units and the activation budget is BOUNDS.maxExprFuel (100,000).

Errors

All failures are {code, ...details} where code is one of:

EXPR_PARSE (malformed program), EXPR_OP (unknown op — static), EXPR_ARITY, EXPR_TYPE, EXPR_PATH (unbound name), EXPR_ARG, EXPR_NUM, EXPR_DIV_ZERO, EXPR_BOUNDS (a bound was exceeded), EXPR_FUEL. Check-time failures carry the same codes; nothing about an error depends on the host.

Consumers

The evaluator is contract machinery, and the contract can put it anywhere a bounded pure program is useful. Consumers share the versioned {"contract","program"} envelope and differ only in the environment the program sees and the result type the consumer requires — the envelope is parsed once and each consumer static-checks the program against its own visible names. Six consumers exist today:

  • expr cells (below) — compute a port value from input ports.
  • recall queries — derive a non-empty, ≤ 4096-byte text query from the recall cell's declared inputs before the host semantic-index effect runs. See organism.md recall cells.
  • edge guard.expr — an {"expr": {…}} guard is evaluated over {"value": delivered} and must return a boolean; the edge fires iff true. Static names are {"value"} only. See organism.md edges.
  • foundry scorer — an optional program in algal.foundry.config.v1 evaluated per case over {"args", "expect", "outputs"}; it must return a boolean and replaces exact-match as the pass claim. See foundry.md.
  • bench scorer — the same optional program in algal.bench.config.v1, over the same {"args", "expect", "outputs"} environment; the pass claim a pareto comparison is built on. See bench.md.
  • bench axes — optional {"name","dir","expr"} entries in algal.bench.config.v1, each evaluated once per system over its aggregate record {"id","manifestKey","manifestDigest","passed","total", "effectCalls","work","usage","attribution"}; each must return a finite number and the recorded values replace the default pareto criteria. See bench.md.
  • More consumers are expected — route conditions, search predicates — anywhere the contract currently hardcodes a predicate.

    The expr cell

    json
    {
      "id": "rate", "kind": "expr",
      "inputs": { "ticket": "json", "base": "json" },
      "expr": { "contract": "algal.expr.v1", "program": [ ... ] },
      "output": { "kind": "choice", "labels": ["high", "std"] }
    }

    Declares consumer inputs (each becomes an env name), the envelope, and an output contract identical to agent cells bound to the single port out. output.onMiss is rejected — programs return exact values. Activation evaluates the program against the delivered inputs, commits out under the output contract, burns 100 + fuel, and records an ordinary cell commit: no effect request, no executor, replayed by re-evaluation under verify. Evaluation failures fail the cell as EXPR_FAILED (EXPR_FUEL surfaces as BUDGET_EXHAUSTED); output-contract violations fail TYPE_MISMATCH like any producer.