Key Concepts
This chapter explains the design decisions that affect how you write code with symplex. Understanding these concepts will help you read error messages, choose the right method variants, and structure your programs effectively.
Contexts and Ownership
Every expression in symplex belongs to a Context. The context owns an arena (a pool of expression nodes) and manages symbol names, assumptions, and evaluation settings.
#![allow(unused)]
fn main() {
use symplex::prelude::*;
let ctx = Context::new();
let x = ctx.symbol("x");
let f = x.powi(2) + 1; // this expression lives in ctx's arena
}
Why contexts exist
Contexts serve two purposes:
-
Isolation. Different contexts have independent symbol tables and assumptions. A server handling multiple users can give each one a separate context without interference.
-
Safety. Expressions from different contexts cannot be mixed. If you try to add an expression from
ctx_ato one fromctx_b, the library detects this and panics with a clear message. This is the only panic in the symbolic layer — it guards against a logic error analogous to indexing out of bounds.
#![allow(unused)]
fn main() {
let ctx_a = Context::new();
let ctx_b = Context::new();
let x = ctx_a.symbol("x");
let y = ctx_b.symbol("y");
// This panics: "cannot combine expressions from different contexts"
// let bad = &x + &y;
}
Cloning and thread safety
Context is Clone — cloning shares the underlying arena via Arc<RwLock<...>>. Multiple threads can hold clones of the same context and create expressions concurrently. Ex (the expression handle type) is Send + Sync.
#![allow(unused)]
fn main() {
let ctx = Context::new();
let ctx2 = ctx.clone(); // shares the same arena
std::thread::spawn(move || {
let y = ctx2.symbol("y");
println!("{}", y.powi(2));
});
}
The Expression Type: Ex
Ex is a type alias for Expr<Numeric>. It is the primary expression handle. Internally, it holds:
- A reference to the context (via
Arc) - An opaque index into the arena (
ExprId)
Ex is Clone (cheap — it’s just an Arc bump and a u32 copy) but not Copy. You will often work with &Ex references to avoid unnecessary clones.
There are also BoolEx (for boolean expressions like x > 0) and SetEx (for set-valued expressions like solution sets). These are distinct types — you cannot pass a BoolEx where an Ex is expected.
The Five API Patterns
Every operation in symplex follows one of five patterns. Knowing which pattern a method uses tells you what to expect from its return type.
Pattern 1: Always returns Ex
Operations where “unchanged” or “unevaluated” is a valid result. These never fail — they always return something meaningful.
#![allow(unused)]
fn main() {
expr.simplify() // might return input unchanged
expr.expand() // might return input unchanged
expr.eval() // sin(0) → 0; symbolic expr → unchanged
expr.diff(&x) // might return Derivative(expr, x) if it can't differentiate
expr.integrate(&x) // might return Integral(expr, x) if no closed form
expr.limit(&x, &a) // might return Limit(expr, x, a)
}
Pattern 2: try_ variant returns Result
For users who need guaranteed closed-form results (e.g., in a code generation pipeline), every Pattern 1 method that can produce an unevaluated form has a try_ twin:
#![allow(unused)]
fn main() {
// CAS-style: always returns something
let anti = expr.integrate(&x);
// Pipeline-style: Err if unevaluated
let anti = expr.try_integrate(&x)?;
}
The try_ variant calls the base method, then checks has_unevaluated(). There is zero code duplication between the two.
Available: try_diff, try_integrate, try_integrate_definite, try_limit, try_limit_left/right/dir, try_series, try_series_at_infinity, try_maclaurin, try_summation, try_product_over, try_laplace, try_inverse_laplace, try_residue, try_gosper_sum, try_solve_ode, try_solve_gt/ge/lt/le.
Some try_ variants carry extra information in the error: try_integrate_definite returns Err(SymplexError::Divergent { .. }) when the integral is proven to diverge, as opposed to Err(ComputationFailed) when no closed form was found.
Pattern 3: Numeric boundary → Result
Operations that cross from symbolic to numeric always return Result, because the conversion can fail if free symbols remain:
#![allow(unused)]
fn main() {
expr.eval_f64() // Err if free symbols remain
expr.eval_complex64() // Err if can't evaluate
expr.eval_decimal(30) // Err if precision exhausted
expr.compile(&["x"]) // Err(FreeSymbol / NotImplemented) → Result<CompiledFn>
expr.to_rust_fn("f", &["x"]) // Err if can't generate code
expr.to_c_fn("f", &["x"]) // same, C99
expr.integrate_numeric(&x, &a, &b) // Err if the quadrature does not converge
}
Solvers whose failure is a mathematical fact also use Result: solve returns Err(InfiniteSolutions) for an identity and Err(NoSolution) for a contradiction or range violation (sin x = 2); linsolve returns Ok(LinearSolution::Inconsistent) because inconsistency is a legitimate answer, but Err(InvalidArgument) for non-linear input.
Pattern 4: Queries → Option
Three-valued queries return Option — the answer might be yes, no, or “can’t determine”:
#![allow(unused)]
fn main() {
expr.is_positive() // Some(true), Some(false), or None
expr.degree(&x) // Some(3) or None (not a polynomial)
expr.equals(&other) // Some(true), Some(false), or None
expr.is_convergent(&k) // decisive answers only
set.contains(&elem) // set membership
matrix.is_symmetric() // structure tests on matrices are three-valued too
}
Pattern 5: Structural preconditions → Result
Operations with structural requirements (e.g., matrix operations that require specific shapes):
#![allow(unused)]
fn main() {
matrix.det() // Err if non-square
matrix.inv() // Err if singular
matrix.matmul(&other) // Err if dimensions don't match
matrix.cholesky() // Err if not symmetric / not positive definite
matrix.minor(0, 0) // Err if out of range (the sub-matrix is minor_matrix)
}
Unevaluated Forms
When symplex cannot compute a closed-form result, it returns an unevaluated symbolic node. This is a deliberate design choice — the library never returns a wrong answer or silently drops a computation.
#![allow(unused)]
fn main() {
let ctx = Context::new();
let x = ctx.symbol("x");
// No closed-form antiderivative exists for exp(x²)
let result = expr!(ctx, exp(x^2)).integrate(&x);
println!("{result}"); // Integral(exp(x^2), x)
}
The expression Integral(exp(x^2), x) is not an error — it is a truthful representation of the mathematical object “the integral of exp(x²) with respect to x.” It can be:
- Displayed as text or LaTeX
- Substituted into larger expressions
- Checked with
.has_unevaluated() - Rejected with
try_integrate()if you need a closed form
Common unevaluated forms:
| Node | Meaning |
|---|---|
Derivative(f, x) | Derivative that couldn’t be computed |
Integral(f, x) | Antiderivative not found |
Integral(f, x, a, b) | Definite integral that could not be decided (DefiniteIntegral node; eval_f64 evaluates it numerically) |
Limit(f, x, a) | Limit couldn’t be determined (including a two-sided limit whose one-sided limits differ) |
Series(f, x, a, n) | Series expansion failed |
Sum(f, k, a, b) / Product(f, k, a, b) | No closed form for the sum / product |
LaplaceTransform(f, t, s) | Not in the Laplace table |
re(z), im(z), conjugate(z), arg(z) | Realness of z unknown |
stirling2(n, k) | Stirling number with symbolic arguments |
RootOf(poly, index) and RootSum(poly, body, var) are not unevaluated: they are complete, exact descriptions of algebraic numbers (with numerical evaluation), so has_unevaluated() returns false for them and try_ methods accept them.
Evaluation Configuration
You can control computational limits via EvalConfig:
#![allow(unused)]
fn main() {
let config = EvalConfig {
max_pow_exponent: 1000, // don't auto-evaluate 2^5000
max_result_digits: 5000, // cap result size
max_evalf_precision: 10_000, // max bits for numerical eval
};
let ctx = Context::with_config(config);
}
When a computation exceeds these limits, the result stays in unevaluated form rather than consuming unbounded memory. For example, 2^5000 with max_pow_exponent = 1000 remains as 2^5000 (a Pow node) instead of computing a 1,505-digit number.
Assumptions
You can declare properties of symbols to help the simplifier:
#![allow(unused)]
fn main() {
let x = sym!(ctx; x, Positive); // x > 0
let n = sym!(ctx; n, Integer); // n ∈ ℤ
}
With x declared positive, sqrt(x²) simplifies to x (without the assumption, the result is |x| or stays as sqrt(x²)).
Assumptions matter for correctness, not just for prettier output. Without Real, z.re() stays re(z), √(z²) does not become |z|, and ∫₀^∞ e^(−a x) dx will not simplify to 1/a (it needs a > 0).
Available assumptions include Positive, Negative, NonNegative, NonPositive, Integer, Real, ExtendedReal, Complex, Even, Odd, Prime, Finite, Zero, NonZero, and their negations (NotPositive, NotZero, …). ctx.symbol_with("a", &[Assumption::Positive]) is the non-macro form; Assumptions::implies and Assumption::negate let you reason about them programmatically.
Next Steps
You now understand the core abstractions. The Guide chapters cover each mathematical domain in depth, and the Cookbook shows complete worked solutions to real problems.