Inside Epsil
This page is about how Epsil is implemented. Nothing here is needed to write Epsil — the rest of the documentation describes the language on its own terms. Read this page if you are embedding Epsil in a host application, building tooling over it, or curious about what a construct actually does underneath.
Epsil is a surface syntax over MathJSON, and its runtime is the Compute Engine. A program is parsed into a MathJSON expression, and that expression is evaluated by the engine. There is no separate Epsil interpreter, no Epsil-specific declaration logic, and no Epsil-side type checker — each language form maps onto a primitive the engine already has.
The JavaScript API
The public language entry point exposes the three stages directly:
import {
ComputeEngine,
executeEpsil,
parseEpsil,
serializeEpsil,
} from "@cortex-js/compute-engine/epsil";
Parsing
parseEpsil(source, url?, options?) returns a MathJSON expression and an
array of diagnostics:
const [expression, diagnostics] = parseEpsil("2x + 1");
Ignoring source-location metadata, the expression is:
["Add", ["Multiply", 2, "x"], 1]
The parser recovers from most syntax errors and returns a partial expression alongside its diagnostics. Every parsed node also carries source offsets so a host can associate a diagnostic or expression with the original text.
Execution
executeEpsil(ce, source, options?) parses a program and evaluates its
top-level statements sequentially in the current scope of ce:
const ce = new ComputeEngine();
const first = executeEpsil(ce, "let x = 5");
const second = executeEpsil(ce, "x = x + 1\nx");
// second.value.re === 6
Reusing the engine preserves declarations between calls, which is the
notebook/REPL execution model. A fresh ComputeEngine starts a fresh session.
The returned object contains the last statement's boxed value and all
diagnostics. Runtime failures are represented as error values rather than
escaping to the host as ordinary exceptions.
To enable $…$ LaTeX islands, inject the engine's LaTeX parser:
const parseLatex = (latex) => ce.parse(latex).json;
const result = executeEpsil(ce, "2 * $\\frac{1}{2}$", { parseLatex });
Host-state pragmas remain disabled unless
allowHostPragmas: true is explicitly supplied. Pragma values are computed by
the parser and inserted into the produced MathJSON before execution begins.
A host can give an evaluation an explicit time budget by wrapping it in the
engine's withTimeLimit() span:
const result = ce.withTimeLimit(
{ ms: 500, label: "epsil-cell" },
() => executeEpsil(ce, source, { parseLatex })
);
See Evaluation for the rest of the cancellation model.
Serialization
serializeEpsil(expression, options?) converts MathJSON back to Epsil:
serializeEpsil(["Add", ["Multiply", 2, "x"], 1]);
// ➔ "2 * x + 1"
The serializer formats an expression; it does not execute it. Comments are
currently lossy on the parse side, so parsing and then serializing source code
does not preserve comments or the author's original whitespace. The serializer
can still emit a /* … */ comment when an expression carries a comment
metadata field, but nothing on the parse side populates that field.
How Epsil lowers to MathJSON
The examples in this section omit the sourceOffsets metadata that every
parsed node carries.
Heads at a glance
| Epsil form | MathJSON |
|---|---|
let x = 5, const c = 1, x: real = 5 | Declare |
x = 5, (a, b) := t | Assign |
type p = …, type alias q = … | DeclareType |
f(x) = …, function f(x) { … } | DefineFunction + Function |
x |-> … | Function |
x: real (annotation on a parameter or body) | Typed |
if c { … } else { … }, a if c else b | If |
match s { p => b } | Match + MatchCase |
== e in a pattern | Pin |
p₁ | p₂ in a pattern | Alternatives |
while, for … in … | Loop |
break, continue | Break(), Continue() |
{ … } after a keyword, do { … }, a multi-statement program | Block |
[a, b] | List |
{a, b} | Set |
(a, b) | Tuple |
{k -> v} | Dictionary + KeyValuePair |
a -> b | KeyValuePair |
a..b | Range |
f(x) (bare symbol callee) | ["f", "x"] |
(expr)(x) (computed callee) | Apply |
xs[i] | At |
p.x | Field |
...p | Spread |
a |> b | Pipe |
x in xs, x is real | Element |
"a\(b)c" | String |
Sequence(1, 2, 3) | Sequence |
Note that MapsTo — the name the operator table uses for |-> — is internal
to parsing. The resulting expression uses Function, not a MapsTo head.
Likewise, is and in produce the same Element expression, which is why a
serialized program spells both of them in.
Declarations
Declarations lower to the engine's Declare operator — not an Epsil-specific
Let/Const head. Declare takes the declared symbol, an optional type
(positional, when present), and a trailing attributes Dictionary carrying
value and, for const, constant: True. const is a binding attribute
(constant: True → the engine's isConstant), not a type — the engine, not
Epsil, enforces it.
let x = 5
["Declare", "x", ["Dictionary", ["KeyValuePair", "value", 5]]]
The type is inferred (integer, here) when no annotation is given. With an
annotation, the type appears as a positional argument before the attributes
dictionary:
let x: real = 5
["Declare", "x", {"str": "real"},
["Dictionary", ["KeyValuePair", "value", 5]]]
A declaration with no initializer omits the attributes dictionary entirely:
let x: real
["Declare", "x", {"str": "real"}]
let x
["Declare", "x"]
const adds a constant key alongside value:
const c = 6.28
["Declare", "c",
["Dictionary", ["KeyValuePair", "value", 6.28],
["KeyValuePair", "constant", "True"]]]
Because declarations lower directly to the engine's own Declare primitive,
there is no separate Epsil-side declaration logic at execution time — the
program evaluates the Declare expression exactly like any other expression.
A destructuring declaration uses the same primitive with the pattern in the name position:
let (q, r) = divmod(17, 5)
["Declare", ["Tuple", "q", "r"],
["Dictionary", ["KeyValuePair", "value", ["divmod", 17, 5]]]]
Assignment
A bare x = 5 — no let/const keyword, no type annotation — lowers to
Assign:
x = 5
["Assign", "x", 5]
The Compute Engine permits Assign to establish a value for a previously
unbound symbol, which is why a bare assignment to an undeclared name works at
all; let is nevertheless the explicit and idiomatic way to introduce a
mutable binding.
Reassigning a const still parses and lowers to ["Assign", "c", 2]; it is
the engine, at evaluation time, that rejects the assignment and produces an
["Error", …] value.
Destructuring assignment puts the pattern in the target position:
(a, b) := (b, a)
["Assign", ["Tuple", "a", "b"], ["Tuple", "b", "a"]]
Type annotations
The parser holds a type annotation as a MathJSON string, which the engine parses with its own type language. Type checking is not a separate Epsil-side pass — it happens at canonicalization/evaluation time, the same way it does for any other declared symbol.
xs: list<integer>
["Declare", "xs", {"str": "list<integer>"}]
f: (real) -> real
["Declare", "f", {"str": "(real) -> real"}]
<, >, |, &, and -> inside the annotation are consumed entirely by the
type subparser — u: integer | boolean holds the whole "integer | boolean"
string, and none of those tokens are visible to (or reinterpreted by) the
surrounding expression grammar.
Typed parameters and typed bodies are represented with Typed nodes:
f(x: integer) -> real = x + 1
["DefineFunction", "f",
["Function",
["Typed", ["Add", "x", 1], {"str": "real"}],
["Typed", "x", {"str": "integer"}]]]
Type declarations
A type statement lowers to the engine's DeclareType operator — the
MathJSON mirror of ce.declareType(). The body is carried as the source text
of the type. The bare form has no attributes; the alias form adds an
attributes dictionary with alias -> True:
type point = tuple<x: number, y: number>
["DeclareType", "point", {"str": "tuple<x: number, y: number>"}]
type alias pair = tuple<number, number>
["DeclareType", "pair", {"str": "tuple<number, number>"},
["Dictionary", ["KeyValuePair", "alias", "True"]]]
A type-parameter clause rides the same dictionary, as the text of the clause:
type alias Pair<T> = tuple<T, T>
["DeclareType", "Pair", {"str": "tuple<T, T>"},
["Dictionary", ["KeyValuePair", "alias", "True"],
["KeyValuePair", "typeParams", {"str": "T"}]]]
The clause is carried without its enclosing </>, and a variance
marker is simply part of that text — the bare form needs no other change:
type tree<out T> = tuple<value: T, children: list<tree<T>>>
["DeclareType", "tree", {"str": "tuple<value: T, children: list<tree<T>>>"},
["Dictionary", ["KeyValuePair", "typeParams", {"str": "out T"}]]]
A type is registered when its statement is canonicalized, which is why the
statements after it — in the same program or in a later cell — can annotate
with it. A type declared by the host with ce.declareType() is visible to a
program the same way, constructor and all.
Functions
Both definition forms lower to the same shape,
["DefineFunction", name, ["Function", body, …params]]. The math style has a
bare expression body:
f(x) = x + 1
["DefineFunction", "f", ["Function", ["Add", "x", 1], "x"]]
f(x, y) = x + y
["DefineFunction", "f", ["Function", ["Add", "x", "y"], "x", "y"]]
The block style wraps the body in a Block:
function f(x) { x + 1 }
["DefineFunction", "f", ["Function", ["Block", ["Add", "x", 1]], "x"]]
A parameter annotation becomes a Typed parameter:
f(x: real) = x + 1
["DefineFunction", "f",
["Function", ["Add", "x", 1], ["Typed", "x", {"str": "real"}]]]
An effect specifier is folded into a full function-type string carried by a
Typed node around the body:
function roll(n) random -> integer { Random(n) }
["DefineFunction", "roll",
["Function",
["Typed", ["Block", ["Random", "n"]],
{"str": "(n: unknown) random -> integer"}],
"n"]]
A literal parameter becomes an anonymous parameter constrained to that exact value (a value type):
fib(0) = 0
["DefineFunction", "fib",
["Function", 0, ["Typed", "literalParam_1", {"str": "0"}]]]
Anonymous functions
x |-> x + 1
["Function", ["Add", "x", 1], "x"]
(x, y) |-> x + y
["Function", ["Add", "x", "y"], "x", "y"]
Because a mapsto binds loosely enough to sit on the right-hand side of an
assignment, f = x |-> x + 1 is an Assign of a Function, not a
DefineFunction:
f = x |-> x + 1
["Assign", "f", ["Function", ["Add", "x", 1], "x"]]
A zero-parameter lambda is a Function with only a body:
() |-> 42
["Function", 42]
Conditionals
Both conditional spellings produce the same If. The block form wraps each
branch in a Block; the a if c else b form uses plain expressions, which is
exactly why it introduces no scope:
if x > 0 { 1 } else { 2 }
["If", ["Greater", "x", 0], ["Block", 1], ["Block", 2]]
if x > 0 { 1 }
["If", ["Greater", "x", 0], ["Block", 1]]
10 if x > 3 else 20
["If", ["Greater", "x", 3], 10, 20]
An else if chain — and, identically, a chained conditional expression —
nests into an If in else position:
if x > 0 { 1 } else if x < 0 { 2 } else { 3 }
[
"If",
["Greater", "x", 0],
["Block", 1],
["If", ["Less", "x", 0], ["Block", 2], ["Block", 3]]
]
match
A match is a Match head over the subject followed by one MatchCase per
case. A MatchCase holds a pattern, an optional guard, and a body:
match x {
0 => "zero"
_ => "other"
}
[
"Match",
"x",
["MatchCase", 0, {"str": "zero"}],
["MatchCase", "_", {"str": "other"}]
]
A binding is written as a wildcard-prefixed symbol (_x), which is the
engine's pattern-matcher spelling for a capture; a rest binding uses the
triple prefix (___rest):
match p {
(x, e) => x + e
}
["Match", "p", ["MatchCase", ["Tuple", "_x", "_e"], ["Add", "x", "e"]]]
match xs {
[first, ...rest] => first
}
["Match", "xs", ["MatchCase", ["List", "_first", "___rest"], "first"]]
match p {
{x -> px, y -> py} => px + py
}
[
"Match",
"p",
[
"MatchCase",
[
"Dictionary",
["KeyValuePair", {"str": "x"}, "_px"],
["KeyValuePair", {"str": "y"}, "_py"]
],
["Add", "px", "py"]
]
]
A pin becomes a Pin node. The parser lowers every non-literal pinned
expression to Pin, whether it names a constant or a runtime variable — it
cannot tell the two apart lexically, and only Pin resolution looks up the
value at match time. A pin of a literal drops the Pin head and matches
structurally:
match x {
== Pi => "is-pi"
_ => "no"
}
[
"Match",
"x",
["MatchCase", ["Pin", "Pi"], {"str": "is-pi"}],
["MatchCase", "_", {"str": "no"}]
]
Or-alternatives become Alternatives, and a range pattern reuses the ordinary
Range head — the pattern form keys on the operator, not on how it was
written, which is why Range(lo, hi) and lo..hi mean the same thing in
pattern position:
match x {
0..9 | 100..109 => "in"
_ => "out"
}
[
"Match",
"x",
[
"MatchCase",
["Alternatives", ["Range", 0, 9], ["Range", 100, 109]],
{"str": "in"}
],
["MatchCase", "_", {"str": "out"}]
]
Infinity and -Infinity bounds lower to the engine's infinity symbols:
match x {
0..Infinity => "nonnegative"
_ => "negative"
}
[
"Match",
"x",
["MatchCase", ["Range", 0, "PositiveInfinity"], {"str": "nonnegative"}],
["MatchCase", "_", {"str": "negative"}]
]
A guard occupies the optional middle slot of a MatchCase:
match n {
n if n > 3 => "big"
_ => "small"
}
[
"Match",
"n",
["MatchCase", "_n", ["Greater", "n", 3], {"str": "big"}],
["MatchCase", "_", {"str": "small"}]
]
A typed binding compiles its type test into that same guard slot, conjoined with any explicit guard:
match n {
n: integer if n > 0 => "positive integer"
_ => "other"
}
[
"Match",
"n",
[
"MatchCase",
"_n",
["And", ["Element", "n", "integer"], ["Greater", "n", 0]],
{"str": "positive integer"}
],
["MatchCase", "_", {"str": "other"}]
]
Because a pattern is parsed as an ordinary expression, an algebraic pattern is
just the corresponding operator with captures as operands, matched by the
engine's general pattern matcher (with the commutative matching it already uses
for Add/Multiply):
match z {
a + b if a > 0 => a
_ => 0
}
[
"Match",
"z",
["MatchCase", ["Add", "_a", "_b"], ["Greater", "a", 0], "a"],
["MatchCase", "_", 0]
]
Such patterns work when evaluating a match, but are not supported by
compile(); compiling a match with an operator pattern fails closed, naming
the offending pattern in the error.
When no case matches, evaluation produces Error("match-no-case", subject).
Loops and control transfer
Both loop forms lower to the engine's imperative Loop. while becomes a
Loop over a Block whose first statement breaks out when the condition
becomes false:
while x > 0 { x }
[
"Loop",
["Block", ["If", ["Not", ["Greater", "x", 0]], ["Break"]], ["Block", "x"]]
]
for x in xs { … } puts the iteration clause in a second operand, using the
engine's Element operator as the iterator clause:
for x in xs { x }
["Loop", ["Block", "x"], ["Element", "x", "xs"]]
Only the loop-variable in introduces that clause. A second, later in in the
collection expression is still the ordinary Element infix operator:
for x in a in b { x }
["Loop", ["Block", "x"], ["Element", "x", ["Element", "a", "b"]]]
break and continue lower to the engine's Break() / Continue()
primitives, and serialize back in that call form. The rule that a break may
not cross a function or lambda boundary is not a style choice: the engine's
Block short-circuits on Break/Continue structurally, so a Break
returned out of a lambda body would otherwise transfer control to whatever loop
happened to be running. The engine's Break(v) — which makes the loop evaluate
to v — has no Epsil spelling yet.
Blocks and programs
A statement block is the engine's Block. A multi-statement program is wrapped
in one; a program consisting of a single statement is returned unwrapped:
a
2
["Block", "a", 2]
a; 2
["Block", "a", 2]
A { … } that follows a keyword is a Block, while a bare { … } is the
collection grammar:
{ 1, 2 }
["Set", 1, 2]
if a { }
["If", "a", ["Block"]]
if a { if b { 1 } }
["If", "a", ["Block", ["If", "b", ["Block", 1]]]]
do { … } produces the same Block in expression position, so a let bound
to a do block nests a Block inside the declaration's value:
let y = do { let t = 3; t + 1 }
["Declare", "y", ["Dictionary", ["KeyValuePair", "value",
["Block", ["Declare", "t", ["Dictionary", ["KeyValuePair", "value", 3]]],
["Add", "t", 1]]]]]
Collections, calls, indexing and field access
[a, b] // ["List", "a", "b"] — [] → ["List"]
{a, b} // ["Set", "a", "b"] — {} → ["Set"]
(a, b) // ["Tuple", "a", "b"]
a..b // ["Range", "a", "b"]
A dictionary is a Dictionary of KeyValuePairs, and an unquoted key becomes
a string key. The empty dictionary, {->}, lowers to ["Dictionary"]:
{ one -> 1, two -> 2 }
["Dictionary",
["KeyValuePair", {"str": "one"}, 1],
["KeyValuePair", {"str": "two"}, 2]]
A call whose callee is a bare symbol uses that symbol as the head; any other
callee goes through Apply. Indexing lowers to At, field access to Field,
and a spread argument to Spread:
f(x, y) // ["f", "x", "y"]
f() // ["f"]
f(1, ...p) // ["f", 1, ["Spread", "p"]]
(getF())(x) // ["Apply", ["getF"], "x"]
(a + b)(2+1) // ["Apply", ["Add", "a", "b"], ["Add", 2, 1]]
xs[i] // ["At", "xs", "i"]
f(x)[0] // ["At", ["f", "x"], 0]
p.x // ["Field", "p", "x"]
a.b.c // ["Field", ["Field", "a", "b"], "c"]
p.x(2) // ["Apply", ["Field", "p", "x"], 2]
A bare, top-level comma-separated sequence with no enclosing delimiter is a
diagnostic, not a Sequence literal. Sequence is available only as an
explicit call: Sequence(1, 2, 3) → ["Sequence", 1, 2, 3].
Strings and LaTeX islands
An interpolated string is a String whose operands alternate between literal
text and the interpolated expressions:
"The solution is \(x)"
["String", {"str": "The solution is "}, "x"]
The text between $…$ delimiters is handed to an injected LaTeX parser,
and the expression it returns is spliced into the Epsil syntax tree at that
point, composing with its surroundings like any other primary:
2 * $\frac{1}{2}$
["Multiply", 2, ["Divide", 1, 2]]
Epsil's parser has no static dependency on the LaTeX parser: it is passed in by
the caller, the same way the engine itself injects LatexSyntax rather than
importing it directly. Without an injected parser, an island produces a
latex-parsing-unavailable diagnostic instead of a spliced expression.
Symbol names and normalization
A symbol name must be a valid MathJSON symbol. When
expressions are boxed for execution, symbol bindings are normalized to
Unicode Normalization Form Canonical Composition (NFC)
and stored and compared in that form. So Å written as U+00C5 LATIN CAPITAL
LETTER A WITH RING ABOVE and as U+0041 LATIN CAPITAL LETTER A followed by
U+030A COMBINING RING ABOVE are the same symbol.
The glyph aliases listed in Naming are
canonicalized at the lexer, so π and Pi are indistinguishable by the time
an expression exists.
Comparison chains
A run of the same relational operator lowers to a single n-ary node:
a < b < c // ["Less", "a", "b", "c"]
A mix of relational operators initially lowers as a left-associated tree:
a < b <= c // ["LessEqual", ["Less", "a", "b"], "c"]
When that tree is boxed by the engine, it is canonicalized to the pairwise
conjunction a < b && b <= c, which is why evaluating a mixed chain has the
usual mathematical chained-comparison semantics.
Errors
A runtime problem — a type error, an out-of-domain argument, reassigning a
const — flows as an embedded ["Error", …] value rather than a thrown
exception. executeEpsil never throws for a runtime problem; it catches the
underlying engine exception (for the handful of paths, like a const
reassignment, that still throw internally) and returns an Error value in its
place. See Errors are values.
Round-trip and serialization normalizations
serializeEpsil and parseEpsil are inverses over the MathJSON the grammar
can produce, up to a small set of documented normalizations.
parseEpsil(serializeEpsil(e)) is structurally equal to e after
applying:
- Number formatting —
2,{num: "2"}and"2"are the same number; the serializer emits a single canonical spelling (with_digit grouping), which re-parses to a{num}object. Negateof a literal —["Negate", 3]serializes to-3and["Negate", -1]to1; both re-parse as a signednumliteral rather than aNegatenode (the sign is folded into the number).Rational→Divide—["Rational", 1, 2]serializes to1 / 2. There is no rational literal in the grammar, so it re-parses as["Divide", 1, 2].- Invisible multiply — a binary
["Multiply", {num}, {sym}]serializes to the juxtaposed form2x(only when the two abut and re-lex unambiguously as a number followed by a symbol). All other products — n-ary, number×group (2(x+1)), group×group — stay explicit*, because(x+y)(3+4)would otherwise re-parse asApply, notMultiply. - Associativity — the left-associative operators
(
Add/Subtract/Multiply/Divide/And/Or) re-parse into left-nested binary trees; a flat n-ary form and its left-nested spelling are the same expression. Elementspellings —isandinproduce the sameElementexpression, so a serialized program spells both of themin.
Comments are not preserved by a round-trip — see Comments.
If and Match have dedicated expression spellings. Other MathJSON heads that
do not have a special surface form serialize as ordinary function calls.
Relationship to the loose math parser
Epsil is a programming-language syntax. The Compute Engine also ships a
loose math parser (ce.parse(src, { canonical: false })) that reads
LaTeX/ASCII-math notation. The two share a few surface forms but are not the
same language, and they overlap only partially:
| Source | Epsil parseEpsil | Loose ce.parse (non-canonical) | Agree? |
|---|---|---|---|
[1, 2, 3] | ["List", 1, 2, 3] | ["List", 1, 2, 3] | ✅ same |
x^2 | ["Power", "x", 2] | ["Power", "x", 2] | ✅ same |
2**3 | ["Power", 2, 3] | math-parser artifact (** is not an operator) | ❌ diverge |
a |> b | ["Pipe", "a", "b"] | ["Pipe", "a", "b"] | ✅ same |
f(x, y) | ["f", "x", "y"] (call) | ["InvisibleOperator", "f", ["Delimiter", …]] | ❌ diverge |
sin | "sin" (a symbol) | ["InvisibleOperator", "s", "i", "n"] | ❌ diverge |
2x | ["Multiply", 2, "x"] | ["InvisibleOperator", 2, "x"] | ❌ diverge |
The remaining divergences are intentional: in Epsil a juxtaposed name is a
single identifier (sin is one symbol, not s·i·n), f(x, y) is a function
call, and ** is exponentiation. The two parsers do agree that |> produces
Pipe. Do not rely on them agreeing except on the rows marked same.