This is the canonical, executable specification of EigenScript. Every
eigenscript code block here is EXECUTED by the test suite
(tests/test_doc_examples.py, suite section [89]) — the gate is opt-OUT.
A block followed by an output block has its stdout compared byte-for-byte;
a block tagged eigenscript fragment k=v ... is run with those free names
bound and must finish cleanly; a block tagged eigenscript nocheck <reason>
states on its own opening line why it is not executed. An untagged block with
no output block fails the suite, so the spec cannot drift from the
implementation and cannot quietly stop being checked.
Companion documents: SYNTAX.md (tutorial-style guide), GRAMMAR.md (formal grammar), LANGUAGE_CONTRACT.md (edge-case promises), BUILTINS.md (built-in functions), OBSERVER.md (observer semantics in depth), COMPARISON.md (EigenScript next to Python/JS/Rust/Lisp).
- Program model
- Lexical structure
- Values and types
- Variables and assignment
- Numbers and arithmetic
- Strings
- Booleans, comparison, and logic
- Bitwise operators
- Conditionals
- Loops
- Lists
- Dictionaries
- Functions
- Closures and lambdas
- The pipe operator
- Pattern matching
- Error handling
- Modules
- Interrogatives: asking your code
- Observer semantics and predicates
- Temporal interrogatives
- Concurrency
- Buffers
- Evaluation model reference
Hosted build profiles determine which extension implementations are compiled
in. In VM/native-JIT evaluation, an unresolved omitted HTTP, network, database,
or model builtin name raises a catchable value error at its first reference,
naming the unavailable capability and required profile. This occurs before
call arguments are evaluated. Local, captured and host bindings take precedence,
including a binding to null; other unknown names retain undefined_name errors.
--api, lint and token-vocabulary discovery describe the language surface,
not callable availability. Direct host global lookup returns actual absence.
Direct AOT adoption, capability imports and host grants remain separate work.
An EigenScript program is a sequence of statements executed top to
bottom. There is no required entry point — the file is the program.
Statements are expressions, assignments, definitions, or control
structures. Blocks are delimited by indentation (like Python), and a
statement ends at the end of its line. A return at module level ends
the program immediately (exit status 0); the returned value is
discarded.
Function application uses the keyword of: f of x calls f with the
argument x. print is an ordinary builtin function.
print of "hello, world"
hello, world
- Comments run from
#to end of line. - Blocks open with a
:at the end of the introducing line and contain the following indented lines. Indentation must be consistent within a block. - Identifiers are
[a-zA-Z_][a-zA-Z0-9_]*. - Keywords include:
is of define as if elif else loop while for in return and or not null try catch break continue import match case unobserved local what who when where why how converged stable improving oscillating diverging equilibrium.
# this is a comment
x is 1 # trailing comments are fine
if x == 1:
print of "block body is indented"
block body is indented
EigenScript is dynamically typed. The runtime types are:
| type label | description | literal |
|---|---|---|
num |
64-bit float (the only number type) | 42, 3.14 |
str |
immutable byte string | "text" |
list |
mutable ordered sequence | [1, 2, 3] |
dict |
mutable string-keyed map | {"k": 1} |
buffer |
flat mutable array of nums | buffer of 8, zeros of 8 |
fn |
user-defined function / closure | define / (x) => x |
builtin |
native function | print |
none |
the null value | null |
type of v returns the type label as a string.
print of (type of 1)
print of (type of "a")
print of (type of [1, 2])
print of (type of {"k": 1})
print of (type of null)
print of (type of print)
num
str
list
dict
none
builtin
Assignment uses is. It is outward-mutable: if the name exists in an
enclosing scope, that binding is updated; otherwise a new binding is
created in the enclosing scope. local name is expr forces the binding
into the current scope even when an outer scope has the same name.
This holds at every block form. A name first bound inside an if
body, a loop while body or a for body is visible after the block, in
exactly the same way, whether the block is at module level or inside a
function — local is the only way to confine a binding to the block.
x is 42
x is x + 1
print of x
name is "outer"
define demo as:
local name is "inner"
return name
print of (demo of null)
print of name
43
inner
outer
Compound assignment operators update in place: += -= *= /= %= &= |= ^= <<= >>=. They work on plain names, dict fields, and indexed elements.
total is 10
total += 5
total *= 2
print of total
d is {"hits": 0}
d.hits += 3
print of d.hits
xs is [1, 2, 3]
xs[1] += 10
print of xs
30
3
[1, 12, 3]
All numbers are 64-bit floats. Integer-valued numbers print without a
decimal point. Division is true division. % is modulo. There is no
exponent operator; use pow of [base, exp].
Numeric literals accept the usual decimal forms plus a leading or
trailing dot and scientific notation: .5, 1., 1e5, 1E5 are all
single numbers. Hexadecimal integer literals are lexed explicitly:
0x10/0X10 is 16, digits 0-9a-fA-F, and the literal ends at the
first non-hex-digit character. Hex-float forms (0x1p4, 0xA.8) are
not numbers. A malformed form like 1.2.3 is a parse error.
print of (7 + 3)
print of (7 - 3)
print of (7 * 3)
print of (7 / 2)
print of (7 % 3)
print of (1 / 3)
print of (pow of [2, 10])
print of (abs of -5)
10
4
21
3.5
1
0.3333333333333333
1024
5
Division by zero is a runtime error, not a silent result: it has no
defined value, so — like an out-of-range index — it raises rather than
inventing one. Uncaught it halts with exit 1; caught, the bound error's
kind is "value". (Modulo by zero raises the same way.)
try:
x is 10 / 0
catch e:
print of e.kind
print of "still running"
value
still running
There is one number kind — the IEEE-754 f64 — and by design there is no bigint or decimal type. This is a chosen position, not an oversight; the consequences are contracts you can rely on:
-
Integer exactness ends at 2^53. Whole numbers up to
9007199254740992are exact; past that the gaps between representable values exceed 1, so+ 1can be invisible. Keep integer identifiers, counters, and money-in-cents below 2^53, or use the bitwise seam below for wider exact integer work. -
Finite by construction — no
NaN, noInfinityever reach your program. Overflow saturates at±1e308instead of becomingInfinity. Every defined operation returns a usable finite number; an undefined one — division or modulo by zero, and (in strict mode, the default)sqrtof a negative or aNaNresult — raises avalueerror rather than inventing a result. With strict mode turned off aNaNresult collapses to0instead (sqrt of -1is0). -
Strict mode is the default;
EIGS_STRICT=0is the per-run opt-out (#1361). With the environment variable unset, empty, or set to anything other than0, an out-of-domain operation —sqrtof a negative,logof≤0,asin/acosoutside[-1, 1]— raises a catchablevalueerror instead of substituting a finite stand-in. SetEIGS_STRICT=0for a run and the substitutions come back (a kernel or grader that wants the finite stand-in). The same switch governs argument-type guards: builtins that would answer a wrong-typed argument with a stand-in —cos of "hello"as0,str_upper of 42as"", a type mistake read as a plausible value — raise a catchabletype_mismatcherror naming the builtin, across the whole builtin surface (builtins.c, the host builtins, the tensor ops, the embedded store, and — since #1007 — the graphics/audio extension, where the stand-in is usuallynullrather than0/"":gfx_rect of [x, y, w, h, "255", 0, 0]drew a BLACK rectangle where red was asked for, in silence). A guard covers the argument's container as well as its elements — a short argument list, or a scalar where a list belonged, is a caller mistake and raises. That half is the one an element-typed probe cannot see, and in the extension it was the difference between "drew nothing" and a silent success:audio_stream_open of [48000]opened the device at the 44100/1 defaults and answered a real device id, so the caller that asked for 48000 was told it got 48000.try: print of (abs of "x") catch e: print of f"{e.kind}: {e.message}" try: print of (sqrt of -1) catch e: print of f"{e.kind}: {e.message}"type_mismatch: abs: expected a number value: sqrt: argument out of domain (negative)Under
EIGS_STRICT=0the same program prints0twice. A0,""ornullthat is a genuine answer is untouched in both modes:try_parseof invalid syntax still returns0,task_aliveof an unknown id still returns0,char_atpast the end is still"", andnumstill coerces (num of ([1, 2])is0— that is its documented contract, not a guard). The distinction is not derivable from the code:task_alivehas tworeturn make_num(0)lines four apart, one a type guard and one the documented answer.tools/strict_differential.shchecks the distinction that can be executed: a guard probe must raise in strict mode, the default (with the variable unset or set to1), and a pinned documented answer must not. Fixed- or optional-shape builtin argument lists reject surplus outer elements in strict mode with a catchabletype_mismatcherror naming the builtin and maximum width. The check precedes the call's mutation, I/O and tape effects.EIGS_STRICT=0keeps each builtin's legacy result, including existing null/error stand-ins. Scalar overloads, lists used as data and genuinely variadic arguments keep their documented meaning.Three further classes are loud in strict mode and unchanged under
EIGS_STRICT=0:- The
NaN→0collapse. UnderEIGS_STRICT=0aNaNstill collapses to0and setsmath_flags.invalid. In strict mode every reachableNaNsource raises a catchablevalueerror naming the builtin:powof a negative base with a fractional exponent,num of "nan",f64_from_bytesof aNaNbit pattern,matmulwhen its accumulation reachesinf - inf,tensor_loadof a file carryingNaNbytes — and the elementwisedivideby zero, which answers0underEIGS_STRICT=0where the/operator raises. The arithmetic operators themselves cannot reach aNaNfrom finite operands (0 / 0andx % 0raise first, and no operand can hold an infinity), so any other source hits a backstop that raises asarithmetic. The JIT bails to the interpreter on a non-finite result, so both tiers raise from the same guard. Buffer storage may retain a raw non-finite kernel result internally, but every scalar read uses the same rule: infinity saturates at ±1e308, while underEIGS_STRICT=0aNaNbecomes0and setsmath_flags.invalid(strict mode raises). - JSON parse failure in
json_path. UnderEIGS_STRICT=0a malformed document is walked leniently and a parse failure answers the same""an absent key does. Under strictjson_pathappliesjson_decode's acceptance test and raises a catchablevalueerror naming the position; JSONfalse,nulland an absent key are answers and stay quiet. - The sentinel and falsy families.
index_of/list_index_of/ord(-1),file_exists/is_dir/is_file/read_text/read_bytes/ls/mkdir/env_get(0/""), and the wrong-type launderers the sweep found (split,scan_ints,buffer,channel_closed,f64_to_bytes,json_build,sort,random_int,random_hex,token_name, sentinel for a valid-but-absent input —index_ofmiss-1,file_existsof a missing path0— is unchanged in both modes. Overflow saturation is the same in both modes. (Division and modulo by zero raise in both modes — no defined value.)zlib_*forms) likewise reject a nonnumeric list element with a builtin-namedtype_mismatcherror by default;EIGS_STRICT=0retains the numeric-zero substitution.str_from_bytesends at a numeric byte that converts to NUL, so elements after that terminator are not inspected.
- The
-
Integer bitwise ops act on int64, exact past 2^32.
&|^~<<>>and theirbit_*builtin forms interpret operands as 64-bit integers, so1 << 40is exact where an f64 mantissa alone would not help. This is the seam for checksums, hashing, and byte protocols (see docs/BUILTINS.md).
print of (9007199254740992 + 1) # +1 is invisible past 2^53
print of (9007199254740993 == 9007199254740992)
print of (1e308 * 10) # saturates, never Infinity
print of (bit_and of [0xFFFFFFFF, 0xFF]) # int64 bitwise
print of (bit_shl of [1, 40]) # exact past 2^32
9007199254740992
true
1e+308
255
1099511627776
Bigint / decimal value kinds would be a VM representation change rippling through the JIT and the AOT (whose numeric speed rests on everything-being-a-double), so they are deferred until a consumer genuinely forces them — not adopted speculatively. Nothing above says "a number is an f64" any more than it must: the contracts are exactness-below-2^53, finiteness, and the int64 bitwise seam, which a future wider numeric kind could still honor.
Strings are immutable. + concatenates (both operands must be strings
— there is no implicit coercion). len of s gives the length. Strings
support indexing, negative indexing, and half-open slicing s[start:end].
s is "hello"
print of (s + " " + "world")
print of (len of s)
print of s[1]
print of s[-1]
print of s[1:4]
print of s[2:]
print of s[:2]
hello world
5
e
o
ell
llo
he
F-strings interpolate expressions inside {}:
name is "Ada"
year is 1815
print of f"{name} was born in {year}"
print of f"sum = {1 + 2 + 3}"
Ada was born in 1815
sum = 6
Each {…} is converted with the builtin string conversion, the one str
names at startup. Rebinding str, in any scope, changes only your own
str of calls, never an f-string (#1322):
define str(x) as:
return "mine"
print of f"<{5}>"
print of (str of 5)
<5>
mine
Convert explicitly with str of n and num of s. num of accepts
decimal and hex-integer strings (hex converts identically on every
profile and stops at the first non-hex character); a string with no
leading number converts to 0:
print of ("value is " + (str of 42))
print of ((num of "10") + 5)
print of (num of "0xFF")
print of (num of "zebra")
value is 42
15
255
0
A str is a byte string, and that is a deliberate, documented position, not
an accident. len, char_at, [] indexing, and slicing all operate on
bytes, not characters. Source is UTF-8, so a non-ASCII literal is stored as
its UTF-8 bytes and round-trips exactly through concatenation, f-strings, and
printing — you just don't get character indexing for free.
print of (len of "hello") # 5 — all ASCII, 1 byte each
print of (len of "héllo") # 6 — é is 2 UTF-8 bytes
euro is "€"
print of (len of euro) # 3 — one character, three bytes
print of (euro == "€") # true — bytes round-trip exactly
print of ("price: " + euro) # f-strings / concat are byte-wise, multibyte-safe
5
6
3
true
price: €
This is the Lua-shaped choice: it keeps the runtime zero-dependency and the
freestanding profile viable, at the cost of native character semantics. When you
need those — counting codepoints, indexing by character, validating input —
lib/utf8.eigs decodes UTF-8 over the byte string (utf8_len,
utf8_codepoints, utf8_at, utf8_char_at, utf8_validate). Native
UTF-8-by-construction is deliberately not adopted: it would ripple through
the VM, JIT, AOT, and every tool, for a bill this scale doesn't need.
bool is a type of its own with two values, the keywords true and false
(#1637). Comparisons (== != < <= > >=), not, the observer predicates, and
every predicate builtin and lib/ predicate return a bool. print and
str give true/false; json_encode writes JSON's true/false and
json_decode reads them back as bools. Logical operators are the words and,
or, not; and/or return one of their operands (not necessarily a bool),
and not always returns a bool.
Truthiness is unchanged: 0, 0.0, null, "", [], {} and false are
falsy; every other value, including true, is truthy.
print of (3 > 2)
print of (3 < 2)
print of (3 == 3)
print of (3 != 3)
print of (3 >= 3)
print of (1 and 0)
print of (1 or 0)
print of (not 0)
print of (type of true)
true
false
true
false
true
0
1
true
bool
A bool is not a number. The exact rule, in every strict mode
(EIGS_STRICT=0 keeps its soft stand-ins for other wrong types, never for a
bool):
- Arithmetic, ordering (
<,sort), indexing, slice bounds,range, anatline and awhenordinal raise on a bool, and so do==and!=between a bool and a number. - A builtin raises when it is given a bool anywhere it does not take one: as
its argument, at a position of its argument list, or as an element of a
list it reads as numbers (
sum of [1, true],sgd_update's gradient). The positions that DO take a bool are few and reviewed: printing and conversion (print,write,str,type of,json_encode), the observer,assert's condition,write_bytes's append flag, and the element slots of containers and channels (append,set_at,dict_set,fill,list_insert_at,send, ajson_buildvalue). The full list istests/bool_fuzz_anyvalue.txt;tests/test_bool_fuzz.shputstrueandfalseinto every argument slot of every builtin and checks it.
So a check written against the old 1/0 answers fails loudly instead of
quietly flipping: write if pred of x: or (pred of x) == true. The membership builtins list_contains and
list_index_of search with the same comparison and raise the same way, so an
old 1/0 membership test cannot quietly flip from found to not-found. Every
other mixed-type pair is simply unequal
(null == false is false, "true" == true is false). A bool converts to
a number only explicitly, by branching on it; num of b raises.
try:
print of (true + 1)
catch e:
print of e.message
try:
print of ((1 < 2) == 1)
catch e:
print of e.message
xs is [10, 20, 30]
try:
print of xs[(1 < 2):]
catch e:
print of e.message
try:
print of (sum of [1, 2 > 1])
catch e:
print of e.message
print of (null == false)
n is 0
if 2 > 1:
n is 1
print of n
cannot apply '+' to bool and num
cannot compare bool and num with '=='
slice bound must be an integer or null, got bool
sum: argument 2 is a bool, which sum does not take there
false
1
Equality on lists and dicts is structural (deep):
print of ([1, [2, 3]] == [1, [2, 3]])
print of ({"a": 1} == {"a": 1})
print of ({"a": 1} == {"a": 2})
true
true
false
A deep comparison walks the pairs in order (lists by index, dicts in the
left operand's key order) and stops at the first unequal pair. A bool/number
pair raises when the walk reaches it, so whether [x, true] == [y, 1] raises
depends on the pairs before it: an earlier unequal pair answers false
first.
print of ([1, true] == [2, 1])
try:
print of ([2, true] == [2, 1])
catch e:
print of e.message
false
cannot compare bool and num with '=='
& | ^ << >> ~ operate on the integer part of nums as 64-bit
two's-complement values. Note ^ is XOR, not exponentiation. The
bit_and/bit_or/bit_xor/bit_not/bit_shl/bit_shr builtins
are the SAME operation in call form — one semantics, two spellings —
so the full unsigned-32-bit range (device registers, CRC polynomials)
works identically through either:
print of (12 & 10)
print of (12 | 10)
print of (12 ^ 10)
print of (1 << 4)
print of (16 >> 2)
print of (~0 & 255)
print of (0xEDB88320 & 0xFFFFFFFF)
print of (bit_and of [0xEDB88320, 0xFFFFFFFF])
print of (bit_shl of [1, 32])
8
14
6
16
4
255
3988292384
3988292384
4294967296
if / elif / else, each introducing an indented block. The
condition is any expression, tested for truthiness.
x is 15
if x > 20:
print of "big"
elif x > 10:
print of "medium"
else:
print of "small"
medium
loop while cond: repeats while the condition is truthy. for v in seq: iterates a list, buffer, or range of n (0 to n-1). The iteration
length is fixed at loop entry: mutating seq inside the body is
well-defined — the loop visits the indices that existed when it started,
reading each element live, so appending does not extend the loop and
removing stops it early (rather than looping forever or reading past the
end). break and continue behave conventionally and do not escape
function-call boundaries. A break or continue with no enclosing loop — including
inside a function body that has no loop of its own — is a compile
error ('break' outside a loop), not a silent no-op.
i is 0
loop while i < 3:
print of i
i is i + 1
for v in [10, 20, 30]:
print of v
for k in range of 5:
if k == 1:
continue
if k == 3:
break
print of k
0
1
2
10
20
30
0
2
Lists are mutable, heterogeneous, zero-indexed. They support negative
indexing, half-open slicing with optional bounds, append, len,
element assignment, comprehension, and destructuring.
xs is [10, 20, 30, 40]
print of xs[0]
print of xs[-1]
print of xs[1:3]
print of xs[2:]
xs[1] is 99
print of xs
append of [xs, 50]
print of (len of xs)
10
40
[20, 30]
[30, 40]
[10, 99, 30, 40]
5
List comprehensions support an optional filter:
xs is [1, 2, 3, 4, 5]
print of [v * v for v in xs]
print of [v for v in xs if v % 2 == 0]
[1, 4, 9, 16, 25]
[2, 4]
Destructuring assignment unpacks a list into names:
[a, b, c] is [1, 2, 3]
print of (a + b + c)
6
Out-of-range indexing is a runtime error (catchable with try; the
caught value is a {kind, message, line} dict — see
Error handling):
xs is [1, 2]
try:
v is xs[10]
catch e:
print of e.kind
print of e.message
index_range
index 10 out of range (list length 2)
Dicts map string keys to values. Access fields with dot syntax or
d["key"]; assign the same way (assignment creates the key if absent).
keys of d lists the keys; len of d counts entries.
d is {"name": "Ada", "year": 1815}
print of d.name
print of d["year"]
d.field is "computing"
d["honor"] is "first programmer"
print of (len of d)
print of (keys of d)
Ada
1815
4
["name", "year", "field", "honor"]
Nested structures compose naturally:
app is {"config": {"debug": 0}, "users": [{"id": 1}, {"id": 2}]}
app.config.debug is 1
print of app.config.debug
print of app.users[1].id
1
2
Key names are not restricted by the keyword table: after . nothing but
a field name can appear, so any word — including keywords like loop,
in, or when (common in json_decode output) — works as a dot key,
read or write, at any chain depth.
ev is {"when": 3, "loop": 1}
ev.loop is ev.loop + 1
print of ev.when
print of ev.loop
3
2
define name(params) as: introduces a function. return exits with a
value; falling off the end returns null. Parameters are separated by
commas, in define and in lambdas alike: define f(a b) and (a b) => e
are parse errors (a trailing comma, define f(a, b,), is accepted).
Calling conventions:
f of x— one argument.f of [a, b, c]— a bare literal list afterofis always an argument list, at every element count:f of []is zero arguments,f of [x]is one argument (x itself, not a 1-element list),f of [a, b]is two.f of (x)— parenthesised single argument. This also works for literal lists:f of ([a, b])binds the whole list[a, b]as the single argument — parentheses always mean "one argument", so only a bare literal list is ever an argument list.f of null— call with no meaningful argument.
define add(a, b) as:
return a + b
define shout(msg) as:
return msg + "!"
print of (add of [3, 4])
print of (shout of "hey")
7
hey!
One rule, one sentence: brackets after of are an argument list;
parentheses are one argument. So f of [x] and f of x are the same
one-argument call, and f of ([x]) passes a literal 1-element list
whole. (Before #405, f of [x] bound the whole list [x] to the
first parameter — lint W017 flags the historically ambiguous
1-element form and names both unambiguous spellings.)
Arity-1 carve-out. "One rule" describes the call site, not what a
1-parameter, non-defaulted callee does with the list it receives. Such
a callee has only one slot, so a 2+-element argument list doesn't
distribute into it — the whole list re-collects and binds to that one
parameter instead: one of [3, 4] (with define one(a)) binds
a = [3, 4], not a = 3. This is what keeps len of [1, 2] returning
2 and print of [1, 2] printing the list — removing the carve-out
would break every 1-parameter function that takes a list.
define one(a) as:
return a
print of (type of (one of [3, 4]))
list
define first(a, b) as:
return a
print of (first of [10, 20])
print of (type of (first of [10]))
print of (first of ([10, 20]))
10
num
[10, 20]
Over-arity raises. The spread is exact: passing more arguments
than the callee's parameter count is a runtime error, not a silent
truncation. With define two(a, b), two of [1, 2, 99] raises a
catchable value-kind error naming both counts — at the call site, in
the interpreter and the JIT alike, and across module boundaries. (Lint
W022 still catches the same-file case earlier, at --lint time.) The
zero/one-parameter callees above are exempt by design. Under-arity is
unchanged and deliberately silent for now: missing parameters bind
null (or fire their default), no error.
define two(a, b) as:
return a - b
try:
print of (two of [1, 2, 99])
catch e:
print of e.kind
print of e.message
value
call passes 3 arguments but the callee takes 2
This holds wherever a user function is entered, not only where it is
written as a call. A function reached as a callback — sort_by's key
function, or the entry point of spawn / task_spawn — raises the same
value-kind error with the same message, so a callback and a direct call
are indistinguishable to the program. For spawn and task_spawn the
error is raised at the spawn site, before the thread or task starts: the
mismatch is known in advance, and an error raised inside a worker has no
route back to the code that made it.
The arity-1 carve-out travels with it. A 1-parameter callee re-collects
the whole argument list at every entry point, so spawn of [one, 5, 6]
binds a = [5, 6] exactly as one of [5, 6] does — it does not bind 5
and discard 6. Under-arity null-fills on all of these paths.
For sort_by's key function specifically, a list element is the
argument list (which is what lets a 2-parameter key destructure a record,
and what makes over-arity on a 3-wide element an error), while a
non-list element is a single argument: a 2-parameter key over [3, 1, 2]
receives a = 3, b = null, the same binding two of (3) produces.
Default parameter values fire on all of these paths too: d of 1 on
define d(a, b is 3) gives [1, 3], and so do spawn of [d, 1],
task_spawn of [d, 1], and a sort_by key reached with one element. An
explicitly supplied argument always wins over a default, on every path.
A key function that raises propagates its own error; sort_by does not
report "key function must return a number" on top of it.
define two(a, b) as:
return a - b
try:
print of (sort_by of [[[3, 1, 2]], two])
catch e:
print of e.kind
print of e.message
value
call passes 3 arguments but the callee takes 2
Syntactic limits. A function or lambda takes at most 16
parameters, a match at most 64 cases, and a list literal at most
1024 elements. Exceeding any of these is a parse error that names
the limit (function exceeds 16 parameters, match exceeds 64 cases,
list literal exceeds 1024 elements) — generated code that outgrows a
cap fails loudly at the cap, never with a stray-token cascade.
Default parameter values use is in the parameter list; defaults fire
for every unsupplied slot:
define scaled(x, factor is 2) as:
return x * factor
print of (scaled of (5))
print of (scaled of [5, 10])
10
50
A define with no parameter list gets one implicit parameter named
n:
define double as:
return n * 2
print of (double of 21)
42
Because n is a real parameter, it shadows any enclosing n — exactly
as a named parameter shadows an outer binding of the same name. Assigning
n is expr inside such a function updates the parameter, not an
outer n; the update-outer scope rule (above) cannot reach a name that
is already bound as a parameter. Give the function an explicit parameter
list when you need n to follow the update-outer rule.
define bump as:
n is 99
return n
n is 5
print of (bump of 7)
print of n
99
5
Recursion works as expected, including the bracketed recursive call
fib of [m - 1] (one argument — see the call rule above):
define fib(m) as:
if m < 2:
return m
return (fib of [m - 1]) + (fib of [m - 2])
print of (fib of 10)
55
Argument passing is by reference for mutable values: a function that mutates a list or dict parameter mutates the caller's value.
define push_two(items) as:
append of [items, 2]
xs is [1]
push_two of xs
print of xs
[1, 2]
Functions capture their defining environment by reference: inner
functions can read and write outer variables, and the captured state
survives after the outer function returns. Lambda syntax is
(params) => expr. A zero-parameter lambda () => expr mirrors the
classic no-parameter define style: it receives the implicit
parameter n (so h is () => n * 2 then h of 21 is 42).
define make_counter as:
count is 0
define step as:
count is count + 1
return count
return step
c is make_counter of null
print of (c of null)
print of (c of null)
add5 is (x) => x + 5
print of (add5 of 1)
apply is (f, v) => f of v
print of (apply of [add5, 10])
1
2
6
15
sort_by is a builtin; map and filter come from the standard
library (lib/list.eigs):
load_file of "lib/list.eigs"
xs is [3, 1, 2]
print of (map of [xs, (v) => v * 10])
print of (filter of [xs, (v) => v > 1])
print of (sort_by of [xs, (v) => v])
[30, 10, 20]
[3, 2]
[1, 2, 3]
value |> f is f of value; pipes chain left to right.
double is (x) => x * 2
inc is (x) => x + 1
print of (5 |> double |> inc)
print of (-3 |> abs)
11
3
match expr: with case arms. Cases compare against literals or
expressions; _ is the wildcard. Without a matching arm and no
wildcard, no arm runs.
code is 404
match code:
case 200:
print of "OK"
case 404:
print of "Not Found"
case _:
print of "other"
target is 9
probe is 9
match probe:
case target:
print of "expressions match too"
Not Found
expressions match too
vm_run_bytecode raises a catchable value error naming a rejected chunk descriptor; a valid program may still return null. sandbox_run reports descriptor rejection in its structured {ok: false, error: ...} result. Its optional fourth limit, max_work, bounds cumulative bytecode instructions across function calls and callback re-entry (default 10,000,000), independently of loop and allocation limits. This meters VM work, not elapsed time: a blocking native callback must return before the sandbox can stop.
Sandbox execution does not update shared temporal history: assignment values, names, counts and observer snapshots stay outside that history even when recording is armed. Ordinary tape assignment records still emit. Host and trusted descriptor history recording resumes normally outside the sandbox; sandbox temporal reads remain refused.
try: / catch name: captures runtime errors. A built-in runtime
error binds a small dict {kind, message, line}: kind is drawn from
a closed vocabulary (below), message is the error text without the
Error line N: frame, line is the 1-based source line. throw of value raises a user error and the catch variable binds the thrown
value itself, unchanged — a thrown string stays a string. An uncaught
runtime error stops the program with a nonzero exit.
line (and the Error line N: header) is the 1-based physical line of
the sub-expression that faulted, even when its statement spans several lines
(#1381). The division below sits on the statement's second line:
total is 0
try:
total is [1,
2 / 0]
catch e:
print of e.line
4
This deliberately differs from temporal filing. There, the same statement's assignment is recorded under its first line (see "Temporal interrogatives"): an error points at where the fault is, and history points at where the statement starts.
A call's own lines never become the caller's: once zero returns, the /
that faults is on the caller's line 5, not zero's line 3 (#1424):
define zero(x) as:
y is x - x
return y
try:
z is 1 / (zero of 4)
catch e:
print of e.line
5
try:
throw of "custom failure"
catch e:
print of ("caught: " + e)
try:
x is undefined_name
catch e:
print of e.kind
print of e.message
print of e.line
print of "execution continues"
caught: custom failure
undefined_name
undefined variable 'undefined_name'
7
execution continues
The kind set is closed — the same design instinct as the closed trajectory vocabulary. Every built-in runtime error carries exactly one of:
| kind | raised by |
|---|---|
undefined_name |
reading a name with no binding |
type_mismatch |
an operation or builtin argument of the wrong type |
value |
right type, unacceptable value (fractional index, chr of 0) |
index_range |
index or slice outside the target's bounds |
parse |
runtime-surfaced parse/compile failure (eval, import, load_file) |
io |
the outside world failed: files, stores, sockets, threads |
limit |
an engine resource cap: stack overflow, size caps |
sandbox |
sandbox policy denial or budget exhaustion |
interrupt |
host-requested abort |
assert |
assert builtin failure |
deadlock |
every cooperative task is blocked and none is runnable (#408) |
internal |
a VM invariant broke (report it) |
Discriminate on kind, not on message text — messages are wording,
kinds are contract:
xs is [1, 2, 3]
define get(i) as:
try:
return xs[i]
catch e:
if e.kind == "index_range":
return null
throw of e
print of (get of 1)
print of (get of 99)
2
null
throw preserves the thrown value: throw a dict (or list) and the
catch variable binds it unchanged, so errors can carry data and be
matched on fields. Thrown strings bind as strings.
define validate(age) as:
if age < 0:
throw of {"kind": "validation", "field": "age", "got": age}
return age
try:
v is validate of (0 - 5)
catch e:
print of (type of e)
print of e.kind
print of e.got
dict
validation
-5
try blocks nest up to 8 deep within one function body (each
function gets its own handler stack, so nesting across a call is not
counted). Going deeper is a compile error rather than a silent
mis-dispatch. Leaving a try by break, continue, or return
unregisters its handler, exactly as reaching the end of the block does.
An uncaught error prints the error, a one-line source excerpt with a
^ caret under the offending column, and a stack trace — every frame
between the failure and the top level, innermost first — then exits
with code 1:
# uncaught: stderr shows
# Error line 6: index 99 out of range (list length 2)
# 6 | v is items[99]
# | ^
# at inner (line 6)
# at middle (line 8)
# at <module> (line 9)
import name loads a module into a namespace: it executes
name.eigs resolved relative to the script (the project) or, failing
that, lib/name.eigs (the standard library) — and binds the module's
top-level definitions as a dict named name. Nothing leaks into the
global scope; names starting with _ stay private to the module.
Resolution is project-first (#821): a name.eigs beside the
importing file wins over a stdlib module of the same name. The stdlib
namespace grows over release to release, so the other order would let a
new stdlib module silently capture an existing project's import. When a
name matches both, the runtime prints a one-line warning to stderr
(once per name per process) naming the file used and the file shadowed
— rename the project file if the stdlib module is the one you want.
The project arm means a file you wrote. An installed stdlib
(<prefix>/lib/eigenscript/, what make install writes) answers the bare
name.eigs shape as readily as lib/name.eigs, but it is the stdlib arm
either way: it never counts as a project file, so it neither warns nor
displaces the stdlib shipped alongside the running binary or extracted
from a bundle (#904).
import math
print of (math.clamp of [15, 0, 10])
print of (type of math)
print of (abs of -10)
10
dict
10
A user module is just a file next to your script:
write_text of ["spec_shapes.eigs", "PI is 3.14159\ndefine area(r) as:\n return PI * r * r\n"]
import spec_shapes
print of spec_shapes.PI
print of (spec_shapes.area of 2)
rm of "spec_shapes.eigs"
3.14159
12.56636
(In a project, the idiom is simply import shapes with shapes.eigs
sitting next to app.eigs.)
import and load_file use one resolution chain. import name first
requests name.eigs, then lib/name.eigs; a project/stdlib collision warns
and uses the project file. For each request, the order is:
- An absolute path is used as-is.
- Relative to the directory of the file containing the call, with
symlinks and
..canonicalized. This is the loaded file's directory for nested loads, and remains the defining file's directory inside a function, includingevalin that function while its caller is running throughimport,load_file, or an embedding host'seigs_eval_filecall. The entry file's compile directory does not override a helper's runtimeeval. - The
eigs_moduleswalk described below. - Relative to the project root: the nearest ancestor of that containing
directory with an
eigs.json, including the containing directory itself. If none exists, this step is skipped. - The existing stdlib locations, in order:
<exe>/../<path>,<exe>/../lib/eigenscript/<path>, the latter again with a leadinglib/stripped, then$HOME/.local/lib/eigenscript/<path>and itslib/-stripped form. Here<exe>is the executable's directory.
There is no process cwd search step, and no containing-directory-parent
fallback. The REPL (including piped input) and the embed API without a file
path use their working directory as the containing directory; this is the
only way the working directory enters resolution. Files using project-root
paths from subdirectories need an eigs.json at their root. Failed resolution
raises an io error naming the containing directory, project root (or
no eigs.json above <dir>), and stdlib roots tried.
Project-local dependencies live under eigs_modules/<name>/<name>.eigs
at the project root (any directory containing eigs.json). The
resolver walks upward from the importing file's directory checking
each level for eigs_modules/<name>/<name>.eigs; once it finds
eigs.json it halts (the project root is the top of the walk). This
is the runtime hook for the --pkg tool; a hand-curated
eigs_modules/ works today.
A module's body executes once per program. Repeated import names
(directly, or transitively through a diamond like a → c, b → c) bind
the same dict and reuse the same module state — top-level side effects
fire on the first import only. The cache is keyed on the canonicalized
absolute path of the resolved file.
write_text of ["spec_cached.eigs", "print of \"side effect\"\nn is 1\n"]
import spec_cached
import spec_cached
print of spec_cached.n
rm of "spec_cached.eigs"
side effect
1
A namespace is a live view, not a snapshot (#1057). name.x reads
the module's current binding x, and name.x is v writes that
binding — the module and its importers see one state, whatever the
value's type:
write_text of ["spec_live.eigs", "hits is 0\ndefine record() as:\n hits is hits + 1\ndefine total() as:\n return hits\n"]
import spec_live
spec_live.record of null
spec_live.record of null
print of spec_live.hits
spec_live.hits is 10
print of (spec_live.total of null)
rm of "spec_live.eigs"
2
10
Before this, the namespace was a shallow copy of the module's
bindings taken at import time, so whether an importer saw live state
depended on the value's TYPE: a dict or list was shared by reference
and tracked, a number or string was frozen and went silently stale, and
a write through the namespace reached only the copy. The failure mode
was a wrong number rather than an error. Values read out of a
namespace are ordinary values — n is name.hits binds the number, not
a live alias.
_-private bindings are not part of the namespace and are not
projected; everything else about a namespace is unchanged — it is still
a dict (type of name is "dict"), still enumerable with keys /
values / len, and its functions are still callable as name.f of x
or extractable as values.
load_file of "path.eigs" is the older, non-namespaced form: it
executes a file directly in the current scope. The standard
library's helper modules (lib/test.eigs's assert_eq, ...) are
conventionally loaded this way.
The same file has the same block and return rules on all three roads (main,
load_file, import). A for binder is loop-scoped and never writes a
same-named outer binding. A for body's plain is updates the nearest existing
binding, including a local in the current or an enclosing loop. Otherwise it
creates a binding in the enclosing scope, like if, loop while, and try.
At an imported module's top level the search stops at the module boundary;
fresh bindings belong to the module and are exported normally, without writing
to the importer.
A top-level return value ends the current file, skipping all later statements:
load_file yields the value to its caller; import finishes its namespace;
the main program discards the value and exits successfully.
There is no function-scope exception (#1105): a binder with no prior binding
inside a function is loop-scoped like any other, so reading it after the loop
raises undefined variable on every road. A pre-existing parameter, local
or module binding is restored after the loop; a post-loop plain assignment to
the name creates a fresh binding.
define probe() as:
for z in [7, 8]:
0
return z
try:
print of (probe of [])
catch e:
print of e.message
x is 5
define over_module() as:
for x in [7, 8]:
0
return x
print of (over_module of [])
undefined variable 'z'
5
Module write boundary. A loaded (or imported) module's functions
can read the loader's globals and call its functions, but they can
never bind a write through to them — a bare name is expr inside a
module function that doesn't refer to a local, a captured name, or the
module's own top-level state creates a fresh local, regardless of what
happens to exist in the loader's scope. The same boundary applies one
level up to an imported module's own top-level statements: a bare
name is expr at an imported file's top level binds in that module's
own scope, never walking through to a same-named binding the importer
happens to already have — counter is 0 at a module's top level can
never rebind an importer's pre-existing counter. load_file is the
one exception, per its older, documented contract above: its top-level
statements still execute directly in the current (caller's) scope, so
a same-named top-level assignment there does bind through.
lib/eigen.eigs snapshots the host values used by its tokenizer, parser, evaluator, import helpers, and fresh meta environments when it loads (#1386). Later host builtin rebinding does not change those dependencies, including entropy's log/divide calls. For load_file, the snapshots use values visible during initialization; loaded code still shares the host scope. String conversion still uses the pristine reserved f-string bridge. Explicit custom environments, debug hooks, and rebinding the interpreter's own helper names remain caller-controlled; captured caller-defined functions retain their own binding behavior.
Builtins in module code (#1388). An imported module's builtin names
resolve against the runtime's own builtin set, never the importer's
bindings: len in module code is the builtin unless the module rebinds
it. A program that rebinds a builtin — define len, a top-level
len is ... or local len — before or after the import changes it for its
own code only, and a module's own define len stays inside that module.
Names that are not builtins still resolve to the importer's globals, as
above. The builtin set is sealed: no is anywhere writes into it — an
assignment that would reach it (from module code, or an eval inside a
module function) creates a binding in the writer's own scope instead.
load_file is not isolated. While an import is running a module's top
level, it runs its file in that module's scope; at every other time,
including inside a module function and inside a host function, it runs
the file's top level in the host program's global scope, so loaded code
sees the host's rebinding of a builtin (the target scope for a module
function is tracked in #1391). Functions an embedder
registers with eigs_register_function belong to the builtin set
(EMBEDDING.md).
write_text of ["spec_iso.eigs", "define count(xs) as:\n return len of xs\n"]
define len(x) as:
return -1
import spec_iso
print of (spec_iso.count of ([1, 2, 3]))
print of (len of [1, 2, 3])
rm of "spec_iso.eigs"
3
-1
Mutable state shared across files can live in a plain top-level binding
— an importer reads and writes it through the live namespace (#1057) —
or in a dict or list whose fields are mutated. Boxing state in a dict
is now a style choice, not a correctness requirement; the standard
library's UI toolkit (lib/ui.eigs's _ui state dict, shared by 17
sub-modules) remains the reference pattern for grouping related state
under one private name.
load_file of "lib/test.eigs" # assert_eq, test_summary, ...
load_file of "mymodule.eigs" # definitions land in *your* scope
Every observed variable can be interrogated. what is x is its value,
who is x its name, when is x the number of times it has been
assigned — every assignment, including those made inside an
unobserved: block, which elides the entropy half of observation —
not assignment, and not the O(1) value-window sample that report,
report_value and the six predicates read on a numeric binding
(#908/#1049); PREDICATES.md lists the readers
that can differ. (where, why, how return the observer's entropy,
entropy-delta, and stability — see OBSERVER.md.)
x is 10
x is 20
x is 30
print of (what is x)
print of (who is x)
print of (when is x)
30
x
3
print of (where is x) # entropy of x's value (a float >= 0)
print of (why is x) # dH: change in entropy at last assignment
print of (how is x) # stability in [0, 1]
Every assignment (outside unobserved) updates an observer that tracks
the value's entropy and its trend. The entropy walk stops at a
reference: a list or dict computes over its own elements, and an element
that is itself a container contributes only its size term log2(count+1)
rather than being entered. This is the rule buffers and text builders
have always followed, so a dict holding a 5-element list measures exactly
as one holding a 5-element buffer. The cost of an observed assignment is
therefore proportional to the value's own size, never to everything it can
reach, and cyclic or shared object graphs are well-defined because they are
never traversed (see OBSERVER.md). Six bare-keyword predicates query
the most recently observed variable: converged, stable,
improving, oscillating, diverging, equilibrium. The canonical
use is a self-terminating loop:
e is 5
loop while not converged:
e is e * 0.5
print of (e < 0.001)
print of converged
true
true
For a numeric binding the predicates classify the value's own
trajectory (#861): the observed signal is the relative step
Δv / max(|v|, |v_prev|, scale) (#1045) — the standard mixed-tolerance
stopping criterion |Δx| ≤ rtol·|x| with the settle deadband as rtol
and dh_zero · scale as the absolute floor (scale is
limit's magnitude and the unit the value is stored in do not matter.
A loop converging to 5, 5000 or 0.005 certifies identically, and a
bank angle reads the same in radians and degrees. Non-numeric bindings (strings,
containers) classify their entropy trajectory as before; the entropy
MEASUREMENT (where is x) is unchanged for everything.
Convergence-halting is opt-in. A loop while is auto-halted on a
quiet observer trajectory (~100 iterations without motion, at any
entropy) only when its condition is observer-based — i.e. references a
predicate, as in loop while not converged. A plain loop whose
condition is an ordinary expression (loop while i < n,
loop while not done) is never halted by the observer; it runs until
its own condition is false. An absolute iteration cap exists only under an
explicitly armed sandbox budget (sandbox_run's max_iter); ordinary
execution never truncates a loop. This keeps loop termination
compositional: a plain loop can't be cut short by what its body — or a
function it calls — happens to assign to the global observer.
The division of labour (#861): converged ends an observer loop when the
value settles at the deadband; stalled ends it when 100 quiet
iterations pass without certification (a runaway pinned at the
saturation ceiling, sub-deadband drift); __loop_exit__ records which
one happened.
report and report_value are reserved observer forms, like the
predicate keywords: neither may be a binding name (including function names,
parameters, local, loop/comprehension variables, destructuring targets,
catch names, or an import module name). They are not first-class values.
Misuse is a compile-time parse error E005, before any statement in that
source unit executes. The rule also applies to REPL input, eval, load_file,
import, the embedding API, and --lint; the CLI accepts source strings with
eigenscript -e '<source>'.
Both forms require an identifier operand, optionally parenthesized:
report of x, report of (x), and report_value of ((x)) query the same
binding history as before. Literals, arithmetic expressions, calls, indexing,
field access, and literal argument lists ([], [x], [x, y]) are rejected
with E005 and “requires a variable name operand”. Assign an expression to a
variable first; a temporary value has no named assignment history. This also
replaces report's old non-identifier fallback (equilibrium, or opaque for
a function) and report_value's undefined-name error. of precedence is
unchanged: report of x + "!" appends to the report of x.
As with other keywords, quoted dict keys and dot fields remain legal:
d.report and d.report_value are data fields, not bindings of reserved names.
match cases compare expressions; they do not introduce binders.
report of x names the most specific band true of the same
trajectory the predicates read (value channel for numerics, entropy
otherwise — #861), resolving oscillating → diverging → improving →
converged → equilibrium → stable. At a full window it agrees with the
bare predicates by construction: it either names a band whose predicate is
true, or — when a full window matches none of them — returns moving. The
bands are not exhaustive, and moving is the honest answer for the gap
(#735); only while the window is still filling may report fall back to an
instantaneous label the predicates don't yet confirm. See
PREDICATES.md.
Entropy is current-state; dH is the assignment trajectory (#711).
where is x — and the entropy every classification surface reads
(report, the predicates, observe's band, trajectory snapshots) —
is recomputed from the binding's current value at ask time, so an
in-place mutation (dict_set, append, an indexed store) is visible:
two containers with identical contents answer identical entropies, no
matter how each got there. why is x (dH) and its windows are a
trajectory of assignments — recorded when the binding is assigned,
deliberately untouched by mutation and never perturbed by a query
(asking never writes anything back):
d is {"k": 1}
dict_set of [d, "k", 999999]
e is {"k": 999999}
print of ((where is d) == (where is e))
print of why is d
true
0
Function values are opaque (#708). A function has no content the
observer can sample — its entropy is a constant — so a binding whose
current value is a function (or builtin) sits outside what the observer
measures. Rather than reporting a confident equilibrium that could
never move, report, report_value, and observe's band answer
opaque, and every predicate is false. This is the same honesty rule
as moving: name the gap instead of picking a plausible band. The
entropy constant is unchanged — containers holding functions measure
exactly as before; only the direct classification of a function-valued
binding names the gap:
define a() as:
return 1
define b(p, q, r) as:
return p + q + r
f is a
f is b
print of report of f
print of (equilibrium of f)
opaque
false
The saturation ceiling is not a rest state (#861). Overflow saturates
at ±1e308 (Numbers, above), which turns an unbounded trajectory into a
fixed point: the dH window fills with zeros and the entropy of 1e308
falls under the low-entropy threshold, so a runaway satisfies every clause
of converged. The window really is quiet — the quiet is an artifact of
the clamp, produced after the evidence of divergence was destroyed. So a
binding sitting at ±1e308 is diverging in both channels and in no rest
band: converged, equilibrium, stable, and improving are all false.
A binding assigned a literal ±1e308 that never overflowed reads
diverging too — the runtime cannot distinguish the two, and this is the
direction that fails loudly. Below the ceiling nothing changes:
z is 2.0
i is 0
loop while i < 20:
z is z * z
i is i + 1
print of report of z
print of (converged of z)
diverging
false
The value channel (report_value of x) is, since #861, the same
classifier the predicate words and report use on numeric bindings —
the two surfaces cannot disagree about one trajectory. Over a window of
relative steps Δv / max(|v|, |v_prev|, scale) — N samples deep, 10
N samples of the observation cadence cannot fold inside the window) —
converged is a full window all
under the settle deadband; stable all under the small-motion band;
equilibrium zero-mean, variance under deadband²; improving monotone
steps contracting geometrically (a summable tail — genuinely closing on
a limit); diverging the #422 raw rule (non-vanishing same-sign steps —
an additive runaway whose relative step vanishes is still unbounded) or
a value at the saturation ceiling; oscillating deadband sign-flips,
non-vanishing alternation (a perpetual oscillation below the deadband is
still an oscillation), or window-scale folding (net travel small against
path length — a sinusoid sampled slower than its half-period).
converged is a stopping criterion, not a proof: vanishing steps do
not imply a limit (the harmonic series' steps vanish; its sum does not
converge), so it means settled at the deadband — the strongest claim a
finite window supports. The deadband is the tolerance knob
spans; the structure rules are deliberately threshold-free.
Trajectories cross call boundaries as snapshots (#421). Observer state
is binding-identity — a value passed to a function arrives with no history —
so trajectory of x captures the binding's observer windows into a plain
dict, and classify of t (value channel; classify of [t, "entropy"] for
the entropy channel) classifies it with the same machinery. classify of
anything that is not a snapshot raises a type_mismatch error:
define judge(t) as:
return classify of t
x is 400000.0
i is 0
loop while i < 40:
x is x + 5000.0
i is i + 1
print of (report_value of x)
print of (judge of (trajectory of x))
diverging
diverging
unobserved: blocks (and loop bodies inside them) skip the
entropy half of observation — use them for hot numeric loops. The
depth is dynamic, so it covers functions called from inside the block; an
observer predicate asked anywhere under one raises, because there is
no trajectory for it to classify (a performance annotation must not
change an answer):
total is 0
unobserved:
i is 0
loop while i < 100000:
total is total + i
i is i + 1
print of total
4999950000
What the block suppresses is the entropy walk, not assignment. The
writes still happen, still land in the history, and are still counted and
addressed like any other: when is x includes them, and each one takes
an ordinal that <kw> is x when <n> can address (#908). The same rule
that makes a predicate raise rather than answer from a dead trajectory
is why the counter does not quietly shrink — a performance annotation
must not change an answer.
For the same reason a scalar assignment inside the block still records
its sample into the value window (#1049): the relative and raw step
enter the 10-deep ring the numeric predicates, report and
report_value read, at O(1) per assignment. So the window is complete,
and the verdicts a numeric binding gives after (or inside) the block are
identical to the ones it gives without it — an elided initialiser no
longer shifts the window-fill boundary, and a mid-stream elided step no
longer merges two steps into one. What is not computed for an elided
assignment is the entropy and everything built on it: where's stored
entropy (the query-time read is unaffected), dH and its window
(why/how, observe's dH pair, a trajectory snapshot's dh/dH),
the tape's observer snapshot, and the bare-predicate alias (a bare
converged keeps reading the last observed binding, so scratch work
inside the block cannot hijack it). Those entropy-channel readers — and
report/the predicates on a non-numeric binding, which route
through the entropy channel — therefore remain sensitive to elision;
PREDICATES.md lists them. (It follows that the
block is not a way to declare a numeric binding without a sample; seed
with null, which is never sampled.)
x is 9.0
unobserved:
x is x * 0.5
x is x * 0.5
print of (report of x)
print of (len of (trajectory of x).rel)
print of (why is x)
moving
2
0
c is 0
c is 1
unobserved:
c is 2
c is 3
print of (when is c)
print of (what is c when 3)
4
2
The two halves side by side. The first program elides two assignments;
when still counts them, why is frozen at the value it had before
the block (the entropy channel never moved), and report answers from
the complete value window:
x is 8.0
x is 4.0
before is (why is x)
unobserved:
x is x * 0.5
x is x * 0.5
print of (when is x)
print of before
print of (why is x)
print of (report of x)
4
0.21866976011171657
0.21866976011171657
moving
The same program without the block. when and report are identical —
that is the #1049 promise, and this pair is what pins it — while why
differs, because the entropy channel is exactly what the block buys back:
x is 8.0
x is 4.0
x is x * 0.5
x is x * 0.5
print of (when is x)
print of (why is x)
print of (report of x)
4
0.08170416594551044
moving
With temporal queries the runtime records assignment history. prev of x is the value x held before its latest assignment. what is x at <line> reads the value x had at a source line. (History recording
turns on automatically when a program contains a temporal query.)
prev of takes a variable name — it looks back through that binding's
history — and binds like any other of (tighter than arithmetic, per the
Application rule above): prev of x + 1 is (prev of x) + 1. A non-name
operand (a literal, an index/dot, or a parenthesised expression) has no
trajectory and is a parse error.
score is 10
score is 25
score is 40
print of (prev of score)
print of score
25
40
print of (what is score at 2) # 25 — line-number qualified history
The line is the physical source line. A newline inside a string, f-string text or an f-string interpolation counts, and an assignment whose value spans several lines (a multi-line list, dict, string or interpolation) is recorded under the statement's first line (#1251, #1381):
x is 5
x is [1,
2]
print of (what is x at 2)
print of (what is x at 1)
[1, 2]
5
The same holds for every binding a statement makes: a destructured name, a
for loop variable (on every iteration), a comprehension variable, a
parameter default, a catch binding, and an import binding are each filed
under the first line of the statement or clause that binds them.
A runtime error inside such a statement reports a different line on purpose: the physical line of the faulting sub-expression (see "Error handling").
at <line> addresses a source line, which is not injective: a line inside
a loop or a repeatedly-called function executes many times, and only the most
recent execution is reachable. when <n> addresses the nth recorded
assignment to that binding instead — 1-based, in execution order — so every
iteration is individually addressable, and the query does not shift meaning
when a line is inserted above it.
x is 0
for i in range of 3:
x is i * 10
print of (what is x when 2)
print of (what is x when 4)
print of (prev of x when 4)
print of (when is x)
0
20
10
4
Every interrogative takes the qualifier: who is x when 2, where is x when 2, and prev of x when n (the value at assignment n - 1).
Retention is bounded — the last 256 assignments per queried binding, so a
long-running loop cannot grow without limit. EIGS_OCC_WINDOW sets a different
window. The two ways a query can come back empty are kept distinct: an ordinal
that has not happened yet answers null, while one that has aged out of the
window raises, because reporting a dropped value as null would be
indistinguishable from one that was never assigned.
spawn of [fn, args...] runs a function on a new thread and returns a
handle; thread_join of handle waits and returns its result. Channels
(channel of null, send, recv, try_recv, recv_timeout)
communicate between threads.
Trace replay matches a spawned worker by its bound parent and that parent's
spawn occurrence, not by the order workers reach a line or nondeterministic
call. Independently created embedding threads supply stable keys before their
first event/take. Missing correspondence raises rather than borrowing another
stream's value. The flat tape IDs, state-association metadata and version 5
compatibility rule are specified in docs/TRACE.md.
Replay never crosses a session header on an ordinary take. The embedding host
explicitly advances at a quiescent boundary; an unread sibling outcome prevents
advance. Replacing memory replay preserves a suspended file's complete context.
Host effects that the tape cannot reconstruct are replay boundaries. In
particular, mktemp raises a catchable filesystem-boundary error before it
creates a file; outside replay it creates a file and returns its fresh path.
Values crossing a channel, thread_join, or cooperative-task boundary are
copied recursively. This includes buffers (payload and shape) and text builders
(bytes and builder metadata). Closures retain their captured environment by
reference, resource handles remain shared, repeated aliases split into separate
copies, and objects below the depth-64 recursion guard remain shared. The
executable kind-by-kind contract is in docs/CONCURRENCY.md.
ch is channel of null
spawn of [(v) => send of [ch, v * 2], 21]
print of (recv of ch)
define work(a, b) as:
return a + b
h is spawn of [work, 4, 5]
print of (thread_join of h)
42
9
A handle is joined exactly once. thread_join claims the handle before
it waits, so a second join — sequential, or from another thread at the same
time — raises a catchable value error instead of answering null; a handle
whose table slot has been recycled by a later spawn raises rather than
joining the new thread; and spawn/channel/task_spawn/store_open raise
a catchable limit error when the 255-slot handle table is full instead of
returning null (#1146). Details and the reasoning: docs/CONCURRENCY.md.
define work(n) as:
return n * 2
h is spawn of [work, 21]
print of (thread_join of h)
try:
print of (thread_join of h)
catch e:
print of f"{e.kind}: {e.message}"
42
value: thread_join: thread handle 1 has already been joined
A worker that dies of an uncaught error prints its trace and the
process exits non-zero (status 1) whether or not anything ever
thread_joins it — the same rule as cooperative tasks below (#493), so a
fire-and-forget thread's failure is never swallowed into a success exit.
This covers a builtin spawned directly (spawn of [recv, 5] raises
"invalid channel" on the worker) as well as a function body. An error
catch-ed inside the worker recovers normally (exit 0). A worker's
exit of N is instead a state-wide, uncatchable stop request: the first
request decides the process status. VM threads observe it at loop back edges
and builtin returns, and main-thread waits in recv, recv_timeout,
thread_join, or usleep are woken so teardown can begin. Once a thread
observes the request, its later script statements do not run (#1149). Native I/O outside these runtime waits is not asynchronously
cancelled: teardown still waits for those workers to return before freeing
state. An interrupted join consumes its handle and defers reaping; it does not
return the target's result. Embedded outer evals have separate stop scopes;
workers retain the scope of their spawning eval (see docs/EMBEDDING.md).
The failure is always a clean exit, never a signal (#1112).
task_spawn creates a cooperative task on the single interpreter
thread (unlike spawn, which uses an OS thread). Tasks are scheduled
round-robin: a task runs until it calls task_yield of null, which
hands control to the next ready task; it resumes where it left off.
Because there is one thread and a fixed policy, the interleaving is
deterministic by construction — the same program prints identically
on every run, with no threads and no clock. Args and results cross the
task boundary deep-copied (share-nothing, like channel sends).
task_join of id blocks until task id finishes and returns its
result (or re-raises its uncaught error as the same {kind, message, line} dict — see Error handling). task_alive of id is 1 until the task finishes.
Tasks are scoped to the OS thread that spawned them: each thread has its
own scheduler and its own ready queue, so task_yield hands control to
the next task on this thread and never disturbs another. Mixing the
two models is therefore well-defined — a spawned worker runs its own
program while the parent interleaves tasks. (Before #739 the suspend
request was process-wide, so one thread's task_yield made every other
thread's next call return null mid-evaluation.)
order is []
define step(tag) as:
order is append of [order, f"{tag}-1"]
task_yield of null
order is append of [order, f"{tag}-2"]
return tag
a is task_spawn of [step, "a"]
b is task_spawn of [step, "b"]
ra is task_join of a
rb is task_join of b
print of order
print of f"{ra}{rb}"
["a-1", "b-1", "a-2", "b-2"]
ab
When the main program returns, any tasks still running are torn down
(the program ends). If every task is blocked with none runnable — for
example two tasks each task_join-ing the other — that is a deadlock
error, reported loudly rather than hanging. The deadlock is delivered
as an ordinary catchable error at the main task's blocked join/recv
site: a try/catch there binds e.kind == "deadlock" and execution
continues after the block. Only if the main task has no handler is the
deadlock terminal (loud message, non-zero exit) — a handler inside a
worker does not catch it, since the deadlock is delivered to main.
A task that dies of an uncaught error prints its stack trace, and if
nothing ever task_joins it the process still exits non-zero — a
fire-and-forget worker's failure is never silently swallowed into a
success exit. task_join-ing the dead task and catch-ing its error
recovers normally (exit 0), exactly as for an inline try/catch. A
task ended deliberately with task_kill is a teardown, not an uncaught
error, and does not by itself fail the process.
Tasks communicate through mailboxes. task_send of [id, value]
appends a deep-copied message to task id's FIFO mailbox (share-
nothing, like channel sends); task_recv of null returns the next
is the non-blocking form.
A task learns its own id with task_self of null — the same
integer space task_spawn returns; the main task is 0. That is what
makes a reply address expressible: a spawner passes task_self of null down as an argument (or a worker sends its own task_self up),
and messages can then flow back to whoever asked — the link pattern
message-based supervision needs.
A task nobody will ever join can be marked fire-and-forget with
task_detach of id (a task may detach itself via task_self). A
detached task releases all its resources the moment it finishes, so a
long-running program can spawn an unbounded stream of short-lived
tasks; an undetached task instead stays joinable until the program
ends. A detached task that dies of an uncaught error still prints its
trace and still makes the process exit non-zero — fire-and-forget
never silently swallows a failure.
worker_id is 0
define worker() as:
a is task_recv of null
b is task_recv of null
return a + b
worker_id is task_spawn of worker
task_send of [worker_id, 10]
task_send of [worker_id, 32]
print of (task_join of worker_id)
42
task_sleep of ticks suspends a task until a virtual clock advances by
wall-clock: it starts at 0 and only ever jumps forward to the earliest
sleeper when nothing else is runnable. So a program that sleeps runs in
zero real time and — like the rest of the task layer — replays identically,
with no dependence on how fast the machine is. Tasks therefore resume in
virtual-time order regardless of the order they were spawned, and the clock
lands on the last wake time.
log is []
define nap(tag, ticks) as:
task_sleep of ticks
log is append of [log, tag]
return tag
a is task_spawn of [nap, "a", 30]
b is task_spawn of [nap, "b", 10]
c is task_spawn of [nap, "c", 20]
task_join of a
task_join of b
task_join of c
print of log
["b", "c", "a"]
By default the ready tasks run round-robin (FIFO). task_sched_seed of n
switches the scheduler to pick the next ready task from a seeded,
platform-independent PRNG. The schedule stays fully deterministic — the
same seed produces the same interleaving on every run and replays
byte-identically, recording no tape nondeterminism — but a different seed
explores a different ordering. This is the lever a deterministic simulation
tester uses to search the space of interleavings while keeping every run
reproducible. Without a seed the scheduler is unchanged, so existing programs
behave exactly as before.
task_sched_seed of 42
order is []
define step(tag) as:
order is append of [order, tag]
task_yield of null
order is append of [order, tag]
return tag
a is task_spawn of [step, "a"]
b is task_spawn of [step, "b"]
c is task_spawn of [step, "c"]
task_join of a
task_join of b
task_join of c
print of order
["b", "c", "a", "b", "c", "a"]
The same program with no seed prints the round-robin order
["a", "b", "c", "a", "b", "c"]; a different seed prints a different — but
equally reproducible — permutation.
buffer of count allocates a flat array of count nums (all 0).
Buffers index, slice, and iterate like lists but hold only numbers —
they are the fast path for numeric work and the JIT. Storing anything but
a number into an element is a runtime error (cannot store str in a buffer), the same rule every other numeric context follows; it was the
last one that silently tolerated a non-number (#1061).
b is buffer of 4
b[0] is 1.5
b[3] is 4
print of b[0]
print of (len of b)
s is 0
for v in b:
s is s + v
print of s
1.5
4
5.5
zeros of n is the same flat container under the name numeric code reaches
for first: it returns a buffer of n zeros, not a list of n boxed
numbers. zeros of [rows, cols] is unchanged — that spelling still builds the
nested-list tensor, because 2-D list code indexes rows. zeros_like of t
mirrors its argument's container: a buffer in gives a buffer out, a list in
gives a list out.
z is zeros of 4
print of (type of z)
print of z
z[1] is 2.5
print of (sum of z)
m is zeros of [2, 3]
print of (type of m)
print of m
print of (type of (zeros_like of z))
buffer
<buffer:4>
2.5
list
[[0, 0, 0], [0, 0, 0]]
buffer
This is a breaking change (#1093). Before it, zeros of n answered a list:
type of (zeros of 4) was list and print of showed [0, 0, 0, 0]. Code
that genuinely needs the list form spells it out — [0 for i in range of n] —
and code that only indexes, assigns, iterates, reduces or passes the vector to
a tensor builtin needs no change, because a buffer supports all of those.
sum of a returns the total of a buffer's (or tensor's) elements, and
norm of a returns the L2 (Euclidean) norm, sqrt(sum_i a[i]*a[i]).
v is buffer of 4
v[0] is 1
v[1] is 2
v[2] is 3
v[3] is 4
print of (sum of v)
print of (norm of v)
10
5.477225575051661
dot of [a, b] returns the sum over i of a[i] * b[i] for two numeric
buffers (the length is the shorter of the two).
a is buffer of 4
b is buffer of 4
a[0] is 1
a[1] is 2
a[2] is 3
a[3] is 4
b[0] is 0.5
b[1] is 1.5
b[2] is 2.5
b[3] is 3.5
print of (dot of [a, b])
25
For all three reductions (sum, norm, dot) the summation order
(association) is unspecified: callers must not depend on the exact low-bit
rounding of the result. This is a deliberate opt-in — it licenses an
optimizing backend (such as the AOT native compiler) to reassociate the sum
across SIMD lanes, which a strict left-to-right loop while accumulation
forbids. The no-NaN/no-Inf invariant still holds. Write the explicit loop when
you need a fixed reduction order.
A buffer can carry a 2-D shape, making it a flat-backed matrix. buffer of [rows, cols] allocates a rows*cols buffer with that shape, and reshape of [buf, rows, cols] shapes an existing flat buffer (the element count must
match). shape of buf returns [rows, cols] for a shaped buffer, or [count]
when unshaped. Indexing stays flat (buf[r*cols + c]).
Every element crossing the buffer/scalar boundary uses the numeric guard:
infinity saturates at ±1e308, while a NaN raises in strict mode or becomes
0 and sets math_flags.invalid under EIGS_STRICT=0. Structural buffer
equality and scalar reductions normalize each input before comparison or
arithmetic. A strict read that raises inside a function retains that
function's source location and call frame. Buffer-to-list tensor materialization
and numeric byte/sample/device conversions apply the same read rule, stopping
at the first raised read. Numeric bytes truncate and wrap modulo 256 after
normalization; audio samples then clamp to their documented sample range.
Raw buffer copies, typed serialization and internal buffer-only kernel work
areas retain their stored representation.
The tensor builtins operate directly on the flat data — no per-call conversion.
matmul of [a, b] multiplies two shaped buffers (a 1-D buffer is a row vector,
so matmul of [vec, mat] returns a 1-D result); matmul_at / matmul_bt
multiply with the first / second operand transposed (aᵀ·b, a·bᵀ) without
materialising the transpose. All three matrix products round each
multiplication to binary64 before adding it to the accumulator, in ascending
inner-index order; multiplication and addition are not fused.
add, subtract, multiply, divide are
elementwise, with a [cols] buffer broadcast over the rows of a
[rows × cols] buffer and a number broadcast over every element; relu,
leaky_relu, softmax, log_softmax, sum, mean, norm, gather
compute on the shape, and scatter_add is gather's in-place dual. The
result is identical to the nested-list tensor form, so storing weights as
shaped buffers is purely a performance choice — and it is the substrate the
reverse-mode autograd tape in lib/autograd.eigs runs on.
gather of [matrix, indices] selects matrix[i][indices[i]] for each row.
An index outside the row raises index_range — in every form, on a list
tensor and on a shaped buffer alike. There is no element at that index, so an
answer of 0.0 would be a stand-in the caller cannot tell from a real 0
(a Q-value, a log-probability); scatter_add, which is gather's gradient and
takes the same index, raises on it too.
q is [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]
print of (gather of [q, [2, 0]])
try:
print of (gather of [q, [2, 3]])
catch e:
print of e["kind"]
print of e["message"]
[3, 4]
index_range
gather: column index 3 out of range for row 1 (cols 3)
Changed in this release (#973/#1093): the list form used to answer 0.0 for
an out-of-range index and the buffer form was added folding the same way. A
tensor that is not a matrix in the per-row form still answers 0.0 for that
row — that is the shape reading, not the index one — and a wrong-typed
argument raises in strict mode, the default, and answers 0.0 only under
EIGS_STRICT=0.
Every tensor builtin that accepts a flat numeric list accepts a buffer in the
same position, and returns a buffer when every tensor operand was a buffer:
add/subtract/multiply/divide/pow, sqrt/exp/log/negative,
matmul, softmax/log_softmax/relu/leaky_relu, gather, shape,
zeros_like, tensor_save, and the numerical_grad/sgd_update family
(including the _rows/_cols variants, whose index vector may also be a
buffer). Mixing a buffer with a list yields a list. The reductions
(sum, mean, norm) return a number from either container. A 1-D buffer
reads as a 1-D tensor and a shaped buffer as its rows x cols 2-D tensor, so
the numbers agree element for element with the equivalent list.
Binary tensor files use a shared 10,000,000-element cap. tensor_load and
tensor_save raise catchable limit errors above it; stream_open requires
an integral count from 1 through that cap, and build_corpus includes file
separators in its capped token count. These limits also raise under
EIGS_STRICT=0; see BUILTINS.md for the I/O contracts.
l is [1.0, 4.0, 9.0]
b is buf_from_list of l
print of (sqrt of l)
print of ((sqrt of b)[2])
print of (type of (sqrt of b))
print of (type of (add of [b, l]))
print of (mean of b)
[1, 2, 3]
3
buffer
list
4.666666666666667
w is buffer of [2, 2]
w[0] is 1
w[1] is 2
w[2] is 3
w[3] is 4
x is buffer of 2
x[0] is 1
x[1] is 1
y is matmul of [x, w]
print of y[0]
print of y[1]
print of (shape of w)
4
6
[2, 2]
The facts that govern every program, in one place:
- Execution: statements run top to bottom; the file is the program. Source is compiled to bytecode and run on a stack VM; hot code is JIT-compiled on x86-64. None of this changes semantics.
- Application:
ofis function application and binds tighter than arithmetic:f of x + 1is(f of x) + 1. - Argument spreading: a literal list argument with 2+ elements
spreads into parameters; a 1-element literal list does not; a
list passed via a variable never spreads. Exception: a 1-parameter,
non-defaulted callee has only one parameter to spread into, so a
2+-element list doesn't spread there either — it re-collects whole
and binds to that one parameter (
one of [3, 4]bindsa = [3, 4]fordefine one(a)). - Scope:
isupdates the nearest enclosing binding or creates a local;localforces the current scope. Functions see and may mutate their defining environment (closure capture by reference). - Values: numbers are 64-bit floats; strings immutable; lists,
dicts, and buffers mutable and passed by reference;
==is structural. - Truthiness:
0,null,"",[],{}are falsy; everything else is truthy. Comparisons yield1/0. - Errors: runtime errors unwind to the nearest
try; uncaught they halt the program with exit code 1. Division and modulo by zero raise avalueerror (they have no defined result). - Observation: every assignment outside
unobservedupdates the observer; predicates and interrogatives read it. Temporal queries additionally record history.
The C embedding API starts observer recording open. Source evals retain
cross-unit history by default; hosts may explicitly promise isolated observer
use with eigs_set_eval_observer_isolated. Missing history then raises
conservatively instead of answering a rest value. See the
embedding observer contract.
With a model loaded, eigen_generate and eigen_eval_loss accept nonempty
prompts up to and including the model's max_seq_len. Longer prompts raise
a catchable value error; neither builtin truncates the supplied prompt.
native_train_step_builtin applies the same refusal rule to the combined
input and output lengths. These limits apply with EIGS_STRICT=0 too.
Generation records either its token list or its context refusal on the trace
tape. Replay reproduces that outcome before consulting the model, even when
the checkpoint is missing or has a different context limit, and preserves the
following call's record.
With the HTTP extension, http_early_bind of port (or of null) listens
while initialization continues, answering 503 with Retry-After: 1.
http_early_bind of [port, "/livez"] explicitly opts one exact GET/HEAD
request target into liveness 200s; http_serve then hands every path to the
normal router. Readiness must check the actual routed resource.
http_response_header of ["X-Eigen-Release", "build-id"] before serving
attaches that field to every response, including startup and static files.
Header names/values are validated and a rejected registration prevents server
startup. See HTTP builtin rules for the full
limits and reserved names (#1128, #1129).
Routes and the static root are fixed once http_serve starts: http_route,
http_route_authed and http_static then raise, including from a code
route, and an uncaught error in a code route's source answers 500 with a
generic body; the error message goes to the server's stderr only (#1140).