A translation guide: each section shows the same program in a language
you already know, then in EigenScript. Every EigenScript block followed
by an output block is executed by the test suite
(tests/test_doc_examples.py) and must produce exactly that output.
The other languages' blocks are illustrative only.
The one-paragraph summary: EigenScript reads like Python (indentation
blocks, dynamic types), applies functions with a keyword like a
concatenative language (f of x), is a Lisp-like single-value-kind
world underneath (everything is one of eight runtime types, code runs
top to bottom, no static pass), and differs from all of them in one
fundamental way: every assignment is observed, and the language has
first-class constructs (converged, what is x, prev of x) for
asking the running program about its own state and history.
| Python | JavaScript | Rust | Lisp | EigenScript | |
|---|---|---|---|---|---|
| typing | dynamic | dynamic | static | dynamic | dynamic |
| numbers | int + float | float | many | many | one: 64-bit float |
| assignment | x = 1 |
let x = 1 |
let x = 1; |
(define x 1) |
x is 1 |
| call | f(x) |
f(x) |
f(x) |
(f x) |
f of x |
| blocks | indentation | braces | braces | parens | indentation |
| booleans | True/False |
true/false |
bool |
#t/#f |
true/false (bool) |
| null | None |
null/undefined |
Option |
nil |
null |
| errors | exceptions | exceptions | Result |
conditions | try/catch |
| self-inspection | none | none | none | macros | interrogatives + observer |
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.
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.
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.
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.
Python:
x = 42
x += 1
print(x) # 43
print(7 / 2) # 3.5
print(7 // 2) # 3 (floor division operator)EigenScript — one number type, true division; floor explicitly:
x is 42
x += 1
print of x
print of (7 / 2)
print of (floor of (7 / 2))
43
3.5
3
Python:
def add(a, b):
return a + b
print(add(3, 4))JavaScript:
const add = (a, b) => a + b;
console.log(add(3, 4));EigenScript — a bare literal list after of is the argument list, at
every element count: f of [a, b] is two arguments, f of [x] is one.
(Parentheses always mean one argument: f of ([x]) passes a literal
1-element list whole.) Parameters are comma-separated in define and in lambdas; a
missing comma (define add(a b)) is a parse error, not a second parameter.
define add(a, b) as:
return a + b
print of (add of [3, 4])
inc is (x) => x + 1
print of (inc of 41)
7
42
Python:
def make_counter():
count = 0
def step():
nonlocal count
count += 1
return count
return stepJavaScript:
function makeCounter() {
let count = 0;
return () => ++count;
}EigenScript — no nonlocal needed: assignment is outward-mutable, so
inner functions write outer variables directly:
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)
1
2
Python:
xs = [1, 2, 3, 4, 5]
print([v * v for v in xs])
print([v for v in xs if v % 2 == 0])
print(list(map(lambda v: v * 10, xs)))EigenScript — same comprehension shape; map/filter come from the
standard library:
load_file of "lib/list.eigs"
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]
print of (map of [xs, (v) => v * 10])
[1, 4, 9, 16, 25]
[2, 4]
[10, 20, 30, 40, 50]
Python separates a list of boxed objects from a NumPy array of raw doubles,
and you pick one per use. EigenScript draws the same line between list and
buffer — and zeros of n hands you the flat one, because numeric code
reaches for that name first (#1093).
Python:
import numpy as np
z = np.zeros(4) # flat float64 array
z[1] = 2.5
print(type(z).__name__, z.sum())
xs = [0] * 4 # a boxed list, when you want one
print(type(xs).__name__, sum(xs))EigenScript — no import, and every tensor builtin takes either container:
z is zeros of 4
z[1] is 2.5
print of (type of z)
print of (sum of z)
xs is [0 for i in range of 4]
print of (type of xs)
print of (sum of xs)
buffer
2.5
list
0
zeros of [rows, cols] still builds the nested list (the 2-D tensor form);
buffer of [rows, cols] and reshape of [buf, rows, cols] build the
flat-backed matrix. matmul, softmax, sum, mean, norm, gather and
the rest accept either, and hand back a buffer when every operand was one.
matmul, matmul_at, and matmul_bt round each multiplication to binary64
before adding it to the accumulator, in ascending inner-index order, on both
containers. They do not fuse the multiplication and addition.
Raw non-finite results may remain in a buffer's internal work area, but every
scalar read applies EigenScript's numeric guard: infinity saturates at
±1e308, and a NaN raises in strict mode (or becomes 0 and sets the invalid
math flag under EIGS_STRICT=0). Structural buffer equality and scalar
reductions normalize each input before comparison or arithmetic; the reduction
association contracts remain unchanged. Mixed buffer/list tensor operations and
numeric byte/sample/device conversions also normalize reads and stop on the
first error. Byte conversion truncates and wraps modulo 256 after normalization.
Raw copies, typed serialization and buffer-only work areas preserve their data.
type_mismatch error by default; EIGS_STRICT=0 retains numeric-zero
substitution. str_from_bytes stops at numeric NUL and does not inspect later
elements, while compression codecs consume and validate the complete list.
One place EigenScript is louder than NumPy: an out-of-range index in gather
raises rather than answering a stand-in, on both containers — NumPy's
np.take raises too, while a fancy-indexed read with an out-of-range entry is
an IndexError there as well, so this matches. It is gather's own history
that changed: the list form used to answer 0.0 (#973/#1093).
Fixed- or optional-shape builtin argument lists reject surplus outer elements in strict mode with a catchable type_mismatch error naming the builtin and maximum width. The check precedes the call's mutation, I/O and tape effects. EIGS_STRICT=0 keeps 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.
JavaScript:
const user = { name: "Ada", year: 1815 };
user.field = "computing";
console.log(user.name, Object.keys(user).length);EigenScript — dot access and bracket access are interchangeable
(keyword-named fields too: d.loop, d.when — like JavaScript member
access, the position disambiguates); assignment creates keys:
user is {"name": "Ada", "year": 1815}
user.field is "computing"
print of user.name
print of (len of (keys of user))
Ada
3
Rust:
match code {
200 => println!("OK"),
404 => println!("Not Found"),
_ => println!("other"),
}EigenScript:
code is 404
match code:
case 200:
print of "OK"
case 404:
print of "Not Found"
case _:
print of "other"
Not Found
Python:
try:
raise ValueError("boom")
except Exception as e:
print(f"caught: {e}")EigenScript — throw of raises, catch name: binds. A thrown value
binds unchanged; a built-in runtime error binds a {kind, message, line} dict with kind from a closed vocabulary (no exception
hierarchy — discriminate on the kind string):
try:
throw of "boom"
catch e:
print of f"caught: {e}"
try:
x is [1] - 1
catch e:
print of f"caught: {e.kind} at line {e.line}"
caught: boom
caught: type_mismatch at line 7
The optional model extension also uses catchable value errors for context
limits: eigen_generate and eigen_eval_loss reject prompts longer than the
loaded model's max_seq_len, while native_train_step_builtin checks the
combined input and output length. This refusal applies even with
EIGS_STRICT=0; context truncation is the caller's explicit choice.
Clojure:
(-> 5 double inc) ; threading macroEigenScript — |> is built in:
double is (x) => x * 2
inc is (x) => x + 1
print of (5 |> double |> inc)
11
EigenScript's trace replay identifies script workers by their parent and that
parent's spawn occurrence. Independent embedding threads use host-provided
stable keys; matching does not depend on first-event scheduling. Unbound replay
calls raise instead of borrowing another thread's nondeterministic values.
The tape association contract is documented in docs/TRACE.md.
Replay session changes require an explicit quiescent host advance, which refuses
while a sibling has an unread outcome. Memory replay suspends and restores the
file source's cursor, pending outcomes, correspondence and strictness together.
Replay refuses host effects it cannot reconstruct: mktemp, for example,
raises at the filesystem boundary before creating a file, while ordinary calls
retain their create-and-return-path behavior.
Python (threads + queue):
import threading, queue
q = queue.Queue()
threading.Thread(target=lambda: q.put(21 * 2)).start()
print(q.get())EigenScript — spawn + channels:
ch is channel of null
spawn of [(v) => send of [ch, v * 2], 21]
print of (recv of ch)
42
Channel messages and joined/task results deep-copy ordinary values, including
buffers and text builders. Closures keep their captured environment by
reference, and resource handles still name shared process state; the executable
kind table and graph-depth limits are in docs/CONCURRENCY.md.
One difference in failure: a Python thread's uncaught exception is printed
and the process still exits 0. A spawned EigenScript worker that dies of
an uncaught error fails the whole run (exit status 1), joined or not — the
same rule as its cooperative tasks (see SPEC.md "Concurrency").
A worker's exit of N requests an uncatchable stop of the whole state:
blocked concurrency calls wake, and VM threads unwind when they observe the
request at a loop back edge or builtin return. The first request sets the
process status. Teardown retains workers until native I/O or host callbacks
return; those calls are not asynchronously cancelled. An embedded worker keeps
the stop scope of its spawning eval, isolated from later outer evals.
Cooperative task IDs are opaque numeric handles: their packed generation
prevents a detached task's recycled slot from naming a later task.
Before the metaphysics, the everyday win. Every numeric fixed-point loop in Python or JavaScript hand-rolls the same three things — and beginners get them wrong: an epsilon threshold, a way to remember the previous value(s), and a max-iteration guard so a non-converging loop doesn't hang.
# Python: the epsilon, the remembering, and the guard are all yours
def newton_sqrt(n, eps=1e-12, max_iter=1000):
guess = n / 2 or 1.0
for _ in range(max_iter): # max-iter guard, by hand
nxt = (guess + n / guess) / 2
if abs(nxt - guess) < eps: # epsilon threshold, by hand
return nxt
guess = nxt # remember the last value, by hand
return guess # ...and hope it convergedEigenScript watches every value settle for you, so the loop is the convergence test — no epsilon, no remembered previous, no guard:
define newton_sqrt as:
guess is n / 2.0
if guess == 0:
guess is 1.0
loop while not converged:
guess is (guess + n / guess) / 2.0
return guess
print of ("sqrt(2) = " + (str of (newton_sqrt of 2)))
sqrt(2) = 1.414213562373095
converged reads the loop's last-assigned value and fires once a full
window of its relative steps (Δv over the step's own scale, #1045 — so
the unit the value is stored in does not matter) sits under the settle
deadband — the standard mixed-tolerance stopping criterion, built in
(#861). This run exits
through the predicate itself in 13 iterations; the deadband is the
diverges ends via the observer's stall backstop with
__loop_exit__ == "stalled" instead of hanging or lying. (Use the loop
inside a function so it gets a fresh binding to watch — see
examples/observer_vs_boilerplate.eigs.) The next section is why this
works; you can reach for it long before you need the theory.
Every assignment (outside unobserved blocks) updates an observer
tracking the value's entropy and trend (for containers, one pass over the
value's own elements — the walk stops at a reference, so a nested container
contributes only its size term, #685). The boundary is explicit too: a
binding holding a function classifies opaque — a function has no content
to sample, and the observer names its blind spots (#708) rather than
reporting a band it cannot defend. (The unobserved boundary is about
the entropy walk only — assignments inside such a block are still counted
by when is x and still addressable by ordinal, #908, and a scalar's
sample still enters the value window the numeric predicates read, #1049,
because a performance annotation must not change an answer; what stays
elided is entropy/dH, so why/how and the non-numeric route can still
differ.) You can ask a variable about
itself, terminate loops on convergence instead of a hand-written
epsilon test, and read a variable's past:
x is 10
x is 20
x is 30
print of (what is x)
print of (who is x)
print of (when is x)
print of (prev of x)
e is 5
loop while not converged:
e is e * 0.5
print of (e < 0.001)
30
x
3
20
true
The past is addressable per assignment, not just as a single step back — so a loop's individual iterations stay reachable after it has finished:
total is 0
for n in [5, 10, 20]:
total is total + n
print of (what is total when 2)
print of (what is total when 3)
print of (what is total when 4)
5
15
35
In Python the equivalent is a list you remembered to append to before the fact, inside a loop you had already decided to instrument. Here the question is asked afterwards, of a program that was not written to answer it. (Retention is bounded — the last 256 assignments per queried binding — so this costs a fixed amount rather than growing with the loop.)
In Python you would write the convergence loop with an explicit threshold; in EigenScript the loop condition is the semantic intent:
# Python: the epsilon is yours to pick, plumb, and tune
while abs(e_prev - e) > 1e-9:
e_prev, e = e, step(e)loop while not converged: # EigenScript: the runtime watches e
e is step of e
And because the measurement is already paid, a trajectory is a first-class
thing you can hand to another function — trajectory of x snapshots the
observer's windows into a plain dict, and classify reads it on the other
side of the call (no equivalent exists elsewhere; a debugger watch window
is the closest analogue, and it can't cross a function boundary either):
define diagnose(t) as:
return classify of t
r is 1.0
i is 0
loop while i < 40:
r is r * 2.0
i is i + 1
print of (diagnose of (trajectory of r))
diverging
One difference to carry across when you port numeric code: EigenScript has
no Infinity, so an overflow that Python would show you as inf saturates
at ±1e308 instead (see SPEC.md, Numbers). A clamped value
stops moving, which would make a runaway look like the calmest possible
trajectory — so the observer treats the ceiling as evidence of divergence,
not of rest (#861). Keep the runaway above going and the verdict holds:
report and report_value are reserved observer forms: unlike ordinary
functions in Python or JavaScript, they cannot be rebound or passed as values.
Their of operand must be a variable name (parentheses allowed); other operands
and all binding attempts fail before the source unit runs with E005.
r is 1.0
i is 0
loop while i < 2000:
r is r * 2.0
i is i + 1
print of (report of r)
print of (converged of r)
diverging
false
Transformations you will apply constantly when porting Python code:
| Python | EigenScript | why |
|---|---|---|
x = v |
x is v |
assignment keyword |
f(a) |
f of a |
application keyword |
f(a, b) |
f of [a, b] |
bare literal list = argument list |
f([a]) (pass a 1-element list) |
f of ([a]) |
parentheses = one argument |
True / False / None |
true / false / null |
bool is not a number: true + 1 and true == 1 raise |
x ** y |
pow of [x, y] |
^ is XOR |
len(x) |
len of x |
builtin, same name |
xs.append(v) |
append of [xs, v] |
builtins, not methods |
while c: |
loop while c: |
loop keyword |
def f(): body using nonlocal |
plain is |
assignment is outward-mutable |
d["k"] / d.get("k") |
d.k or d["k"] |
dot works on any dict |
A worked port — Python before:
def word_lengths(words):
out = {}
for w in words:
out[w] = len(w)
return out
print(word_lengths(["ada", "grace"]))EigenScript after:
define word_lengths(words) as:
out is {}
for w in words:
out[w] is len of w
return out
print of (word_lengths of (["ada", "grace"]))
{"ada": 3, "grace": 5}
(Note the of (["ada", "grace"]) — parenthesised: parentheses always
make the argument a single value, so the literal list arrives whole. A
bare of ["ada", "grace"] would also work here — two arguments to a
one-parameter function pack back into a list — but the parenthesised
form says "one list" directly and works for any arity.)
Unlike a working-directory-based include path, EigenScript resolves imports
and loads from the file containing the call, then the eigs_modules walk,
then the nearest eigs.json project root, then stdlib locations. Absolute
paths are used as-is. There is no process cwd search; the REPL (including piped
input) and the embed API without a file path use their working directory as the
containing directory. A function retains its defining file's directory through
eval, including calls made while another module is being imported or an
embedding host is executing eigs_eval_file. The complete chain is in
SPEC, Modules.
Main, import and load_file share these rules: a for binder is loop-scoped
and never writes an outer binding. Plain is in its body updates the nearest
existing binding, including a loop-local; otherwise it creates a binding in
the enclosing scope, like other blocks. In an imported module this search
stops at the module boundary, preserving the importer's bindings. Top-level return ends the file:
load_file yields its value, import finishes its namespace, and main discards
its value.
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 (Python, by contrast, leaks the
loop variable into the enclosing function). A pre-existing parameter, local
or module binding is restored after the loop.
define probe() as:
for z in [7, 8]:
0
return z
try:
print of (probe of [])
catch e:
print of e.message
undefined variable 'z'
An imported module's namespace is a live view of that module's
bindings, the way Python's module objects are — m.x reads the current
binding and m.x is v writes it, for a number or string exactly as for
a dict. It is not a copy taken at import time, so no "box your module
state in a container or importers go stale" rule exists (#1057):
write_text of ["cmp_live.eigs", "seen is 0\ndefine tick() as:\n seen is seen + 1\n"]
import cmp_live
cmp_live.tick of null
print of cmp_live.seen
rm of "cmp_live.eigs"
1
The namespace is still an ordinary dict, so keys, values, len and
indexing work on it — closer to a Lua module table than to a Rust mod,
which has no runtime value at all.
Builtin names inside a module are isolated the way Python's are: a Python
module's len is unaffected by __main__ rebinding len, and an
EigenScript module's len, str or append is the builtin unless the
module itself rebinds it (#1388). Unlike Python, a name the module does not
bind and that is not a builtin still resolves to the importer's globals.
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 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).