Skip to main content

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 preservedyesyesno (rewritten)
Operator definitions resolved (bound)noyesyes
expr.type answersunknownyesyes
Usable in .add(), .mul(), …noyesyes
.subs() / .map() shape-preservingyesyesno (result re-canonicalizes)
Arity and type errors checkednonoyes
Scope processing, binder shadowingnonoyes

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.

Notice guarantee

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

  1. Sugar heads are rewritten. InvisibleOperator becomes Multiply, a function application or At, depending on context; Delimiter is erased; Subtract becomes Add(…, Negate(…)); Sequence is 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]
  2. Operand order. Add and Multiply sort 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"]
  3. 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"]
  4. Exact literal folding. Integers, rationals and radicals fold in Add and Multiply. Machine floats, infinities and NaN deliberately do not.

    ce.parse('2+x+5', { form: 'raw' }).json; // ➔ ["Add", 2, "x", 5]
    ce.parse('2+x+5').json; // ➔ ["Add", "x", 7]
  5. Generic-symbol folds. x/x becomes 1 and 1^x becomes 1 at 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; // ➔ 1
    ce.parse('1^x').json; // ➔ 1
  6. Literal normalization. Negate(2) becomes -2; a literal a literal a + bi becomes Complex(a, b). The Complex fold 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; // ➔ -2

    ce.parse('2+3\\imaginaryI').json; // ➔ ["Complex", 2, 3]
    ce.parse('\\imaginaryI b').json; // ➔ ["Multiply", "ImaginaryUnit", "b"]
  7. Symbol resolution. Constant symbols resolve at canonicalization, not at parse time: on a raw or structural tree e is still the symbol e.

    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 returns undefined for 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 \sum over i rebinds i inside 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 \pi is already the symbol Pi on a raw tree — lookupDefinition('pi') finds nothing.

  8. 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_1At(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
Not full alpha-equivalence

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

Unreleased

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.

Behavior change

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 between passes, not mid-pass

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.