Symbolica 2.0: programmable symbols – Symbolica | Modern Computer Algebra
Pangram verdict · v3.3
We believe that this document is fully human-written
AI likelihood · overall
HumanArticle text · 1,319 words · 6 segments analyzed
Introduction Symbolica is a high-performance symbolic computation framework for Python and Rust. You can use it to manipulate symbolic expressions and turn them into fast numerical kernels for computing Jacobians, numerical optimization, integration, and much more. Today marks the 2.0 release of Symbolica, with many exciting new features and improvements. The theme of this release is programmable symbols: more of Symbolica’s behavior can now be customized by the user. This makes it possible to define mathematical objects that simplify, differentiate, expand, print, and evaluate like built-ins. Since 1.0, Symbolica has accumulated improvements in several directions:
a simpler Rust API with more operator overloading and builder-style APIs a symbol registration system, with namespaces, aliases, tags, user data, and custom hooks a redesigned evaluator interface that supports double-float arithmetic and JIT compilation richer output for notebooks and documents, including HTML output, graph and polynomial display, Typst output, colorized printing, and more structural multiline formatting new built-in mathematical functions, including gamma, polylogarithms, Bessel functions, Riemann zeta, and related series/evaluation hooks
See the migration guide to learn more about the changes and how to update your code.
Better output Symbolica gained an automatic line-wrapping output mode, with colored brackets, similar to how code is styled. This makes it easier to read large, nested expressions. In notebooks, such as Jupyter or Marimo, the default output is a colorful HTML-mode and you can easily switch to LaTeX mode. Typst output is available now as well.
Improved Rust API One of the most visible changes is that ordinary Rust programs need far fewer imports and fewer long type paths. The new prelude collects the common traits, macros, domains, and evaluator types that most users need. Rust ergonomics have also been improved with additional overloads, automatic type conversions, builder patterns, and a call method on symbols:
Symbolica 2.0Symbolica 1.0
use symbolica::prelude::*;
fn main() { let (x, y, f) =
symbol!("x", "y", "f"); let e: Atom = 2 + (x + 1).pow(-2) + f.call((1 + y, y)) / 3; let s = e.series(x, 0, 1).unwrap();
println!("{e}"); // -> 2+1/3*f(1+y,y)+1/(1+x)^2 println!("{s}"); // -> 3+1/3*f(1+y,y)-2*x+𝒪(x^2) } use symbolica::{ atom::{Atom, AtomCore}, function, symbol, };
fn main() { let (x, y, f) = symbol!("x", "y", "f"); let e = Atom::num(2) + (Atom::var(x) + 1).npow(-2) + function!(f, Atom::num(1) + y, y) / 3; let s = e.series(x, Atom::num(0), 1.into(), true).unwrap();
println!("{e}"); // -> 2+1/3*f(1+y,y)+1/(1+x)^2 println!("{s}"); // -> 3+1/3*f(1+y,y)-2*x+𝒪(x^2) }
Structures used for settings and functions with many arguments now use a builder pattern. For example, the construction of a high-performance numerical evaluator now looks like this:
Symbolica 2.0Symbolica 1.0
use symbolica::prelude::*;
fn main() -> Result<(), EvaluationError> { let mut evaluator = parse!("x^2 + 2*x + 1 + f(x)") .evaluator(&[parse!("x")]) .add_function(symbol!("f"), vec![symbol!("y")], parse!("cos(y + 1)"))? .horner_iterations(2) .build()? .map_coeff(&|c| c.re.to_f64());
println!("{}",
evaluator.evaluate_single(&[3.0])); Ok(()) } use symbolica::{atom::AtomCore, evaluate::{FunctionMap, OptimizationSettings}, parse, symbol};
fn main() { let mut fn_map = FunctionMap::new(); fn_map .add_function( symbol!("f"), "f".to_string(), vec![symbol!("y")], parse!("cos(y + 1)"), ) .unwrap();
let params = vec![parse!("x")]; let optimization_settings = OptimizationSettings { horner_iterations: 2, ..OptimizationSettings::default() }; let mut evaluator = parse!("x^2 + 2*x + 1 + f(x)") .evaluator(&fn_map, ¶ms, optimization_settings) .unwrap() .map_coeff(&|c| c.re.to_f64());
println!("{}", evaluator.evaluate_single(&[3.0])); }
With the builder pattern, settings arguments that used to belong to different substructures (add_function belongs to FunctionMap, horner_iterations belongs to OptimizationSettings) can now be set together in a more fluent way. Also note that fallible operations now return a dedicated error type.
Programmable symbols In Symbolica 1.0, a symbol could already carry important algebraic attributes:
Python Rust
from symbolica import *
x, y = S("x", "y") dot = S("dot", is_symmetric=True, is_linear=True)
print(dot(3*x + y, x + 2*y)) # -> 3*dot(x,x)+7*dot(x,y)+2*dot(y,y) use symbolica::prelude::*;
fn main() { let dot = symbol!("dot"; Symmetric, Linear); println!("{}", parse!("dot(3*x+y, x+2*y)")); // -> 3*dot(x,x)+7*dot(x,y)+2*dot(y,y) }
In 2.0, symbols can now install more hooks that run at specific points in the algebraic lifecycle:
Hook Use case
Normalization Rewrite a symbol or function when it is normalized.
Printing Override Symbolica, LaTeX, or custom-mode output.
Derivative Define a custom derivative rule.
Series Teach Symbolica how a function behaves near a singular point.
Evaluation Register numerical implementations for evaluators.
For example, let us define a gamma function. Since gamma has a pole at 0, we cannot Taylor expand around 0. Using the series hook we can tell Symbolica how to regularize the function near that point by applying the identity \(\Gamma(a + 1) = a \Gamma(a)\):
Python Rust
from typing import Sequence from symbolica import *
x = S("x")
def regularize_gamma(args: Sequence[Series]) -> tuple[Expression, Expression] | None: if args[0].get_coefficient(0) == 0: a = args[0].to_expression() return (1 / a, S("gamma")(a + 1))
gamma = S( "gamma", derivative=lambda t, _: t * E("digamma")(t[0]), series=regularize_gamma, ) print(gamma(x).series(x, 0, 0)) # gamma(1)*x^-1+gamma(1)*digamma(1)+𝒪(x^1) use symbolica::prelude::*;
fn main() { let _gamma = symbol!( "gamma", der = |f, index, out| { if index == 0 { let arg = f.as_fun_view().unwrap().get(0); **out = symbol!("gamma").call(arg) * symbol!("digamma").call(arg); } }, series = |args| { if args[0].coefficient(0.into()) == Atom::Zero { let a = args[0].to_atom(); Some((1 / &a, symbol!("gamma").call(a + 1))) } else { None } } );
let x = symbol!("x"); println!("{}", parse!("gamma(x)").series(x, 0, 0).unwrap()); }
For this example, the built-in gamma function simplifies further to 1/x-γ.
Evaluators: from expression trees to compiled kernels Expression evaluation has seen the largest amount of engineering since 1.0.
The high-level flow is still the same: Symbolica rewrites an expression into a small instruction program, optimizes it, then evaluates it many times for different numeric inputs. See this blog post for the details of how this works.
Evaluators for symbols Let us define a custom symbol cosh and teach Symbolica how to evaluate it for different numeric domains. We can do this by registering an evaluation hook:
Python Rust
import cmath import math from symbolica import *
x = S("x") cosh = S( "cosh", eval={ "float": lambda args: math.cosh(args[0]), "complex": lambda args: cmath.cosh(args[0]), "cpp": "template<typename T> T python_cosh2(T a) { return std::cosh(a); }", }, )
expr = cosh(1/2) + x expr.to_float() # -> 1.1276259652063807 + x use symbolica::prelude::*;
fn main() { let cosh = symbol!( "cosh", eval = EvaluationInfo::new() .register(|args: &[f64]| args[0].cosh()) .register(|args: &[Complex<f64>]| args[0].cosh()) );
let x = symbol!("x"); let expr = cosh.call(Atom::num(1) / 2) + x; println!("{}", expr.to_float(16)); // -> 1.127625965206381+x }
Since it is known how to evaluate cosh, Symbolica looks up a suitable evaluator in the call to to_float.
JIT compilation Besides generating custom ASM, C++, and CUDA code, Symbolica can now compile evaluators just in time. This uses the symjit crate by Shahriar Iravanian, with whom we worked closely to make the integration feel native to Symbolica. The JIT path supports custom evaluator hooks and is now the default evaluation backend for Python. In practice, it is competitive with the custom ASM backend while keeping compilation times under control.
Double-float arithmetic Symbolica 2.0 adds a double-float evaluation path.
Double-float arithmetic stores a number as the unevaluated sum of two f64s, giving roughly 106 bits of precision (31 decimal digits compared to 16 for ordinary doubles) while staying more than 3x faster than arbitrary-precision Float arithmetic. In Python, evaluate_with_prec(..., 32) takes this path automatically.
Special functions Another major development made possible by the new hook system is that Symbolica has grown a larger mathematical vocabulary. Symbolica now supports polygamma functions, polylogarithms, Bessel functions, Riemann zeta, geometric functions and their inverses, with evaluation hooks and Laurent/Puiseux series behavior around poles. Some special values normalize immediately: from symbolica import *
print(E("gamma(1/2)")) # 𝜋^(1/2) print(E("polylog(-3, z)")) # z*(1+4*z+z^2)/(1-z)^4 All special functions can be evaluated: g = E("gamma(5/6)") print(g) # gamma(5/6) print(g.to_float()) # 1.128787029908126 Symbolica can use special functions inside optimized evaluators: from symbolica import * import numpy as np
x = S('x') e = E('pi + zeta(3) + gamma(x)') ev = e.evaluator([x])
print(e) # gamma(x)+zeta(3)+𝜋 print(ev.evaluate(np.array([[2.0]]))) # [[5.34364956]] When exporting code, constants such as \(\pi\) and \(\zeta(3)\) are replaced by their numerical value, so the generated code does not need to depend on a special function library.
Faster manipulation Not all important changes are visible in the public API.