Symbolic Math Library (arael-sym)
arael-sym provides a lightweight computer algebra system built around a reference-counted expression tree (E). Expressions are constructed from symbols and constants, combined with standard arithmetic operators (which auto-simplify), and then differentiated, evaluated, pretty-printed, or compiled to Rust source code.
This crate is the symbolic engine behind the arael optimization framework, where it powers compile-time constraint differentiation and code generation. It can also be used independently for any symbolic math task.
See examples/sym_demo.rs for a runnable walkthrough covering every section below (cargo run --example sym_demo).
Scope and limitations
arael-sym is focused on what's needed for nonlinear optimization: scalar expressions, differentiation, and code generation. Compared to a full CAS like Python's SymPy, it does not support:
- Symbolic integration
- Equation solving (solve for x)
- Symbolic matrix algebra (symbolic determinant, inverse, eigenvalues)
- Polynomial factoring, GCD, partial fractions
- Limits, series expansion, Taylor series
- Assumptions / domain reasoning (positive, real, integer)
- Pattern matching / rewrite rules
- Pretty-printing of intermediate simplification steps
Basics
use *;
use sym;
sym!
The symbols! macro expands each bare identifier to
symbol("<name>") and returns a tuple.
The sym! macro auto-inserts .clone() on variable reuse, eliminating ownership boilerplate.
Every expression has type arael::sym::E, defined as struct E(Rc<Expr>). Cloning is cheap (a reference-count bump) -- the .clone() calls sym! inserts don't duplicate the expression tree.
Auto-simplification
All operations auto-simplify:
sym!
Derivatives
The library implements all standard calculus rules:
sym!
Trigonometric derivatives
sym!
Expansion and Collection
sym!
Evaluation and Substitution
use hashmap;
sym!
Kinematics example
sym!
Free Variables
sym!
Linear Algebra
sym!
Output Formatting / Code Generation
Any E renders three ways: Display for human reading, to_latex() for typeset output, and to_rust("f64") / to_rust("f32") for generated Rust code (the scalar type controls powf suffixes and literal formatting).
sym!
Output:
Display: (-x + 1)^2 + 100 * (-x^2 + y)^2
LaTeX: \left(-x + 1\right)^{2} + 100 \cdot \left(-x^{2} + y\right)^{2}
Rust f64: (-x + 1.0_f64).powf(2.0_f64) + 100.0_f64 * (-x.powf(2.0_f64) + y).powf(2.0_f64)
Common Subexpression Elimination
cse(&[expr0, expr1, ...]) walks a batch of expressions, finds subtrees that appear more than once across the batch, and factors them into named intermediates. Paired with to_rust, it produces generated code that computes the shared work once.
Continuing the Rosenbrock example, its value and its two partial derivatives share y - x*x and 1 - x:
sym!
Output:
let __x1 = -x + 1.0_f64;
let __x0 = -x.powf(2.0_f64) + y;
let f = __x1.powf(2.0_f64) + 100.0_f64 * __x0.powf(2.0_f64);
let df_dx = -400.0_f64 * (x * __x0) - 2.0_f64 * __x1;
let df_dy = 200.0_f64 * __x0;
y - x*x and 1 - x each appear once, as __x0 and __x1, rather than being recomputed at every use. CSE is applied automatically by arael's constraint code-generation macro, where batches grow much larger -- one SLAM constraint went from 47000 ops to ~400 after CSE.
Custom Functions
The library can build named function nodes (Expr::Func) that carry a body, formal parameters, and a behavioural kind. Use these when you want a function that participates in differentiation and code generation but stays distinct in the expression tree (e.g., to avoid CSE across the call boundary, or to call out to an extern Rust function at eval time).
Three families exist, picked based on how derivatives and numeric eval are produced:
| constructor | body for diff / codegen | numeric eval | per-arg derivs |
|---|---|---|---|
simple_func1 / 2 / func |
symbolic body inlined | inlined body | auto-diffed |
simple_func1_derivs / 2_derivs / _derivs |
symbolic body inlined | inlined body | explicit |
extern_func1 / 2 / func |
(none -- external) | eval_fn: fn(&[f64]) -> f64 |
explicit |
Each constructor returns a closure that, when applied to actual argument expressions, produces an Expr::Func E.
Symbolic with auto-diff
sym!
The body lambda runs once with placeholder symbols to capture the body expression; auto-differentiation operates on that body when the resulting Func is differentiated.
Symbolic with explicit derivatives
When auto-diff would yield brittle or expensive derivatives, supply them explicitly:
sym!
The arael-sym built-ins safe_sqrt, safe_atan2, safe_asin, safe_acos, rad_diff, and rad_sum are themselves built using these simple_func*_derivs / extern_func* constructors -- their source is a useful reference for non-trivial derivative wiring.
Extern (call out to a Rust function at eval)
When the body is implemented natively (not as a symbolic expression), use extern_func1/2/func. The function is generated as a normal Rust call (call_path(args...)) in to_rust_* codegen, and uses eval_fn for numeric evaluation.
sym!
FuncKind: the underlying enum
Every Expr::Func carries one of three FuncKind variants:
FuncKind::Symbolic { body }-- the simplest case; the body is auto-differentiated and inlined for evaluation and codegen.FuncKind::SymbolicDerivs { body, derivs }-- body for evaluation/codegen, explicit per-argument derivatives.FuncKind::Extern { derivs, eval_fn, call_path }-- explicit derivatives, native eval function, codegen emitscall_path(args...).
You can construct Expr::Func values directly via FuncKind if you need to bypass the constructors above; usually the constructors are easier.
Switching and Clamping: heaviside, clamp
heaviside(x)
The Heaviside step function: 0 for x < 0, 1 for x >= 0. Auto-differentiates to 0 everywhere -- the true derivative is a Dirac delta, whose applications in numeric calculations are limited, so we drop it. H is a parser-level alias: parse("H(x)") is the same as parse("heaviside(x)").
clamp(value, lo, hi)
Clamps the value to [lo, hi] for numeric evaluation. Differentiation passes through as if clamp were the identity on value: d/dvar clamp(v, lo, hi) is v.diff(var), independent of the bounds. This makes clamp useful for bounding the input of an inner function whose math is undefined or numerically unstable outside [lo, hi], without the derivative flattening to zero at the boundary.
sym!
The catch: at |x| >= 1, clamp(x, -1, 1) = +/-1, so asin's derivative 1 / sqrt(1 - x^2) becomes 1 / sqrt(0) -- numerically NaN or infinite. The pass-through derivative is the right choice for inputs strictly inside [lo, hi], but it doesn't tame a singularity at the boundary. When the inner function has one (as asin does at |x| = 1), the standard fix is to replace the auto-diffed derivative with an epsilon-regularised explicit derivative via simple_func1_derivs, as the next subsection shows.
Example: building safe_asin from scratch
The arael-sym built-in safe_asin combines clamp for the body with an epsilon-regularised derivative supplied via simple_func1_derivs:
sym!
Why explicit derivatives? Auto-differentiating asin(clamp(x, -1, 1)) would produce (d/dx clamp) / sqrt(1 - clamp(x, -1, 1)^2), which still diverges at the boundary because clamp's derivative is the identity. The regularised version replaces sqrt(1 - x^2) with sqrt(1 - clamp(x, -1, 1)^2 + eps^2) (clamp on both the body and the derivative input) and uses identity to defend the subtraction from simplifier reordering.
The same pattern -- simple_func*_derivs plus clamp and/or epsilon-regularisation in the derivative -- is how safe_acos, safe_sqrt, safe_atan2, and similar are implemented.
Parsing
parse(input) reads an expression in standard infix notation: arithmetic, parentheses, function calls, the ^ operator for power, and the named constants pi and e. Numeric literals accept an optional scientific exponent (1e-12, 2.5E+2). Anything else becomes a free symbol.
let e: E = "x^2 + 3*x + 1".parse.unwrap;
let f = parse.unwrap;
let g = parse.unwrap;
println!; // cos(x)^2 * exp(sin(x)) - exp(sin(x)) * sin(x)
Built-in functions recognised: sin, cos, tan, asin, acos, atan, atan2, sinh, cosh, tanh, exp, ln, log2, log10, sqrt, abs, heaviside (alias H), clamp, pow, rad_diff, rad_sum, safe_atan2, safe_sqrt, safe_asin, safe_acos, identity. The full list is also enumerable at runtime via function_names() / FUNCTIONS.
User-defined functions: parse_with_functions + FunctionBag
The plain parse only knows the built-in function set. To recognise additional functions defined at runtime, pass a FunctionBag to parse_with_functions:
let mut bag = new;
bag.add1.unwrap;
let e = parse_with_functions.unwrap;
assert_eq!;
The parser checks the bag first then falls back to built-ins, so:
- An empty
FunctionBagbehaves exactly like plainparse(built-ins always available). - Adding a name that matches a built-in shadows it for the duration of the parse.
parse(s)is shorthand forparse_with_functions(s, &FunctionBag::new()).
Ways to register a function in the bag:
// add1 / add2: pass the closure returned by simple_func1 / simple_func2
// (or extern_func1 / extern_func2). The bag invokes it
// once with placeholder symbols to extract name, params,
// and kind.
bag.add1.unwrap;
bag.add2.unwrap;
// addN: n-ary closure. Takes `Vec<E>`, matching the shape of
// `simple_func` / `simple_func_derivs` / `extern_func`. No
// upper arity bound.
bag.addN.unwrap;
// add: register an already-formed Expr::Func E directly (e.g. after
// pre-applying a constructor to placeholder symbols).
let cube = simple_func1;
bag.add.unwrap;
// add_symbolic: explicit name + parameter list + body. Use when the
// body is an already-built E (e.g. from parse).
bag.add_symbolic;
For escape-hatch cases there's also add_with_kind(name, params, FuncKind) that takes the parts directly.
Plus remove(name) -> bool, contains(name), names() -> Vec<String>, entries() -> impl Iterator<Item=(&str, usize)> for management, and get_info(name) -> Option<(&[String], &FuncKind)> for read-only inspection.
Parameter shadowing
Formal parameters always shadow outer variables of the same name during the function body's evaluation. This is what you want for an interactive REPL: defining sq(x) = x*x after x = 5 should still yield 9 when you call sq(3), not 25.
use hashmap;
let mut bag = new;
bag.add_symbolic;
let e = parse_with_functions.unwrap;
let vars = hashmap!;
assert_eq!; // 3*3, not 5*5
See examples/calc_demo.rs for a complete bc-style REPL built on FunctionBag + parse_with_functions, with readline-style history.