#2026·Graphite

Tracking Issue: Math expression parser/calculator

Author: KeavonCreated Oct 9, 2024Updated Sep 17, 2026
LabelsHelp WantedGood First Issue

This is our library at /libraries/math-parser that uses a parsing framework that takes a string at runtime and calculates its result, including units and dimensional analysis.

Roadmap

  • Scalars (real numbers)
  • Particle1 (complex numbers)
  • Vector1/Vector2/Vector3 and Particle2/Particle3 (up to quaternions)
  • Matrices (linear and affine maps)
  • Static typing: sorts (values vs. matrices), the value ladder, the matrix diamond (Linear/Affine × 2D/3D), and refinements
  • Units
  • Documentation
  • Error reporting (partially done: parse errors carry byte spans and evaluation errors are typed, but there is no source-snippet rendering)
  • Compiling into Graphene AST

Operators

  • Infix operators:
    • Addition: +
    • Subtraction: -,
    • Multiplication: *, ×,
    • Division: /, ÷
    • Modulo: % (floored, so the result takes the sign of the modulus and (-3.2) % 2 is 0.8, matching the Modulo node's default; x % turn wraps an angle into one revolution)
    • Exponentiation: ^
    • Equals: ==
    • Not Equals: !=,
    • Less Than or Equal To: <=,
    • Greater Than or Equal To: >=,
    • Less Than: <
    • Greater Than: >
    • Or: ||,
    • And: &&,
  • Prefix operators:
    • Unary Plus: +
    • Unary Minus: -,
    • Not: !, ¬
  • Postfix operators:
    • Factorial: !
  • Grouping operators:
    • Parentheses: ( )
    • Magnitude bars: |x| (the Euclidean magnitude over all parts, the absolute value on a scalar; the per-part fold is abs(); |a||b| lexes as the magnitude of a OR b, so users must write |a| |b|, while a || with two magnitudes open closes both, so |a*|b|| is valid but |a|b|| is an error)

Chained comparisons

A chain like a < b < c is a single n-ary predicate: each comparison reads only the original operands and the individual results meet in a conjunction, so no comparison's result ever feeds another comparison.

  • a < b < c and longer chains assert the relation between each adjacent pair, like interval notation (0 <= x < 1), never C-style (a < b) < c
  • A chain must stay within one direction family, </<=/== or >/>=/==; mixing directions is a parse error
  • a != b != c asserts that all pairs are distinct, matching Mathematica's Unequal (deliberately not Python's adjacent-pairs reading)

Functions

  • Trig:
    • sin(), cos(), tan()
    • csc(), sec(), cot()
  • Inverse trig:
    • asin(), acos(), atan(), atan2()
    • acsc(), asec(), acot()
  • Hyperbolic:
    • sinh(), cosh(), tanh()
    • csch(), sech(), coth()
  • Inverse hyperbolic:
    • asinh(), acosh(), atanh()
    • acsch(), asech(), acoth()
  • Logarithm:
    • Natural log: ln()
    • Logarithm base N: logN() (alias: log_N())
      • Examples: log2(), log3.25(), log_10()
    • Logarithm with variable base: log(x, b)
  • Exponents:
    • Power of e: exp(n)
    • Power with variable exponent: the ^ operator, as x^n
  • Roots:
    • Square root: sqrt()
    • Cube root: cbrt()
    • Root base N: rootN(x) (alias: root_N(x))
      • Examples: root2(), root3.25(), root_10()
    • Root with variable degree: root(x, n)
  • Rounding:
    • Floor: floor() (toward -∞)
    • Ceiling: ceil() (toward +∞; ceil(-0.5) is 0 because -0 isn't a thing)
    • Round: round() (half away from 0: round(2.5) is 3, round(-2.5) is -3)
    • Truncate: trunc() (toward 0)
    • Fractional part: fract() (x - trunc(x), keeping the sign: fract(-1.25) = -0.25)
    • Sign: sign() (-1, 0, or 1, with 0 for zero)
    • Absolute value: abs() (per part, so abs(p) folds a point into the first quadrant or octant, the SDF box being |max(d, 0)| + min(max([i] d, [j] d), 0) where d = abs(p) - b; equals |x| on a scalar, while the bars are the Euclidean magnitude on every rung)
    • Snap: snap(x, step) = round(x / step) step
  • Range:
    • Clamp: clamp(x, R) (to the range on every axis it spans: R^-1 x clamped to 0..1 and mapped back, so a rotated box clamps to itself)
    • Minimum: min(a, b, ...)
    • Maximum: max(a, b, ...)
  • Interpolation:
    • Lerp: lerp(a, b, t)
    • Slerp: slerp(a, b, t) (unit quaternions)
    • Remap: remap(x, A, B) (from range A to range B; equals B A^-1 x)
  • Integers:
    • Greatest common divisor: gcd()
    • Least common multiple: lcm()
  • Statistics:
    • Average: avg(a, b, ...)
    • Geometric mean: geomean(a, b, ...)
    • Harmonic mean: harmmean(a, b, ...)
    • Root mean square: rms(a, b, ...)
    • Euclidean norm: hypot(a, b, ...) (sqrt(|a|^2 + |b|^2 + ...))
    • Median: median(a, b, ...)
    • Mode: mode(a, b, ...) (smallest of the most frequent values; errors when no value repeats)
    • Variance: variance(a, b, ...) (population)
    • Standard deviation: stddev(a, b, ...) (population)
    • Count: count(a, b, ...)
  • Combinatorics:
    • Combinations: choose(n, r) ("n choose r", the binomial coefficient)
    • Permutations: pick(n, r) ("n pick r", the falling factorial)
  • Logical
    • Piecewise: {a if cond, b if cond, ...}, the cases of math's cases notation, with an optional c otherwise case ({-1 if x < 0, 0 if x == 0, 1 if x > 0}, {0 if x < 0, x if 0 <= x < 1, 1 otherwise})
    • Exclusive or: xor() (variadic parity; operands must each be 0 or 1)
  • Vectors:
    • Conjugate: conj() (negates the vector part on every rung; equals [1;-i;-j;-k] q)
    • Dot product: dot(a, b) (the inner product over all parts 1, i, j, k; equals [1](a * conj(b)))
    • Cross product: cross(a, b) (of the vector parts, with zero weight; equals (a*b - b*a) / 2)
    • Normalize: normalize() (evaluation error at zero)
    • Angle: angle(a, b) (from a to b, signed by the turn's direction seen from +k, so rotate(a, angle(a, b)) is parallel to b; a heading is angle(i, v), an inclination angle(k, v), a complex argument angle(i, [1;i] z))
    • Rotate: rotate(v, angle, axis = k)
    • Rotor: rotor(angle, axis = k) = cos(θ/2) + sin(θ/2) axis (applied as q v conj(q), which scales by |q|² when q is not unit)
    • Axis: axis(q) (a rotor's unit axis; its angle is 2 angle(1, q))
    • Project: project(a, b)
    • Reject: reject(a, b)
    • Reflect: reflect(v, n)
    • Distance: distance(a, b) = |a - b|
    • Perpendicular: perp(v) = cross(k, v)
  • Noise (deterministic, so the same input always gives the same value):
    • Smooth noise: noise(p, tile) (Perlin, the Noise Pattern node's default, one 4D field in 0..1 so (10..20) noise(p) lands on a range; a lower rung samples a slice with the missing axes at zero, and a seed is a Particle3 offset added by hand, noise(p + seed), so a 2D pattern moves to a fresh slice or evolves smoothly along w; the optional tile range makes the field repeat with the range's extent on each axis it spans, each extent a whole number of lattice units, so any box of that size tiles seamlessly; octaves are written out, noise(p) + noise(2p) / 2)
    • White noise: random(p) (an uncorrelated value in 0..1 for each distinct p, whose every part must be a whole number, an error otherwise, so an index or id is used as is and a position is quantized first, random(floor(p / 10px)); (-5..5) random(p) scatters within a range; seeded the same way, random(p + seed))

Reducer classification

The library classifies an input string as either a lone reducer token or an expression, for hosts whose UI offers an operator-only input mode. A lone token expands to the expression written out over the host's whole item list: operators interleave (+ becomes a + b + c), functions wrap (min becomes min(a, b, c)). A token is legal only if that expansion is well-formed at every item count.

  • Left-fold operators +, -, *, /, %, &&, || -> pairwise accumulation, left-associative: ((a + b) + c)
  • Right-fold operator ^ -> the power tower a ^ (b ^ c), matching the expression grammar's associativity
  • Chain comparisons <, <=, >, >=, ==, != -> the chained-comparison semantics above; zero or one items evaluate to 1 (true), since no pair exists to fail the relation, matching Mathematica
  • Variadic functions min, max, gcd, lcm, hypot, avg, geomean, harmmean, rms, median, mode, variance, stddev, count, xor -> a single call over all items
  • Over a matrix list: * and / compose in list order, +, -, and avg are pointwise, count counts, and the other reducers are errors
  • An empty item list yields the operator's identity element (+ -> 0, * -> 1, && -> 1, || -> 0), an error for operators without one (-, /, %, ^), or the function's own zero-argument behavior (count() -> 0, min() -> error)

Branch indices (multi-valued functions)

Every multi-valued function accepts one extra optional trailing argument m, an integer (rounded to the nearest whole number) selecting which of the function's mathematically-many values to return. Omitting m is identical to m = 0, which returns exactly the value the function produces today, so all existing expressions are unchanged. Offsets that leave the real line lie in the input's own complex plane, along its normalized vector part (i for a real input). The language always returns a single value; enumerating branches is done by the graph evaluating the expression across a range of supplied m values, and a nonzero m types the result as Particle1 like a domain climb.

  • Roots: sqrt(x, m), cbrt(x, m), root(x, n, m), rootN(x, m) -> the m = 0 value times e^(2π n̂ m / n) (n is 2 for sqrt, 3 for cbrt); for whole-number n this wraps mod n, so every integer m is valid; the ^ operator stays principal-only, a chosen branch of a power being exp(ln(x, m) y)
  • Natural log: ln(x, m) -> ln(x) + 2π n̂ m
  • Log base b: log(x, b, m) and the suffixed forms logN(x, m) -> (ln(x) + 2π n̂ m) / ln(b), with the base's log always principal
  • Two-argument arctangent: atan2(y, x, m) -> atan2(y, x) + 2πm
  • Inverse trig with period π: atan(x, m), acot(x, m) -> the m = 0 value plus πm
  • Inverse trig, alternating: asin(x, m), acsc(x, m) -> (-1)^m times the m = 0 value, plus πm
  • Inverse trig, paired: acos(x, m), asec(x, m) -> the m = 0 value plus πm for even m; the negated m = 0 value plus π(m + 1) for odd m
  • Inverse hyperbolic: asinh(x, m)/acsch(x, m), acosh(x, m)/asech(x, m), atanh(x, m)/acoth(x, m) -> the same three formulas as their trig counterparts, with π n̂ m in place of πm

Constants

  • Value constants (lowercase only):
    • Infinity: inf, infinity,
    • Basis vector: i (complex numbers)
    • Basis vectors: j, k (quaternions)
    • Pi: pi, π
    • Tau: tau, τ
    • Euler's Number: e
    • Golden Ratio: phi, φ
    • True and False: true = 1, false = 0
  • Matrix constant (uppercase only):
    • Identity matrix: I

Variables

  • Single-letter variables (examples: x, y, z)
  • Multi-letter variables (examples: theta, alpha, beta, gamma)
  • Non-Latin letters (examples: λ, , א)
  • Names are Unicode identifiers as Rust spells them, XID_Start then XID_Continue, so a name begins with a letter and may carry combining marks (example: a decomposed é), while digits, marks, invisible formatting characters, and (unlike Rust, which adds it as a special case) an underscore cannot begin one
  • The keywords if, otherwise, and where are never names, whether bound by the host or by where
  • Deferred until a host scope needs to publish names an identifier cannot spell: backtick-quoted names for variables with spaces or operator characters; the quoted text is the whole bound symbol, namespace prefix included, and the case rule reads its first character after any prefix. Markdown's code-span rule: a run of N backticks opens and the next run of exactly N closes, so a name containing backticks is quoted with a longer run, and one space is stripped from each end when both are present, so a name may begin or end with a backtick or consist only of them
      `Layer Name`
      `x-offset`
      `2nd Point`
      `#Stroke Width`
      ``Layer `Old` Name``
      `` ` `` (a variable named as the backtick character)
  • Environment-bound variables are case-sensitive and shadow the constants above, except , which is a literal rather than a name; the \ prefix always reaches the builtin
  • Uppercase-initial identifiers name matrices
  • Non-uppercase-initial identifiers name values
  • Namespace prefixes: a name never begins with ASCII punctuation, so #, $, ~, and @ stay available to join \ as prefixes whenever a host scope needs namespacing
  • Builtin namespace prefix: \name is the language's own constant or function regardless of bindings (\pi, \e, \i, \I, \sin), an error if no builtin has that name; the case rule applies after the prefix

Bindings

  • where names values for the expression before it (examples: V where r = d/2, d = 4, {sin(r)/r if r != 0, 1 otherwise} where r = hypot(x, y))
  • Function bindings with a fixed, nonzero arity (example: f(0) + f(1) where f(t) = t^2 + c, c = 3)
  • A where clause stands at the top level of the expression or directly inside parentheses and runs to their closing parenthesis or the end of input, so every comma in it separates bindings (example: 2 (a + b where a = x^2, b = y^2))
  • Bindings are ordered by dependency, not position: a binding's value or a function's body may use any other binding in its clause, functions included, and a cycle is an error (recursion, mutual recursion, or self-reference like x = x + 1), so every expression finishes
  • Scoping is lexical: a clause's names are visible only within its parentheses, parameters shadow every outer name, and a clause shadows enclosing clauses, host bindings, and builtins (still reachable through \); defining one name twice in the same clause is an error
  • Calls and values are separate namespaces told apart by position, so f = 2, f(t) = t + 1 defines both and where sin = 2 leaves sin(x) the sine
  • Function application takes precedence over implicit multiplication for any function name in scope, with where functions shadowing host functions, which shadow builtins, so where k = 2 leaves k(x + 1) a product
  • Evaluation is lazy and memoized: a binding is evaluated at most once and only if used, and arguments pass unevaluated, so an error the result never reaches is never raised; an unused binding is not an error
  • Functions are second-class: they are defined and called, never passed, returned, or stored as values
  • Bound names and parameters follow the case rule, and each call is typed as its substituted body, so one function serves every rung it is called with

Number and unit representations

  • Scientific notation (examples: 1e-6, 2.5E3)
  • Units (examples: 5m, 5 m, 3.5kg, 3.5 kg, 2.5m/s^2, 2.5 m/s^2)

Number systems

  • Basis 1: Real numbers (examples: 0, 42, -42, 0.5, 3.14159, -2.71828)
  • Bases 1, i: Complex numbers (examples: 3i, -2.5i, 1.5e-3i, 2.5e3 + 2.1e-2i)
  • Bases 1, i, j, k: Quaternions
    • Vector1, zero weight (examples: 3i, -2.5i)
    • Vector2, zero weight (examples: 3i + 2j, -3i - 2j)
    • Vector3, zero weight (examples: 3i + 2j + k, -3i - 2j - k)
    • Particle1, the complex numbers (examples: 4 + 3i, -4 - 2.5i)
    • Particle2 (examples: 4 + 3i + 2j, -4 - 3i - 2j)
    • Particle3, the full quaternion (examples: 4 + 3i + 2j + k, -4 - 3i - 2j - k)
  • Matrices: [a; ...] by rows and [a, ...] by columns (see Matrices)

Value semantics and number ladder

Every value is semantically a quaternion; narrower storage (integer, real, complex) is an internal optimization that is never observable. All behavior is decided by a value's mathematical content, never its storage form.

For consumption outside the library, setting inputs and querying results, the subset ladder ⊂ ℕ ⊂ ℤ ⊂ ℝ ⊂ ℂ ⊂ ℍ maps to concrete types: = {0, 1} is bool, ℕ is the u{8, 16, 32, 64, 128} types and #[hard(0..)] node bounds, ℤ is the i{8, 16, 32, 64, 128} types, ℝ is f32 and f64 (the Scalar rung, which Graphite labels Number), ℂ is Particle1, and ℍ is Particle3, with Particle2 between them. The weighted types are one generic, Weighted<V> { w: f64, vector: V }, with Particle1, Particle2, and Particle3 as its instances over the unweighted Vector1(f64), Vector2([f64; 2]), and Vector3([f64; 3]), which are their weight-zero refinements. The boundary is these structs and plain arrays with no graphics dependency; Graphite converts them to its glam-backed wire types in one adapter.

  • Value identity: n + 0i is exactly n, and -0 is exactly +0; adding or removing zero-valued parts can never change any result
  • Ladder rungs: Bool ⊂ Integer ⊂ Scalar (1) ⊂ Particle1 (1, i, the complex numbers) ⊂ Particle2 (1, i, j) ⊂ Particle3 (1, i, j, k, the quaternions), each rung being the values whose remaining bases are zero, with the unweighted Vector1/Vector2/Vector3 as each particle rung's weight-zero refinement (naturals are a nonnegativity constraint on integers, not a rung; rationals are skipped, so integer division promotes directly to Scalar)
  • Basis order: ijk = xyz, the real part is the weight w, and values are written and queried in Hamilton's order w, x, y, z; the weight is not the trailing homogeneous coordinate of a graphics xyzw vector, since matrices never divide by it, so w-last does not apply
  • * is always the Hamilton product, never componentwise (v * v = -|v|²; a complex number rotates the 1, i plane, so rotating a canvas Vector2 is rotate())
  • Promotion: an operation whose answer does not exist at a value's rung climbs minimally (sqrt(-4) -> 2i, ln(-1) -> , asin(2) -> its complex value); a real input needing a complex answer resolves into the (1, i) plane
  • Branch selection keys on value, not storage: root(-8, 3) and root(-8 + 0i, 3) are both -2
  • Integer exactness: integer-rung arithmetic (+, -, *, %, gcd, lcm) is exact beyond f64's 2^53 limit; overflow past the widest storage promotes to Scalar
  • No NaN: no operation returns NaN; indeterminate forms (0/0, ∞ - ∞, 0 * ∞) are evaluation errors, and a NaN arriving through a host binding is an evaluation error at its point of use
  • Ordering is real-only: <, <=, >, >= are evaluation errors on values with nonzero vector parts
  • Componentwise mapping: floor, ceil, round, trunc, fract, sign, abs, min, max, and clamp act on each part of a vector (GLSL's split, with abs per part and |x| the length)
  • Functions of a Particle3 (a quaternion) act in the input's own complex plane (spanned by 1 and its normalized vector part); x / y = x * y⁻¹ and q^p = exp(ln(q) * p)
  • Minimal representation: the evaluation API reports the lowest rung that losslessly holds a result, so hosts can choose the right query
  • Booleans are the refinement {0, 1} of the integer rung, not a separate type: comparisons evaluate to 1 or 0, and host boolean bindings enter as 1 or 0
  • Logical contexts (&&, ||, xor(), prefix !, piecewise conditions) require operands exactly 0 or 1 and error otherwise, so a general number becomes a truth val