Skip to content

Latest commit

 

History

History
727 lines (606 loc) · 25 KB

COMPARISON.md

File metadata and controls

727 lines (606 loc) · 25 KB

EigenScript compared to Python, JavaScript, Rust, and Lisp

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.

At a glance

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.

Variables and arithmetic

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

Functions

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

Closures: the counter

Python:

def make_counter():
    count = 0
    def step():
        nonlocal count
        count += 1
        return count
    return step

JavaScript:

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

Lists: map / filter / comprehension

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]

Numeric arrays: the list is not the fast container

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.

Dictionaries / objects

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

Pattern matching

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

Error handling

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.

Pipes (vs Lisp threading / shell pipes)

Clojure:

(-> 5 double inc)   ; threading macro

EigenScript — |> is built in:

double is (x) => x * 2
inc is (x) => x + 1
print of (5 |> double |> inc)
11

Concurrency

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.

Convergence loops: boilerplate you stop writing

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 converged

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

What has no equivalent elsewhere: the observer

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

Before / after: porting checklist

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

File loading and block scope

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.

HTTP startup and response attribution

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