Assignment uses is. It is outward-mutable: if a variable exists in a
parent scope, assignment updates that binding. If not found, it creates a
new local.
x is 42
name is "hello"
data is [1, 2, 3, 4, 5]
Use local name is expr when a binding must be created or updated only in
the current evaluator scope, even if a parent scope already has a name with
the same identifier. It does not create a new block scope for if or loop;
it binds whichever environment is active for that statement.
name is "outer"
define example as:
local name is "inner"
return name
result is example of null # "inner"
name # still "outer"
Numbers are finite by construction. EigenScript does not expose NaN or
Inf values to user code:
1 / 1e-320 # 1e+308
sqrt of -1 # 0
exp of 999999 # 1e+308
asin of 5 # 1.5708
num of "nan" # 0
Exact division or modulo by zero emits a warning and returns 0. Results
that would overflow to infinity saturate at +/-1e308; NaN collapses to
0; domain-limited functions clamp inputs where appropriate.
All arithmetic, bitwise, and shift operators have compound forms:
x += 3 # x is x + 3
x -= 1 # x is x - 1
x *= 2 # x is x * 2
flags |= 0x80 # flags is flags | 0x80
flags &= 0x0F # flags is flags & 0x0F
val <<= 4 # val is val << 4
These work on variables, dot-access, and index-access:
obj.score += 10
arr[i] *= 2
buf[0] ^= 0xFF
Available: +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=
Define functions with named parameters in parentheses:
define add(a, b) as:
return a + b
result is add of [3, 4] # 7
define greet(name, age) as:
return f"Hello {name}, you are {age}"
print of (greet of ["Jon", 30])
The argument list is unpacked into the named parameters automatically.
(With a named parameter list, n is not defined — the implicit n exists
only when a function has no parameter list.)
Functions can also be defined without named parameters. The argument is n:
define square as:
return n * n
result is square of 5 # 25
For multiple arguments in classic style, pass a list and unpack manually:
define add_three as:
return n[0] + n[1] + n[2]
result is add_three of [10, 20, 30] # 60
Pass null:
define greet as:
print of "Hello!"
greet of null
Prefix a string with f to enable expression interpolation with {...}:
name is "World"
x is 42
print of f"Hello {name}!"
print of f"x = {x}, doubled = {x * 2}"
print of f"list length: {len of items}"
Expressions inside {} are evaluated and converted to strings. Use \{ and
\} for literal braces.
Both plain and f strings recognize the escapes \n, \t, \r, \\, and
\". Any other \x yields the literal character x (the backslash is
dropped) — which is exactly why \{ and \} produce literal braces.
Run eigenscript with no arguments to enter the REPL:
$ eigenscript
EigenScript 0.26.0
Type 'exit' or Ctrl-D to quit.
eigs> x is 42
eigs> print of f"x = {x}"
x = 42
eigs> define double(n) as:
... return n * 2
...
eigs> double of 21
=> 42
Multi-line input (functions, loops, conditionals) is detected automatically
when a line ends with :. A blank line ends the block.
if x > 0:
print of "positive"
elif x == 0:
print of "zero"
else:
print of "negative"
While loop:
loop while counter < limit:
counter is counter + 1
For loop — iterates over a list:
for i in range of 10:
print of i
for item in items:
print of item
try:
x is items[100]
catch err:
print of f"Caught: {err}"
Errors inside a try block are caught and bound to the variable after
catch: runtime errors bind their message string; a thrown value
binds unchanged, so errors can carry data:
try:
throw of {"kind": "validation", "field": "age"}
catch e:
print of e.kind # "validation"
Without a try block, an error is fatal: it prints to stderr with a
stack trace (every frame from the failure to the top level) and the
program exits non-zero.
Use throw to raise errors from user code:
define safe_divide(a, b) as:
if b == 0:
throw of "division by zero"
return a / b
try:
result is safe_divide of [10, 0]
catch err:
print of f"Error: {err}"
Try/catch blocks can be nested. Errors re-thrown in a catch block are caught by the outer try.
Functions capture their defining environment:
define make_adder(x) as:
define adder(y) as:
return x + y
return adder
add5 is make_adder of 5
print of (add5 of 10) # 15
This works for factory patterns, callbacks, and higher-order programming.
Arithmetic: +, -, *, /, %
Comparison: ==, !=, <, >, <=, >=
Logical: and, or, not
String: + (concatenation when both sides are strings)
+adds two numbers or concatenates two strings. It does not coerce across types —"n=" + 42raises. To build text from mixed types use an f-string (f"n={count}") orstr of.- Ordering (
<,>,<=,>=) requires both operands to be the same comparable type (number/number or string/string). Comparing mixed types (e.g."3" < 4) raises a runtime error rather than silently returning false — catch it withtry/catchif you mean it. - Equality (
==,!=) never coerces. Numbers, strings, and null compare by value; lists and dicts compare structurally (so[1,2] == [1,2]is true andmatchworks on list/dict patterns); functions compare by identity. Operands of different types are simply not equal —"3" == 3isfalse, never an error.
Function application with of binds tighter than +/-/*//. So
len of xs - 1 means (len of xs) - 1, which is usually what you want.
The flip side: sqrt of x + 1 means (sqrt of x) + 1, not
sqrt of (x + 1). When the argument is itself an arithmetic expression,
parenthesize it:
y is sqrt of (x + 1) # not sqrt of x + 1
print of (total + tax) # not print of total + tax (would print total, then error)
items is [1, 2, 3, 4, 5]
print of items[0] # 1
print of (len of items) # 5
append of [items, 6] # mutates items
Dictionary literals use {} with string keys:
config is {"host": "localhost", "port": 8080, "debug": 1}
Dot access:
print of config.host # "localhost"
print of config.port # 8080
Any word can follow the dot, including keywords — d.loop, d.in,
d.when all work: the position after . is unambiguous, so
keyword-named keys (e.g. from json_decode of external data) are
reachable by dot as well as by bracket.
Bracket access:
key is "host"
print of config["host"] # "localhost"
print of config[key] # "localhost"
Nested dictionaries:
app is {"db": {"host": "localhost", "port": 5432}, "name": "myapp"}
print of app.db.host # "localhost"
Builtins:
print of (keys of config) # ["host", "port", "debug"]
print of (values of config) # ["localhost", 8080, 1]
print of (has_key of [config, "host"]) # 1
dict_set of [config, "timeout", 30] # mutates config
dict_remove of [config, "debug"] # mutates config
The eval builtin executes a string as EigenScript code and returns the result:
result is eval of "1 + 2" # 3
eval of "print of 42" # prints 42
code is "x is 10\nprint of x"
eval of code
Evaluated code runs in the caller's current scope. At top level this is the global scope; inside a function, new names stay in that function's scope.
import name loads a module into a namespace — a dict named
name holding the module's top-level definitions. It resolves
lib/name.eigs (the standard library) first, then name.eigs
relative to your script. Nothing leaks into the global scope; names
starting with _ stay private to the module.
import math
result is math.clamp of [15, 0, 10] # 10
import shapes # your shapes.eigs, next to the script
a is shapes.area of 2
load_file is the non-namespaced form — it executes a file directly
in the current scope:
load_file of "lib/math.eigs"
print of (abs of -5)
The loaded file's definitions are added to the global environment.
For non-absolute paths, load_file searches (in order):
- The path as given, relative to the current working directory.
<script_dir>/<path>— relative to the script being executed.<script_dir>/../<path>— relative to the script's parent directory.<executable_dir>/../<path>— relative to the EigenScript binary.<executable_dir>/../lib/eigenscript/<path>— installed stdlib layout.~/.local/lib/eigenscript/<path>— user-local stdlib fallback.
The third step is what lets a script in examples/ pick up lib/foo.eigs
without the caller having to cd to the repository root. The executable
relative steps let external projects use the source-tree or installed stdlib
without copying lib/*.eigs into each project. .. segments
embedded in the load_file argument itself are resolved by the OS
normally — there is no sandbox, so a script can read any file the
invoking user can read.
Absolute paths (starting with /) are used verbatim with no fallback.
Every value in EigenScript tracks its own observer state. Interrogatives are
the query interface. Asking costs nothing extra — the state is maintained as
you go — but that maintenance is paid on every assignment outside
unobserved:, whether or not anything asks (OBSERVER.md):
| Keyword | Returns | Example |
|---|---|---|
what is x |
Current value (scalar), or length (list/string) | what is loss → 55.0 |
who is x |
Variable name as string | who is loss → "loss" |
when is x |
Observation age (number of assignments) | when is loss → 4 |
where is x |
Entropy (information content) | where is loss → 0.832 |
why is x |
dH (rate of entropy change; negative while descending into a basin = improving) | why is loss → -0.27 |
how is x |
Settledness of the last step, 1 - min(1, |dH| / dh_zero) — a real gradient in [0, 1] since #412: 1 when the step is inside the deadband, 0 when it clears it |
how is loss → 0 |
loss is 0.9
loss is 0.5
loss is 0.2
print of (what is loss) # 0.2
print of (why is loss) # negative — descending into a basin (entropy falling)
print of (how is loss) # 0 — the last step is far outside the deadband
print of (when is loss) # 3 — three assignments
Note that why tracks the entropy trajectory, not the raw value: a
magnitude shrinking toward 1 (e.g. 100 → 55) climbs the entropy ridge and
reads as diverging (positive dH), while one descending into a basin below 1
reads as improving. See docs/OBSERVER.md for the full model.
Use interrogatives for debugging, convergence detection, or understanding runtime behavior without writing logging code.
Two forms reach backward through a variable's assignment history.
Both work on top-level (global) bindings, are always on — no
environment variable or debug build required — and return null
on a miss rather than erroring.
prev of x — the value x held immediately before its most
recent assignment:
loss is 100.0
loss is 80.0
print of (prev of loss) # 100 — the value before the latest assign
Returns null when x has been assigned fewer than two times. Note
the connector: prev uses of (like a function call), while the six
observer interrogatives use is.
at <expr> qualifier — any value-returning interrogative can be
pinned to a source line. The operand is a full expression evaluated at
query time; the answer is the last value bound at or before that line:
x is 1 # line 1
x is 2 # line 2
x is 3 # line 3
print of (what is x at 2) # 2
print of (prev of x at 3) # value before the assign at/before line 3
print of (when is x at 2) # assignment count up to line 2
print of (who is x at 2) # "x"
print of (where is x at 2) # entropy as of the line-2 assign
print of (why is x at 2) # dH as of the line-2 assign
A line inside a loop is asked once, not per iteration. "The last value bound at or before that line" is a statement about the run, so a line the loop executed four times answers with the fourth assignment — the final one, not the first (#736). This is where the rule stops matching intuition:
total is 0
for i in range of 4:
total is total + i # line 3, runs four times
print of (what is total at 3) # 6 — the LAST pass (0+1+2+3), not the first
print of (when is total at 3) # 5 — all five assignments, seed included
To pin a single iteration, record it yourself (snapshots[i] is total);
at addresses source lines, and a loop body's line has one history, not one
per pass.
All seven temporal forms support at. The observer-derived three
(where/why/how) answer from per-assign observer snapshots that
are captured only when the compiled program actually contains such a
query — programs that never ask pay nothing. Queries before the first
assignment to the name return null.
state_at of line — the whole-program version: returns a dict
mapping every tracked binding to the value it held at or before line
(names not yet assigned by then are omitted). See
BUILTINS.md. Names of the form __name__ are reserved for
the runtime — the observed-loop machinery binds __loop_exit__ and
__loop_iterations__ there — and are filtered out of state_at and of
--step's bindings listing (#736). Don't use that form for your own
variables; they would be hidden from both.
prev, and at inside an interrogative, are soft keywords — outside
these positions they parse as ordinary identifiers, so existing code
using them as variable names keeps working.
For recording full execution traces and replaying nondeterministic runs deterministically, see TRACE.md.
The observer system classifies value trajectories automatically:
status is report of loss # "improving"
state is observe of loss # [status, entropy, dH, prev_dH]
Six trajectory states: improving, diverging, stable, equilibrium,
oscillating, converged.
Predicates — boolean keywords for use in conditions:
loop while not converged:
loss is loss * 0.9
if stable:
print of "reached stable band"
if oscillating:
lr is lr * 0.5 # reduce learning rate
The observer tracks every variable. Predicates check the most recently
observed value. report of x and observe of x let you query any
specific variable.
The observer runs on every assignment so interrogations are always cheap. That cost is unavoidable when the value might be observed later — and wasted when you know a region won't be.
The unobserved block is the user-level opt-out:
unobserved:
game.px is game.px + game.vx * DT
game.py is game.py + game.vy * DT
game.angle is game.angle + DT
Inside the block:
- Numeric mutations land in place. Local-var and dict-field
assignments whose right-hand side is pure arithmetic over numbers
update the existing
Value's data, keeping pointer identity. No intermediate allocation. - Observer tracking is skipped. Entropy / dH / obs_age on touched
values stay frozen at whatever the last observed write left them.
Interrogatives (
why is game.px,how is game.px) will return stale answers until the next observed assignment. - The last-observer pointer is not updated. Predicates in scope continue to report whatever was last observed outside the block.
Outside the block, normal observed behavior resumes. Nested unobserved
blocks compose — the inner block doesn't re-enable observation.
This mirrors what tensor code already does at the C level via
arena_mark / arena_reset (lifecycle-scoped) and the save-restore
pointer pattern in numerical_grad_* (identity-preserving raw swap).
unobserved is the same idea at statement scope, visible in user
source.
When to reach for it: game loops, physics integrators, and other
hot paths where you'll inspect state via normal reads (game.px),
not via interrogatives (what is game.px).
Libraries live in lib/ and are loaded with load_file:
| Module | Functions |
|---|---|
lib/math.eigs |
abs, max_val, min_val, clamp, lerp, dot |
lib/list.eigs |
map, filter, reduce, reverse, zip, flatten |
lib/string.eigs |
join, repeat, pad_left |
lib/sanitize.eigs |
sanitize_text, is_garble, clean_response |
lib/auth.eigs |
auth_login, auth_check, auth_logout |
See BUILTINS.md for the complete builtin reference.
The one-element spread trap: f of [x] (a one-element list) binds the whole
list to the first parameter — it does not spread. For a single argument to a
multi-parameter (or defaulted) function, write f of (x); f of ([a, b]) passes
a literal list whole. (f of x for a bare value is fine.)
Strings are bytes, not characters. len/char_at/indexing operate on bytes;
a multibyte character has length > 1. Use lib/utf8.eigs for codepoint
semantics. See SPEC.md → Text and Unicode.
Numbers are finite. No NaN/Inf reach your code — they collapse to 0 /
saturate at ±1e308 (see Numeric Semantics above). Integer exactness ends at
2^53; integer bitwise ops act on int64.