Skip to content

Latest commit

 

History

History
600 lines (459 loc) · 18.4 KB

File metadata and controls

600 lines (459 loc) · 18.4 KB

EigenScript Syntax Guide

Variables and Assignment

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"

Numeric Semantics

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.

Compound Assignment

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: +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=

Functions

Named Parameters (recommended)

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.)

Classic Style

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

No arguments

Pass null:

define greet as:
    print of "Hello!"

greet of null

String Interpolation

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.

Interactive REPL

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.

Conditionals

if x > 0:
    print of "positive"
elif x == 0:
    print of "zero"
else:
    print of "negative"

Loops

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

Error Handling

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.

Closures

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.

Operators

Arithmetic: +, -, *, /, % Comparison: ==, !=, <, >, <=, >= Logical: and, or, not String: + (concatenation when both sides are strings)

Type rules

  • + adds two numbers or concatenates two strings. It does not coerce across types — "n=" + 42 raises. To build text from mixed types use an f-string (f"n={count}") or str 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 with try/catch if 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 and match works on list/dict patterns); functions compare by identity. Operands of different types are simply not equal — "3" == 3 is false, never an error.

of binds tighter than arithmetic

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)

Lists

items is [1, 2, 3, 4, 5]
print of items[0]         # 1
print of (len of items)   # 5
append of [items, 6]      # mutates items

Dictionaries

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

eval

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.

Modules (import / load_file)

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.

Path resolution

For non-absolute paths, load_file searches (in order):

  1. The path as given, relative to the current working directory.
  2. <script_dir>/<path> — relative to the script being executed.
  3. <script_dir>/../<path> — relative to the script's parent directory.
  4. <executable_dir>/../<path> — relative to the EigenScript binary.
  5. <executable_dir>/../lib/eigenscript/<path> — installed stdlib layout.
  6. ~/.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.

Interrogatives — Ask Your Code

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 loss55.0
who is x Variable name as string who is loss"loss"
when is x Observation age (number of assignments) when is loss4
where is x Entropy (information content) where is loss0.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 loss0
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.

Temporal Interrogatives — Ask About the Past

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.

Observer Semantics

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.

Unobserved Blocks — Opting Out

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).

Standard Library

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.

Gotchas

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.