The Structural Tier
The structural tier is the middle ground between a raw parse and a canonical expression: the tree keeps the shape it was parsed with, but its operator definitions are resolved, so it has a type and can be transformed. This page collects the contracts a pipeline built on that tier can rely on.
Most applications never need this page: parse, canonicalize, evaluate. It is written for consumers that carry an expression through several transformation passes — an expansion fixpoint, a classifier tuned on parse shape, an incremental editor — and need to know exactly what survives each pass and what the engine may change underneath them.
The Three Tiers
raw { form: 'raw' } | structural { form: 'structural' } | canonical (default) | |
|---|---|---|---|
| Parse vocabulary preserved | yes | yes | no (rewritten) |
| Operator definitions resolved (bound) | no | yes | yes |
expr.type answers | unknown | yes | yes |
Usable in .add(), .mul(), … | no | yes | yes |
.subs() / .map() shape-preserving | yes | yes | no (result re-canonicalizes) |
| Arity and type errors checked | no | no | yes |
| Scope processing, binder shadowing | no | no | yes |
To create a structural expression use ce.parse(s, { form: 'structural' }),
ce.expr(json, { form: 'structural' }) or
ce.function(op, ops, { form: 'structural' }). Note that ce._fn() does
not accept the option. The legacy spellings { canonical: false } (raw)
and { structural: true } still work at runtime, but are no longer part of
the typed API surface — new code should use form.
const expr = ce.parse('2x - (a+b)', { form: 'structural' });
console.log(expr.json);
// ➔ ["Subtract", ["InvisibleOperator", 2, "x"], ["Delimiter", ["Add", "a", "b"]]]
console.log(expr.isStructural, expr.isCanonical);
// ➔ true false
console.log(expr.type.toString());
// ➔ "broadcastable<number>"
console.log(expr.subs({ a: ce.expr(3) }).json);
// ➔ ["Subtract", ["InvisibleOperator", 2, "x"], ["Delimiter", ["Add", 3, "b"]]]
// still structural: the sugar survived the substitution
Structural boxing preserves the shape it is given; it does not recover
one. A structural box of an already-canonical tree leaves it canonical
(Add(a, Negate(b)) stays Add(a, Negate(b))) — there is no projection back
from canonical to parse shape. A tree that must stay in parse vocabulary has
to be non-canonical from the parse onward.
.evaluate() leaves the tier.evaluate() and .N() always answer with a canonical value. On a
structural or raw tree they evaluate the expression's canonical form, so
every tier agrees on the value — including binders (Sum, Integrate, D,
a function literal), whose bound variables are declared by canonicalization
and would otherwise be evaluated unbound:
console.log(ce.parse('\\sum_{n=1}^{3} n', { form: 'structural' }).evaluate());
// ➔ 6 (same as the canonical tree)
console.log(ce.parse('\\sum_{n=1}^{3} n', { form: 'raw' }).evaluate());
// ➔ 6
console.log(ce.parse('\\sum_{n=1}^{3} n').evaluate());
// ➔ 6
A bare symbol behaves the same way. A structural or raw symbol is not bound to a definition, so it is resolved in the current scope before being evaluated: it answers with its assigned value, not with itself. A symbol with no value evaluates to itself (as a canonical symbol).
ce.assign('x', 5);
console.log(ce.box('x', { form: 'raw' }).evaluate());
// ➔ 5 (same as the canonical symbol)
console.log(ce.box('zzz', { form: 'raw' }).evaluate());
// ➔ zzz (no value: the symbol itself)
The receiver itself is not changed — it stays on its tier — but the
result is canonical: it is in canonical vocabulary, and it is not a
structural expression. A pipeline that must stay on the structural tier
should not route through .evaluate().
Also note that .subs() is not capture-avoiding on any tier. If a
substitution reaches into a binder body, avoiding name collisions is the
caller's responsibility.
Parse Vocabulary vs Canonical Vocabulary
The vocabulary a fresh parse produces and the vocabulary canonicalization produces differ in a bounded set of ways, listed below.
The raw parse vocabulary is a documented interchange vocabulary, not an implementation detail: consumers pattern-match these spellings and we will not respell them silently. Any change to a spelling on this page lands as a called-out BREAKING entry in the CHANGELOG — never slipped into a release unannounced. Build on the current spellings; watch the changelog line, not the trees.
The vocabulary is versioned, not frozen: this is a notice guarantee, not a promise that it never changes.
What Differs
-
Sugar heads are rewritten.
InvisibleOperatorbecomesMultiply, a function application orAt, depending on context;Delimiteris erased;SubtractbecomesAdd(…, Negate(…));Sequenceis spliced.ce.parse('2x', { form: 'raw' }).json; // ➔ ["InvisibleOperator", 2, "x"]ce.parse('2x').json; // ➔ ["Multiply", 2, "x"]ce.parse('a - b', { form: 'raw' }).json; // ➔ ["Subtract", "a", "b"]ce.parse('a - b').json; // ➔ ["Add", "a", ["Negate", "b"]]ce.parse('(a+b)', { form: 'raw' }).json; // ➔ ["Delimiter", ["Add", "a", "b"]]ce.parse('(a+b)').json; // ➔ ["Add", "a", "b"]ce.parse('f(1,2)', { form: 'raw' }).json;// ➔ ["InvisibleOperator", "f", ["Delimiter", ["Sequence", 1, 2], "'(,)'"]]ce.parse('f(1,2)').json; // ➔ ["f", 1, 2] -
Operand order.
AddandMultiplysort their operands canonically.ce.parse('-2+x', { form: 'raw' }).json; // ➔ ["Add", ["Negate", 2], "x"]ce.parse('-2+x').json; // ➔ ["Add", "x", -2]ce.parse('\\pi x 2', { form: 'raw' }).json;// ➔ ["InvisibleOperator", "Pi", "x", 2]ce.parse('\\pi x 2').json; // ➔ ["Multiply", 2, "Pi", "x"] -
Flattening. Associative operators flatten, and the fence goes with them.
ce.parse('(a+b)+c', { form: 'raw' }).json;// ➔ ["Add", ["Delimiter", ["Add", "a", "b"]], "c"]ce.parse('(a+b)+c').json; // ➔ ["Add", "a", "b", "c"] -
Exact literal folding. Integers, rationals and radicals fold in
AddandMultiply. Machine floats, infinities andNaNdeliberately do not.ce.parse('2+x+5', { form: 'raw' }).json; // ➔ ["Add", 2, "x", 5]ce.parse('2+x+5').json; // ➔ ["Add", "x", 7] -
Generic-symbol folds.
x/xbecomes1and1^xbecomes1at canonicalization. This is a documented engine convention, not a bug: the operands are gone entirely.ce.parse('\\frac{x}{x}', { form: 'raw' }).json; // ➔ ["Divide", "x", "x"]ce.parse('\\frac{x}{x}').json; // ➔ 1ce.parse('1^x').json; // ➔ 1 -
Literal normalization.
Negate(2)becomes-2; a literal a literala + bibecomesComplex(a, b). TheComplexfold requires both parts to be literal — a symbolic imaginary term stays a product.ce.parse('-2', { form: 'raw' }).json; // ➔ ["Negate", 2]ce.parse('-2').json; // ➔ -2ce.parse('2+3\\imaginaryI').json; // ➔ ["Complex", 2, 3]ce.parse('\\imaginaryI b').json; // ➔ ["Multiply", "ImaginaryUnit", "b"] -
Symbol resolution. Constant symbols resolve at canonicalization, not at parse time: on a raw or structural tree
eis still the symbole.ce.parse('e', { form: 'raw' }).json; // ➔ "e"ce.parse('e').json; // ➔ "ExponentialE"To ask whether a name would resolve to a constant — and to get the answer for the current scope chain, so a user redeclaration is respected — use
ce.lookupDefinition(name). It returnsundefinedfor an undeclared name and has no auto-declare side effect, so it is safe to call speculatively:const v = ce.lookupDefinition('i')?.value;const isImaginaryUnit = v?.isConstant === true && v.value?.im === 1;Two traps. A symbol-level query cannot see binder shadowing (a
\sumoverirebindsiinside its body), so mask binder-bound names first. And the query key is the post-parse symbol name: the LaTeX→symbol mapping is a parse step, so\piis already the symbolPion a raw tree —lookupDefinition('pi')finds nothing. -
Binder rewrites. Scoped operators normalize their bound variables and canonicalize their bodies. See below.
What Does Not Differ
These constructs read the same on both routes, so a walk tuned on parse trees needs no defensive re-parse for them:
ce.parse('\\frac{x}{y}', { form: 'raw' }).json; // ➔ ["Divide", "x", "y"]
ce.parse('\\frac{x}{y}').json; // ➔ ["Divide", "x", "y"]
ce.parse('a<b<c', { form: 'raw' }).json; // ➔ ["Less", "a", "b", "c"]
ce.parse('a<b<c').json; // ➔ ["Less", "a", "b", "c"]
ce.parse('\\sqrt{x}', { form: 'raw' }).json; // ➔ ["Sqrt", "x"]
ce.parse('\\sqrt{x}').json; // ➔ ["Sqrt", "x"]
ce.parse('x_1', { form: 'raw' }).json; // ➔ "x_1"
ce.parse('x_1').json; // ➔ "x_1"
One case to keep in mind rather than delete: the v_1 → At(v, 1)
projection over a declared list happens at parse time, so both routes
agree — but that means parse shape is declaration-sensitive. The same
source parses differently depending on what is in scope:
ce.parse('v_1', { form: 'raw' }).json; // ➔ "v_1"
ce.declare('v', 'list<number>');
ce.parse('v_1', { form: 'raw' }).json; // ➔ ["At", "v", 1]
A walk tuned on parse trees inherits that sensitivity whether or not it ever touches a canonical tree.
Binder Spellings
Binder limits are carried as a Tuple on the raw and structural routes and
as Limits on the canonical route:
ce.parse('\\sum_{n=1}^{10} n^2', { form: 'structural' }).json;
// ➔ ["Sum", ["Power", "n", 2], ["Tuple", "n", 1, 10]]
ce.parse('\\sum_{n=1}^{10} n^2').json;
// ➔ ["Sum", ["Power", "n", 2], ["Limits", "n", 1, 10]]
ce.parse('\\prod_{k=1}^{5} k', { form: 'structural' }).json;
// ➔ ["Product", "k", ["Tuple", "k", 1, 5]]
ce.parse('\\prod_{k=1}^{5} k').json;
// ➔ ["Product", "k", ["Limits", "k", 1, 5]]
A structural Function body carries no Block wrapper; the canonical
form adds one:
ce.parse('x \\mapsto x^2 + 1', { form: 'structural' }).json;
// ➔ ["Function", ["Add", ["Power", "x", 2], 1], "x"]
ce.parse('x \\mapsto x^2 + 1').json;
// ➔ ["Function", ["Block", ["Add", ["Power", "x", 2], 1]], "x"]
D retains its Delimiter; canonicalization erases it:
ce.parse('\\frac{d}{dx}(x^2+1)', { form: 'structural' }).json;
// ➔ ["D", ["Delimiter", ["Add", ["Power", "x", 2], 1]], "x"]
ce.parse('\\frac{d}{dx}(x^2+1)').json;
// ➔ ["D", ["Add", ["Power", "x", 2], 1], "x"]
Integrate combines both rewrites — the Tuple becomes Limits and the
integrand is lifted into a Function/Block:
ce.parse('\\int_0^1 x^2 dx', { form: 'structural' }).json;
// ➔ ["Integrate", ["Power", "x", 2], ["Tuple", "x", 0, 1]]
ce.parse('\\int_0^1 x^2 dx').json;
// ➔ ["Integrate", ["Function", ["Block", ["Power", "x", 2]], "x"],
// ["Limits", "x", 0, 1]]
Undeclared Call Heads Bind Vacuously
A structural box of a function expression whose head is undeclared succeeds. There is no error, no auto-declare, and no operator definition:
const expr = ce.expr(['myHead', 1, 2], { form: 'structural' });
console.log(expr.isValid); // ➔ true
console.log(expr.errors); // ➔ []
console.log(expr.operatorDefinition); // ➔ undefined
console.log(ce.lookupDefinition('myHead')); // ➔ undefined
The head is simply not bound — the node is inert. This is the intended behavior, and it is covered by the notice guarantee above.
The canonical route does the opposite: it auto-declares the head as a
function with an inferred type, a write to the current lexical scope.
const ce2 = new ComputeEngine();
ce2.expr(['myHead', 1, 2]); // canonical
const def = ce2.lookupDefinition('myHead');
console.log(def.value.type.toString()); // ➔ "function"
console.log(def.value.inferredType); // ➔ true
Inert versus declaring is precisely the structural/canonical split. If the head is declared as an operator, the structural box binds it normally:
ce.expr(['Sin', 1], { form: 'structural' }).operatorDefinition?.name;
// ➔ "Sin"
Bound-Symbol Equality
This section describes the Structural tier of comparison — Same and
expr.isSame(). See
Comparing Expressions
for the three tiers and how they differ. This contract is a property of
binder identity, not of value following, so it is unchanged by the
equality-tier semantics: the arithmetic and prover tiers do not enter into
it.
Bound-symbol equality is per-binder-instance. Each canonicalization of a
binder mints fresh bindings, so the n of one Sum and the n of a
separately built Sum are different variables, deliberately.
Compare at, or below, a common root. Whole-tree comparison handles the bound occurrences correctly:
const a = ce.parse('\\sum_{n=1}^{10} n^2');
const b = ce.parse('\\sum_{n=1}^{10} n^2');
console.log(a.isSame(b)); // ➔ true
Detached cross-tree leaf comparison is out of contract. A bound leaf
lifted out of its tree carries no binder context to be equivalent under, so
comparing it against the same-position leaf of another tree is not a
meaningful question — and answers false:
console.log(a.op1.op1.isSame(b.op1.op1)); // ➔ false — out of contract
Comparing roots first is not a workaround; it is the sanctioned pattern.
Reflexivity is guaranteed. The identical object always compares equal to itself, bound or not:
const leaf = a.op1.op1;
console.log(leaf.isSame(leaf)); // ➔ true
isSame() compares occurrences under a common binder by name, so it is
alpha-aware — fresh binding identity does not defeat it. It is not
alpha-equivalent: renaming a bound variable changes the answer.
ce.parse('\\sum_{n=1}^{10} n^2').isSame(ce.parse('\\sum_{m=1}^{10} m^2'));
// ➔ false
Error Attachment and Incremental Validity
A pipeline that edits a tree in place wants to re-check only what it changed. Two properties bound what that is allowed to mean.
An Error Attaches at the Nearest Constraining Ancestor
An incompatible-type error attaches at the nearest ancestor whose signature
or canonical handler actually constrains the propagated type. That is not
"the immediate parent": broadcasting and type-transparent operators pass the
change through without erroring, so the attachment point can be arbitrarily
far above the edit.
const mk = (leaf) =>
ce.expr(['Sum', ['Power', 'n', 2], ['Limits', 'n', 1, ['Sin', ['Sqrt', leaf]]]]);
console.log(mk(4).isValid); // ➔ true
console.log(mk(['List', 1, 2]).isValid); // ➔ false
console.log(mk(['List', 1, 2]).json);
// ➔ ["Sum", ["Power", "n", 2],
// ["Limits", "n", 1,
// ["Error", ["ErrorCode", "'incompatible-type'", "'number'", "'vector<2>'"]]]]
The edit is at the operand of Sqrt, three levels down. Both the edited
subtree and its parent probe as valid on their own —
console.log(ce.expr(['Sqrt', ['List', 1, 2]]).isValid); // ➔ true
console.log(ce.expr(['Sin', ['Sqrt', ['List', 1, 2]]]).isValid); // ➔ true
— and the error materializes only at the Sum bound, the first ancestor that
requires a number.
isValid Is Not a Complete Oracle
A scalar→collection change frequently produces no box-time error at all. Broadcastable lift at applications defers collection mismatches to runtime by design:
ce.declare('g', '(string) -> number');
console.log(ce.expr(['g', 42]).isValid);
// ➔ false — ["g", ["Error", ["ErrorCode", "'incompatible-type'", "'string'", "'finite_integer'"]]]
console.log(ce.expr(['g', ['List', 1, 2]]).isValid);
// ➔ true — the lift makes it a broadcast application
console.log(ce.expr(['g', ['List', 1, 2]]).type.toString());
// ➔ "vector<2>"
Only operators that constrain collections inside their canonical handlers —
Sum bounds, above — fire at box time. Part of this class surfaces only at
evaluate or compile time. Do not treat .isValid as a complete validity
oracle for a type-changing edit.
The Sound Incremental Rule
Compare the changed subtree's type before and after the edit.
- Type unchanged ⇒ no ancestor's validity can change. Probing the subtree alone suffices.
- Type changed ⇒ re-probe from the root, or walk upward re-boxing one ancestor at a time and stop as soon as a node's recomputed type equals its pre-edit type.
ce.declare('k', '(number) -> number');
ce.expr(['k', 'x']).type.toString(); // ➔ "number"
ce.expr(['k', ['List', 1, 2]]).type.toString(); // ➔ "vector<2>"
// type changed ⇒ ancestors must be re-probed
Validity itself is compositional, and Error nodes attach at the offending
operator, so a subtree probe (ce.expr(changedSubtreeJson).isValid, then
discard the tree) costs work proportional to the change rather than to the
whole expression. Note that a canonical probe writes to scope — it
auto-declares undeclared heads and free symbols. Contain those writes with a
per-call scope (below), or with ce.pushScope()/ce.popScope().
Per-Call Scope Control
The API described in this section is not yet released. It is documented here as a contract; the spellings are subject to the notice guarantee only once it ships. Check the CHANGELOG for the release that carries it.
Canonical parsing and boxing write to the engine's current lexical scope:
free symbols are declared by usage inference, undeclared call heads are
auto-declared, the subscript fold resolves names, and an Assign left-hand
side is pre-declared. A consumer parsing untrusted or out-of-order input
needs to contain those writes.
The scope Option
ce.parse(latex, { scope });
ce.expr(json, { scope });
When a scope is supplied, the parse or box runs with it as the current
lexical scope. Lookups walk scope → parents, and all auto-declares and
inference land rooted at the supplied scope. Discarding the scope discards
the writes.
ce.expr's existing scope option steers lookup only today —
auto-declares still land in the engine's current scope. Receiving the writes
is a semantic change to a shipped option, and lands as a called-out BREAKING
CHANGELOG entry.
ce.createScope()
There is deliberately no separate bindings option: the declarations table
is the scope's initializer, so scope stays the single per-call option and
no option-interaction rules exist.
ce.createScope(
bindings?: Record<string, Type | TypeString | BoxedDefinition>,
parent?: Scope
): InspectableScope;
const scope = ce.createScope({ h: 'function', p: 'tuple<3>' });
const expr = ce.parse(row, { scope });
parent defaults to the engine's current lexical scope at call time;
callers with ordering discipline should pass their document scope explicitly.
Each entry is declared into the fresh scope, shadowing outer declarations
innermost-wins. Reusing one scope across several calls is supported and
useful — parsing every row of a document into the same scope accumulates the
harvest across rows.
With the caller supplying every free name, a parse becomes a function of (latex, dictionary, declarations) plus the engine's standard library.
BoxedDefinition initializer values. A harvested definition re-installs
the same definition object, not a fresh holder: binding identity and
write-version continuity are preserved, so seeding pass N+1 from pass N's
harvest keeps write-version-keyed caches warm by construction. Two
constraints: definitions are engine-bound, so this works within one engine
only; and installing one definition in two live scopes makes it shared
mutable state — a type narrowing through either scope is visible in both.
That aliasing is the intended semantics for sequential pass-seeding, and is
documented rather than prevented.
Reading Back What a Parse Declared
createScope() returns an InspectableScope — structurally a Scope,
assignable anywhere a Scope is accepted, plus a read surface. A scope's
content is opaque by design (Scope.bindings is publicly
Map<string, unknown>, holding internal records whose shape is not API), so
these methods are the supported decode.
interface InspectableScope extends Scope {
declarations(): ReadonlyArray<{
name: string;
type: BoxedType;
inferred: boolean;
def: BoxedDefinition;
}>;
narrowings(): ReadonlyArray<{
name: string;
from: BoxedType;
to: BoxedType;
def: BoxedDefinition;
}>;
dispose(): void;
}
declarations() reports the scope's own bindings — the initializer's entries
with their post-inference types, alongside anything auto-declared during the
call. inferred is true when the type was auto-declared or inferred from
usage rather than stated by the caller; an initializer entry declared with an
explicit type reports false even if inference later narrowed it. For an
operator definition, type is its signature. Types are answered as
BoxedType — the same boxed form as expr.type — so entry.type.toString()
is the canonical type spelling and entry.type.matches(…) works directly.
Both listings are sorted lexicographically by name, deterministically and
independently of the order in which declares interleaved; a fingerprint over
[name, type.toString()] pairs is stable.
const scope = ce.createScope(heads);
const expr = ce.parse(row, { scope });
const harvested = scope.declarations();
// … read inferred parameter types; drop `scope` when done
One known boundary: the engine's interned common symbols (Pi, e, i,
True, …) resolve ahead of the lexical scope chain, so a binding for one of
those names is recorded in declarations() but does not shadow the constant
during boxing — the same behavior as ce.declare of the same name.
Callers that want no harvest simply drop the scope reference; there is no separate discard path. A caller-owned scope is never pushed through the engine's scope-discard path, so its definitions are not auto-disposed: holding a harvested definition after the parse, or after dropping the scope, is supported.
dispose() is a deterministic release of the event subscriptions held by
constant- and dynamic-valued definitions. It is idempotent, and definitions
stay data-readable afterwards; re-installing a disposed definition is out
of contract.
dispose() bumps each definition's write version once. Consumers whose
caches are keyed on write version must call it between passes.
A hand-rolled { parent, bindings } object literal still satisfies Scope
and works as the scope option, but only createScope() products are
inspectable.
Known Residual: Outer-Scope Narrowing
An ephemeral scope contains new declarations; it does not contain narrowing of declarations that already exist. Type inference during a contained parse can still narrow a symbol declared in an outer scope.
That residual is observable rather than silent: narrowings() reports every
outer definition the contained parse narrowed, with its before and after
type. A caller that needs containment rather than observation can compare or
restore from that listing.