Introduction
symplex is a symbolic mathematics library for Rust. It represents mathematical expressions as exact symbolic objects — not floating-point approximations — and provides operations for differentiation, integration, summation, equation solving, simplification, series expansion, integral transforms, and code generation.
Who This Is For
symplex is designed for Rust developers who need symbolic computation as part of a larger system. Typical use cases include:
-
Robotics and control systems. Derive a Jacobian or transfer function symbolically, then generate optimized Rust or C code that runs in a real-time control loop. The library includes Denavit–Hartenberg parameter support, Lagrangian dynamics, state-space models, and Laplace/Fourier/Z transforms.
-
Code generation from mathematics. Write a formula once as a symbolic expression, differentiate it, simplify, and emit an optimized Rust or C99 function with common subexpression elimination,
fma, and a self-contained special-function runtime. -
Physics and engineering with units. symplex provides compile-time dimensional analysis: adding a
Massto aLengthis a compiler error, and differentiatingLengthwith respect toTimeproducesVelocity. -
Numerical methods development. Derive finite-difference stencils, verify integration formulas, compute Taylor series and formal power series symbolically, then evaluate numerically with arbitrary precision or adaptive quadrature.
What It Provides
| Category | Capabilities |
|---|---|
| Calculus | Differentiation (all elementary and special functions), indefinite integration (15+ strategies incl. Risch + LRT log-to-real), definite and improper integration with divergence detection, adaptive Gauss–Kronrod quadrature, one-sided limits (Gruntz), Taylor/Laurent/asymptotic/formal power series, residues |
| Summation | Faulhaber, Gosper, telescoping, binomial sums, p-series (ζ(2m) exact), power-series recognition, infinite products, convergence tests |
| Algebra | Expansion, Berlekamp–Zassenhaus factoring over ℤ (any degree), multivariate factoring, resultants/discriminants, GCD, partial fractions, rational normal form (ratsimp), Gröbner bases, a public rewrite-rule engine with AC matching and tracing |
| Polynomials | Poly view with exact rational or symbolic coefficients, monomial/coefficient access, exact evaluation, coefficient matrices for certificate searches, exact sign of a polynomial on an interval (Sturm) |
| Solving | Polynomials through quartic by radicals, RootOf/RootSum beyond, transcendental via Lambert W, general periodic solutions, linear systems (unique/parametric/inconsistent), polynomial systems with algebraic solutions, damped Newton, inequalities, 16 ODE classes + initial-value problems, linear recurrences |
| Linear programming | Exact simplex over ℚ with shadow prices and Farkas infeasibility certificates; feasible_nonneg for non-negative combinations |
| Sets & logic | Interval algebra with a normal form, three-valued membership/subset queries, reduce_inequalities, NNF/CNF/DNF, DPLL satisfiability, truth tables |
| Linear algebra | Determinant, inverse, eigenvalues (exact RootOf for irreducible cubics/quartics), Jordan form, matrix exponential/power/square root, QR, Cholesky, LDLᵀ, LU, Gram–Schmidt, structure tests, norms, least squares; over ℤ: Hermite and Smith normal forms, integer kernels, lattice determinants |
| Numerical optimisation | Brent/bisection/Newton root finding, Nelder–Mead, Brent and golden-section scalar minimisation, deterministic differential evolution, floating-point and exact least-squares polynomial fits, trapezoidal rule |
| Transforms | Laplace (forward/inverse, initial/final value), Fourier (three conventions), Mellin (with fundamental strip), Z, Fourier series on arbitrary intervals |
| Complex analysis | re/im/conjugate/arg honest about unknown realness, as_real_imag, polar form, complex infinity |
| Number theory | Pollard–Brent rho + ECM factorization, BPSW primality, modular square roots, discrete logarithms, primitive roots, continued fractions, Pell and other Diophantine equations, CRT |
| Combinatorics | Stirling numbers (both kinds), Bell, Catalan, derangements, Fibonacci/Lucas, Bernoulli/Euler numbers, multinomial coefficients, integer partitions |
| Special functions | Gamma, log-gamma, digamma/polygamma, erf/erfc, Beta, Bessel J/Y/I/K, Lambert W, Si/Ci/Ei/li, Riemann zeta, Legendre/Chebyshev/Hermite/Laguerre polynomials — all with arbitrary-precision evaluation |
| Algebraic numbers | ℚ(α) field arithmetic with exact zero/sign testing, minimal polynomials, Vieta’s formulas |
| Output | Rust and C99 code generation with CSE, compiled closures, LaTeX, JSON serialization, plots (text/SVG/TikZ) |
| Units | 30 physical quantity types with compile-time dimension checking, ~100 unit conversions (all exact rationals) |
What It Does Not Provide
Being clear about limitations is important for evaluating whether this library fits your needs.
- Geometry, statistics, and tensor algebra are not implemented. If you need these, SymPy is the more complete choice today.
- PDE solving is not available. ODE solving covers 16 classes; partial differential equations are out of scope for now.
- Group theory is limited. There is no permutation group, symmetric group, or abstract algebra module.
- Hypergeometric / Meijer-G machinery is absent; definite integration relies on antiderivatives, symmetry, and a table of ~30 classical improper integrals.
- Interactive notebooks are not part of the library. symplex is a Rust library, not an application. A basic REPL is available as an example (
cargo run --example repl), andsymplex-wasmexposes aSessionAPI for the browser. - Test coverage, while substantial (~11,000 tests including cross-validation against SymPy), is far less than what SymPy has accumulated over 30 years of development.
Design Principles
These choices are deliberate and pervasive:
-
Exact arithmetic. All numbers are
Ratio<BigInt>. The expression1/3is the exact rational one-third, not0.33333.... Floating-point numbers appear only when explicitly requested viaeval_f64(),compile(), orintegrate_numeric(). -
Explicit state. Every expression belongs to a
Context. There is no hidden global state. This makes the library safe for multi-tenant servers, concurrent computation, and deterministic testing. -
Type safety. Numeric expressions (
Ex), boolean expressions (BoolEx), and set-valued expressions (SetEx) are distinct Rust types. Passing a boolean tosin()is a compile error. -
Thread safety.
ContextisClone(Arc-based) andExisSend + Sync. Multiple threads can work with the same context without data races. -
Honest failure. Operations that cannot produce a closed-form result return unevaluated symbolic forms.
∫x^x dxreturnsIntegral(x^x, x)— a truthful representation of the problem — rather than an incorrect value or a panic.∫₋₁¹ dx/x²isErr(Divergent), not−2.re(z)staysre(z)unlesszis known to be real. Numerical operations returnResult.
How to Read This Book
- What’s New in 0.3 tours the current release — polynomial views, exact linear programming, integer normal forms and numerical optimisation; What’s New in 0.2 summarises the previous release for readers upgrading from 0.1.
- Getting Started covers installation, creating your first expressions, and the key concepts you need to be productive.
- Guide chapters are tutorial-style introductions to each domain. Every code block is a complete program you can paste into
main.rs(blocks markedignoreare fragments). - Cookbook entries are worked solutions to real engineering and science problems, backed by runnable examples in
examples/. - Reference documents the API conventions, error handling, the 0.1 → 0.2 migration, and a migration guide for SymPy users.
For API documentation of individual functions and types, see docs.rs/symplex.
What’s New in 0.13 / 0.14
0.14 continues on data: stats::reliability (Cronbach’s α, KR-20,
split-half, item analysis, κ confidence intervals and tests, Cochran’s Q,
Somers’ D / Goodman–Kruskal γ, χ² residuals, Pearson inference),
stats::regression (exact OLS/WLS with the full inference table, logistic
regression by IRLS), stats::survival (Kaplan–Meier with Greenwood
variances, Nelson–Aalen, log-rank — exact), stats::sequential (Wald’s
SPRT for screening as answers arrive), stats::information (KL, JS,
mutual information, exactly), stats::multivariate (multivariate normal,
covariance/correlation matrices, PCA) and stats::order (order statistics
as distributions). See the guide’s later sections.
Statistics on data. 0.12 made distributions compose; 0.13 turns to the data people actually collect — many raters answering many items — and answers the questions asked of it exactly. See Analysing Rater and Response Data for the walk-through.
- Inter-rater agreement (
stats::agreement): percent agreement, Cohen’s κ (plain and weighted), Scott’s π, Fleiss’ κ, Gwet’s AC1, Krippendorff’s α (nominal / ordinal / interval / ratio, with missing data), the six ICC forms, Kendall’s W — every one an exact rational, matching statsmodels / thekrippendorffpackage to the last digit. - Label aggregation (
stats::aggregation): majority and weighted votes, Dawid–Skene EM, Bradley–Terry, per-rater accuracy / precision / recall / F₁, exact Clopper–Pearson and Wilson intervals, gold-question screening. - Hypothesis tests (
stats::hypothesis): exact binomial, Fisher, McNemar and sign tests (p-values as rationals); t (Student, Welch, paired), z, one-way ANOVA; Mann–Whitney (exact null distribution or asymptotic), Wilcoxon, Kruskal–Wallis, Friedman, Spearman, Kendall, KS; χ² and G tests; effect sizes; Bonferroni / Holm / BH / BY; bootstrap and permutation; power and sample size. Statistics are exact expressions and p-values exact expressions through the symbolic StudentT / χ² / F CDFs. - Estimation (
stats::estimation): MLE and method of moments for the standard families, log-likelihood / AIC / BIC, conjugate Bayesian posteriors (Beta–Binomial, Gamma–Poisson, Normal, Dirichlet) with credible intervals and exact predictive tables. - Markov chains (
stats::markov) on exactQMatrixtransition matrices: stationary distributions, classes and periods, absorption probabilities and times, hitting probabilities and times. - Descriptive statistics (
stats::data), exact: moments, quantiles (both conventions), ranks, rank correlations, robust summaries and outlier screens. Distribution::quantile_f64: numeric inverse CDF for every family, through the exact CDF when it cannot be compiled.
What’s New in 0.12
Statistics, redesigned to compose. A distribution is now a
Family — a struct with its
support, density and closed forms — behind an opaque Distribution
handle that owns the generic machinery. That is what makes the new
constructions one-liners:
x.given(&event)— conditioning (Truncated):E[N | N > 0] = √(2/π),E[B | B ≥ 2] = 325/131.x.transform("Y", &g)—aX + bwith every closed form transported (Affine), strictly monotone maps andX²/|X|by the change-of-variables formula (Transformed), finite ranges mapped and merged.Distribution::mixture(&[(w, F), …]).- Your own families: implement
Family, wrap withDistribution::from_family.
Events through the set machinery. With numeric bounds any boolean
combination of relations in the variable is accepted — P(N² < 1),
P(N < −1 ∨ N > 1), E[X | X² > 1] — and several 0.11 answers that were
wrong are fixed (P(X = 3 ∧ X > 5), P(X > 1 ∧ X ≥ 2), P(X = ½) for an
integer variable, Uniform(0,1).cdf(3)).
New closed forms. betainc / betainc_regularized (SymPy’s
4-argument form) give Beta, StudentT and the new FDistribution
their CDFs; erfinv compiles, so Normal/LogNormal (and everything
built on them) sample; Σ C(k+c, k) xᵏ and the binomial theorem with
symbolic n and p close, so NegativeBinomial needs no polynomial
workaround.
See Migrating from 0.11 to 0.12 for the one-line fixes to code that matched on the old enums.
What’s New in 0.9 and 0.10
symplex 0.9.0 is a comprehensiveness release — additive over 0.8.2 — driven by a module-by-module survey of the crate against SymPy 1.14. Four areas got a first thorough pass; every new function is tested against reference values from that SymPy. The CHANGELOG has the full list.
Algebraic numbers and polynomial algebra
Ex::minimal_polynomial(&x) gives the minimal polynomial over ℚ of an algebraic-number expression (√2 + √3 → x⁴ − 10x² + 1), gcd_all/lcm_all are the variable-free multivariate gcd and lcm, Ex::groebner(&polys, &vars, MonomialOrder::Lex) and reduce_modulo expose Gröbner bases and normal forms without the MultiPoly dance, real_roots(&x) returns the real roots of a rational polynomial as exact RootOfs in increasing order, factor_mod(&x, p) factors over GF(p), and resultant_symbolic/discriminant_symbolic handle polynomials whose other coefficients are symbols (disc(ax² + bx + c) = b² − 4ac).
Special functions
Twenty-four functions that appear as integration results or in physics: erfi, erfinv, erfcinv, expint/E1, Shi, Chi, fresnels, fresnelc, lowergamma, uppergamma, polylog, dirichlet_eta, the Airy functions and their derivatives, complete and incomplete elliptic integrals (elliptic_k/e/f/pi), and the Gegenbauer, Jacobi, associated Legendre and associated Laguerre polynomials. Each has exact special values, a derivative rule, arbitrary-precision evalf (checked at 40 digits against mpmath on every branch — series, asymptotic, continued fraction, reflection), Display, LaTeX and parse support. integrate now reaches ∫e^{x²} = (√π/2)·erfi(x), ∫sinh(x)/x = Shi(x), ∫cosh(x)/x = Chi(x).
Analysing a function
SymPy’s calculus.util on Ex: singularities, stationary_points, maximum/minimum on unions of intervals (with one-sided limits at open or infinite endpoints), is_increasing/is_decreasing/is_monotonic/is_convex (exact for polynomial and rational derivatives via Sturm sequences; three-valued, never a guess), periodicity and function_range.
Matrices
singular_values, condition_number, a pinv that is now defined for every matrix (rank-deficient inputs go through the full-rank factorisation), rank_decomposition, hessenberg, companion, jordan_block, permanent, row/column insertion, deletion and permutation, inv_mod, matrix_log, casoratian, and an exact lll lattice reduction on ZMatrix with rational Gram–Schmidt.
0.10.0 — budgets, and the rest of the survey
0.10.0 answers a downstream generator’s request for a budget on a single prover call: PolyhedronOpts::default().with_time_limit(Duration::from_secs(150)) (or with_deadline / with_max_pivots) makes a PolyhedronProver::prove return Unknown — with budget_exhausted: Some(BudgetHit::Deadline | MaxPivots) and a Display that says so — instead of running on; the deadline is checked at every simplex pivot, across all stages and the i64 → i128 → BigInt arithmetic fallback, so a 50 ms limit returns at 50 ms. The same Budget is available directly on LpProblem::with_budget (status LpStatus::BudgetExhausted) and, as a time limit, on SosOpts. prove_poly now accepts goals carrying unused generators.
The comprehensiveness pass continues: ntheory gains nthroot_mod for any modulus, polynomial_congruence, quadratic_residues, primorial, is_carmichael and friends; a new discrete module has exact convolutions, the number-theoretic transform, Walsh–Hadamard and Möbius transforms; Context::parse_bool parses relations and Boolean connectives and parse_implicit accepts sin x/2 sin x; and expressions render to Presentation MathML (to_mathml), srepr/DOT (to_srepr, to_dot) and executable Python/NumPy/Julia (to_python, to_numpy, to_julia, with _fn variants sharing the CSE pass). Two small breaking changes in fresh 0.8 API — Tactic::Apply, Decl.preamble (use Decl::new + builders) — and LpStatus::BudgetExhausted are listed first in the CHANGELOG.
What’s New in 0.7
symplex 0.7.0 is a breaking release whose theme is the shape of the API rather than new mathematics: one result type for every certificate search, a Certificate trait, option structs that can grow without breaking anyone, a library that is verified not to panic, and a much faster exact polytope core. Migrating from 0.6 to 0.7 lists every change with its one-line fix; the CHANGELOG has the details.
One Outcome for every prover
The four provers used to return four enums with the same three arms and slightly different payloads (Refuted { point: Vec<Q> } here, Refuted { point: Q } there, Unknown { farkas, degree } versus Unknown { reason }). They now all return
pub enum Outcome<C, U> {
Proved(C), // a re-verified certificate
#[non_exhaustive]
Refuted { point: Vec<(Ex, Q)>, value: Q, param_value: Option<Q> },
Unknown(U), // what the search tried
}
under the familiar aliases BoxOutcome, HalfLineOutcome, PolyhedronOutcome, SosOutcome — so PolyhedronOutcome::Proved(c) still reads as before, a counterexample is always (variable, value) pairs, and is_proved / certificate / into_certificate / refutation / unknown / map_certificate are one implementation. The Unknown payloads are small #[non_exhaustive] structs (BoxUnknown, HalfLineUnknown, PolyhedronUnknown, SosUnknown) that implement Display, so println!("{u}") says what was tried.
The Certificate trait
BoxCertificate (the Handelman certificate, until now confusingly named Certificate), HalfLineCertificate, RealLineCertificate, PolyhedronCertificate and SosCertificate implement certificates::Certificate — goal, verify, to_lean / to_lean_with, to_json / from_json (the latter re-verifying). Generic code — a prover that falls back to another, a report over a mixed list — can now treat them alike; the inherent methods are unchanged, so nothing needs the trait in scope to keep working. RealLineCertificate gained the JSON round trip and Display it was missing.
Options that can grow
LeanOpts, PolyhedronOpts and SosOpts are #[non_exhaustive]: construct them with ::default() and the with_* builders (all three now have one per field), or assign fields on a mut default. Adding an option is no longer a breaking change — the 0.4 → 0.5 LeanOpts episode does not repeat.
Verified not to panic
CONTRIBUTING.md now spells out a practical no-panic policy (validate at the boundary, Result for failure, Option for absence, debug_assert! for invariants, std-style try_ siblings for indexing) and a ratchet test enforces it over src/: 108 unwrap/expect/unreachable! sites in library code were removed, one of them a reachable panic (wronskian on an expression-budget overflow), and the allowlist is down to the two documented logic errors, the arena’s u32 index conversion and a compile-time assertion macro. The methods that could fail on user-supplied shapes now say so in their types (StateSpace::{controllability_matrix, observability_matrix, discretize_zoh, riccati_residual, ackermann}, robotics::homogeneous, the dynamics functions); char_poly and wronskian return NaN on an ill-shaped model and have try_ siblings.
The polytope core, 3× on a real workload
Profiling a downstream decision-tree generator showed half its time in Polytope::vertices — Ratio<BigInt> containment tests, a gcd per multiplication — and most of the rest in volume re-enumerating vertices at every level of its recursion. vertices now runs in integer arithmetic throughout (half-spaces scaled once, distinct hyperplanes only, the fraction-free kernel producing each point as X / D, containment as the sign of a·X + b·D), is cached on the polytope, and volume hands each facet its own vertices. is_full_dimensional() (one LP) and interior_point() replace volume() > 0; HalfSpace::normalized() is the key that identifies a cut with its flip. The generator went from 103 s to 34 s with byte-identical output, and the users’ own MultiPoly-based tooling is served by PolyhedronProver::prove_poly, PolyhedronCertificate::used_hyps, MultiPoly::{eval_var, affine_form, as_constant, to_ex} and Q / MultiPoly in the prelude (shipped in 0.6.1).
Lean wrapping and bullets
wrap_lean measures its continuation indent from the tactic column past · bullets, so a wrapped · have … := by tac no longer swallows the next tactic into its by block (both shapes compiled against Mathlib).
What’s New in 0.6
symplex 0.6.0 is an additive release with one theme: sums of squares. The CHANGELOG has the details.
prove_sos
The certificates of 0.3–0.5 all multiply non-negative hypotheses (box bounds, half-line shifts, polyhedron facets). The one class they could not reach is a polynomial that is non-negative on all of ℝⁿ with an interior zero that is not a square factor — (x − 1)² + (y − 1)², or the AM–GM form x⁴ + y⁴ + z⁴ + 1 − 4xyz. certificates::prove_sos(goal, &vars, &SosOpts::default()) proves those by an exact sum-of-squares decomposition g = Σ dₖ·pₖ² with rational dₖ > 0 and rational-coefficient pₖ, re-verified by expanding it.
Under the hood this is the Peyrl–Parrilo pipeline made exact and self-contained: the Gram semidefinite program g = mᵀQm, Q ⪰ 0 is solved by a small dense primal–dual interior-point method written for the crate (HKM direction, Mehrotra predictor–corrector, exact steps to the cone boundary; the Gram matrices here have a few dozen rows, so no external solver is needed), the solution is rounded and projected back onto the coefficient constraints exactly, and positive semidefiniteness is decided by the rational L·D·Lᵀ of QMatrix::ldl_psd — which is the decomposition.
Goals with real zeros only have singular Gram matrices, which rounding cannot hit; the search then performs facial reduction, reading the kernel off the numerical solution and making it exact — directly when the kernel is a rational subspace, otherwise through its integer relations (LLL on the kernel lattice, after Newton-refining the zeros of the goal to double precision) — before restricting to that face and solving again. Sums of two or three random squares with irrational common zeros come back as exactly those squares (119 of 120 random cases through degree 6).
Refuted { point, value } carries an exact point where the goal is negative; a goal that is non-negative but not a sum of squares (Motzkin’s polynomial) is Unknown, never Proved.
Lean export
SosCertificate::to_lean emits
theorem two_squares (x y : ℝ) : 0 ≤ x ^ 2 + y ^ 2 - 2 * x - 2 * y + 2 := by
have h : x ^ 2 + y ^ 2 - 2 * x - 2 * y + 2 = (2 : ℝ) * (-(x / 2) - y / 2 + 1) ^ 2 + (1 / 2 : ℝ) *
(-x + y) ^ 2 := by ring
rw [h]
positivity
— ring checks the identity, positivity closes the sum of non-negative terms; two deterministic steps. Eight shapes were compiled against Mathlib with the long-line linter on and the emitted text is pinned to that file. lean_hints returns the sq_nonneg (pₖ) terms for an nlinarith skeleton of your own, and certificates round-trip through JSON with re-verification like the other kinds.
What’s New in 0.5
symplex 0.5.0 is a small minor release driven by the first hours of use of the 0.4 polyhedron certificates. Two mechanical breaking changes: PolyhedronOutcome::Refuted gained a param_value field, so patterns that destructure it need .. (or the new field), and LeanOpts gained two fields (use ..Default::default() or the with_* builders, as recommended since 0.4). Everything else is additive; the CHANGELOG has the list.
A prover over a fixed hypothesis set
A decision tree certifies dozens of facets per leaf against the same hypotheses. PolyhedronProver::new(&hyps, param, &opts)? parses them and builds every LP stage’s product basis once; .prove(&goal) and .prove_empty() then run only the goal-dependent part (λ columns, monomial rows, the LP). The one-shot functions are wrappers over it.
let prover = PolyhedronProver::new(&hyps, Some((&j, &j0)), &PolyhedronOpts::default())?;
for facet in &leaf_facets {
match prover.prove(facet)? { … }
}
let empty = prover.prove_empty()?;
Lean rendering hooks
LeanOpts::with_symbol_text("J", "(j : ℝ)")renders a symbol as arbitrary Lean text wherever it occurs — goals, hypotheses,λ, thehgline — without touching hypothesis names likee1J. The parameter of a proof is usually a cast natural; now no token-aware post-processing is needed.LeanOpts::with_single_fraction(true)prints a rational function over one denominator ((-(8 * j) - 2) / (7 * j + 4)), undoing the split thatexpandleaves.PolyhedronLeanSteps::to_block(indent)re-flows to Mathlib’s width with the indent included, andwrap_leannever starts a continuation line with:=, sohave hg : … := bykeeps its:= by.
Parametric polytopes and volume in any dimension
polytope::ParametricPolytope::new(&hyps, &vars, &j) holds the family {x : hₖ(j, x) ≥ 0}; .at(&j) instantiates it exactly and polytope_at / vertices_at / volume_at cache per sample, so a tree builder can drop its own affine-evaluation and vertex code. Polytope::volume is no longer limited to three dimensions: an exact facet decomposition around the vertex centroid recurses on each facet’s own H-representation (the norms cancel, so everything stays rational), verified on hypercubes and simplices through dimension 5.
Exact PSD test
QMatrix::ldl_psd() returns the rational L·D·Lᵀ of a positive-semidefinite matrix (and None otherwise), the building block of the sums-of-squares certificates in 0.6.
What’s New in 0.4
symplex 0.4.0 is a minor release with two mechanical breaking changes. The CHANGELOG has the complete list, and Migrating from 0.3 to 0.4 shows the two source changes an upgrading program may need.
The theme is certificates on parametric polyhedra — the question a decision procedure asks thousands of times when its cells move with a parameter — together with the exact geometry of the cells themselves. It builds directly on the 0.3.5 exact matrix core and integer-pivoting simplex: each certificate is a few small exact LPs, and each takes milliseconds.
Certificates on a parametric polyhedron
certificates::prove_nonnegative_on_polyhedron(goal, hyps, Some((&j, &j0)), &PolyhedronOpts::default()) proves g ≥ 0 on {x : hₖ(j, x) ≥ 0} for every real j ≥ j₀ by the identity
λ(j)·g = Σ μ · jᵃ (j − j₀)ᵇ · hₖ + Σ μ · jᵃ (j − j₀)ᵇ + μ₀ (+ Σ μ · hₖ hₗ), λ(j) = 1 + Σ νₐ jᵃ, μ, ν ≥ 0,
whose polynomial multiplier λ on the goal is what makes j-dependent facets certifiable at all. prove_polyhedron_empty is the same identity with the goal −1, proving a cell empty for every j. The search is staged from the smallest basis upwards (λ = 1 and degree-1 multipliers first; pairwise products of hypotheses last), returns Proved / Refuted { point, value } (an exact point of the set) / Unknown, and re-verifies every certificate with polynomial arithmetic. Without a parameter it is a plain Farkas / pairwise certificate on a fixed polyhedron.
The Lean export writes the proof a person would: have h0K := mul_nonneg hK0 h0 per product, linarith only […] over exactly those facts, nonneg_of_mul_nonneg_right when λ ≠ 1, False for emptiness. lean_steps returns the same lines with your hypothesis names for an existing proof skeleton. Fifteen distinct shapes were compiled against Mathlib with the long-line linter on, and the emitted text is pinned to that compiled file.
→ Cookbook: parametric polyhedra
Exact polytopes
symplex::polytope::Polytope is a convex polyhedron in ℚⁿ from half-spaces: exact vertices (via QMatrix::solve), volume (dimension ≤ 3 in 0.4, any dimension since 0.5), contains, is_empty / any_point / bounding_box / is_bounded (exact LP), irredundant, split by a hyperplane, and from_exprs / to_exprs to move between affine Ex hypotheses and half-space data — so a cell can be measured, cut and handed to the certificate search.
→ Exact Linear Programming: polytopes
Certificates as data
Certificate, HalfLineCertificate and PolyhedronCertificate serialise to plain data (to_data / to_json: expression trees plus "p/q" rationals) and back (from_data(&ctx, …) / from_json). Reconstruction re-verifies the identity exactly and rejects anything that does not hold, so a certificate produced by one process can be accepted by another without trusting the producer — the same guarantee the Lean export gives, one step earlier.
Smaller additions
Poly::try_new—Poly::newwith the reason for failure (which generator sits inside a function, under a negative power, under a fractional or symbolic power, or in an exponent).Poly::terms_iter()(borrowed, no allocation) andPoly::coeffs_rational().LeanOpts::prefer_subtraction((1 / 2 : ℝ) - rinstead of-r + (1 / 2 : ℝ)) and thewith_*builders.HalfLineCertificate::lean_hints(hk, &opts)— the hint list alone, for a proof skeleton.linsolvedocuments that an over-determined but consistent system isUnique.
Breaking changes
Two, both mechanical: Ex::roots_count_real is gone (use count_real_roots_in), and LeanOpts struct literals need ..Default::default(). See Migrating from 0.3 to 0.4.
What’s New in 0.3
symplex 0.3 is an additive release. Nothing in the public API changed signature; the CHANGELOG has the complete list, and the behaviour changes at the end of this page are the only things an upgrading program might notice.
The theme of the release is polynomials and certificates as data. 0.2 could tell you that (x + 1)² = 2(x + 1) + (x² − 1); 0.3 lets you ask for the multipliers — exactly, over ℚ, with non-negativity constraints if you need them — and hand back a proof a reader can check by expanding. Around that core sit four new public modules: poly_ex (the Poly view), linprog (exact simplex), normalforms (Hermite and Smith forms over ℤ) and optimize (deterministic f64 root finding, minimisation and fitting).
By the numbers
Measured while preparing this page (grep -c '#[test]' over src/ and tests/; wc -l over src/**/*.rs):
| 0.2 | 0.3 | |
|---|---|---|
#[test] functions | 10,177 | 11,021 |
Lines in src/ | ~163K | ~174K |
| New public modules | — | poly_ex, linprog, normalforms, optimize |
| Signature-breaking changes | many (see the 0.2 migration guide) | 0 |
Polynomial views
Ex::as_poly(&[&x, &y]) (or Poly::new) views an expression as a sparse polynomial in explicit generators, with coefficients that are Ex values — exact rationals or symbolic parameters such as a + 1. Terms come back in lex-descending order (SymPy’s Poly.terms()), and you get coeff_monomial, total_degree, degree_in, degree_list, leading_coeff, all_coeffs, exact eval at any point, partial evaluation eval_gen, add/sub/mul/pow/derivative/scale, content_and_primitive, monic, nroots, and round-trips to the Gröbner-basis representation (to_multipoly / from_multipoly). Poly::monomial_basis and Poly::coefficient_matrix turn a family of polynomials into a Matrix — one row per monomial, one column per polynomial — so “find λ with goal = Σ λᵢhᵢ” becomes a call to linsolve_matrix or to the LP solver.
Rational normal forms and solve output
Ex::ratsimp is a canonical form for rational expressions in all variables at once: one fraction, common factors cancelled by a multivariate GCD, integer-primitive numerator and denominator, positive leading coefficient in the denominator. Non-rational subexpressions (sin x, π, √x) are opaque indeterminates, as in SymPy’s cancel. simplify_rational now is ratsimp, and solve applies it to solutions with symbolic coefficients, so ((3r − 1)/(j + 1) − (r + 1)/(2j)).solve(&r) returns (3*j + 1)/(5*j - 1) instead of a fraction of fractions. degree, coeffs, coeff, leading_coeff and is_polynomial accept parameter coefficients. poly_is_nonnegative_on(&x, &lo, &hi) and poly_is_positive_on decide the sign of a univariate polynomial on an interval exactly (square-free decomposition + Sturm count), with endpoints that may be ±∞.
→ Algebra: ratsimp · Solving: symbolic coefficients · Sign on an interval
Exact linear programming
symplex::linprog is a two-phase dense simplex over Ratio<BigInt> with Bland’s rule (no cycling), a builder (LpProblem::maximize(c).le(row, rhs).ge(…).eq(…).bounds(j, lo, hi).free(j).solve()), a SciPy-shaped linprog, a Matrix front end linprog_matrix, and feasible_nonneg for the question “is there an x ≥ 0 with A·x = b?”. LpStatus is Optimal / Infeasible / Unbounded — none of them an error. An optimal solution carries exact shadow prices (duals, with complementary slackness and strong duality holding as identities); an infeasible one carries an exact Farkas certificate (farkas) proving that no feasible point exists. Sizes up to about 100 rows × 200 variables solve in seconds in release builds.
→ Exact Linear Programming · Cookbook: Polynomial Inequality Certificates
Integer lattices and normal forms
symplex::normalforms computes, over BigInt, the row-style Hermite normal form H = U·A (unique; hermite_normal_form_with_transform returns the unimodular U), the column-style H = A·V in SymPy’s convention (column_hermite_normal_form), the Smith normal form S = U·A·V with its invariant factors (smith_normal_form[_with_transforms]), a ℤ-basis of the integer kernel (integer_nullspace — strictly more than the rational nullspace scaled up), is_unimodular and lattice_determinant (the index of a column lattice in ℤᵐ). Matrix gained hermite_normal_form, smith_normal_form and integer_nullspace methods. Non-integer entries are an InvalidArgument, never a rounding. ntheory gained gcd_many, lcm_many, igcd, ilcm and rational_lcm_of_denominators.
→ Integer Lattices and Normal Forms
Matrix ergonomics
extract(&rows, &cols), select_rows, select_cols, delete_row, delete_col; the three-valued is_integer_matrix (next to the existing is_zero); nnz; subs_map (simultaneous substitution); and exact conversions to and from the num types — to_rational_rows, to_bigint_rows, Matrix::from_ratio, Matrix::from_bigint, Matrix::from_f64_rows (each float becomes the exact dyadic rational it denotes). These are the glue between Matrix and the LP / normal-form modules.
→ Matrices: selecting sub-matrices · Integer normal forms
Numerical optimisation and fitting
symplex::optimize: brent_root (Brent–Dekker), bisect, newton_root; nelder_mead (dimension-adaptive coefficients, NaN treated as +∞); minimize_scalar (Brent) and golden_section; differential_evolution (DE/rand/1/bin with a Nelder–Mead polish, seeded SplitMix64 — bit-identical results for the same seed); poly_fit (Householder QR, ascending coefficients — NumPy’s polyfit is highest-degree first), poly_fit_exact over ℚ, linear_fit, eval_poly, trapezoid. On Ex: find_root_bracket, minimize_numeric, minimize_scalar_numeric, minimize_global_numeric, poly_fit_points — each compiles the expression first, so a stray free symbol is a FreeSymbol error rather than a NaN. Every routine is bounded by an explicit iteration budget and never panics; the minimisers report an exhausted budget through MinimizeResult::converged so the best point is never discarded.
0.3.5: the exact matrix core
Everything numeric that used to run through the expression arena — RREF, rank, nullspace, determinant, inverse, linear solves, the integer normal forms and the simplex tableau — now runs on plain BigInt / Ratio<BigInt> storage with fraction-free algorithms. Nothing changed signature (cargo-semver-checks 223/223); the speed-ups apply to existing code automatically.
QMatrix and ZMatrix (in the prelude and in symplex::matrix) are dense row-major matrices with Ratio<BigInt> / BigInt entries: constructors (new, from_i64, from_fn, identity, zeros, diag, row_vector, col_vector, from_flat), indexing and slicing (get, try_get, row, col, submatrix, hstack, vstack), arithmetic (add, sub, matmul, scale, transpose, trace, the operators + − *) and Display/Debug in the same layout as Matrix. QMatrix computes rref, rank, nullspace, columnspace, rowspace, det, inv and solve fraction-free: rows are scaled to integers and reduced with Bareiss’s Gauss–Jordan rule, whose intermediate values are minors of the input, so all divisions are exact and no gcd runs in the inner loop. ZMatrix carries the Bareiss determinant, rank, content, the Hermite and Smith normal forms with transforms, integer_nullspace, is_unimodular and lattice_determinant. Conversions are explicit and lossless: ZMatrix::try_from(&matrix) / QMatrix::try_from(&matrix) (constant arithmetic is folded first; a symbolic entry is an error, not an approximation), to_matrix(&ctx), to_qmatrix, to_zmatrix, clear_denominators.
Matrix routes rational input through the core. Matrix::{rref, rank, nullspace, columnspace, rowspace, left_nullspace, det, inv, solve, solve_least_squares, pinv}, linsolve, linsolve_matrix and every function in symplex::normalforms detect an all-rational matrix and run on QMatrix/ZMatrix. Results are unchanged (the RREF is unique; parametric linsolve solutions print identically); only the time changes. Measured in a release build on random integer matrices with entries in [−9, 9]:
| Operation | 0.3.4 | 0.3.5 |
|---|---|---|
Matrix::rref, 30×36 | 208 ms | 4.2 ms |
Matrix::det, 30×30 | 42 ms | 0.55 ms |
Matrix::solve, 30×30 | 138 ms | 1.5 ms |
Matrix::inv, 30×30 | 470 ms | 10 ms |
linsolve_matrix, 30×30 | 135 ms | 1.5 ms |
Matrix::inv, 60×60 | — | 118 ms |
Integer-pivoting simplex. The LP tableau in symplex::linprog is now integral with a common denominator (Bareiss / Edmonds integer pivoting): each constraint row is scaled once to clear its denominators, every pivot keeps all entries integers, and the ratio and sign tests are cross-multiplied integer comparisons. The entering and leaving choices are made on the same rational values as before, so the pivot sequence is the same — on 4,000 random LPs with fractional data, degenerate rows, all three relations and free/two-sided-bounded variables, x, objective, duals and Farkas vectors are byte-identical to 0.3.4 — and certificate-sized problems run 5–30× faster (40 rows × 100 variables: 1.1 s → 40 ms; 60 × 160: 2.8 s → 80 ms; the degree-5 three-variable Handelman search: 3.6 s → 0.9 s).
Why not a faster bignum crate? Before building this, num-bigint 0.4 was benchmarked against dashu 0.6 on these kernels. dashu was ~9× faster on Gauss–Jordan over rationals but only 1.2–2× faster on integer kernels — the gap was Ratio’s per-operation gcd normalisation, not raw bignum speed. Fraction-free elimination over num-bigint beat dashu’s rational elimination by 5× and the old code by 40×, with no change to the public Ratio<BigInt> types (as_rational, linprog::Q, the num_* re-exports). So num-bigint stays.
→ Matrices: exact matrices over ℚ and ℤ · Exact Linear Programming: performance
Behaviour changes
There are no signature-breaking changes in 0.3. Two behaviours changed in ways an existing program could observe:
degree,coeffs,coeff,leading_coeffandis_polynomialnow succeed for polynomials whose coefficients are symbolic parameters (a·x² + (a + 1)·x + 3inx). In 0.2 they returnedNone/false. Code that usedNonefrom these methods to mean “has parameters” should testPoly::has_rational_coeffsorfree_symbolsinstead.simplify_rationaland the results ofsolvewith symbolic coefficients are now put into rational normal form withratsimp. The values are mathematically equal to the 0.2 results; only the printed form differs (a single cancelled fraction, e.g.(3*j + 1)/(5*j - 1)). Tests that compare against a string may need updating; tests that compare withequalsor by substitution do not.
Everything else on this page is new API.
What’s New in 0.2
symplex 0.2.0 is a large release. This page is the tour; the complete list — including every breaking change and its replacement — is in the CHANGELOG, and the migration guide walks through the code changes you will need.
The theme of the release is never silently wrong. Several 0.1 operations guessed (∫₋₁¹ dx/x² = −2, re(z) = z, solve(x − x) = []); in 0.2 they return an Err, an unevaluated node, or None.
By the numbers
Measured on the release commit:
| 0.1 | 0.2 | |
|---|---|---|
ExprNode variants | ~80 | 91 |
#[test] functions | ~6,000 | 10,177 |
Lines in src/ | ~103K | ~163K |
| ODE classes | 13 | 16 |
| Code generation targets | Rust | Rust, C99, compiled closures |
Complex analysis
New Re, Im, Conjugate, Arg nodes and Ex::{re, im, conjugate, arg, as_real_imag, expand_complex, polar, abs_squared, is_real_valued}. Conjugation distributes over sums, products and integer powers and commutes with real-analytic functions; realness is decided by the assumption system, never assumed. New constants EulerGamma, Catalan, GoldenRatio, and complex infinity (zoo, the value of 1/0).
→ Complex Analysis and Special Functions
Definite, improper and numeric integration
integrate_definite / try_integrate_definite detect interior singularities, handle infinite bounds and endpoint singularities via one-sided limits, exploit symmetry, integrate Abs/Heaviside/DiracDelta/Piecewise, and consult a ~30-entry table of improper integrals (with symbolic parameters under assumptions). integrate_numeric is adaptive Gauss–Kronrod. Residues work at poles of any order and at infinity.
→ Definite Integration and Quadrature
Summation, products and series
summation, product_over, hypergeometric_ratio, is_convergent, series_at_infinity, and an Ex-based FormalPowerSeries with lazy exact coefficients, closed-form general terms, arithmetic, composition, inversion and reversion.
Solving
solve reports identities as Err(InfiniteSolutions) and contradictions/range violations as Err(NoSolution). solve_general returns periodic families with an integer parameter. linsolve returns Unique/Parametric/Inconsistent. Polynomial systems return algebraic solutions. solve_numeric_system is a damped Newton method. ODEs gained nth-order constant-coefficient, Clairaut and Riccati classes, initial-value problems (solve_ode_ivp) and system IVPs. rsolve_linear solves linear recurrences.
Sets and logic
SetEx has a normal form (simplify), set algebra (difference, symmetric_difference, absolute_complement), three-valued queries (contains, is_subset, is_disjoint, is_empty), topology (boundary, closure, interior), and conversion to conditions. BoolEx has to_nnf/to_cnf/to_dnf, is_tautology, satisfiable, truth_table, and eval folds relations through assumptions. reduce_inequalities turns a conjunction of conditions into a set.
The rule engine is public
Rule, RuleSet, Bindings, RewriteOpts, Step; Ex::{rewrite, rewrite_once, rewrite_traced, rewrite_with, simplify_with_rules, simplify_traced}; AC matching with rest__ sequence wildcards; rule! macro rules via RuleSet::from_macro_rules. Plus new targeted simplifiers: sqrtdenest, signsimp, powdenest(force), expand_with(ExpandOpts), nsimplify, rcollect, subs_algebraic.
Matrices
The eigen family (eigenvals, eigenvects, diagonalize, jordan_form, matrix_exp) no longer takes a dummy variable. Irreducible cubic/quartic characteristic polynomials give exact, evaluable RootOf eigenvalues instead of Cardano swell. New: qr, ldl, gram_schmidt, matrix_exp_t, matrix_pow_symbolic, matrix_sqrt, hessian, wronskian, structure tests, norms, least squares, Index/IndexMut, scalar operators on both sides.
→ Matrices
Transforms
One-sided limits (limit_left/limit_right/limit_dir), Fourier transforms in three conventions, Mellin transforms with their fundamental strip, Laplace table/inverse extensions and initial/final-value theorems, Fourier series on arbitrary intervals with exact coefficients, Z-transform extensions.
Number theory and factoring
Berlekamp–Zassenhaus factoring for any degree, multivariate factoring, a polynomial-algebra API on Ex (resultant, discriminant, sqf_list, poly_div, poly_gcdex, nroots, real_roots_isolate, …), factorint with Pollard–Brent rho + ECM, BPSW isprime, sqrt_mod, discrete_log, primitive_root, primepi, continued fractions, Egyptian fractions, Pell equations, sums of squares, Pythagorean triples, and integer sequences.
→ Number Theory and Combinatorics
Code generation
compile() returns Result<CompiledFn> and covers every numerically evaluable node; compile_many shares CSE across a gradient. to_rust_fn embeds only the special-function helpers it needs. New C99 backend (to_c_fn). CodegenOptions::{use_mul_add, checked_domain, emit_runtime} and runtime_module()/c_runtime() for multi-function files.
Ergonomics
Context::{from_f64, from_f64_approx, from_f64_nice, from_bigint, from_ratio, from_i128, rational_str, decimal_str, complex, symbols, symbols_indexed, apply, sum, product}; operators with f64/i32/u64/i128/BigInt/Ratio and compound assignment; Ex::{as_rational, as_bigint, as_i64, compare_numeric, is_less_than, probably_equal, eval_at}; Equation arithmetic and solve_for; Debug for Ex prints the expression.
Companion crates
symplex-wasm: a persistentSession(define names, evaluate, differentiate, solve) plus a full stateless API includingintegrate_definiteandto_c_fn.symplex-build: exact DH parameters (0.3→3/10),generate_fk_matrix,"fk_matrix"in TOML configs.
Installation
Adding symplex to Your Project
cargo add symplex
Or add it directly to your Cargo.toml:
[dependencies]
symplex = "0.3"
Minimum Supported Rust Version
symplex requires Rust 1.93+ (Edition 2024). Check your version with:
rustc --version
If you need to update:
rustup update stable
Platform Support
symplex is pure Rust with no C dependencies. It builds on any platform that Rust targets, including:
- Linux (x86_64, aarch64)
- macOS (x86_64, Apple Silicon)
- Windows (x86_64)
- WebAssembly (via
wasm-pack— see thesymplex-wasmcrate)
All dependencies are MIT or Apache-2.0 licensed. There are no LGPL, GPL, or proprietary dependencies.
Feature Flags
symplex currently has no optional feature flags. All functionality is included by default. This may change in future releases as the library grows.
Companion Crates
| Crate | Purpose |
|---|---|
symplex-macros | The expr!, matrix!, eq!, dim!, rule! procedural macros (a dependency of symplex; you do not add it yourself) |
symplex-build | Build-time code generation: run the CAS in build.rs and emit no_std Rust for firmware |
symplex-wasm | wasm-bindgen bindings with a persistent Session for browser notebooks and demos |
Dependencies
The library depends on the following crates, all of which are widely used in the Rust ecosystem:
| Crate | Purpose |
|---|---|
num-bigint, num-rational, num-traits, num-integer | Arbitrary-precision arithmetic |
smallvec | Stack-allocated small vectors for expression nodes |
parking_lot | Fast reader-writer locks for thread-safe contexts |
astro-float | Arbitrary-precision floating-point for numerical evaluation |
serde, serde_json | JSON serialization of expressions |
tracing | Optional structured logging (enable with RUST_LOG=symplex=debug) |
typenum | Compile-time type-level integers for dimensional analysis |
thiserror | Error type derivation |
The proc-macro crates (symplex-macros) additionally depend on syn, quote, and proc-macro2 for the expr!, matrix!, dim!, and related macros.
Verifying Your Installation
Create a file and run it to confirm everything works:
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let x = ctx.symbol("x");
let f = x.powi(2).diff(&x);
println!("d/dx(x²) = {f}");
// Should print: d/dx(x²) = 2*x
}
cargo run
Tracing / Debug Output
symplex uses the tracing crate for structured logging. To see what the library is doing internally (simplification steps, integration strategy selection, etc.):
RUST_LOG=symplex=debug cargo run --example quickstart
This is useful for understanding why a particular computation produces the result it does, or for diagnosing performance issues.
Next Steps
Continue to First Steps to create your first symbolic expressions.
First Steps
This chapter walks through the basics of creating and manipulating symbolic expressions in symplex. By the end, you will know how to build expressions, evaluate them, differentiate, and integrate.
Creating a Context
Every expression in symplex belongs to a Context. The context owns the expression storage (an arena) and manages symbol names and assumptions. You create one at the start of your program:
#![allow(unused)]
fn main() {
use symplex::prelude::*;
let ctx = Context::new();
}
You can create multiple contexts if you need isolated environments (e.g., separate user sessions on a server). Expressions from different contexts cannot be mixed — the library enforces this at runtime.
Defining Symbols
Symbols are named unknowns. They represent the variables in your expressions:
#![allow(unused)]
fn main() {
let x = ctx.symbol("x");
let y = ctx.symbol("y");
}
There is also a convenience macro for declaring multiple symbols at once:
#![allow(unused)]
fn main() {
symplex::syms!(ctx; x, y, z);
}
Symbols are symbolic — they don’t have a value until you substitute one. Calling x.eval_f64() on a bare symbol returns Err because there is nothing to evaluate.
Building Expressions
Arithmetic operators
Expressions support the standard Rust arithmetic operators. Because Ex is not Copy (it is Clone and Send + Sync), you typically work with references:
#![allow(unused)]
fn main() {
let f = &x * &x + &x * 2 + 1; // x² + 2x + 1
let g = &x.powi(3) - &y; // x³ - y
}
Integer literals (1, 2, etc.) are automatically converted to exact rational constants. There is no floating-point contamination.
The expr! macro
For more complex expressions, the expr! macro provides mathematical notation:
#![allow(unused)]
fn main() {
let f = expr!(ctx, x^2 + 2*x + 1);
let g = expr!(ctx, sin(x)^2 + cos(x)^2);
let h = expr!(ctx, x^3 - 3*x^2 + 2*x);
}
The macro recognizes standard mathematical functions (sin, cos, tan, exp, ln, sqrt, abs, gamma, erf, and many others), the constants pi and E, and the caret ^ for exponentiation.
Exact rationals
Constants are exact. The rational number 1/3 is stored as the ratio of two arbitrary-precision integers, not as 0.33333...:
#![allow(unused)]
fn main() {
let half = ctx.rational(1, 2); // exactly 1/2
let third = ctx.rational(1, 3); // exactly 1/3
let big = ctx.int(1_000_000_007); // arbitrary-precision integer
}
Functions
Standard mathematical functions are methods on Ex:
#![allow(unused)]
fn main() {
let a = x.sin(); // sin(x)
let b = x.exp(); // exp(x)
let c = x.ln(); // ln(x)
let d = x.sqrt(); // √x
let e = x.powi(5); // x⁵
let f = x.pow(&y); // x^y
let g = x.factorial(); // x!
let h = x.gamma(); // Γ(x)
}
Displaying Expressions
Expressions implement Display for plain-text output and have a to_latex() method for LaTeX:
#![allow(unused)]
fn main() {
let f = expr!(ctx, x^2 + 2*x + 1);
println!("{f}"); // x^2 + 2*x + 1
println!("{}", f.to_latex()); // x^{2} + 2x + 1
}
Evaluating Expressions
Symbolic evaluation
.eval() applies exact simplification rules — reducing sin(0) to 0, exp(ln(x)) to x, computing 5! to 120, and so on — without any floating-point approximation:
#![allow(unused)]
fn main() {
let a = ctx.int(5).factorial().eval();
println!("{a}"); // 120
let b = expr!(ctx, sin(pi)).eval();
println!("{b}"); // 0
}
Numerical evaluation
.eval_f64() converts a fully determined expression (no free symbols) to an f64. It returns Result because the conversion can fail:
#![allow(unused)]
fn main() {
let val = expr!(ctx, sin(1) + cos(1)).eval_f64().unwrap();
println!("{val:.6}"); // 1.381773
}
If free symbols remain, you get an error:
#![allow(unused)]
fn main() {
let result = x.sin().eval_f64();
assert!(result.is_err()); // "expression contains free symbol 'x'"
}
Substitution
Use .subs() to replace a symbol with a value or another expression:
#![allow(unused)]
fn main() {
let f = expr!(ctx, x^2 + 1);
// Substitute x = 3 (exact integer)
let at_3 = f.subs(&x, &ctx.int(3)).eval();
println!("{at_3}"); // 10
// Substitute x = y + 1 (symbolic)
let shifted = f.subs(&x, &(&y + 1));
println!("{shifted}"); // (y + 1)^2 + 1
}
For quick numerical substitution there is a convenience method:
#![allow(unused)]
fn main() {
let val = f.eval_f64_with(&[(&x, 3)]).unwrap();
println!("{val}"); // 10.0
}
Differentiation
.diff(&var) computes the symbolic derivative with respect to a variable. It handles the chain rule, product rule, quotient rule, and all elementary functions:
#![allow(unused)]
fn main() {
let f = expr!(ctx, x^3 - 3*x^2 + 2*x);
let df = f.diff(&x);
println!("{df}"); // 3*x^2 - 6*x + 2
// Second derivative
let d2f = df.diff(&x);
println!("{d2f}"); // 6*x - 6
}
Higher-order derivatives have a convenience method:
#![allow(unused)]
fn main() {
let d4 = expr!(ctx, x^6).diff_n(&x, 4);
println!("{d4}"); // 360*x^2
}
Partial derivatives work the same way — just specify which variable:
#![allow(unused)]
fn main() {
let g = expr!(ctx, x^2 * y + y^3);
println!("∂g/∂x = {}", g.diff(&x)); // 2*x*y
println!("∂g/∂y = {}", g.diff(&y)); // x^2 + 3*y^2
}
Integration
.integrate(&var) computes the indefinite integral. The library uses multiple strategies (polynomial, u-substitution, by-parts, partial fractions, trigonometric, Risch algorithm, heuristic integration):
#![allow(unused)]
fn main() {
let f = expr!(ctx, x^2);
let anti = f.integrate(&x);
println!("{anti}"); // 1/3*x^3
}
Definite integrals:
#![allow(unused)]
fn main() {
let zero = ctx.int(0);
let one = ctx.int(1);
let area = expr!(ctx, x^2).integrate_definite(&x, &zero, &one);
println!("{area}"); // 1/3
}
Definite integration is not a naive F(b) − F(a): it looks for singularities inside the interval, handles infinite bounds, and reports divergence instead of returning a wrong number. See Definite Integration and Quadrature.
When integration cannot find a closed form, it returns an unevaluated Integral node rather than failing silently:
#![allow(unused)]
fn main() {
let hard = expr!(ctx, exp(x^2));
let result = hard.integrate(&x);
println!("{result}"); // Integral(exp(x^2), x)
}
You can check whether a result contains unevaluated forms:
#![allow(unused)]
fn main() {
if result.has_unevaluated() {
println!("no closed form found");
}
}
Or use the try_integrate variant, which returns Err if the result is not fully evaluated:
#![allow(unused)]
fn main() {
match hard.try_integrate(&x) {
Ok(anti) => println!("closed form: {anti}"),
Err(_) => println!("no closed form"),
}
}
Simplification
.simplify() tries a dozen strategies (evaluation, expansion, factoring, trigonometric, logarithmic, power and radical rules, assumption-aware refinement, …), keeps the result with the fewest operations, and iterates to a fixpoint:
#![allow(unused)]
fn main() {
let expr = expr!(ctx, sin(x)^2 + cos(x)^2);
println!("{}", expr.simplify()); // 1
let expr = (&x + 1).powi(2) - &x.powi(2) - &x * 2;
println!("{}", expr.simplify()); // 1
}
.simplify_with(&SimplifyOpts::single_pass()) runs one pass; .simplify_traced(&SimplifyOpts::default()) also returns the strategies and rules that fired. Targeted simplifiers (simplify_trig, expand_log, sqrtdenest, powdenest, …) and your own rewrite rules are covered in Algebra and The Rule Engine.
Putting It Together
Here is a complete example that finds the critical points of a polynomial:
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let x = ctx.symbol("x");
let f = expr!(ctx, x^3 - 3*x + 1);
let df = f.diff(&x);
println!("f(x) = {f}");
println!("f'(x) = {df}");
// Critical points: f'(x) = 0
let crits = df.solve_or_empty(&x);
for c in &crits {
let val = f.subs(&x, c).eval();
println!(" f({c}) = {val}");
}
}
Next Steps
Continue to Key Concepts for a deeper understanding of how the library works — context ownership, the five API patterns, and how to handle unevaluated results.
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.
Calculus
This chapter covers differentiation, indefinite integration, limits and series. Definite and improper integration have their own chapter, as do summation and formal power series.
Every code block is a complete program; run it with cargo run in a crate that depends on symplex.
Differentiation
diff(&x) handles the chain, product and quotient rules, all elementary functions, and the special functions (Gamma, digamma → polygamma, erf, Bessel, orthogonal polynomials, Si/Ci/Ei/li). diff_n takes higher derivatives; partial derivatives are just diff with respect to another symbol.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
println!("{}", expr!(ctx, sin(x^2)).diff(&x)); // 2*x*cos(x^2)
println!("{}", expr!(ctx, x^6).diff_n(&x, 4)); // 360*x^2
println!("{}", expr!(ctx, x^2 * y + y^3).diff(&y)); // x^2 + 3*y^2
println!("{}", x.digamma().diff(&x)); // polygamma(1, x)
println!("{}", x.bessel_j(&ctx.int(0)).diff(&x)); // -1/2*besselj(1, x) + 1/2*besselj(-1, x)
println!("{}", x.si().diff(&x)); // sin(x)/x
}
Formal derivatives
formal_diff builds a Derivative node without evaluating it. This is how you write differential equations (see Solving Equations) and finite-difference stencils:
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, h);
let y = ctx.symbol("y");
let ode = &y.formal_diff(&x) + &y; // y' + y (unevaluated)
println!("{ode}"); // y + Derivative(y, x)
println!("{}", ode.solve_ode(&y, &x)); // C1*exp(-x)
// Fornberg finite differences: central stencil {x-h, x, x+h}
let stencil = [&x - &h, x.clone(), &x + &h];
let d = x.powi(3).differentiate_finite(&x, &stencil, 1).expand();
println!("{d}"); // h^2 + 3*x^2
}
Indefinite integration
integrate(&x) tries polynomial, u-substitution, by-parts, partial fractions, trigonometric, Risch, Rothstein–Trager, Lazard–Rioboo–Trager and heuristic strategies. When nothing applies you get an unevaluated Integral node; use try_integrate if that should be an error.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
println!("{}", expr!(ctx, x * exp(x)).integrate(&x)); // x*exp(x) - exp(x)
println!("{}", expr!(ctx, 1 / (x^2 - 1)).integrate(&x)); // partial fractions
println!("{}", expr!(ctx, sin(x)^3).integrate(&x));
println!("{}", expr!(ctx, 1 / (x^2 + 1)).integrate(&x)); // atan(x)
// No elementary antiderivative: honest unevaluated form
let hard = expr!(ctx, exp(x^2)).integrate(&x);
println!("{hard} unevaluated: {}", hard.has_unevaluated());
assert!(expr!(ctx, exp(x^2)).try_integrate(&x).is_err());
}
Limits
limit(&x, &point) uses the Gruntz algorithm (with a work budget so it cannot hang). 0.2 adds one-sided limits: limit_left, limit_right, limit_dir(&x, &a, Direction::Left). The two-sided limit returns an unevaluated Limit node when the one-sided limits differ.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let zero = ctx.int(0);
println!("{}", expr!(ctx, sin(x) / x).limit(&x, &zero)); // 1
println!("{}", (1 + 1 / &x).pow(&x).limit(&x, &ctx.infinity())); // E
println!("{}", ((1 - &x.cos()) / &x.powi(2)).limit(&x, &zero)); // 1/2
println!("{}", (1 / &x).limit_right(&x, &zero)); // oo
println!("{}", (1 / &x).limit_left(&x, &zero)); // -oo
println!("{}", (1 / &x).limit(&x, &zero)); // Limit(1/x, x, 0)
println!("{}", (&x * &x.ln()).limit_right(&x, &zero)); // 0
println!("{}", x.floor().limit_dir(&x, &ctx.int(1), Direction::Left)); // 0
}
Series
series(&x, &point, n) gives a Taylor or Laurent expansion with n terms (Puiseux series are refused rather than approximated). maclaurin(&x, n) is the expansion at zero; series_at_infinity(&x, n) is the asymptotic expansion.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
println!("{}", x.exp().series(&x, &ctx.int(0), 5));
// 1/24*x^4 + 1/6*x^3 + 1/2*x^2 + x + 1
println!("{}", (1 / &x.sin()).series(&x, &ctx.int(0), 4)); // Laurent: 1/x + x/6 + …
println!("{}", (&(&x.powi(2) + 1).sqrt() - &x).series_at_infinity(&x, 4));
// -1/8*x^(-3) + 1/(2*x)
}
For exact coefficients of arbitrary order and closed-form general terms, see FormalPowerSeries in Summation and Series.
Residues
residue(&z, &point) works at poles of any order; residue_at_infinity gives Res_{z=∞}, so the sum of all residues can be checked to vanish.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let z = ctx.symbol("z");
let f = 1 / (&z.powi(2) + 1);
println!("{}", f.residue(&z, &ctx.i_unit())); // -1/2*I
println!("{}", (&z.exp() / &z.powi(3)).residue(&z, &ctx.int(0))); // 1/2 (third-order pole)
println!("{}", f.residue_at_infinity(&z)); // 0
}
Analysing a function (0.9)
The calculus.util family from SymPy lives directly on Ex. Every method works over the reals, takes the variable explicitly, and describes domains and results with SetEx (intervals, finite sets, unions) so they compose with the sets API.
| Method | SymPy | Returns |
|---|---|---|
singularities(&x, domain) | singularities | SetEx of points where the expression is undefined |
stationary_points(&x, domain) | stationary_points | SetEx of real zeros of the derivative |
maximum(&x, &domain) / minimum | maximum / minimum | supremum / infimum as an Ex (oo allowed) |
is_increasing, is_decreasing, is_strictly_increasing, is_strictly_decreasing, is_monotonic | same | Option<bool> |
is_convex(&x, &domain) | is_convex | Option<bool> |
periodicity(&x) | periodicity | Option<Ex> (Some(0) for a constant) |
function_range(&x, &domain) | function_range | SetEx, the image |
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let reals = ctx.reals();
// Where is it undefined? `None` means the whole real line.
let f = 1 / (&x.powi(2) - 1);
println!("{}", f.singularities(&x, None).unwrap()); // {-1, 1}
println!("{}", x.ln().singularities(&x, None).unwrap()); // {0}
// Critical points, extrema and the image on an interval.
let g = &x.powi(3) - &x * 3;
let dom = ctx.interval(&ctx.int(-2), &ctx.int(2), IntervalKind::Closed); // [-2, 2]
println!("{}", g.stationary_points(&x, None).unwrap()); // {-1, 1}
println!("{}", g.maximum(&x, &dom).unwrap()); // 2
println!("{}", g.minimum(&x, &dom).unwrap()); // -2
println!("{}", g.function_range(&x, &dom).unwrap()); // [-2, 2]
// Open and infinite endpoints are handled with one-sided limits, so the
// supremum need not be attained and the range tracks open ends.
let tail = ctx.interval(&ctx.int(1), &ctx.infinity(), IntervalKind::RightOpen); // [1, oo)
println!("{}", (1 / &x).minimum(&x, &tail).unwrap()); // 0
println!("{}", (1 / &x).function_range(&x, &tail).unwrap()); // (0, 1]
println!("{}", x.powi(2).maximum(&x, &reals).unwrap()); // oo
println!("{}", x.exp().function_range(&x, &reals).unwrap()); // (0, oo)
// Monotonicity and convexity are three-valued: `None` is "undecided".
let half = ctx.interval(&ctx.int(0), &ctx.infinity(), IntervalKind::RightOpen);
println!("{:?}", x.powi(3).is_increasing(&x, &reals)); // Some(true)
println!("{:?}", x.powi(3).is_strictly_increasing(&x, &reals)); // Some(true)
println!("{:?}", x.powi(2).is_increasing(&x, &reals)); // Some(false)
println!("{:?}", x.powi(2).is_increasing(&x, &half)); // Some(true)
println!("{:?}", x.powi(2).is_convex(&x, &reals)); // Some(true)
println!("{:?}", x.powi(3).is_convex(&x, &reals)); // Some(false)
// Fundamental periods.
println!("{}", (&(&x * 2).sin() + &(&x * 3).cos()).periodicity(&x).unwrap()); // 2*pi
println!("{}", x.tan().periodicity(&x).unwrap()); // pi
println!("{:?}", x.powi(2).periodicity(&x)); // None
}
A few things to know:
- Exact first. Extremum candidates (stationary points, closed endpoints, endpoint limits) are compared with
equalsand the sign of their difference; the only numeric step orders two candidates that are already proven distinct. Polynomial and rational derivatives are decided by Sturm sequences (Poly::is_nonnegative_on); other derivatives go through the assumption system and the inequality solver, and anything undecided isNoneorErr(NotImplemented)— never a guess. - Periodic families. Zeros of
sin,cos,tanare enumerated inside a bounded domain (tan(x).singularities(&x, Some(&[0, 10]))is{pi/2, 3*pi/2, 5*pi/2}); on an unbounded domain the infinite family is returned as a condition set such asConditionSet(x, cos(x) == 0)rather than truncated to the principal branches. - Continuity is required by
maximum,minimumandfunction_range: singularities inside the domain, or discontinuous / opaque nodes (floor,sign,Piecewise, unknown functions), giveErr(NotImplemented).abskinks are fine and are included among the candidates. - Deliberate differences from SymPy.
is_strictly_increasing(x³, ℝ)isSome(true)(SymPy testsℝ ⊆ {f' > 0}and answersNone);is_monotonicis the three-valued or of increasing and decreasing (SymPy asks whetherf'has no zeros, sox³isFalsethere);periodicity(sin(x)²)is the fundamental periodpi(SymPy:2*pi).
Where to go next
- Definite Integration and Quadrature —
integrate_definite, improper integrals, divergence detection, Gauss–Kronrod. - Summation and Series —
summation,product_over, convergence,FormalPowerSeries. cargo run --example calculusfor a longer tour.
Definite Integration and Quadrature
New in 0.2. The 0.1 method definite_integral computed F(b) − F(a) from an antiderivative, which is wrong whenever the integrand has a singularity inside [a, b] (∫₋₁¹ dx/x² came out as −2). It has been replaced by three methods with precise contracts.
| Method | Returns | Use when |
|---|---|---|
integrate_definite(&x, &a, &b) | Ex — a closed form, or an unevaluated DefiniteIntegral node (Integral(f, x, a, b)) | Exploring; chaining |
try_integrate_definite(&x, &a, &b) | Result<Ex> — Err(Divergent), Err(ComputationFailed), Err(InvalidArgument) | Pipelines that must distinguish “diverges” from “don’t know” |
integrate_numeric(&x, &a, &b) | Result<f64> | You want a number and accept floating point |
What integrate_definite does
- Evaluates the integrand and locates its real singularities inside
[a, b]. - Splits at interior singularities and treats each piece as an improper integral via one-sided limits of the antiderivative; a piece with an infinite one-sided limit makes the whole integral divergent.
- Handles infinite bounds the same way.
- Applies symmetry shortcuts (odd integrand on a symmetric interval, periodicity).
- Resolves
Abs,Sign,Heaviside,DiracDeltaandPiecewiseintegrands by splitting at their breakpoints. - If no antiderivative exists, consults a table of ~30 classical improper integrals (Gaussian, Dirichlet, Fresnel,
xⁿe⁻ˣ,x/(eˣ−1),ln x,1/(1+x⁴), …) whose entries may carry symbolic parameters guarded by assumptions.
If none of this decides the integral, the result is an unevaluated DefiniteIntegral node, displayed Integral(f, x, a, b) — never a guessed finite number.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let (zero, one, inf) = (ctx.int(0), ctx.int(1), ctx.infinity());
// Proper integrals
println!("{}", x.powi(2).integrate_definite(&x, &zero, &one)); // 1/3
println!("{}", x.sin().integrate_definite(&x, &zero, &ctx.pi())); // 2
// Improper integrals
println!("{}", (-&x).exp().integrate_definite(&x, &zero, &inf)); // 1
println!("{}", (-x.powi(2)).exp().integrate_definite(&x, &ctx.neg_infinity(), &inf)); // sqrt(pi)
println!("{}", (&x.sin() / &x).integrate_definite(&x, &zero, &inf)); // 1/2*pi
println!("{}", x.ln().integrate_definite(&x, &zero, &one)); // -1
println!("{}", (1 / &x.sqrt()).integrate_definite(&x, &zero, &one)); // 2
println!("{}", (&x / (&x.exp() - 1)).integrate_definite(&x, &zero, &inf)); // 1/6*pi^2
// Non-smooth integrands
println!("{}", x.abs().integrate_definite(&x, &ctx.int(-2), &ctx.int(3))); // 13/2
println!("{}", (&x.dirac_delta() * &x.cos()).integrate_definite(&x, &ctx.int(-1), &one)); // 1
let pw = Ex::piecewise(&[(&x.powi(2), &x.lt(&one)), (&(2 - &x), &x.ge(&one))]);
println!("{}", pw.integrate_definite(&x, &zero, &ctx.int(2))); // 5/6
}
Symbolic parameters need assumptions
∫₀^∞ e^(−a x) dx = 1/a is only true for a > 0. The table checks such conditions through the assumption system and refuses otherwise.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let (zero, inf) = (ctx.int(0), ctx.infinity());
let a = ctx.symbol_with("a", &[Assumption::Positive]);
let n = ctx.symbol_with("n", &[Assumption::Positive]);
println!("{}", (-(&a * &x)).exp().integrate_definite(&x, &zero, &inf)); // 1/a
println!("{}", (-(&a * &x.powi(2))).exp().integrate_definite(&x, &ctx.neg_infinity(), &inf)); // sqrt(pi/a)
println!("{}", (&x.sin() * &(-(&a * &x)).exp()).integrate_definite(&x, &zero, &inf)); // 1/(a^2 + 1)
println!("{}", (&x.pow(&(&n - 1)) * &(-&x).exp()).integrate_definite(&x, &zero, &inf)); // Gamma(n)
// Unknown sign: stays unevaluated instead of assuming a > 0
let b = ctx.symbol("b");
let r = (-(&b * &x)).exp().integrate_definite(&x, &zero, &inf);
assert!(r.has_unevaluated());
}
Divergence is an error, not a number
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let one = ctx.int(1);
match x.powi(-2).try_integrate_definite(&x, &ctx.int(-1), &one) {
Err(SymplexError::Divergent { reason, .. }) => println!("diverges: {reason}"),
other => println!("unexpected {other:?}"),
}
assert!(matches!(
(1 / &x).try_integrate_definite(&x, &one, &ctx.infinity()),
Err(SymplexError::Divergent { .. })
));
// The non-try variant keeps it symbolic.
let kept = x.powi(-2).integrate_definite(&x, &ctx.int(-1), &one);
assert!(kept.has_unevaluated());
}
An undecided definite integral keeps its bounds: the DefiniteIntegral node prints as Integral(f, x, a, b) (LaTeX \int_a^b f\,dx), round-trips through parse, binds x for free_symbols/subs, differentiates by the Leibniz rule, and eval_f64() evaluates it by quadrature. Ex::eval_integrals() re-attempts every formal definite integral inside an expression. Use try_integrate_definite when you need to know why it was undecided.
Numeric quadrature
integrate_numeric compiles the integrand (so it must contain no free symbol other than the variable and only nodes compile supports) and runs adaptive Gauss–Kronrod G7/K15 quadrature. Infinite bounds are mapped to a finite interval. integrate_numeric_with(&x, &a, &b, &QuadOpts) returns a QuadResult { value, error } (the estimate and its estimated absolute error) and lets you set tolerances and the subdivision limit.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let (zero, one) = (ctx.int(0), ctx.int(1));
let v = x.sin().integrate_numeric(&x, &zero, &ctx.pi()).unwrap();
assert!((v - 2.0).abs() < 1e-12);
// No elementary antiderivative — numerics is the right tool
let opts = QuadOpts { rel_tol: 1e-12, ..QuadOpts::default() };
let q = x.powi(2).exp().integrate_numeric_with(&x, &zero, &one, &opts).unwrap();
println!("∫₀¹ e^(x²) dx ≈ {:.15} ± {:.1e}", q.value, q.error); // 1.462651745907181
// A divergent integral does not converge and is reported as an error.
assert!(x.powi(-2).integrate_numeric(&x, &ctx.int(-1), &one).is_err());
}
Conditionally convergent oscillatory tails such as ∫₀^∞ sin(x)/x dx are beyond plain adaptive quadrature and also return an error; use integrate_definite (which knows the closed form) for those.
For integrating a plain Rust closure, symplex::definite::quadrature(&f, a, b, &opts) exposes the same algorithm and returns the same QuadResult.
Residues
Residues are the contour-integration counterpart and live on Ex as well:
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let z = ctx.symbol("z");
let g = (&z + 2) / (&z * (&z - 1));
println!("{}", g.residue(&z, &ctx.int(0))); // -2
println!("{}", g.residue(&z, &ctx.int(1))); // 3
println!("{}", g.residue_at_infinity(&z)); // -1 (= −(−2 + 3))
}
See cargo run --example definite_integration for the full tour.
Summation and Series
New in 0.2: a summation engine on Ex (summation, product_over), convergence tests, asymptotic series, and an Ex-based FormalPowerSeries.
Finite sums
summation(&k, &lower, &upper) tries, in order: polynomial (Faulhaber) sums of any degree, geometric and arithmetico-geometric sums, telescoping (partial fractions in k), binomial identities (Σ P(k)·C(n,k)·xᵏ for any polynomial P), and Gosper’s algorithm for hypergeometric terms. When nothing applies the result is an unevaluated Sum node; try_summation returns Err(ComputationFailed) instead.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; k, x);
// Assumptions on the bound let the engine use n! and 2^n freely.
let n = ctx.symbol_with("n", &[Assumption::Integer, Assumption::Positive]);
let (zero, one) = (ctx.int(0), ctx.int(1));
println!("{}", k.summation(&k, &one, &n)); // 1/2*n^2 + 1/2*n
println!("{}", k.powi(5).summation(&k, &one, &n)); // 1/6*n^6 + 1/2*n^5 + 5/12*n^4 - 1/12*n^2
println!("{}", ctx.int(2).pow(&k).summation(&k, &zero, &n)); // 2^(n + 1) - 1
println!("{}", (1 / (&k * (&k + 1))).summation(&k, &one, &n)); // -1/(n + 1) + 1
println!("{}", n.binomial(&k).summation(&k, &zero, &n)); // 2^n
println!("{}", (&k * &n.binomial(&k)).summation(&k, &zero, &n)); // n*2^(n - 1)
println!("{}", (&k * &ctx.int(2).pow(&k)).summation(&k, &zero, &n)); // 2^(n + 1)*(n - 1) + 2
println!("{}", (1 / &k).summation(&k, &one, &n)); // harmonic(n)
// A geometric sum with symbolic ratio is only valid for x ≠ 1:
println!("{}", x.pow(&k).summation(&k, &zero, &n));
// Piecewise(n + 1 if x == 1, (-x^(n + 1) + 1)/(-x + 1) if x != 1)
assert!(k.sin().try_summation(&k, &one, &n).is_err());
}
Infinite series
Infinite upper bounds go through p-series (ζ(2m) exact; odd p gives a symbolic zeta(p); alternating variants give ln 2, Catalan’s constant), power-series recognition (Σ xᵏ/k! = eˣ, geometric series), telescoping limits, and Gosper tails. Divergent series evaluate to oo where the divergence is provable.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; k, x);
let (zero, one, inf) = (ctx.int(0), ctx.int(1), ctx.infinity());
println!("{}", k.powi(-2).summation(&k, &one, &inf)); // 1/6*pi^2
println!("{}", k.powi(-4).summation(&k, &one, &inf)); // 1/90*pi^4
println!("{}", k.powi(-3).summation(&k, &one, &inf)); // zeta(3)
println!("{}", (ctx.int(-1).pow(&k) / (&k * 2 + 1).powi(2)).summation(&k, &zero, &inf)); // Catalan
println!("{}", (ctx.int(-1).pow(&k) / &k).summation(&k, &one, &inf)); // -ln(2)
println!("{}", (1 / &k.factorial()).summation(&k, &zero, &inf)); // E
println!("{}", (&x.pow(&k) / &k.factorial()).summation(&k, &zero, &inf)); // exp(x)
println!("{}", (1 / (&k * (&k + 1))).summation(&k, &one, &inf)); // 1
println!("{}", (1 / &k).summation(&k, &one, &inf)); // oo
}
Convergence
is_convergent(&k) and is_absolutely_convergent(&k) apply the p-series, ratio, root, alternating-series and comparison tests and return Some(true)/Some(false) only when a test is decisive; otherwise None. hypergeometric_ratio(&k) returns t(k+1)/t(k) as a rational function when the term is hypergeometric.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; k);
let alt = ctx.int(-1).pow(&k) / &k;
assert_eq!(k.powi(-2).is_convergent(&k), Some(true));
assert_eq!((1 / &k).is_convergent(&k), Some(false));
assert_eq!(alt.is_convergent(&k), Some(true));
assert_eq!(alt.is_absolutely_convergent(&k), Some(false));
let ratio = (ctx.int(2).pow(&k) / &k.factorial()).hypergeometric_ratio(&k).unwrap();
println!("{ratio}"); // 2/(k + 1)
}
Products
product_over handles constant, polynomial-factorable, telescoping and factorial-type products, plus classical infinite products.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; k);
let n = ctx.symbol_with("n", &[Assumption::Integer, Assumption::Positive]);
println!("{}", k.product_over(&k, &ctx.int(1), &n)); // n!
println!("{}", (1 + 1 / &k).product_over(&k, &ctx.int(1), &n)); // n + 1
println!("{}", (1 - k.powi(-2)).product_over(&k, &ctx.int(2), &ctx.infinity())); // 1/2
}
Formal power series
fps(&x, &point) and fps_maclaurin(&x) return a FormalPowerSeries: coefficients are computed lazily and exactly on demand, and general_term(&k) returns a closed form for the k-th coefficient when one is recognised. Series support add, sub, mul, scale, compose, inverse (1/f), reversion (compositional inverse), derivative, integral, and truncate(n) back to an Ex.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, k);
let sin = x.sin().fps_maclaurin(&x);
let cos = x.cos().fps_maclaurin(&x);
let exp = x.exp().fps_maclaurin(&x);
println!("{:?}", sin.coefficients(8).iter().map(|c| c.to_string()).collect::<Vec<_>>());
// ["0", "1", "0", "-1/6", "0", "1/120", "0", "-1/5040"]
println!("{}", sin.coefficient(51)); // -1/51! (exact)
println!("{}", sin.general_term(&k).unwrap()); // sin(1/2*k*pi)/k!
println!("{}", exp.general_term(&k).unwrap()); // 1/k!
println!("{}", sin.mul(&cos).unwrap().truncate(6)); // 2/15*x^5 - 2/3*x^3 + x
println!("{:?}", cos.inverse().unwrap().coefficients(7).iter().map(|c| c.to_string()).collect::<Vec<_>>());
// sec x: ["1", "0", "1/2", "0", "5/24", "0", "61/720"]
println!("{:?}", sin.reversion().unwrap().coefficients(6).iter().map(|c| c.to_string()).collect::<Vec<_>>());
// asin x: ["0", "1", "0", "1/6", "0", "3/40"]
let gauss = exp.compose(&(-&x.powi(2)).fps_maclaurin(&x)).unwrap();
println!("{:?}", gauss.coefficients(7).iter().map(|c| c.to_string()).collect::<Vec<_>>());
// e^(-x²): ["1", "0", "-1", "0", "1/2", "0", "-1/6"]
}
Finite differences
finite_diff::finite_diff_weights(order, &points, &x0) returns exact Fornberg weights; Ex::differentiate_finite(&x, &points, order) applies them to an expression (formal Derivative nodes inside are replaced by their stencils; order = 0 does just that replacement).
use symplex::prelude::*;
use symplex::finite_diff::finite_diff_weights;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, h);
let stencil = [&x - &h, x.clone(), &x + &h];
let w = finite_diff_weights(1, &stencil, &x);
println!("{:?}", w.iter().map(|e| e.to_string()).collect::<Vec<_>>());
// ["-1/(2*h)", "0", "1/(2*h)"]
println!("{}", x.powi(3).differentiate_finite(&x, &stencil, 1).expand()); // h^2 + 3*x^2
println!("{}", x.powi(4).differentiate_finite(&x, &stencil, 2).expand()); // 2*h^2 + 12*x^2
}
See cargo run --example summation_and_series for the full tour.
Complex Analysis and Special Functions
New in 0.2. symplex is a CAS over ℂ: a symbol with no assumptions may be complex, and the library refuses to pretend otherwise.
re, im, conjugate, arg
Ex::{re, im, conjugate, arg} are constructed with as much evaluation as the structure and assumptions allow. For a symbol declared Real the parts are immediate; for an unassumed symbol you get an unevaluated re(z) node (0.1 silently assumed every symbol real — a wrong answer for conjugate(z)).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let x = ctx.symbol_with("x", &[Assumption::Real]);
let y = ctx.symbol_with("y", &[Assumption::Real]);
let z = ctx.symbol("z"); // may be complex
let i = ctx.i_unit();
let w = &x + &i * &y;
println!("{}", w.re()); // x
println!("{}", w.im()); // y
println!("{}", w.conjugate()); // x - y*I
println!("{}", w.abs_squared()); // x^2 + y^2
println!("{}", w.arg()); // atan2(y, x)
println!("{}", z.re()); // re(z) — not assumed real
println!("{}", z.conjugate()); // conjugate(z)
println!("{}", (&z.powi(2) + 1).conjugate()); // conjugate(z)^2 + 1 (distributes)
println!("{}", z.exp().conjugate()); // exp(conjugate(z)) (commutes with exp)
println!("{}", (&i * &z).re()); // -im(z)
println!("{}", z.exp().re()); // cos(im(z))*exp(re(z))
println!("{:?} {:?}", (&x.powi(2) + 1).is_real_valued(), z.is_real_valued()); // Some(true) None
}
Splitting into real and imaginary parts
as_real_imag() returns (re, im) as a pair; expand_complex() rewrites the expression as re + im·I; polar() returns a Polar { modulus, argument } struct (symplex::expr_complex::Polar).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let x = ctx.symbol_with("x", &[Assumption::Real]);
let y = ctx.symbol_with("y", &[Assumption::Real]);
let w = &x + &ctx.i_unit() * &y;
let (re, im) = w.powi(2).as_real_imag();
println!("({re}) + ({im})i"); // (x^2 - y^2) + (2*x*y)i
let (re, im) = w.exp().as_real_imag();
println!("({re}) + ({im})i"); // (cos(y)*exp(x)) + (sin(y)*exp(x))i
let (re, im) = w.sin().as_real_imag();
println!("({re}) + ({im})i"); // (sin(x)*cosh(y)) + (cos(x)*sinh(y))i
let (re, im) = (1 / &w).as_real_imag();
println!("({re}) + ({im})i"); // (x/(x^2 + y^2)) + (-y/(x^2 + y^2))i
println!("{}", (&ctx.i_unit() * &x).cos().expand_complex()); // cosh(x)
}
Concrete complex numbers
Gaussian rationals are exact. Use expand() (or expand_complex()) to multiply out and as_real_imag() to divide; abs_squared() avoids the square root.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let i = ctx.i_unit();
let a = ctx.complex(&ctx.int(3), &ctx.int(4)); // 3 + 4i
let b = ctx.int(1) - &i * 2; // 1 − 2i
println!("{}", (&a * &b).expand()); // -2*I + 11
let (re, im) = (&a / &b).as_real_imag();
println!("{re} + {im}i"); // -1 + 2i
println!("{}", a.abs_squared()); // 25
println!("{}", (ctx.int(1) + &i).arg().eval()); // 1/4*pi
println!("{}", (ctx.int(1) + &i).powi(8).expand()); // 16
println!("{}", (&i * &ctx.pi()).exp().eval()); // -1
println!("{}", ctx.int(-1).ln().eval()); // pi*I
println!("{}", ctx.int(-4).sqrt()); // 2*I
println!("{}", i.exp().eval_complex64().unwrap()); // 0.5403…+0.8414…i (a `Complex64`)
}
Current gaps, stated plainly: abs(3 + 4i) is not folded to 5 by eval/simplify (use abs_squared()), and ln(i) / i^i stay symbolic (evaluate with eval_decimal).
Complex infinity
1/0 evaluates to complex infinity zoo (Context::complex_infinity()), distinct from the signed real infinities oo and -oo. The parser accepts zoo.
New constants
| Constant | Context method | Notes |
|---|---|---|
| γ (Euler–Mascheroni) | euler_gamma() | rationality unknown → assumption system leaves it open |
| G (Catalan) | catalan() | positive, real, finite |
| φ (golden ratio) | golden_ratio() | algebraic; nsimplify recognises φ² − φ − 1 = 0, simplify does not yet |
| ∞̃ | complex_infinity() | zoo |
All evaluate to arbitrary precision with eval_decimal(digits).
Special functions
0.2 adds si, ci, ei, li, zeta, polygamma(n, x) and kronecker_delta(i, j), with exact special values, derivative rules and evalf; Digamma folds at positive integers and half-integers; Bessel I/K and orthogonal polynomials of any degree evaluate numerically; all Bessel functions and orthogonal polynomials have derivative rules.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let x = ctx.symbol_with("x", &[Assumption::Real]);
println!("{}", ctx.int(1).digamma().eval()); // -EulerGamma
println!("{}", ctx.rational(1, 2).digamma().eval()); // -EulerGamma - 2*ln(2)
println!("{}", ctx.int(1).polygamma(&ctx.int(1)).eval()); // 1/6*pi^2
println!("{}", ctx.int(1).polygamma(&ctx.int(2)).eval()); // -2*zeta(3)
println!("{}", x.digamma().diff(&x)); // polygamma(1, x)
println!("{} {} {} {}", ctx.int(2).zeta().eval(), ctx.int(4).zeta().eval(),
ctx.int(0).zeta().eval(), ctx.int(-1).zeta().eval()); // 1/6*pi^2 1/90*pi^4 -1/2 -1/12
println!("{}", ctx.int(3).zeta().eval_decimal(30).unwrap()); // 1.20205690315959428539973816151
println!("{} {}", ctx.int(0).si().eval(), ctx.infinity().si().eval()); // 0 1/2*pi
println!("{} {}", x.si().diff(&x), x.li().diff(&x)); // sin(x)/x 1/ln(x)
println!("{}", ctx.int(1).ei().eval_decimal(25).unwrap()); // 1.89511781635593675546652
println!("{} {}", x.kronecker_delta(&x), ctx.int(1).kronecker_delta(&ctx.int(2))); // 1 0
println!("{}", ctx.int(2).bessel_k(&ctx.int(0)).eval_decimal(20).unwrap()); // 0.11389387274953343565
println!("{}", ctx.rational(1, 3).legendre(&ctx.int(10)).eval()); // 13597/59049
}
The parser accepts all of these by name (re, im, conjugate/conj, arg, si, ci, ei, li, zeta, polygamma, zoo), and compile() / to_rust_fn / to_c_fn support Γ, lnΓ, ψ, erf/erfc, W, B, Bessel and orthogonal polynomials.
See cargo run --example complex_analysis for the full tour.
More special functions (0.9)
0.9 adds the remaining SymPy special functions that show up as integration
results and in physics. All of them are Apply nodes with SymPy’s names, so
they print, parse (ctx.parse("erfi(x)")) and serialise like besselj; the
method lives on the argument and takes the parameters first, as with
x.bessel_j(&nu).
| Function | Constructor | Exact values (eval) | Derivative |
|---|---|---|---|
| erfi, erf⁻¹, erfc⁻¹ | x.erfi(), x.erfinv(), x.erfcinv() | erfi(0) = 0, odd; erfinv(±1) = ±∞; erfcinv(1) = 0 | 2e^{x²}/√π; (√π/2) e^{erfinv²} |
| Eₙ, E₁ | x.expint(&n), x.e1() | Eₙ(0) = 1/(n−1), E₀(x) = e^{−x}/x, Eₙ(∞) = 0 | −Eₙ₋₁(x) |
| Shi, Chi | x.shi(), x.chi() | Shi(0) = 0, odd; Chi(0) = −∞ | sinh x/x, cosh x/x |
| Fresnel S, C | x.fresnels(), x.fresnelc() | S(0) = 0, S(±∞) = ±1/2, odd | sin(πx²/2), cos(πx²/2) |
| γ(s,x), Γ(s,x) | x.lowergamma(&s), x.uppergamma(&s) | closed forms for integer and half-integer s (Γ(1,x) = e^{−x}, Γ(0,x) = E₁(x), Γ(½,x) = √π erfc(√x), …) | ±x^{s−1}e^{−x} |
| Liₛ(z) | z.polylog(&s) | Liₛ(0) = 0, Liₛ(1) = ζ(s), Liₛ(−1) = −η(s), Li₁ = −ln(1−z), Li₀ = z/(1−z), Li₋ₙ rational, Li₂(½) | Liₛ₋₁(z)/z |
| η(s) | s.dirichlet_eta() | η(1) = ln 2, η(s) = (1−2^{1−s})ζ(s) when ζ(s) folds | formal |
| Ai, Bi, Ai′, Bi′ | x.airyai(), x.airybi(), x.airyaiprime(), x.airybiprime() | values at 0 in terms of Γ(⅓), Γ(⅔); limits at ±∞ | Ai′ = airyaiprime, Ai″ = x·Ai |
| K(m), E(m) | m.elliptic_k(), m.elliptic_e() | K(0) = E(0) = π/2, E(1) = 1, K(1) = z∞, K(½) = Γ(¼)²/(4√π) | (E − (1−m)K)/(2m(1−m)), (E − K)/(2m) |
| F(φ|m), Π(n|m) | phi.elliptic_f(&m), n.elliptic_pi(&m) | F(0|m) = 0, F(φ|0) = φ, F(π/2|m) = K(m); Π(0|m) = K(m), Π(n|0) = π/(2√(1−n)), Π(n|n) = E(n)/(1−n) | ∂φF = 1/√(1−m sin²φ) (∂ₘF formal); both partials of Π |
| Cₙ^(a), Pₙ^(a,b), Pₙ^m, Lₙ^(a) | x.gegenbauer(&n, &a), x.jacobi(&n, &a, &b), x.assoc_legendre(&n, &m), x.assoc_laguerre(&n, &a) | explicit polynomials for integer n (symbolic a, b allowed); Cₙ^(½) = Pₙ, Cₙ^(1) = Uₙ, Pₙ^(0,0) = Pₙ^0 = Pₙ, Lₙ^(0) = Lₙ | 2a Cₙ₋₁^(a+1), (n+a+b+1)/2 Pₙ₋₁^(a+1,b+1), (nxPₙ^m − (n+m)Pₙ₋₁^m)/(x²−1), −Lₙ₋₁^(a+1) |
assoc_legendre uses the Condon–Shortley phase like SymPy (P₁¹(x) = −√(1−x²));
the elliptic integrals take the parameter m = k².
Everything evaluates numerically to arbitrary precision (eval_decimal(40)
agrees with mpmath) for real arguments in the real domain: the error-function
family by series / asymptotics / Halley iteration, Eₙ, γ, Γ by series
and Legendre’s continued fraction, Liₛ by the direct series or the
expansion in ln z (any real z ≠ 1 for s ≤ 0, |z| ≤ 1 otherwise),
Airy by Maclaurin series with guard bits or the large-|x| asymptotic
expansions, and the elliptic integrals by Carlson’s symmetric forms
(n < 1, m < 1). Arguments outside these domains (complex Liₛ, K(m > 1),
erfinv(|y| ≥ 1), …) return Err(Unevaluable).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let (x, z, a) = (ctx.symbol("x"), ctx.symbol("z"), ctx.symbol("a"));
println!("{}", x.erfi().diff(&x)); // 2*exp(x^2)/sqrt(pi)
println!("{}", ctx.rational(7, 10).erfi().eval_decimal(20).unwrap()); // 0.94028293383350747659
println!("{}", x.uppergamma(&ctx.int(3)).eval()); // x^2*exp(-x) + 2*x*exp(-x) + 2*exp(-x)
println!("{}", ctx.int(1).polylog(&ctx.int(2)).eval()); // 1/6*pi^2
println!("{}", z.polylog(&ctx.int(-1)).eval()); // z*(-z + 1)^(-2)
println!("{}", ctx.int(0).elliptic_k().eval()); // 1/2*pi
println!("{}", ctx.rational(1, 2).elliptic_k().eval_decimal(20).unwrap()); // 1.8540746773013719184
println!("{}", x.gegenbauer(&ctx.int(2), &a).eval()); // 2*a^2*x^2 + 2*a*x^2 - a
println!("{}", x.airyaiprime().diff(&x)); // x*airyai(x)
println!("{}", x.polylog(&ctx.int(2)).to_latex()); // \operatorname{Li}_{2}\left(x\right)
}
to_lean returns NotImplemented for all of these (Mathlib has no standard
spelling), and compile / to_rust_fn / to_c_fn report the missing runtime
rather than generating code.
Algebra
Expansion, factoring, rational functions, polynomial algebra, and simplification. The pattern-matching engine that powers simplify is covered in The Rule Engine; the Poly view of an expression as explicit (monomial, coefficient) data is in Polynomials as Data.
Expansion and collection
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y, t);
println!("{}", expr!(ctx, (x + 1)^3).expand()); // x^3 + 3*x^2 + 3*x + 1
println!("{}", (&x + &y).powi(3).expand_multinomial()); // x^3 + 3*x*y^2 + y^3 + 3*y*x^2
println!("{}", (&x * &y + &x * &t + &y * &t + &x).rcollect(&[&x, &y])); // t*y + x*(t + y + 1)
println!("{}", expr!(ctx, x^2 * y + x * y^2).collect(&x));
// expand_with: deep = false stops at function boundaries
let opts = ExpandOpts { deep: false, ..ExpandOpts::default() };
let e = (&x + 1) * &((&y + 1).powi(2)).sin();
println!("{}", e.expand_with(&opts)); // x*sin((y + 1)^2) + sin((y + 1)^2)
}
expand() no longer splits (x·y)^a for symbols of unknown sign — that identity fails over ℂ. expand_power_base(true) forces it when you know it is safe.
Factoring
Univariate factoring over ℤ uses Berlekamp–Zassenhaus (any degree; 0.1 was limited to small Kronecker degrees). Multivariate factoring uses Kronecker substitution. factor_list returns the content and (factor, multiplicity) pairs.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
println!("{}", expr!(ctx, x^12 - 1).factor(&x));
// (x - 1)*(x + 1)*(x^2 + x + 1)*(x^2 + 1)*(x^2 - x + 1)*(x^4 - x^2 + 1)
let p = ((&x.powi(5) - &x - 1) * (&x.powi(4) + &x + 1) * (&x.powi(2) + 1)).expand();
println!("{}", p.factor(&x)); // (x^5 - x - 1)*(x^4 + x + 1)*(x^2 + 1)
let (content, factors) = p.factor_list(&x);
println!("{content} {:?}", factors.iter().map(|(f, m)| format!("({f})^{m}")).collect::<Vec<_>>());
println!("{}", expr!(ctx, x^3 - x*y^2 + x^2 - y^2).factor_all()); // (x + 1)*(x + y)*(x - y)
println!("{}", expr!(ctx, x^2 + 1).factor(&x)); // x^2 + 1 (irreducible over ℤ)
println!("{:?}", expr!(ctx, x^4 + 1).is_irreducible(&x)); // Some(true)
}
Rational functions
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
println!("{}", expr!(ctx, (x^2 - 1) / (x - 1)).cancel(&x)); // x + 1
println!("{}", expr!(ctx, 1 / (x^2 - 1)).partial_fractions(&x)); // -1/(2*(x + 1)) + 1/(2*(x - 1))
println!("{}", (1 / &x + 1 / (&x + 1)).together());
let (num, den) = expr!(ctx, (x + 1) / (x - 1)).as_numer_denom();
println!("{num} / {den}");
}
Rational normal form: ratsimp
New in 0.3, ratsimp is the canonical form for rational expressions in all variables at once: a single fraction P/Q with common polynomial factors cancelled (multivariate GCD), integer-primitive numerator and denominator, and a positive leading coefficient in Q. Non-rational subexpressions (sin x, π, √x) are treated as opaque indeterminates, exactly like SymPy’s cancel. simplify_rational now produces the same normal form, and solve uses it for solutions with symbolic coefficients.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y, a);
println!("{}", (ctx.int(1) / (&x + ctx.int(1) / &y) + ctx.int(1) / (&y + ctx.int(1) / &x)).ratsimp());
// (x + y)/(x*y + 1)
println!("{}", ((&x.powi(2) - &y.powi(2)) / (&x - &y)).ratsimp()); // x + y
println!("{}", (ctx.int(1) / &x + ctx.int(1) / (&x + 1)).ratsimp()); // (2*x + 1)/(x^2 + x)
// degree / coeffs / coeff / leading_coeff / is_polynomial accept parameter coefficients
let e = &a * &x.powi(2) + (&a + 1) * &x + 3;
println!("{:?}", e.degree(&x)); // Some(2)
println!("{:?}", e.coeffs(&x).map(|cs| cs.iter().map(|c| c.to_string()).collect::<Vec<_>>()));
// Some(["3", "a + 1", "a"])
println!("{}", e.leading_coeff(&x).unwrap()); // a
println!("{}", e.is_polynomial(&x)); // true
}
(lhs - rhs).ratsimp() is 0 exactly when two rational expressions agree — the cheapest way to check an identity. For the polynomial data behind these expressions (terms, coefficient matrices, exact evaluation, sign on an interval) see Polynomials as Data.
Polynomial algebra on Ex
New in 0.2 (rational coefficients unless noted): resultant, discriminant, sqf_list, square_free_part, is_squarefree, poly_div/poly_quo/poly_rem, poly_gcdex, poly_gcd/poly_lcm, decompose, content_primitive, leading_coeff, monic, poly_compose, poly_shift, poly_reverse, poly_interpolate, count_real_roots, real_roots_isolate, nroots. All return Option/Result and give None for non-polynomial input. Since 0.3, degree, coeffs, coeff, leading_coeff and is_polynomial also accept symbolic (parameter) coefficients, and poly_is_nonnegative_on / poly_is_positive_on decide the sign of a polynomial on an interval exactly (see Polynomials as Data).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
println!("{}", (&x.powi(2) + 1).resultant(&(&x.powi(2) - 2), &x).unwrap()); // 9
println!("{}", (&x.powi(3) - &x).discriminant(&x).unwrap()); // 4
let (q, r) = (&x.powi(3) + &x * 2 + 1).poly_div(&(&x.powi(2) + 1), &x).unwrap();
println!("q = {q}, r = {r}"); // q = x, r = x + 1
let e = (&x.powi(2) - 1).poly_gcdex(&(&x.powi(2) - &x * 2 + 1), &x).unwrap(); // ExtendedGcd { gcd, x, y }
println!("{}·f + {}·g = {}", e.x, e.y, e.gcd); // 1/2·f + -1/2·g = x - 1
let sq = ((&x - 1).powi(2) * (&x + 2).powi(3) * &x).expand();
let (_, sqf) = sq.sqf_list(&x).unwrap();
println!("{:?}", sqf.iter().map(|(f, m)| format!("({f})^{m}")).collect::<Vec<_>>());
// ["(x)^1", "(x - 1)^2", "(x + 2)^3"]
println!("{:?}", (&x.powi(4) + &x.powi(2) * 2 + 1).decompose(&x).iter().map(|e| e.to_string()).collect::<Vec<_>>());
// ["x^2 + 2*x + 1", "x^2"] (outer ∘ inner)
let pts = [(ctx.int(0), ctx.int(1)), (ctx.int(1), ctx.int(3)), (ctx.int(2), ctx.int(9))];
println!("{}", Ex::poly_interpolate(&pts, &x).unwrap()); // 2*x^2 + 1
let q5 = &x.powi(5) - &x - 1;
println!("{:?}", q5.count_real_roots(&x)); // Some(1)
for Complex64 { re, im } in q5.nroots(&x, 12).unwrap() {
println!("{re:.10} {im:+.10}i");
}
// Isolating intervals carry their kind: a Sturm cell is `(lo, hi]`, a root
// hit exactly by the bisection is the point `[r, r]`.
println!("{:?}", (&x.powi(3) - &x * 2 - 5).real_roots_isolate(&x).iter()
.map(|iv| iv.to_string()).collect::<Vec<_>>());
// ["(17157/8192, 4291/2048]"]
}
Since 0.18 these scale to high degree: root isolation and counting
evaluate signs in ℤ[x] and real_roots / root_of start the numeric
root finder behind a RootOf index from the Newton polygon of the
coefficients, so the root in (0, 1) of a degree-50 binomial-tail
polynomial (stats::aggregation::proportion_interval_exact) is named in
a few seconds even in a debug build — previously root_of gave None
at degree ≥ 40. root_of names only the requested root, so prefer it to
real_roots(&x)[k] for one root of a large polynomial. Should the
factorisation over ℤ not be certified complete (an exhausted
recombination budget, not a matter of degree), RootOf(g, k) names a
square-free but possibly reducible g: it still evaluates correctly but
is not a canonical form. nroots returns exactly 0.0 for a real part
that is below the iteration’s convergence tolerance (10⁻³⁰ relative to
max(1, |z|)), so x² + 1 gives ±i rather than -7.7e-93 ± i, while
genuinely tiny roots (x² − 10⁻⁴⁰ → ±10⁻²⁰) are kept; whether a root
is real is still decided exactly.
Simplification
simplify() runs a dozen strategies (eval, expand, factor, trig, log, cancel, power, radical, assumption-aware refinement, …), keeps the result with the fewest operations, and iterates to a fixpoint. simplify_with(&SimplifyOpts) controls the iteration count; simplify_traced shows what fired. Targeted simplifiers are available when you know which identity you want:
| Method | Does |
|---|---|
simplify_trig, fu, trig_power_linearize, trig_half_angle | Trigonometric identities (Fu’s algorithm) |
expand_trig, trig_combine | sin(a+b) ↔ products |
expand_log, log_combine, expand_log_with(force), log_combine_with(force) | Logarithm rules (guarded by assumptions unless forced) |
simplify_powers, powdenest(force), expand_power_base(force), expand_power_exp(force) | Power rules |
sqrtdenest | √(5 + 2√6) → √2 + √3 |
signsimp | Canonicalise signs: −t·(−x − y) → t·(x + y) |
rationalize_denom, simplify_rational | Radical and rational denominators |
simplify_combinatorial | Factorials, binomials, Gamma |
nsimplify(tol), nsimplify_with_constants(&[&pi], tol) | Recognise a float as a rational or a rational multiple of a constant |
refine, refine_with | Apply assumptions (√(x²) → x for x ≥ 0) |
rewrite_as_exp, rewrite_as_trig | Change representation |
separate_vars, separate_vars_additive, separate_vars_dict | Split products/sums by variable groups |
subs_algebraic(&old, &new) | Substitute x² → u inside x⁴, x³, 1/x² |
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y, t);
println!("{}", (&x.sin().powi(2) + &x.cos().powi(2)).simplify()); // 1
println!("{}", (ctx.int(5) + ctx.int(24).sqrt()).sqrt().sqrtdenest()); // sqrt(2) + sqrt(3)
println!("{}", ((-&x - &y) * (-&t)).signsimp()); // t*(x + y)
println!("{} / {}", x.powi(2).sqrt().powdenest(false), x.powi(2).sqrt().powdenest(true)); // abs(x) / x
println!("{}", ctx.from_f64(0.333333333333).unwrap().nsimplify(1e-9)); // 1/3
println!("{}", ctx.from_f64(std::f64::consts::PI / 2.0).unwrap()
.nsimplify_with_constants(&[&ctx.pi()], 1e-12)); // 1/2*pi
println!("{}", x.powi(4).subs_algebraic(&x.powi(2), &y)); // y^2
println!("{}", (&x * 2).exp().subs_algebraic(&x.exp(), &y)); // y^2
}
Gröbner bases
symplex::groebner computes reduced Gröbner bases (Buchberger with FGLM order conversion) over sparse multivariate polynomials (symplex::multipoly); symplex::polysys::solve_system_ex builds on it (see Solving Equations).
Polynomials as Data
An Ex is a tree. When you know an expression is a polynomial in some symbols, you usually want a different view of it: a finite list of (monomial, coefficient) pairs that you can index, iterate, multiply, evaluate exactly, and lay out as a matrix. In 0.3 that view is Poly (symplex::poly_ex::Poly), reached from any expression with as_poly(&[&x, &y]).
Two things distinguish Poly from the rational-coefficient machinery in symplex::multipoly:
- Coefficients are
Ex. They may be exact rationals or symbolic parameters — anything free of the generators.a·x² + (a + b)·x + 3is a perfectly good polynomial inx. - Nothing is approximated. Every operation is exact, and
to_ex()rebuilds an expression equal to the (expanded) input.
Terms are always reported in descending lexicographic order of the exponent vectors — the order of SymPy’s Poly.terms().
Viewing an expression as a polynomial
as_poly (equivalently Poly::new) expands the expression and collects it by monomial. It returns None if a generator appears in a non-polynomial position — inside a function, under a negative or fractional power, or in an exponent.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let e = (&x + &y * 2).powi(2) * &x - &y.powi(3);
let p = e.as_poly(&[&x, &y]).unwrap();
println!("{p}"); // Poly(x^3 + 4*x^2*y + 4*x*y^2 - y^3, x, y)
for (mono, coeff) in p.terms() {
println!("x^{} y^{} · {coeff}", mono[0], mono[1]);
}
// x^3 y^0 · 1
// x^2 y^1 · 4
// x^1 y^2 · 4
// x^0 y^3 · -1
println!("{:?}", p.monoms()); // [[3, 0], [2, 1], [1, 2], [0, 3]]
println!("{:?}", p.coeffs()); // [Ex(1), Ex(4), Ex(4), Ex(-1)]
println!("{}", p.coeff_monomial(&[1, 2]).unwrap()); // 4
println!("{}", p.coeff_monomial(&[5, 0]).unwrap()); // 0 (absent monomials are zero)
println!("{:?} {:?} {:?}", p.total_degree(), p.degree_in(&x), p.degree_list());
// Some(3) Some(3) [3, 3]
println!("{} {:?}", p.leading_coeff(), p.leading_monomial()); // 1 Some([3, 0])
println!("{} {} {}", p.num_terms(), p.is_homogeneous(), p.has_rational_coeffs());
// 4 true true
println!("{}", p.to_ex()); // x^3 + 4*x*y^2 - y^3 + 4*y*x^2
assert!(x.sin().as_poly(&[&x]).is_none()); // generator inside a function
assert!((ctx.int(1) / &x).as_poly(&[&x]).is_none()); // negative power
assert!(x.pow(&y).as_poly(&[&x]).is_none()); // generator in an exponent
}
Other structural queries: is_zero, is_ground (constant), is_univariate, is_linear, gens(), num_gens(), leading_term(), and equals(&other) (same generators, identical normalised coefficients). Poly::from_terms(&ctx, &gens, vec![(exps, coeff), …]), Poly::zero, Poly::one and Poly::constant build polynomials directly.
Symbolic coefficients
Any symbol that is not a generator becomes part of the coefficients. The same expression can be viewed with different generator lists, and the plain Ex methods degree, coeffs, coeff, leading_coeff and is_polynomial now accept parameter coefficients too (in 0.2 they required rational coefficients).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, a, b);
let e = &a * &x.powi(2) + (&a + &b) * &x + &x.powi(2) + 3;
println!("{e}"); // a*x^2 + x^2 + x*(a + b) + 3
// Ex methods — ascending order, like 0.2:
println!("{:?}", e.degree(&x)); // Some(2)
let cs: Vec<String> = e.coeffs(&x).unwrap().iter().map(|c| c.to_string()).collect();
println!("{cs:?}"); // ["3", "a + b", "a + 1"]
println!("{}", e.coeff(&x, 1).unwrap()); // a + b
println!("{}", e.leading_coeff(&x).unwrap()); // a + 1
println!("{}", e.is_polynomial(&x)); // true
// Poly view in x alone — all_coeffs is dense and highest-degree first (SymPy order):
let p = e.as_poly(&[&x]).unwrap();
let dense: Vec<String> = p.all_coeffs().unwrap().iter().map(|c| c.to_string()).collect();
println!("{dense:?}"); // ["a + 1", "a + b", "3"]
println!("{}", p.has_rational_coeffs()); // false
// Promote a to a generator: now b is the only parameter.
let q = e.as_poly(&[&x, &a]).unwrap();
for (mono, coeff) in q.terms() {
println!("x^{} a^{} · {coeff}", mono[0], mono[1]);
}
// x^2 a^1 · 1
// x^2 a^0 · 1
// x^1 a^1 · 1
// x^1 a^0 · b
// x^0 a^0 · 3
println!("{:?}", x.pow(&a).degree(&x)); // None (x^a is not polynomial in x)
}
Note the two orderings: Ex::coeffs is ascending ([a₀, a₁, …], unchanged from 0.2), while Poly::all_coeffs is descending with zeros filled in, matching SymPy’s all_coeffs().
Exact evaluation and arithmetic
eval substitutes a value for every generator and evaluates; values may be rationals, radicals or expressions. eval_gen substitutes one generator (by a constant or a polynomial in the remaining generators) and returns a Poly with one generator fewer. Arithmetic (add, sub, mul, neg, scale, pow, derivative) requires identical generator lists and returns Result.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let p = (&x.powi(2) * &y - &y / 2 + ctx.rational(1, 3)).as_poly(&[&x, &y]).unwrap();
println!("{}", p.eval(&[&ctx.rational(3, 2), &ctx.rational(-4, 5)]).unwrap()); // -16/15
println!("{}", p.eval(&[&ctx.int(2).sqrt(), &ctx.int(1)]).unwrap()); // 11/6
println!("{}", p.eval_gen(&x, &ctx.int(2)).unwrap()); // Poly(7/2*y + 1/3, y)
println!("{}", p.eval_gen(&x, &(&y + 1)).unwrap()); // Poly(y^3 + 2*y^2 + 1/2*y + 1/3, y)
println!("{}", p.derivative(&y).unwrap().to_ex()); // x^2 - 1/2
let s = (&x + &y).as_poly(&[&x, &y]).unwrap();
let d = (&x - &y).as_poly(&[&x, &y]).unwrap();
println!("{}", s.mul(&d).unwrap().to_ex()); // x^2 - y^2
println!("{}", s.pow(3).unwrap().to_ex()); // x^3 + 3*x*y^2 + y^3 + 3*y*x^2
println!("{}", s.add(&d).unwrap().to_ex()); // 2*x
println!("{}", s.sub(&d).unwrap().to_ex()); // 2*y
println!("{}", s.neg().to_ex()); // -x - y
println!("{}", s.scale(&ctx.rational(1, 2)).unwrap().to_ex()); // 1/2*x + 1/2*y
assert!(s.mul(&d).unwrap().equals(&(&x.powi(2) - &y.powi(2)).as_poly(&[&x, &y]).unwrap()));
assert!(s.add(&(&x + 1).as_poly(&[&x]).unwrap()).is_err()); // different generators
// Rational-coefficient helpers
let q = (&x.powi(2) * -4 + &x * 6).as_poly(&[&x]).unwrap();
let (c, prim) = q.content_and_primitive().unwrap();
println!("{c} · ({})", prim.to_ex()); // -2 · (2*x^2 - 3*x)
println!("{}", q.monic().unwrap().to_ex()); // x^2 - 3/2*x
}
Since 0.18 a Poly whose coefficients are all rational literals is held as an exact MultiPoly<Lex> rather than as one Ex per monomial, and add/sub/mul/pow/scale/derivative/eval/eval_gen between such polynomials run on rationals without touching the expression arena (products and powers accumulate integer numerators over a common denominator and reduce once). The representation is invisible: terms(), Display, to_ex() and to_multipoly() report exactly what they did before, a symbolic coefficient switches the polynomial to the Ex form, and mixed operations convert the exact side. Expect roughly 5–15× on dense products and 10–30× on exact evaluation compared with 0.17.
Coefficient matrices and exact linear systems
The reason Poly exists is to make questions like “is goal a linear combination of h₁, …, hₖ?” mechanical. Poly::monomial_basis collects every monomial that occurs in a family, and Poly::coefficient_matrix lays the family out with one row per monomial and one column per polynomial. The unknown multipliers λ then satisfy M·λ = coefficients of goal, which linsolve_matrix solves exactly — including the under- and over-determined cases.
use symplex::prelude::*;
use symplex::poly_ex::Poly;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let h1 = (&x + 1).as_poly(&[&x]).unwrap();
let h2 = (&x.powi(2) - 1).as_poly(&[&x]).unwrap();
let goal = (&x + 1).powi(2).as_poly(&[&x]).unwrap();
let basis = Poly::monomial_basis(&[&h1, &h2, &goal]).unwrap();
println!("{basis:?}"); // [[2], [1], [0]]
let m = Poly::coefficient_matrix(&[&h1, &h2], &basis).unwrap();
println!("{m}");
let rhs: Vec<Ex> = basis.iter().map(|mono| goal.coeff_monomial(mono).unwrap()).collect();
match linsolve_matrix(&m, &Matrix::col_vector(rhs)).unwrap() {
LinearSolution::Unique(pairs) => {
for (var, val) in pairs {
println!("{var} = {val}"); // x1 = 2, x2 = 1
}
}
other => println!("{other:?}"),
}
// So (x + 1)² = 2·(x + 1) + 1·(x² − 1).
// x² + x + 1 is not in the span:
let goal2 = (&x.powi(2) + &x + 1).as_poly(&[&x]).unwrap();
let rhs2: Vec<Ex> = basis.iter().map(|mono| goal2.coeff_monomial(mono).unwrap()).collect();
println!("{:?}", linsolve_matrix(&m, &Matrix::col_vector(rhs2)).unwrap()); // Inconsistent
}
Output of the matrix:
[
[0, 1],
[1, 0],
[1, -1]
]
When the multipliers must be non-negative — the situation in every positivity certificate — feed the same matrix to the exact LP solver instead: Matrix::to_rational_rows() gives the rows in the form linprog::feasible_nonneg wants. The Polynomial Inequality Certificates cookbook entry does this end to end, and Exact Linear Programming describes the solver.
Rational normal form: ratsimp
ratsimp puts a rational expression into a canonical P/Q: one fraction, common factors cancelled by a multivariate GCD, integer-primitive numerator and denominator, and a positive leading coefficient in Q. Maximal non-rational subexpressions (sin x, π, √x) are treated as opaque indeterminates, exactly as SymPy’s cancel does. simplify_rational is now the same normal form.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y, r, j);
println!("{}", ((&x.powi(2) - &y.powi(2)) / (&x - &y)).ratsimp()); // x + y
println!("{}", (ctx.int(1) / &x + ctx.int(1) / &y).ratsimp()); // (x + y)/(x*y)
let nested = ctx.int(1) / (&x + ctx.int(1) / &y) + ctx.int(1) / (&y + ctx.int(1) / &x);
println!("{nested} → {}", nested.ratsimp());
// 1/(x + 1/y) + 1/(1/x + y) → (x + y)/(x*y + 1)
println!("{}", (x.sin().powi(2) / x.sin()).ratsimp()); // sin(x)
println!("{}", ((x.sin().powi(2) - x.cos().powi(2)) / (x.sin() - x.cos())).ratsimp());
// sin(x) + cos(x) — a difference of squares, no trig identity involved
let (n, d) = (ctx.int(1) / &x + ctx.int(1) / (&x + 1)).ratsimp().as_numer_denom();
println!("{n} / {d}"); // 2*x + 1 / x^2 + x
let mixed = (&x.powi(2) * 2 + &x * 4) / (&x * 6 + 12) + ctx.rational(1, 3);
println!("{mixed} → {}", mixed.ratsimp());
// (2*x^2 + 4*x)/(6*x + 12) + 1/3 → 1/3*x + 1/3
println!("{}", (&x + 1).ratsimp()); // x + 1 (already normal)
println!("{}", x.exp().ratsimp()); // exp(x) (unchanged)
// `solve` with parameter coefficients returns ratsimp'd solutions.
let eqn = (&r * 3 - 1) / (&j + 1) - (&r + 1) / (&j * 2);
println!("{}", eqn.solve(&r).unwrap()[0]); // (3*j + 1)/(5*j - 1)
println!("{}", eqn.simplify_rational()); // (5*j*r - 3*j - r - 1)/(2*j^2 + 2*j)
}
ratsimp is the right tool for checking an identity: (lhs − rhs).ratsimp() is structurally 0 exactly when the two sides agree as rational functions. cancel(&x) (single variable) and together() (no cancellation) remain available for lighter-weight jobs.
Sign of a polynomial on an interval
poly_is_nonnegative_on(&x, &lo, &hi) and poly_is_positive_on decide, exactly, whether a univariate polynomial with rational coefficients is ≥ 0 (resp. > 0) on the closed interval [lo, hi]. The method is a square-free decomposition (to find the roots where the sign can change), a Sturm count to check that none lies strictly inside the interval, and one sample point. Endpoints must be rationals or ±∞. The answer is None for non-polynomial input or symbolic coefficients.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, a);
let (ninf, inf) = (ctx.neg_infinity(), ctx.infinity());
let sq = &x.powi(2) - &x * 2 + 1; // (x − 1)²
println!("{:?} {:?}",
sq.poly_is_nonnegative_on(&x, &ninf, &inf),
sq.poly_is_positive_on(&x, &ninf, &inf)); // Some(true) Some(false)
let cubic = &x.powi(3) - &x;
println!("{:?} {:?} {:?}",
cubic.poly_is_nonnegative_on(&x, &ctx.int(2), &inf),
cubic.poly_is_nonnegative_on(&x, &ctx.int(-2), &inf),
cubic.poly_is_nonnegative_on(&x, &ctx.int(-1), &ctx.int(0))); // Some(true) Some(false) Some(true)
let wobble = &x.powi(4) - &x.powi(2) * 5 + 4; // (x² − 1)(x² − 4)
println!("{:?} {:?} {:?}",
wobble.poly_is_nonnegative_on(&x, &ctx.rational(-1, 1), &ctx.rational(1, 1)),
wobble.poly_is_positive_on(&x, &ctx.rational(-1, 1), &ctx.rational(1, 1)),
wobble.poly_is_nonnegative_on(&x, &ctx.rational(3, 2), &ctx.rational(7, 4)));
// Some(true) Some(false) Some(false)
println!("{:?} {:?}",
(&x.powi(2) + 1).poly_is_positive_on(&x, &ninf, &inf),
(&a * &x + 1).poly_is_nonnegative_on(&x, &ninf, &inf)); // Some(true) None
println!("{:?}", x.sin().poly_is_nonnegative_on(&x, &ninf, &inf)); // None
println!("{:?}", wobble.count_real_roots(&x)); // Some(4)
}
For the roots themselves use count_real_roots, real_roots_isolate and nroots (see Algebra).
Numeric roots
Poly::nroots(digits) is Ex::nroots for the univariate, rational-coefficient case; anything else is an InvalidArgument error rather than a wrong answer. Roots come back as Vec<Complex64> (num_complex, re-exported in the prelude); real roots have im == 0.0 exactly.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, a);
let p = (&x.powi(5) - &x - 1).as_poly(&[&x]).unwrap();
for Complex64 { re, im } in p.nroots(12).unwrap() {
println!("{re:.10} {im:+.10}i");
}
// -0.7648844336 -0.3524715460i
// -0.7648844336 +0.3524715460i
// 0.1812324445 -1.0839541013i
// 0.1812324445 +1.0839541013i
// 1.1673039783 +0.0000000000i
println!("{}", (&a * &x + 1).as_poly(&[&x]).unwrap().nroots(10).unwrap_err());
// Poly::nroots: invalid argument: polynomial must have rational coefficients
println!("{}", (&x * &a).as_poly(&[&x, &a]).unwrap().nroots(10).unwrap_err());
// Poly::nroots: invalid argument: polynomial must be univariate
}
Bridge to MultiPoly and Gröbner bases
symplex::groebner and symplex::polysys work on MultiPoly<GrevLex>, a rational-coefficient sparse polynomial indexed by variable position. to_multipoly() converts a Poly whose coefficients are all rational (None otherwise), and Poly::from_multipoly(&ctx, &gens, &mp) converts back, so you can move between the two worlds without touching the low-level representation.
use symplex::prelude::*;
use symplex::groebner::groebner_basis;
use symplex::poly_ex::Poly;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let f1 = (&x.powi(2) + &y.powi(2) - 1).as_poly(&[&x, &y]).unwrap();
let f2 = (&x - &y).as_poly(&[&x, &y]).unwrap();
let gb = groebner_basis(&[f1.to_multipoly().unwrap(), f2.to_multipoly().unwrap()]);
for g in &gb {
println!("{}", Poly::from_multipoly(&ctx, &[&x, &y], g).unwrap().to_ex());
}
// y^2 - 1/2
// x - y
// Normal form of x³ modulo the ideal:
let target = x.powi(3).as_poly(&[&x, &y]).unwrap().to_multipoly().unwrap();
let refs: Vec<_> = gb.iter().collect();
let rem = target.reduce(&refs);
println!("{}", Poly::from_multipoly(&ctx, &[&x, &y], &rem).unwrap().to_ex()); // 1/2*y
let a = ctx.symbol("a");
println!("{:?}", (&a * &x).as_poly(&[&x]).unwrap().to_multipoly().is_none()); // true
}
symplex::polysys::solve_system_ex (see Solving Equations) does the whole pipeline — Gröbner basis, triangularisation, algebraic back-substitution — when what you want is the solution set rather than the basis.
See cargo run --example polynomials for the complete program these snippets are drawn from.
Algebraic numbers and polynomial algebra (0.9)
0.9 adds a layer of Ex methods over the exact engines above, so the common polynomial-algebra questions no longer need a detour through Poly/MultiPoly. Every method takes and returns Ex; the ones that answer a query return Option (None when the input is not of the required shape), the ones that validate caller-supplied structure return Result.
Minimal polynomials
minimal_polynomial(&var) (SymPy minimal_polynomial) returns the minimal polynomial over ℚ of an algebraic constant built from rationals, radicals, i, φ, sums, products, integer powers and reciprocals. The result is integer-primitive with a positive leading coefficient, exactly as SymPy prints it; None means the number was not recognised as algebraic (π, e, free symbols, transcendental functions).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let x = ctx.symbol("x");
let a = ctx.int(2).sqrt() + ctx.int(3).sqrt();
println!("{}", a.minimal_polynomial(&x).unwrap()); // x^4 - 10*x^2 + 1
let cbrt2 = ctx.int(2).pow(&ctx.rational(1, 3));
println!("{}", cbrt2.minimal_polynomial(&x).unwrap()); // x^3 - 2
println!("{}", ctx.rational(3, 4).minimal_polynomial(&x).unwrap()); // 4*x - 3
let b = ctx.int(1) / (ctx.int(1) + ctx.int(2).sqrt());
println!("{}", b.minimal_polynomial(&x).unwrap()); // x^2 + 2*x - 1
assert!(ctx.pi().minimal_polynomial(&x).is_none());
}
Multivariate gcd and lcm without naming variables
gcd_all / lcm_all (SymPy gcd(f, g) / lcm(f, g)) treat both inputs as polynomials over ℚ in all of their free symbols. The result follows MultiPoly::gcd‘s normalisation: integer coefficients, positive leading coefficient (grevlex), integer content equal to the gcd of the inputs’ contents — for polynomials over ℤ that is the ordinary gcd over ℤ, gcd(2x, 4x) = 2x. Non-polynomial input (sin x, 1/x, π·x) gives None.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y, z);
let f = &x.powi(2) - &y.powi(2);
println!("{}", f.gcd_all(&(&x - &y)).unwrap()); // x - y
println!("{}", f.lcm_all(&(&x - &y)).unwrap()); // x^2 - y^2
println!("{}", (&x * &y * &z + &x * &y).gcd_all(&(&x * &z + &x)).unwrap()); // x*z + x
println!("{}", (&x * 2).gcd_all(&(&x * 4)).unwrap()); // 2*x
assert!(x.sin().gcd_all(&x).is_none());
}
Gröbner bases and normal forms from Ex
Ex::groebner(&polys, &vars, order) computes the reduced (monic) Gröbner basis in the given variables under MonomialOrder::Lex or MonomialOrder::GrevLex (symplex::multipoly::MonomialOrder); reduce_modulo(&basis, &vars, order) is the remainder of multivariate division — the unique normal form when basis is a Gröbner basis for that order, so it is zero exactly for members of the ideal. Variables must be distinct symbols and every polynomial must have rational coefficients; anything else is an InvalidArgument error naming the offending input.
use symplex::multipoly::MonomialOrder;
use symplex::prelude::*;
fn main() -> Result<(), SymplexError> {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let vars = [x.clone(), y.clone()];
let gens = [&x.powi(2) + &y.powi(2) - 1, &x - &y];
let lex = Ex::groebner(&gens, &vars, MonomialOrder::Lex)?;
for g in &lex {
println!("{g}");
}
// x - y
// y^2 - 1/2
let grevlex = Ex::groebner(&gens, &vars, MonomialOrder::GrevLex)?; // [y^2 - 1/2, x - y]
println!("{}", x.powi(3).reduce_modulo(&lex, &vars, MonomialOrder::Lex)?); // 1/2*y
let member = (&x.powi(2) - &y.powi(2)).reduce_modulo(&grevlex, &vars, MonomialOrder::GrevLex)?;
assert!(member.is_zero_structural());
Ok(())
}
Exact real roots
real_roots(&var) lists the distinct real roots of a rational-coefficient polynomial in increasing order (decided exactly with Sturm sequences): rational roots as numbers, every other root as the RootOf(g, k) node that solve already uses for degree ≥ 5, where g is the irreducible factor over ℤ and k its index among g’s complex roots. RootOf evaluates numerically (eval_f64, eval_decimal) and prints as such; root_of(&var, k) is the k-th real root (0-based). Unlike SymPy’s real_roots, a repeated root is listed once (as in count_real_roots).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let x = ctx.symbol("x");
let roots = (&x.powi(3) - &x * 2).real_roots(&x).unwrap();
for r in &roots {
println!("{r} ≈ {}", r.eval_f64().unwrap());
}
// RootOf(x^2 - 2, 0) ≈ -1.41421356…
// 0 ≈ 0
// RootOf(x^2 - 2, 1) ≈ 1.41421356…
let largest = (&x.powi(3) - &x * 2).root_of(&x, 2).unwrap(); // RootOf(x^2 - 2, 1)
assert_eq!(largest, roots[2]);
assert_eq!((&x.powi(2) + 1).real_roots(&x), Some(vec![])); // no real roots
}
Factoring modulo a prime
factor_mod(&var, p) (SymPy factor_list(f, modulus=p)) factors a rational-coefficient polynomial over GF(p) into (lc, [(monic irreducible factor, multiplicity)]) with coefficients in [0, p). A composite p is an InvalidArgument error, as is a coefficient whose denominator is divisible by p; the finite-field arithmetic supports odd primes below 2³¹.
use symplex::prelude::*;
fn main() -> Result<(), SymplexError> {
let ctx = Context::new();
let x = ctx.symbol("x");
let (lc, factors) = (&x.powi(2) + 1).factor_mod(&x, 5)?;
println!("{lc}: {:?}", factors.iter().map(|(f, m)| format!("({f})^{m}")).collect::<Vec<_>>());
// 1: ["(x + 2)^1", "(x + 3)^1"]
let (_, factors) = (&x.powi(2) + 1).factor_mod(&x, 3)?; // irreducible mod 3
assert_eq!(factors.len(), 1);
assert!((&x.powi(2) + 1).factor_mod(&x, 6).is_err());
Ok(())
}
Resultants and discriminants with symbolic coefficients
resultant and discriminant (0.2) require every coefficient to be rational. resultant_symbolic and discriminant_symbolic accept parameter coefficients: they build the Sylvester matrix over Ex entries, take its determinant with Matrix::det, and expand, so the answer is a polynomial in the parameters. discriminant_symbolic is division-free (the leading coefficient is eliminated with one row operation before the determinant).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, a, b, c, p, q);
let quad = &a * &x.powi(2) + &b * &x + &c;
println!("{}", quad.discriminant_symbolic(&x).unwrap()); // -4*a*c + b^2
let cubic = &x.powi(3) + &p * &x + &q;
println!("{}", cubic.discriminant_symbolic(&x).unwrap()); // -4*p^3 - 27*q^2
println!("{}", (&x - &a).resultant_symbolic(&(&x - &b), &x).unwrap()); // a - b
}
The Rule Engine
New in 0.2: the pattern-matching engine behind simplify is public. You can write your own rewrite rules, apply them to a fixpoint or a single pass, trace what fired, and interleave them with the built-in simplifier.
Everything lives in the prelude: Rule, RuleSet, Bindings, RewriteOpts, RewriteStrategy, Step, and the Ex methods rewrite, rewrite_once, rewrite_traced, rewrite_with, rewrite_with_traced, simplify_with_rules, simplify_traced.
Wildcards
A rule is built from two ordinary expressions. Any symbol whose name ends in _ is a wildcard; a name ending in __ is a sequence wildcard that absorbs the remaining terms of a sum or product (possibly none, binding to 0 or 1).
| Pattern symbol | Matches |
|---|---|
a_ | any single sub-expression (inside Add/Mul: one term, or the remaining terms if it is the last plain wildcard) |
rest__ | the remaining terms of an Add/Mul |
Add and Mul are matched associatively and commutatively with a bounded backtracking search; every other node is matched structurally.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let (a, b) = (ctx.symbol("a_"), ctx.symbol("b_"));
let sin_sq = Rule::new("sin_sq", &a.sin().powi(2), &(1 - &a.cos().powi(2)));
let ln_add = Rule::new("ln_add", &(&a.ln() + &b.ln()), &(&a * &b).ln());
let rules = RuleSet::from_rules(vec![sin_sq.clone(), ln_add]);
println!("{}", (&x.sin().powi(2) + 3).rewrite(&rules)); // -cos(x)^2 + 4
println!("{}", (&x.ln() + &y.ln() + &(&x + 1).ln()).rewrite(&rules)); // ln(x*y*(x + 1))
// Inspect a match
let bindings = sin_sq.matches(&(&x * 2).sin().powi(2)).unwrap();
println!("{}", bindings.get("a_").unwrap()); // 2*x
}
Rule::try_new rejects rules whose right-hand side mentions an unbound wildcard, or whose left-hand side is a bare wildcard.
Guards and closure right-hand sides
Rule::new_with_guard takes a predicate on the Bindings; Rule::new_fn computes the replacement in a closure (returning None means “does not apply”). Guards and closures run without the context lock, so any Ex method is allowed.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let a = ctx.symbol("a_");
let rest = ctx.symbol("rest__");
// exp(3 + x + y) → exp(3)·exp(x + y), but only for a numeric term and a
// non-empty remainder (otherwise exp(3) alone would match forever).
let split = Rule::new_with_guard(
"exp_split",
&(&a + &rest).exp(),
&(&a.exp() * &rest.exp()),
|b| {
b.get("a_").is_some_and(|v| v.expr_type() == ExprType::Number)
&& b.get("rest__").is_some_and(|r| !r.is_zero_structural())
},
);
println!("{}", (&x + &y + 3).exp().rewrite(&RuleSet::from_rules(vec![split])));
// exp(3)*exp(x + y)
// Evaluate small factorials, leave the rest alone.
let fact = Rule::new_fn("small_factorial", &a.factorial(), |b| {
let n = b.get("a_")?.as_i64()?;
(0..=20).contains(&n).then(|| b.get("a_").unwrap().context().from_i128((1..=n as i128).product()))
});
let e = &ctx.int(6).factorial() + &x.factorial() + &ctx.int(30).factorial();
println!("{}", e.rewrite(&RuleSet::from_rules(vec![fact]))); // 30! + x! + 720
}
The rule! macro
Rules can also be written in mathematical notation with the rule! macro, which works on the arena inside ctx.with_arena_mut; wrap the result with RuleSet::from_macro_rules.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let raw = ctx.with_arena_mut(|arena| {
vec![
rule!(arena, "pyth", sin(w_)^2 + cos(w_)^2 => 1),
rule!(arena, "double_angle", 2 * sin(w_) * cos(w_) => sin(2 * w_)),
]
});
let trig = RuleSet::from_macro_rules(&ctx, raw);
let e = &x.sin().powi(2) + &x.cos().powi(2) + &x.sin() * &x.cos() * 2;
println!("{}", e.rewrite(&trig)); // sin(2*x) + 1
}
Strategies and iteration
rewrite iterates bottom-up to a fixpoint (at most RewriteOpts::default().max_iterations = 50 passes; a tree-size guard stops runaway growth). rewrite_once is a single pass. rewrite_with(&rules, &opts) selects RewriteStrategy::{BottomUp, TopDown, Innermost} and the iteration cap.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let a = ctx.symbol("a_");
let flatten = RuleSet::from_rules(vec![Rule::new("flatten", &a.exp().exp(), &a.exp())]);
let nested = x.exp().exp().exp().exp();
println!("{}", nested.rewrite_once(&flatten));
println!("{}", nested.rewrite(&flatten)); // exp(x)
let opts = RewriteOpts::default().strategy(RewriteStrategy::TopDown).max_iterations(5);
println!("{}", nested.rewrite_with(&flatten, &opts)); // exp(x)
}
Tracing
rewrite_traced returns every rule application as a Step { rule_name, before, after }. simplify_traced(&SimplifyOpts) does the same for the built-in simplifier: strategy-level entries are named strategy:<name> and rule-level entries carry the rule name. RuleSet::standard(&ctx) gives you the built-in rules as a RuleSet.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let e = &(&x + 1).powi(2) - &x.powi(2) - &x * 2;
let (result, steps) = e.simplify_traced(&SimplifyOpts::default());
println!("{e} → {result}"); // … → 1
for s in &steps {
println!("[{}] {} ⇒ {}", s.rule_name, s.before, s.after);
}
println!("{} standard rules", RuleSet::standard(&ctx).len()); // 22
}
Interleaving with the simplifier
simplify_with_rules(&extra) alternates simplify() with your rules until nothing changes — the way to teach the simplifier an identity it does not know.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let a = ctx.symbol("a_");
let sinh_def = (&a.exp() - &(-&a).exp()) / 2;
let extra = RuleSet::from_rules(vec![Rule::new("sinh_def", &a.sinh(), &sinh_def)]);
let e = &x.sinh() - &(&x.exp() - &(-&x).exp()) / 2;
println!("{}", e.simplify()); // unchanged: simplify does not know sinh_def
println!("{}", e.simplify_with_rules(&extra)); // 0
}
Algebraic substitution
subs is structural: x⁴.subs(x², u) leaves x⁴ alone. subs_algebraic recognises the old expression inside powers, products and sums; every rewrite is an identity in old.
self | old | subs_algebraic |
|---|---|---|
x^4 | x^2 | u^2 |
x^3 | x^2 | u*x |
1/x^2 | x^2 | 1/u |
x^6 + 3x^2 + 1 | x^2 | u^3 + 3u + 1 |
exp(2x) | exp(x) | u^2 |
See cargo run --example rule_engine for the full tour.
Solving Equations
Single equations, general (periodic) solutions, linear and polynomial systems, numeric systems, inequalities, ordinary differential equations with initial conditions, and recurrences.
solve
solve(&x) returns Result<Vec<Ex>>. Polynomials are solved through quartic by radicals; degree ≥ 5 gives RootOf nodes (exact, numerically evaluable, and not counted as unevaluated). Transcendental equations use inversion peeling and Lambert W. Results are evaluated, so asin(1/2) comes back as π/6.
The 0.2 contract is that solve never lies:
| Situation | Result |
|---|---|
| Finitely many solutions | Ok(vec![…]) |
Identity (x − x = 0) | Err(SymplexError::InfiniteSolutions { .. }) |
Contradiction (0·x + 1 = 0) or range violation (sin x = 2, eˣ = −1, ` | x |
| Solver has no method | Err(SymplexError::ComputationFailed { .. }) |
solve_or_empty maps every error to an empty vector when you do not care why.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
println!("{:?}", expr!(ctx, x^2 - 5*x + 6).solve(&x).unwrap()); // [Ex(3), Ex(2)]
println!("{:?}", (&x.sin() - &ctx.rational(1, 2)).solve(&x).unwrap()); // [Ex(1/6*pi), Ex(5/6*pi)]
println!("{:?}", (&x.exp() - 5).solve(&x).unwrap()); // [Ex(ln(5))]
println!("{:?}", (&x.powi(2) + 1).solve(&x).unwrap()); // [Ex(I), Ex(-I)]
println!("{}", (&x.powi(5) - &x - 1).solve(&x).unwrap()[0]); // RootOf(x^5 - x - 1, 0)
assert!(matches!((&x - &x).solve(&x), Err(SymplexError::InfiniteSolutions { .. })));
assert!(matches!((&x.sin() - 2).solve(&x), Err(SymplexError::NoSolution { .. })));
}
Symbolic coefficients are returned in rational normal form
Since 0.3, when the coefficients of a linear or quadratic equation are themselves parameters, the solutions (and the quadratic discriminant) are passed through ratsimp. A parametric equation whose coefficients are fractions therefore comes back as one cancelled fraction, not a fraction of fractions. The values are the same as in 0.2; only the printed form changed.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; r, j, x, a, b, c);
// (3r − 1)/(j + 1) = (r + 1)/(2j), solved for r
let eqn = (&r * 3 - 1) / (&j + 1) - (&r + 1) / (&j * 2);
println!("{}", eqn.solve(&r).unwrap()[0]); // (3*j + 1)/(5*j - 1)
for s in (&a * &x.powi(2) + &b * &x + &c).solve(&x).unwrap() {
println!("{s}");
}
// (-b + sqrt(-4*a*c + b^2))/(2*a)
// (-b - sqrt(-4*a*c + b^2))/(2*a)
// x²/a + 2x + a = 0: the discriminant 4 − 4 simplifies to 0 → one double root
for s in (&x.powi(2) / &a + &x * 2 + &a).solve(&x).unwrap() {
println!("{s}"); // -a
}
let lin = &a * &x / (&a + 1) - &b / (&a - 1);
println!("{}", lin.solve(&x).unwrap()[0]); // (a*b + b)/(a^2 - a)
}
General solutions
solve returns principal branches. solve_general returns the complete solution families of periodic equations, expressed with a fresh integer-assumed parameter (n, or n1, n2, … if n is taken). GeneralSolution::instance(k) substitutes a concrete integer.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let fam = (&x.sin() - &ctx.rational(1, 2)).solve_general(&x).unwrap();
for s in &fam.solutions {
println!("{s}"); // 2*n*pi + 1/6*pi, 2*n*pi + 5/6*pi
}
println!("{:?}", fam.parameters); // [Ex(n)]
println!("{:?}", fam.instance(1)); // [Ex(13/6*pi), Ex(17/6*pi)]
let tan = (&x.tan() - 1).solve_general(&x).unwrap();
println!("{}", tan.solutions[0]); // n*pi + 1/4*pi (parameter name may differ)
}
Linear systems
linsolve(&eqs, &vars) accepts Ex (meaning expr = 0) or Equation values, allows symbolic coefficients, and returns a LinearSolution:
Unique(Vec<(var, value)>),Parametric { solution, free }— every variable is given; pivots in terms of the free variables, free variables mapped to themselves,Inconsistent— a legitimate mathematical outcome, so it is a variant rather than anErr(which is reserved for malformed input such as non-linear equations).
linsolve_matrix(&a, &b) solves A·x = b for rectangular or singular A (unknowns are named x1, x2, …); Context::solve_system is the same solver.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y, z, a, b);
let vars = [x.clone(), y.clone(), z.clone()];
let sol = linsolve(&[&x + &y + &z - 6, &x - &y + 2 * &z - 5, &x * 2 + &y - &z - 1], &vars).unwrap();
println!("{sol:?}"); // Unique([(x, 1), (y, 2), (z, 3)])
let sol = linsolve(&[&x + &y + &z - 6, &x - &y - 2], &vars).unwrap();
if let LinearSolution::Parametric { solution, free } = &sol {
println!("{solution:?} free {free:?}"); // x = -z/2 + 4, y = -z/2 + 2, z = z; free [z]
}
println!("{}", sol.get(&x).unwrap()); // -1/2*z + 4
assert!(linsolve(&[&x + &y - 1, &x + &y - 2], &[x.clone(), y.clone()]).unwrap().is_inconsistent());
// Equations and symbolic coefficients
let sol = linsolve(&[eq!(ctx, a * x + y = 1), eq!(ctx, x - y = b)], &[x.clone(), y.clone()]).unwrap();
println!("{}", sol.get(&x).unwrap()); // (b + 1)/(a + 1)
let am = matrix![ctx, [1, 2, 3], [4, 5, 6], [7, 8, 9]];
let bm = Matrix::col_vector(vec![ctx.int(6), ctx.int(15), ctx.int(24)]);
println!("{:?}", linsolve_matrix(&am, &bm).unwrap()); // Parametric: x1 = x3, x2 = -2*x3 + 3
}
Polynomial systems
symplex::polysys::solve_system_ex(&eqs, &vars) uses Gröbner bases (Buchberger + FGLM) and returns algebraic solutions (radicals, not just rationals) for zero-dimensional systems; positive-dimensional systems return Err(InfiniteSolutions).
use symplex::prelude::*;
use symplex::polysys::solve_system_ex;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let vars = [x.clone(), y.clone()];
let sols = solve_system_ex(&[&x.powi(2) + &y.powi(2) - 1, &x - &y], &vars).unwrap();
println!("{sols:?}"); // [[1/2*sqrt(2), 1/2*sqrt(2)], [-1/2*sqrt(2), -1/2*sqrt(2)]]
let sols = solve_system_ex(&[&x.powi(2) + &y.powi(2) - 1, &x.powi(2) - &y], &vars).unwrap();
println!("{} solutions, y = {}", sols.len(), sols[0][1]); // 4, 1/2*sqrt(5) - 1/2
assert!(matches!(solve_system_ex(&[&x + &y - 1], &vars), Err(SymplexError::InfiniteSolutions { .. })));
}
Numeric systems
solve_numeric_system(&eqs, &vars, &x0) is a damped Newton method with a symbolic Jacobian; solve_numeric_system_with takes NewtonOpts { tol, max_iter, .. }. For a single equation, solve_numeric(&x, x0, max_iter, tol) exists on Ex.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let f1 = &x.powi(2) + &y.powi(2) - 4;
let f2 = &x.exp() + &y - 1;
let root = solve_numeric_system(&[f1.clone(), f2], &[x.clone(), y.clone()], &[1.0, -1.0]).unwrap();
println!("{:.10} {:.10}", root[0], root[1]); // 1.0041687385 -1.7296372870
println!("{:e}", f1.eval_f64_with(&[(&x, root[0]), (&y, root[1])]).unwrap()); // ~1e-16
}
Inequalities
solve_gt/ge/lt/le return a SetEx (sign-chart method; absolute values supported). reduce_inequalities and BoolEx::solve_for handle conjunctions — see Sets and Logic.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
println!("{}", expr!(ctx, x^2 - 4).solve_gt(&x)); // (-oo, -2) ∪ (2, oo)
println!("{}", (&(&x - 1).abs() - 2).solve_lt(&x)); // (-1, 3)
println!("{}", (&x.abs() - 3).solve_ge(&x)); // (-oo, -3] ∪ [3, oo)
}
Ordinary differential equations
Build the ODE as an expression in y and y.formal_diff(&x) (nested for higher orders) and call solve_ode(&y, &x). classify_ode names the class; 0.2 supports 16: simple/full separable, first-order linear (constant/variable coefficient), exact, integrating factor, Bernoulli, Riccati, Euler–Cauchy, homogeneous-coefficient, second-order constant-coefficient (homogeneous/non-homogeneous), variation of parameters, reduction of order, nth-order constant-coefficient, and Clairaut.
solve_ode_ivp(&y, &x, &[InitialCondition { order: k, x: x0, value }, …]) pins the constants with conditions y^(k)(x0) = value. solve_riccati takes a known particular solution. ode::solve_ode_system_ivp(&A, &t, &x0) solves x' = A x with initial state.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, t);
let y = ctx.symbol("y");
let d1 = y.formal_diff(&x);
let d2 = d1.formal_diff(&x);
let zero = ctx.int(0);
let ode = &d2 + &y;
println!("{:?}", ode.classify_ode(&y, &x)); // SecondOrderLinearCCHomogeneous
println!("{}", ode.solve_ode(&y, &x)); // C1*cos(x) + C2*sin(x)
let ics = [
InitialCondition { order: 0, x: zero.clone(), value: ctx.int(0) }, // y(0) = 0
InitialCondition { order: 1, x: zero.clone(), value: ctx.int(1) }, // y'(0) = 1
];
let sol = ode.solve_ode_ivp(&y, &x, &ics).unwrap();
println!("{}", sol.simplify()); // sin(x)
let third = &d2.formal_diff(&x) - &d1; // y''' − y' = 0
println!("{:?} {}", third.classify_ode(&y, &x), third.solve_ode(&y, &x));
// NthOrderLinearConstCoeff C1 + C2*exp(x) + C3*exp(-x)
let clairaut = &y - &x * &d1 - &d1.powi(2); // y = x y' + (y')²
println!("{:?} {}", clairaut.classify_ode(&y, &x), clairaut.solve_ode(&y, &x)); // Clairaut C1^2 + C1*x
let riccati = &d1 - &y.powi(2) + &(&ctx.int(2) / &x.powi(2));
println!("{}", riccati.solve_riccati(&y, &x, &(&ctx.int(1) / &x)).unwrap());
// x^2/(-1/3*x^3 + C1) + 1/x
let a = matrix![ctx, [0, 1], [-1, 0]];
let sys = symplex::ode::solve_ode_system_ivp(&a, &t, &[ctx.int(1), ctx.int(0)]).unwrap();
println!("{} {}", sys[0].simplify(), sys[1].simplify()); // cos(t) -sin(t)
}
solve_ode returns an unevaluated DSolve node when no method applies; try_solve_ode makes that an error, and check_ode_solution verifies a candidate.
Recurrences
rsolve::rsolve_linear(&coeffs, forcing, &n, &ics) solves c₀·a(n) + c₁·a(n+1) + … + c_k·a(n+k) = f(n) for rational constants cᵢ and forcing terms that are sums of c·n^d·bⁿ; initial values a(0), a(1), … are optional (unused constants stay as C1, C2, …). rsolve_first_order(&p, &q, &n, a0) solves a(n+1) = p(n)·a(n) + q(n).
use symplex::prelude::*;
use symplex::rsolve::{rsolve_first_order, rsolve_linear};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; n);
// Fibonacci: a(n+2) − a(n+1) − a(n) = 0
let fib = rsolve_linear(&[ctx.int(-1), ctx.int(-1), ctx.int(1)], None, &n, &[ctx.int(0), ctx.int(1)]).unwrap();
println!("{}", fib.subs_i64(&n, 10).eval().simplify()); // 55
// Towers of Hanoi: a(n+1) − 2a(n) = 1
println!("{}", rsolve_linear(&[ctx.int(-2), ctx.int(1)], Some(&ctx.int(1)), &n, &[ctx.int(0)]).unwrap()); // 2^n - 1
println!("{}", rsolve_linear(&[ctx.int(6), ctx.int(-5), ctx.int(1)], None, &n, &[]).unwrap()); // C1*3^n + C2*2^n
println!("{}", rsolve_first_order(&(&n + 1), &ctx.int(0), &n, Some(&ctx.int(1))).unwrap()); // n!
}
See cargo run --example linear_systems_and_ivp, equation_solving and ode_solving.
Exact Linear Programming
symplex::linprog solves linear programs over ℚ: a two-phase dense simplex running on Ratio<BigInt>, with Bland’s rule after the first degenerate step so it cannot cycle. Optima, shadow prices and infeasibility certificates are exact — there are no tolerances and no “numerically infeasible” verdicts. That is what makes it useful as the engine behind certificate searches (Farkas lemmas, Positivstellensatz-style combinations, Carathéodory decompositions), where a floating-point solver can only say “probably”.
The module works on plain Vec<Q> data (Q = Ratio<BigInt>), with two small constructors: qi(n) for an integer and q(n, d) for n/d. LpSolution::x_ex(&ctx) converts an optimum back into Ex rationals, and linprog_matrix accepts Matrix data directly.
The builder
LpProblem::minimize(c) / maximize(c) start a program in c.len() variables; .le(row, rhs), .ge(row, rhs), .eq(row, rhs) add constraint rows; .bounds(j, Bounds::closed(lo, hi)) (or Bounds::at_least(lo), Bounds::at_most(hi)) and .free(j) change a variable’s bounds from the default 0 ≤ xⱼ < ∞; .solve() returns Result<LpSolution>.
use symplex::prelude::*;
use symplex::linprog::{LpProblem, Q, qi};
/// `Ratio<BigInt>` displays as `3/2`, but `{:?}` on a `Vec<Q>` is verbose — format by hand.
fn show(v: &[Q]) -> String {
format!("({})", v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(", "))
}
fn main() {
// max 5x + 4y s.t. 6x + 4y ≤ 24, x + 2y ≤ 6, −x + y ≤ 1, y ≤ 2, x, y ≥ 0
let sol = LpProblem::maximize(vec![qi(5), qi(4)])
.le(vec![qi(6), qi(4)], qi(24))
.le(vec![qi(1), qi(2)], qi(6))
.le(vec![qi(-1), qi(1)], qi(1))
.le(vec![qi(0), qi(1)], qi(2))
.solve()
.unwrap();
println!("{:?}", sol.status); // Optimal
println!("{}", show(&sol.x)); // (3, 3/2)
println!("{}", sol.objective.clone().unwrap()); // 21
println!("{}", show(&sol.duals)); // (3/4, 1/2, 0, 0)
println!("{}", sol.is_optimal()); // true
let ctx = Context::new();
println!("{:?}", sol.x_ex(&ctx)); // [Ex(3), Ex(3/2)]
}
The remaining examples on this page reuse the show helper.
Statuses
solve() only returns Err for malformed input — no variables, a row of the wrong length, bounds on a variable that does not exist. The three mathematical outcomes are values of LpStatus:
status | Set fields | Meaning |
|---|---|---|
Optimal | x, objective, duals | finite optimum |
Infeasible | farkas (Some unless the bounds alone contradict) | no feasible point |
Unbounded | — | the objective improves without limit |
use symplex::Bounds;
use symplex::linprog::{LpProblem, qi};
fn main() {
let sol = LpProblem::maximize(vec![qi(1), qi(1)]).le(vec![qi(1), qi(-1)], qi(1)).solve().unwrap();
println!("{:?} {:?} {:?}", sol.status, sol.objective, sol.farkas); // Unbounded None None
println!("{}", LpProblem::maximize(vec![qi(1), qi(1)]).le(vec![qi(1)], qi(1)).solve().unwrap_err());
// linprog: invalid argument: constraint 0 has 1 coefficients but there are 2 variables
println!("{}", LpProblem::maximize(vec![qi(1)]).bounds(3, Bounds::free()).solve().unwrap_err());
// linprog: invalid argument: bounds were set for variable 3 but there are only 1 variables
// Contradictory bounds: infeasible, but there is no constraint certificate to give.
let bad = LpProblem::minimize(vec![qi(1)]).bounds(0, Bounds::closed(qi(3), qi(1))).solve().unwrap();
println!("{:?} {:?}", bad.status, bad.farkas); // Infeasible None
}
Bounds and free variables
// min x − y s.t. x + y ≤ 3, −2 ≤ x, 0 ≤ y ≤ 1
let sol = LpProblem::minimize(vec![qi(1), qi(-1)])
.le(vec![qi(1), qi(1)], qi(3))
.bounds(0, Bounds::at_least(qi(-2)))
.bounds(1, Bounds::closed(qi(0), qi(1)))
.solve()
.unwrap();
println!("{:?} x* = {} objective {} duals {}", sol.status, show(&sol.x), sol.objective.unwrap(), show(&sol.duals));
// Optimal x* = (-2, 1) objective -3 duals (0) — the constraint is slack, so its price is 0
// A free variable: min x s.t. 2x ≥ −5
let free = LpProblem::minimize(vec![qi(1)]).free(0).ge(vec![qi(2)], qi(-5)).solve().unwrap();
println!("{:?} x* = {} objective {}", free.status, show(&free.x), free.objective.unwrap());
// Optimal x* = (-5/2) objective -5/2
Duals and complementary slackness
When the status is Optimal, duals holds one shadow price yᵢ per constraint, in insertion order: the rate of change of the optimal objective value of the problem as posed with respect to bᵢ. The sign conventions that follow from that definition are, quoting the module documentation:
for a minimisation
yᵢ ≤ 0on≤rows andyᵢ ≥ 0on≥rows (the signs flip for a maximisation),yᵢis free on=rows, and with the reduced costsr = c − Aᵀy:
- complementary slackness:
yᵢ·(aᵢ·x* − bᵢ) = 0for every row;rⱼ = 0unlessx*ⱼsits at a finite bound (for a minimisationrⱼ ≥ 0at a lower bound andrⱼ ≤ 0at an upper bound; reversed for a maximisation);- strong duality:
cᵀx* = yᵀb + Σⱼ rⱼ x*ⱼ, which reduces tocᵀx* = yᵀbunder the default boundsx ≥ 0.
All of these are identities you can check with exact arithmetic:
use symplex::linprog::{LpProblem, Q, qi};
fn show(v: &[Q]) -> String {
format!("({})", v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(", "))
}
fn main() {
let rows = [
(vec![qi(6), qi(4)], qi(24)),
(vec![qi(1), qi(2)], qi(6)),
(vec![qi(-1), qi(1)], qi(1)),
(vec![qi(0), qi(1)], qi(2)),
];
let mut p = LpProblem::maximize(vec![qi(5), qi(4)]);
for (row, rhs) in &rows {
p = p.le(row.clone(), rhs.clone());
}
let sol = p.solve().unwrap();
// Strong duality under x ≥ 0: cᵀx* = yᵀb.
let ytb: Q = rows.iter().zip(&sol.duals).map(|((_, b), y)| b * y).sum();
println!("cᵀx* = {} yᵀb = {ytb}", sol.objective.clone().unwrap()); // cᵀx* = 21 yᵀb = 21
// Complementary slackness: only binding rows have a non-zero price.
for (i, (row, b)) in rows.iter().enumerate() {
let slack: Q = row.iter().zip(&sol.x).map(|(a, x)| a * x).sum::<Q>() - b;
println!("row {i}: a·x* − b = {slack:>4}, y = {:>3}, y·slack = {}",
sol.duals[i], &slack * &sol.duals[i]);
}
// row 0: a·x* − b = 0, y = 3/4, y·slack = 0
// row 1: a·x* − b = 0, y = 1/2, y·slack = 0
// row 2: a·x* − b = -5/2, y = 0, y·slack = 0
// row 3: a·x* − b = -1/2, y = 0, y·slack = 0
// y₀ = 3/4 is ∂(optimum)/∂b₀: raising b₀ from 24 to 25 adds exactly 3/4.
let sol2 = LpProblem::maximize(vec![qi(5), qi(4)])
.le(vec![qi(6), qi(4)], qi(25))
.le(vec![qi(1), qi(2)], qi(6))
.le(vec![qi(-1), qi(1)], qi(1))
.le(vec![qi(0), qi(1)], qi(2))
.solve()
.unwrap();
println!("b₀ = 25 → objective {}", sol2.objective.unwrap()); // 87/4
// Minimisation with ≥ rows: prices are ≥ 0.
let m = LpProblem::minimize(vec![qi(1), qi(1)])
.ge(vec![qi(1), qi(2)], qi(1))
.ge(vec![qi(3), qi(1)], qi(1))
.solve()
.unwrap();
println!("x* = {}, objective {}, duals {}", show(&m.x), m.objective.unwrap(), show(&m.duals));
// x* = (1/5, 2/5), objective 3/5, duals (2/5, 1/5)
}
Farkas certificates
When the status is Infeasible, farkas is a vector y with one entry per constraint that proves infeasibility. From the module documentation:
yᵢ ≥ 0on≤rows,yᵢ ≤ 0on≥rows, free on=rows, such that, withg = Aᵀy,Σⱼ inf { gⱼ·xⱼ : lⱼ ≤ xⱼ ≤ uⱼ } > yᵀbwhere every infimum is finite (
gⱼ > 0 ⇒ lⱼfinite,gⱼ < 0 ⇒ uⱼfinite,gⱼ = 0contributes0). Any feasiblexwould satisfy(Aᵀy)·x ≤ yᵀb, so the inequality proves that none exists. With no finite bounds this is the textbook formAᵀy = 0, yᵀb < 0.
Under the default bounds x ≥ 0 the infima are all 0, so the certificate reads Aᵀy ≥ 0 and yᵀb < 0:
use symplex::linprog::{LpProblem, LpStatus, qi};
use num_traits::Signed;
fn main() {
// x + y ≤ 1 and x + y ≥ 2 cannot both hold.
let sol = LpProblem::minimize(vec![qi(1), qi(1)])
.le(vec![qi(1), qi(1)], qi(1))
.ge(vec![qi(1), qi(1)], qi(2))
.solve()
.unwrap();
assert_eq!(sol.status, LpStatus::Infeasible);
let y = sol.farkas.clone().unwrap();
println!("y = ({}, {})", y[0], y[1]); // y = (1, -1): y₀ ≥ 0 on the ≤ row, y₁ ≤ 0 on the ≥ row
let g = &y[0] + &y[1]; // both columns of A are (1, 1)
let ytb = &y[0] + &(&y[1] * qi(2));
println!("Aᵀy = ({g}, {g}), yᵀb = {ytb}"); // Aᵀy = (0, 0), yᵀb = -1
assert!(!g.is_negative() && ytb.is_negative());
println!("{} {}", sol.x.len(), sol.duals.is_empty()); // 0 true (no point, no prices)
}
In words: adding the first row to −1 times the second gives 0 ≤ −1. The cookbook shows a Farkas vector being read as a linear functional that separates a polynomial from a cone.
feasible_nonneg: is b a non-negative combination?
feasible_nonneg(&a_eq, &b_eq) answers “is there an x ≥ 0 with A·x = b?” — Ok(Some(x)) with a witness, or Ok(None). It is the query behind most certificate searches and is exact even when the data have denominators like 1/3 and 1/7.
use symplex::linprog::{Q, feasible_nonneg, q, qi};
fn show(v: &[Q]) -> String {
format!("({})", v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(", "))
}
fn main() {
// μ ≥ 0 with μ₁/3 + μ₂/7 + 2μ₃/5 = 1 and μ₁ + μ₂ + μ₃ = 4
let a = vec![vec![q(1, 3), q(1, 7), q(2, 5)], vec![qi(1), qi(1), qi(1)]];
let b = vec![qi(1), qi(4)];
match feasible_nonneg(&a, &b).unwrap() {
Some(mu) => {
println!("μ = {}", show(&mu)); // μ = (0, 7/3, 5/3)
for (row, rhs) in a.iter().zip(&b) {
let lhs: Q = row.iter().zip(&mu).map(|(c, m)| c * m).sum();
assert_eq!(&lhs, rhs);
}
println!("A·μ = b exactly");
}
None => println!("no non-negative combination"),
}
// x + y = −1 has no non-negative solution.
println!("{:?}", feasible_nonneg(&[vec![qi(1), qi(1)]], &[qi(-1)]).unwrap()); // None
// "Is (2, 3, 3) in the cone spanned by (1,0,1), (0,1,1), (1,1,0)?"
// Columns are the generators, so build the rows by transposing.
let cols = [[qi(1), qi(0), qi(1)], [qi(0), qi(1), qi(1)], [qi(1), qi(1), qi(0)]];
let target = [qi(2), qi(3), qi(3)];
let rows: Vec<Vec<Q>> = (0..3).map(|i| cols.iter().map(|c| c[i].clone()).collect()).collect();
println!("{:?}", feasible_nonneg(&rows, &target).unwrap().map(|v| show(&v))); // Some("(1, 2, 1)")
}
Note the orientation: feasible_nonneg takes rows of A. When your generators are naturally columns — a list of vectors, or polynomials laid out by Poly::coefficient_matrix — use nonneg_combination(&vectors, &target) instead, which asks the cone-membership question directly. Both have a certified form: feasible_nonneg_certified and nonneg_combination return a Feasibility, whose Infeasible { farkas } variant carries the separating vector y (y·vⱼ ≥ 0 for every generator, y·target < 0), so there is no need to re-pose the system as an LpProblem to obtain the proof of impossibility.
use symplex::linprog::{Feasibility, Q, nonneg_combination, qi};
fn show(v: &[Q]) -> String {
format!("({})", v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(", "))
}
fn main() {
let cone = [vec![qi(1), qi(0), qi(1)], vec![qi(0), qi(1), qi(1)], vec![qi(1), qi(1), qi(0)]];
match nonneg_combination(&cone, &[qi(2), qi(3), qi(3)]).unwrap() {
Feasibility::Feasible(lambda) => println!("λ = {}", show(&lambda)), // λ = (1, 2, 1)
Feasibility::Infeasible { .. } => println!("outside the cone"),
}
// (1, 0, 0) is outside: it would need λ₁ + λ₃ = 1, λ₂ + λ₃ = 0, λ₁ + λ₂ = 0.
match nonneg_combination(&cone, &[qi(1), qi(0), qi(0)]).unwrap() {
Feasibility::Infeasible { farkas: Some(y) } => println!("separating y = {}", show(&y)), // separating y = (-1, 1, 1)
other => println!("{other:?}"),
}
// y·(1,0,1) = 0, y·(0,1,1) = 2, y·(1,1,0) = 0 are all ≥ 0, while y·(1,0,0) = −1 < 0.
}
SciPy-shaped linprog
linprog(c, a_ub, b_ub, a_eq, b_eq, bounds) minimises cᵀx subject to A_ub·x ≤ b_ub, A_eq·x = b_eq and per-variable bounds (a &[Bounds<Q>], one per variable; empty means x ≥ 0). Constraints are numbered ≤ rows first, then = rows — that is the order of duals and farkas.
// min −x − y s.t. x + 2y ≤ 4, 3x + y ≤ 6, x, y ≥ 0
let sol = linprog(
&[qi(-1), qi(-1)],
&[vec![qi(1), qi(2)], vec![qi(3), qi(1)]],
&[qi(4), qi(6)],
&[],
&[],
&[],
)
.unwrap();
println!("{:?} x* = {} objective {}", sol.status, show(&sol.x), sol.objective.unwrap());
// Optimal x* = (8/5, 6/5) objective -14/5
Matrix input: linprog_matrix
linprog_matrix(objective, &c, a_ub, b_ub, a_eq, b_eq) takes Matrix data. Entries are constant-folded with eval() first, so 1 + 2 or 1/2 + 1/3 are fine; a symbol or π is rejected with a clear error rather than approximated. Bounds are the default x ≥ 0.
use symplex::prelude::*;
use symplex::linprog::{Objective, linprog_matrix};
fn main() {
let ctx = Context::new();
let c = matrix![ctx, [3], [2]];
let a = Matrix::new(vec![
vec![ctx.int(1), ctx.int(1)],
vec![ctx.int(1), &ctx.int(1) + &ctx.int(2)], // folded to 3
])
.unwrap();
let b = matrix![ctx, [4], [6]];
let sol = linprog_matrix(Objective::Maximize, &c, Some(&a), Some(&b), None, None).unwrap();
println!("x* = {:?}, objective {}", sol.x_ex(&ctx), sol.objective.unwrap());
// x* = [Ex(4), Ex(0)], objective 12
let x = ctx.symbol("x");
let bad = Matrix::new(vec![vec![x, ctx.int(1)]]).unwrap();
println!("{}", linprog_matrix(Objective::Minimize, &c, Some(&bad), Some(&matrix![ctx, [1]]), None, None).unwrap_err());
// linprog_matrix: invalid argument: A_ub must contain only numeric literals; found `x`
}
Polytopes from half-spaces
symplex::polytope::Polytope (0.4) is a convex polyhedron {x ∈ ℚⁿ : aᵢ·x + bᵢ ≥ 0} with exact geometry built on the LP and on QMatrix: is_empty / any_point / bounding_box / is_bounded are LP calls; vertices solves every n × n sub-system exactly and keeps the points inside; volume (any dimension) is an exact facet decomposition around the vertex centroid; irredundant drops half-spaces that touch no vertex; split cuts by a hyperplane; from_exprs / to_exprs translate to and from affine Ex hypotheses, so a cell can go straight into prove_nonnegative_on_polyhedron.
use symplex::prelude::*;
use symplex::polytope::Polytope;
use symplex::linprog::{q, qi};
fn main() {
let ctx = Context::new();
let (r, t) = (ctx.symbol("r"), ctx.symbol("t"));
// The unit box cut by t ≥ r and r + t ≤ 3/2.
let cell = Polytope::from_exprs(
&[r.clone(), 1 - &r, t.clone(), 1 - &t, &t - &r, ctx.rational(3, 2) - &r - &t],
&[r.clone(), t.clone()],
)
.unwrap();
let v: Vec<String> = cell.vertices().unwrap().iter().map(|p| format!("({}, {})", p[0], p[1])).collect();
println!("{}", v.join(", ")); // (0, 0), (0, 1), (1/2, 1), (3/4, 3/4)
println!("{}", cell.volume().unwrap()); // 7/16
println!("{}", cell.irredundant().unwrap().num_halfspaces()); // 5 (1 - r is implied)
let halves = cell.split(&[qi(-1), qi(0)], q(1, 2)); // cut at r = 1/2: `1/2 - r ≥ 0` is the left piece
println!("{} {}", halves.nonnegative.volume().unwrap(), halves.nonpositive.volume().unwrap()); // 3/8 1/16
println!("{}", cell.contains(&[q(1, 4), q(1, 2)])); // true
}
Everything is exact and every answer is a rational; the enumeration is O(C(m, n)) linear solves and the volume recursion visits every face, which is the right trade for the handful of cells a decision tree produces (dimension ≤ 5) and the wrong one for large polyhedra. Since 0.6.1 the enumeration runs in integer arithmetic (half-spaces scaled once, distinct hyperplanes only, containment as the sign of a·X + b·D), the vertex list is cached on the polytope, and volume hands each facet its own vertices instead of re-enumerating — about 10× on the vertex work. Ask is_full_dimensional() (one LP) rather than volume() > 0 when that is the question, and interior_point() for a point with positive slack everywhere; HalfSpace::normalized() is the key that identifies a candidate cut with its flip and rescalings. When the cell’s facets depend on a parameter, ParametricPolytope::new(&hyps, &vars, &j) holds the family and polytope_at / vertices_at / volume_at(&j_value) instantiate it exactly with a per-sample cache (0.5).
Cut scoring — hundreds of candidate planes per tree node, each needing the vertex sets of both pieces — should not re-enumerate: cell.clip(&h) derives them from the cached vertices in one pass. The vertex cache carries each vertex’s tight set (vertices_with_tight(): the indices of the half-spaces through it), and two vertices are joined by an edge exactly when the normals of their common tight half-spaces have rank n − 1 — for simple vertices that is just “share n − 1 indices”, for degenerate ones (a pyramid’s apex, a cube cut through a vertex, a face given by h ≥ 0 and h ≤ 0) an exact rank decides, so no crossing is invented or lost. Each edge with endpoints on opposite sides contributes the exact crossing vᵢ + h(vᵢ)/(h(vᵢ) − h(vⱼ))·(vⱼ − vᵢ); the result Clip { pos, neg, on } equals, as sets, with_halfspace(h).vertices() and its flipped counterpart. pos_is_full_dimensional() / neg_is_full_dimensional() answer the dimension question by a rank on the vertices (no LP, exact for bounded cells — also is_full_dimensional_from_vertices() on any polytope), and pos_polytope(&cell, &h) / neg_polytope return the pieces with their vertex cache already filled, so a tie-breaking volume() on them, or a further clip, enumerates nothing. In the downstream tree builder this replaced split + two vertices() per candidate and the same scores fell out byte for byte.
Performance
Since 0.3.5 the tableau uses integer pivoting: each constraint row is scaled once to clear its denominators, and every pivot then follows Bareiss’s fraction-free rule, so all entries stay integers sharing one common denominator (the current pivot, ±det B). Nothing in the inner loop computes a gcd; the ratio test and every sign test are integer comparisons. Results are identical to the rational tableau — same pivots, same optimum, same duals — because the same Dantzig/Bland choices are made on the same rational values, only represented differently.
Each pivot is still O(m·n) big-integer operations, but the constants are much smaller: in a release build a 40-row × 100-variable program went from 1.1 s to 41 ms, a 60 × 160 one from 2.8 s to 85 ms, and the degree-5 three-variable Handelman search in the certificates cookbook from 3.6 s to 0.9 s. Beyond a few hundred rows, or when the data are floating-point measurements to begin with, an exact solver is still the wrong tool — the numerical routines in Numerical Optimisation or an external LP library are. The pivot count is capped at 10 000 + 50·(m + n); exceeding it is reported as ComputationFailed, though Bland’s rule makes that a theoretical rather than a practical concern.
See cargo run --example exact_lp for the complete program.
Numerical Optimisation
symplex::optimize is a small, dependable set of f64 routines — bracketed root finding, derivative-free minimisation, global search in a box, least-squares fitting, the trapezoidal rule — plus Ex methods that compile an expression with compile and hand the closure to the matching routine. It fills the gap between “I have an exact symbolic answer” and “I need a number now and the equation has no closed form”.
Three conventions hold everywhere in the module:
- Deterministic and bounded. Every routine has an explicit iteration budget;
differential_evolutiondraws its random numbers from a local SplitMix64 generator seeded byDeOpts::seed, so identical inputs give bit-identical results. - Nothing panics. Bad input (a bracket without a sign change,
degree ≥ len, reversed bounds) isInvalidArgument; running out of iterations or meeting a non-finite value isComputationFailed. The minimisers that return aMinimizeResultreport an exhausted budget throughconverged == falseinstead of an error, so the best point found is never thrown away. - Polynomial coefficients are ascending:
[c₀, c₁, …, c_d]meansc₀ + c₁x + … + c_d xᵈ. (NumPy’spolyfitis highest-degree first.)
Bracketing roots
brent_root(f, a, b, &opts) is Brent–Dekker: inverse quadratic interpolation, secant and bisection steps chosen adaptively, so it converges superlinearly on smooth functions and never slower than bisection. bisect is the bullet-proof fallback. Both require f(a)·f(b) < 0 and return a point within xtol + rtol·|x| of a sign change (RootOpts::default() is xtol = 2e-12, rtol = 4ε, max_iter = 100). newton_root(f, df, x0, &opts) polishes from a point and detects divergence instead of looping.
On an Ex, find_root_bracket(&x, a, b) compiles and brackets in one call; free symbols other than x are a FreeSymbol error, not a silent NaN.
use std::f64::consts::PI;
use symplex::prelude::*;
use symplex::optimize::{RootOpts, bisect, brent_root, newton_root};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
println!("{:.15}", brent_root(|t| t * t - 2.0, 0.0, 2.0, &RootOpts::default()).unwrap()); // 1.414213562373136
println!("{:.15}", bisect(|t| t * t - 2.0, 0.0, 2.0, &RootOpts::default()).unwrap()); // 1.414213562372424
println!("{:.15}", newton_root(|t| t * t * t - 2.0, |t| 3.0 * t * t, 1.0, &RootOpts::default()).unwrap());
// 1.259921049894873
println!("{}", brent_root(|t| t * t + 1.0, -1.0, 1.0, &RootOpts::default()).unwrap_err());
// brent_root: invalid argument: f(a) and f(b) must have opposite signs: f(-1) = 2, f(1) = 2
// Newton on atan(x) from x₀ = 2 diverges — reported, not looped:
assert!(newton_root(f64::atan, |t| 1.0 / (1.0 + t * t), 2.0, &RootOpts::default()).is_err());
// Kepler's equation E − 0.3·sin E = 1, defined symbolically.
let kepler = &x - x.sin() * ctx.rational(3, 10) - 1;
let e = kepler.find_root_bracket(&x, 0.0, PI).unwrap();
println!("{e:.15}"); // 1.288091313212269
println!("{:.2e}", kepler.compile(&["x"]).unwrap().call(&[e])); // 3.95e-13 (residual)
// Newton with a *symbolically* differentiated derivative agrees:
let f = kepler.compile(&["x"]).unwrap();
let df = kepler.diff(&x).compile(&["x"]).unwrap();
let n = newton_root(|t| f.call(&[t]), |t| df.call(&[t]), 1.0, &RootOpts::default()).unwrap();
println!("{:.1e}", (n - e).abs()); // 4.3e-13
let loose = RootOpts { xtol: 1e-6, ..RootOpts::default() };
println!("{:.7}", (x.cos() - &x).find_root_bracket_with(&x, 0.0, 1.0, &loose).unwrap()); // 0.7390851
let a = ctx.symbol("a");
println!("{}", (&x.powi(2) - &a).find_root_bracket(&x, 0.0, 2.0).unwrap_err());
// expression contains free symbol 'a'
}
For a system of equations, solve_numeric_system (damped Newton with a symbolic Jacobian) is in Solving Equations.
Nelder–Mead
nelder_mead(f, &x0, &opts) is the downhill-simplex method with the standard reflect/expand/contract/shrink steps; for more than two variables it uses the dimension-adaptive coefficients that keep the method usable in higher dimensions. NaN objective values are treated as +∞, so the simplex simply moves away from regions where f is undefined. It returns a MinimizeResult { x, fun, iterations, evaluations, converged }.
MinimizeOpts::default() is xtol = 1e-8, ftol = 1e-12, max_iter = 0 (meaning 200·n) and initial_step = 0.0 (SciPy’s 5 % perturbation of each coordinate of x0).
use symplex::prelude::*;
use symplex::optimize::{MinimizeOpts, nelder_mead};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let rosen = |p: &[f64]| (1.0 - p[0]).powi(2) + 100.0 * (p[1] - p[0] * p[0]).powi(2);
let opts = MinimizeOpts { max_iter: 2000, ..MinimizeOpts::default() };
let r = nelder_mead(rosen, &[-1.2, 1.0], &opts).unwrap();
println!("x = ({:.6}, {:.6}), f = {:.2e}", r.x[0], r.x[1], r.fun); // x = (1.000000, 1.000000), f = 1.10e-18
println!("{} {} {}", r.iterations, r.evaluations, r.converged); // 116 219 true
// Exhausting the budget is not an error: you still get the best vertex.
let tight = MinimizeOpts { max_iter: 20, ..MinimizeOpts::default() };
let r = nelder_mead(rosen, &[-1.2, 1.0], &tight).unwrap();
println!("{} {:.4}", r.converged, r.fun); // false 2.0022
// The same problem as an Ex: `minimize_numeric` / `minimize_numeric_with`.
let rosen_ex = (1 - &x).powi(2) + 100 * (&y - &x.powi(2)).powi(2);
let r = rosen_ex.minimize_numeric_with(&[&x, &y], &[-1.2, 1.0], &opts).unwrap();
println!("x = ({:.6}, {:.6}), f = {:.2e}", r.x[0], r.x[1], r.fun); // x = (1.000000, 1.000000), f = 1.10e-18
let bowl = (&x - 1).powi(2) + (&y + 2).powi(2);
let r = bowl.minimize_numeric(&[&x, &y], &[0.0, 0.0]).unwrap();
println!("x = ({:.6}, {:.6}), f = {:.2e}, converged = {}", r.x[0], r.x[1], r.fun, r.converged);
// x = (1.000000, -2.000000), f = 5.36e-18, converged = true
println!("{}", bowl.minimize_numeric(&[&x], &[0.0]).unwrap_err()); // expression contains free symbol 'y'
}
Scalar minimisation
minimize_scalar(f, a, b, &opts) is Brent’s localmin (golden-section steps plus parabolic interpolation) and golden_section is the pure golden-section search — slower but immune to parabolic mis-steps. Both return a ScalarMinimum { x, value } (the minimiser and the objective there); the interval may be reversed. On an Ex: minimize_scalar_numeric(&x, a, b).
use symplex::prelude::*;
use symplex::optimize::{MinimizeOpts, golden_section, minimize_scalar};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
// x·ln x has its minimum −1/e at x = 1/e.
let g = |t: f64| t * t.ln();
let brent = minimize_scalar(g, 0.1, 2.0, &MinimizeOpts::default()).unwrap();
let golden = golden_section(g, 0.1, 2.0, &MinimizeOpts::default()).unwrap();
println!("{:.10} {:.12}", brent.x, brent.value); // 0.3678794418 -0.367879441171
println!("{:.10} {:.12}", golden.x, golden.value); // 0.3678794415 -0.367879441171
println!("{:.10}", (-1.0f64).exp()); // 0.3678794412
// Γ has its minimum on (0, ∞) near 1.4616.
let m = x.gamma().minimize_scalar_numeric(&x, 1.0, 2.0).unwrap();
println!("{:.8} {:.10}", m.x, m.value); // 1.46163212 0.8856031944
}
The location is only resolved to about √ε·|x| ≈ 1e-8 relative — the objective is flat to rounding on that scale, which is why x above agrees with 1/e to ten digits but not fifteen, while value is correct to twelve.
Differential evolution (deterministic)
differential_evolution(f, &bounds, &opts) is DE/rand/1/bin — Latin-hypercube initialisation, one trial vector per member from three distinct others, binomial crossover, clipping to the box — followed by a Nelder–Mead polish of the best member. bounds is a slice of closed Interval<f64>s, one per coordinate (Interval::closed(lo, hi) or (lo..=hi).into(); an open or half-open kind is rejected, since trial points are clamped onto the endpoints). Every evaluation point, including during the polish, lies inside bounds. DeOpts::default() is population max(15n, 8), 300 generations, CR = 0.7, F = 0.8, tol = 1e-8, seed = 0. On an Ex: minimize_global_numeric(&vars, &bounds, &opts).
use std::f64::consts::PI;
use symplex::prelude::*;
use symplex::optimize::{DeOpts, differential_evolution};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
// Rastrigin: many local minima, global minimum 0 at the origin.
let rastrigin = |p: &[f64]| {
10.0 * p.len() as f64 + p.iter().map(|v| v * v - 10.0 * (2.0 * PI * v).cos()).sum::<f64>()
};
let bounds = [Interval::closed(-5.12, 5.12), Interval::closed(-5.12, 5.12)];
let r = differential_evolution(rastrigin, &bounds, &DeOpts::default()).unwrap();
println!("f = {:.2e}, |x| < 1e-6: {}, generations {}, evaluations {}, converged {}",
r.fun, r.x.iter().all(|v| v.abs() < 1e-6), r.iterations, r.evaluations, r.converged);
// f = 3.55e-15, |x| < 1e-6: true, generations 85, evaluations 2629, converged true
// Same seed, same inputs → identical result (MinimizeResult is PartialEq).
let seeded = DeOpts { seed: 7, ..DeOpts::default() };
let a = differential_evolution(rastrigin, &bounds, &seeded).unwrap();
let b = differential_evolution(rastrigin, &bounds, &seeded).unwrap();
println!("{}", a == b); // true
// Himmelblau's function has four global minima with f = 0.
let h = (&x.powi(2) + &y - 11).powi(2) + (&x + &y.powi(2) - 7).powi(2);
let square = [Interval::closed(-5.0, 5.0), Interval::closed(-5.0, 5.0)];
let r = h.minimize_global_numeric(&[&x, &y], &square, &DeOpts::default()).unwrap();
println!("f = {:.2e} at ({:.4}, {:.4})", r.fun, r.x[0], r.x[1]); // f = 4.52e-16 at (3.0000, 2.0000)
println!("{}", differential_evolution(rastrigin, &[Interval::closed(1.0, -1.0)], &DeOpts::default()).unwrap_err());
// differential_evolution: invalid argument: each bound must be a finite interval with lower <= upper, got [1, -1]
println!("{}", differential_evolution(rastrigin, &[Interval::open(-1.0, 1.0)], &DeOpts::default()).unwrap_err());
// differential_evolution: invalid argument: each bound must be a closed interval [lower, upper], got (-1, 1)
}
Which of Himmelblau’s four minima is found depends on the seed; the values printed above are for seed = 0. Floating-point transcendental functions can differ in the last bit between platforms, so the trajectory is reproducible on one machine rather than universally — the converged optimum is the same.
Fitting: floating point versus exact
poly_fit(&xs, &ys, degree) is a backward-stable least-squares fit (column-scaled Vandermonde, Householder QR; the normal equations are never formed) returning ascending coefficients; eval_poly(&c, x) evaluates them by Horner’s rule and linear_fit returns a LinearFit { slope, intercept }. poly_fit_exact(&points, degree) solves the normal equations over ℚ, so for consistent data it recovers the exact polynomial, and for inconsistent data the exact least-squares solution. Ex::poly_fit_points(&ctx, &points, &x, degree) is the same thing returning an Ex.
use num_bigint::BigInt;
use num_rational::Ratio;
use symplex::prelude::*;
use symplex::optimize::{LinearFit, eval_poly, linear_fit, poly_fit, poly_fit_exact};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
// Six samples of x²/3 − x/2 + 1/7.
let xs: Vec<f64> = (-2..=3).map(f64::from).collect();
let ys: Vec<f64> = xs.iter().map(|t| t * t / 3.0 - t / 2.0 + 1.0 / 7.0).collect();
let c = poly_fit(&xs, &ys, 2).unwrap();
println!("[{:.12}, {:.12}, {:.12}]", c[0], c[1], c[2]);
// [0.142857142857, -0.500000000000, 0.333333333333] ← c₀, c₁, c₂ (ascending)
println!("{:.6}", eval_poly(&c, 10.0)); // 28.476190
println!("{}", poly_fit(&[0.0, 1.0], &[0.0, 1.0], 2).unwrap_err());
// poly_fit: invalid argument: degree 2 needs at least 3 points, got 2
let LinearFit { slope, intercept } = linear_fit(&[0.0, 1.0, 2.0, 3.0], &[1.0, 0.0, 4.0, 2.0]).unwrap();
println!("{slope:.12} {intercept:.12}"); // 0.700000000000 0.700000000000
// The same six samples over ℚ: exact recovery.
let q = |n: i64, d: i64| Ratio::new(BigInt::from(n), BigInt::from(d));
let pts: Vec<(Ratio<BigInt>, Ratio<BigInt>)> = (-2..=3)
.map(|i| {
let t = q(i, 1);
(t.clone(), &t * &t / q(3, 1) - &t / q(2, 1) + q(1, 7))
})
.collect();
let c = poly_fit_exact(&pts, 2).unwrap();
println!("{} {} {}", c[0], c[1], c[2]); // 1/7 -1/2 1/3
// …and as an Ex.
let pts_ex: Vec<(Ex, Ex)> = (-2..=3)
.map(|i| {
let xi = ctx.int(i);
let yi = &xi.powi(2) * ctx.rational(1, 3) - &xi * ctx.rational(1, 2) + ctx.rational(1, 7);
(xi, yi.eval())
})
.collect();
println!("{}", Ex::poly_fit_points(&ctx, &pts_ex, &x, 2).unwrap()); // 1/3*x^2 - 1/2*x + 1/7
// Inconsistent data: the exact least-squares line, and the interpolating cubic.
let noisy = [(ctx.int(0), ctx.int(1)), (ctx.int(1), ctx.int(0)), (ctx.int(2), ctx.int(4)), (ctx.int(3), ctx.int(2))];
println!("{}", Ex::poly_fit_points(&ctx, &noisy, &x, 1).unwrap()); // 7/10*x + 7/10
println!("{}", Ex::poly_fit_points(&ctx, &noisy, &x, 3).unwrap()); // -11/6*x^3 + 8*x^2 - 43/6*x + 1
}
Use the exact fit when the data are exact (tabulated values, coefficients recovered from a known-degree polynomial, interpolation) and the floating-point fit when the data are measurements. Ex::poly_interpolate (Algebra) is the special case degree + 1 == points.len().
Trapezoidal rule
trapezoid(&ys, &xs) integrates sampled data on an arbitrary (non-uniform) grid: Σ ½·(xᵢ₊₁ − xᵢ)·(yᵢ + yᵢ₊₁).
use std::f64::consts::PI;
use symplex::optimize::trapezoid;
fn main() {
let grid: Vec<f64> = (0..=1000).map(|i| i as f64 / 1000.0).collect();
let samples: Vec<f64> = grid.iter().map(|t| t * t).collect();
println!("{:.9}", trapezoid(&samples, &grid).unwrap()); // 0.333333500
let sin_samples: Vec<f64> = grid.iter().map(|t| (PI * t).sin()).collect();
println!("{:.9} {:.9}", trapezoid(&sin_samples, &grid).unwrap(), 2.0 / PI); // 0.636619249 0.636619772
println!("{}", trapezoid(&[1.0], &[0.0, 1.0]).unwrap_err());
// trapezoid: invalid argument: ys and xs must have the same length, got 1 and 2
}
When you have the integrand as an expression rather than samples, integrate_numeric (adaptive Gauss–Kronrod, Definite Integration) is both faster and far more accurate.
When to prefer the symbolic solvers
Reach for symplex::optimize when the problem is genuinely numerical: a transcendental equation with no closed form, a black-box objective, measured data. Prefer the exact machinery when it applies, because it answers a different (better) question:
| You want | Numeric | Exact |
|---|---|---|
| Roots of a polynomial | find_root_bracket (one root, needs a bracket) | solve (all roots, radicals/RootOf), real_roots_isolate, nroots |
| Roots of a transcendental equation | find_root_bracket, newton_root | solve (Lambert W, inversion), solve_general for families |
| Systems of equations | solve_numeric_system | linsolve, polysys::solve_system_ex |
| A minimum of a differentiable function | nelder_mead, minimize_scalar | diff + solve, hessian for classification |
| A global minimum in a box | differential_evolution | poly_is_nonnegative_on for proving a bound in 1-D; LP certificates in several |
| A feasible point / optimum of a linear program | — | linprog (Exact Linear Programming) |
| A polynomial through points | poly_fit | poly_fit_exact, poly_interpolate |
| An integral | trapezoid (samples), integrate_numeric (expression) | integrate_definite |
A numeric answer tells you where a root is to twelve digits; the exact answer tells you how many roots there are and that none was missed. When both are available, use the exact form to decide and the numeric form to display.
See cargo run --example numeric_optimization for the complete program.
Sets and Logic
SetEx (set-valued expressions) and BoolEx (boolean expressions) are distinct types from Ex, checked at compile time. 0.2 gives both a real algebra.
Building sets
| Constructor | Result |
|---|---|
ctx.interval(&lo, &hi, IntervalKind::Closed) (Open, LeftOpen, RightOpen) | [lo, hi], (lo, hi), (lo, hi], [lo, hi) |
Interval::closed(lo, hi).to_set() | the same from an Interval<Ex> |
ctx.finite_set(&[a, b, c]) | {a, b, c} (sorted, deduplicated) |
ctx.reals(), ctx.empty_set(), ctx.universal_set() | ℝ, ∅, U |
x.closed_interval(&hi), x.open_interval(&hi) | intervals from an Ex endpoint |
expr.solve_gt(&x) etc. | solution sets of inequalities |
Set construction is cheap and lazy — a.union(&b) is stored as a union — and simplify() computes the normal form (disjoint sorted intervals plus a finite set). difference, symmetric_difference and absolute_complement return normalised results directly.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let a = ctx.interval(&ctx.int(0), &ctx.int(5), IntervalKind::Closed); // [0, 5]
let b = ctx.interval(&ctx.int(3), &ctx.int(10), IntervalKind::LeftOpen); // (3, 10]
let s = ctx.finite_set(&[ctx.int(1), ctx.int(2), ctx.int(7)]);
println!("{}", a.union(&b)); // [0, 5] ∪ (3, 10] (lazy)
println!("{}", a.union(&b).simplify()); // [0, 10]
println!("{}", a.intersection(&b).simplify()); // (3, 5]
println!("{}", a.difference(&b)); // [0, 3]
println!("{}", a.symmetric_difference(&b)); // [0, 3] ∪ (5, 10]
println!("{}", a.absolute_complement()); // (-oo, 0) ∪ (5, oo)
println!("{}", s.union(&a).simplify()); // [0, 5] ∪ {7}
println!("{}", s.difference(&ctx.finite_set(&[ctx.int(2)]))); // {1, 7}
}
Queries
All queries are three-valued (Option<bool>) and None means “cannot decide”, typically because a symbol is involved.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let a = ctx.interval(&ctx.int(0), &ctx.int(5), IntervalKind::Closed);
let b = ctx.interval(&ctx.int(3), &ctx.int(10), IntervalKind::LeftOpen);
println!("{:?} {:?} {:?}", a.contains(&ctx.int(3)), a.contains(&ctx.int(7)), a.contains(&x));
// Some(true) Some(false) None
println!("{:?}", ctx.int(3).is_in(&a)); // Some(true)
println!("{:?}", a.is_subset(&ctx.interval(&ctx.int(0), &ctx.int(10), IntervalKind::Closed))); // Some(true)
println!("{:?}", a.is_disjoint(&ctx.interval(&ctx.int(5), &ctx.int(6), IntervalKind::Open))); // Some(true)
println!("{:?}", a.intersection(&ctx.interval(&ctx.int(6), &ctx.int(7), IntervalKind::Closed)).is_empty()); // Some(true)
println!("{:?} {:?}", b.is_open(), a.is_closed()); // Some(false) Some(true)
let ab = a.union(&b).simplify();
println!("{} {} {}", ab.inf().unwrap(), ab.sup().unwrap(), ab.measure().unwrap()); // 0 10 10
println!("{} {} {}", a.boundary().unwrap(), b.closure().unwrap(), a.interior().unwrap());
// {0, 5} [3, 10] (0, 5)
}
as_intervals() returns Vec<Interval<Ex>> (each with lower, upper and kind; an isolated point is Interval::point(p)) for a set that is a union of intervals, and Interval<Ex>::to_set() goes back; as_finite_set() returns the elements of a finite set; to_condition(&x) converts a set into the BoolEx “x ∈ set”.
From conditions to sets
reduce_inequalities(&[BoolEx], &x) intersects a list of conditions in one variable into a set; BoolEx::solve_for(&x) does the same for a single (possibly compound) condition.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let conds = [x.gt(&ctx.int(0)), x.le(&ctx.int(5)), (&x.powi(2) - 4).gt(&ctx.int(0))];
println!("{}", reduce_inequalities(&conds, &x).unwrap()); // (2, 5]
println!("{}", x.gt(&ctx.int(0)).and(&x.lt(&ctx.int(3))).solve_for(&x).unwrap()); // (0, 3)
println!("{}", (&x.powi(2) - 1).ge(&ctx.int(0)).solve_for(&x).unwrap()); // (-oo, -1] ∪ [1, oo)
let a = ctx.interval(&ctx.int(0), &ctx.int(5), IntervalKind::Closed);
println!("{}", a.to_condition(&x).unwrap()); // x >= 0 & 5 >= x
}
Boolean logic
Boolean atoms are relations (x.gt(&y), x.eq_expr(&y), …) combined with and, or, not, implies. BoolEx::simplify applies boolean algebra (absorption, complementation, constant folding); to_nnf/to_cnf/to_dnf compute normal forms; is_tautology, is_contradiction and satisfiable use DPLL with unit propagation and respect declared assumptions; atoms() lists the atoms and truth_table(&atoms) enumerates them.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; p, q, r);
let (pp, qq, rr) = (p.gt(&ctx.int(0)), q.gt(&ctx.int(0)), r.gt(&ctx.int(0)));
println!("{}", pp.and(&qq).not().to_nnf()); // 0 >= p | 0 >= q
println!("{}", pp.and(&qq).or(&rr).to_cnf()); // (p > 0 | r > 0) & (q > 0 | r > 0)
println!("{}", pp.or(&qq).and(&rr).to_dnf()); // p > 0 & r > 0 | q > 0 & r > 0
println!("{}", pp.and(&qq).or(&pp).simplify()); // p > 0
println!("{:?}", pp.or(&pp.not()).is_tautology()); // Some(true)
println!("{:?}", pp.and(&pp.not()).is_contradiction()); // Some(true)
println!("{:?}", pp.and(&qq).implies(&pp).is_tautology()); // Some(true)
for (inputs, out) in pp.and(&qq).truth_table(&[pp.clone(), qq.clone()]).unwrap() {
println!("{inputs:?} → {out}");
}
}
The boolean simplifier does not yet recognise every consensus pattern ((p ∧ q) ∨ (p ∧ ¬q) stays as written), but is_tautology on the equivalence proves it.
Relations and assumptions
BoolEx::eval folds relations through the assumption system: for a symbol declared Positive, pos > 0 evaluates to True; for an unassumed x, x² + 1 > 0 stays open because x might be complex.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let pos = ctx.symbol_with("pos", &[Assumption::Positive]);
let t = ctx.symbol_with("t", &[Assumption::Real]);
println!("{}", pos.gt(&ctx.int(0)).eval()); // True
println!("{}", pos.lt(&ctx.int(0)).eval()); // False
println!("{}", t.powi(2).ge(&ctx.int(0)).eval()); // True
println!("{}", (&x.powi(2) + 1).gt(&ctx.int(0)).eval()); // x^2 + 1 > 0
}
Piecewise expressions
Ex::piecewise(&[(value, condition), …]) builds a Piecewise node; piecewise_simplify() drops false branches, stops at the first true branch, removes unreachable repeats, merges adjacent branches with identical values, and collapses a single true branch to its value. Piecewise integrands are handled by integrate_definite, and compile/to_rust_fn/to_c_fn turn them into if/ternary chains.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let never = ctx.int(1).gt(&ctx.int(2));
let pw = Ex::piecewise(&[(&x, &never), (&x.powi(2), &x.gt(&ctx.int(0))), (&x.powi(2), &x.le(&ctx.int(0)))]);
println!("{}", pw.piecewise_simplify()); // x^2
}
Assumptions as values
Assumptions is a pair of Props bitflags (known true / known false) with forward-chaining inference. implies compares two assumption sets; Assumption::negate gives the Not* variant; Props::EXTENDED_REAL is new in 0.2 (is_real and is_finite are both None for an ExtendedReal symbol, since it may be ±∞).
use symplex::prelude::*;
fn main() {
let mut positive = Assumptions::default();
positive.assert_true(Props::POSITIVE);
println!("{positive}"); // commutative, complex, real, positive, nonnegative, nonzero, finite, …
let nonneg = Assumptions { known_true: Props::NONNEGATIVE, ..Assumptions::default() };
assert!(positive.implies(&nonneg));
println!("{:?}", Assumption::Positive.negate()); // NotPositive
}
See cargo run --example sets_and_logic for the full tour.
Matrices
Matrix is a dense matrix of Ex entries — exact rationals, radicals, or symbols. Construct one with matrix![ctx, [1, 2], [3, 4]], Matrix::new(rows)?, Matrix::identity(&ctx, n), Matrix::zeros(&ctx, m, n), Matrix::from_fn(m, n, |i, j| …), Matrix::diag, Matrix::col_vector/row_vector, Matrix::from_i64(&ctx, &[&[1, 2], &[3, 4]])?, or Matrix::try_from(vec_of_rows)?.
Basics and ergonomics
Shape-sensitive operations return Result; entries are indexed with m[(i, j)] (and IndexMut); scalars multiply from either side.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let mut m = matrix![ctx, [1, 2], [3, 4]];
println!("{}", m.det().unwrap()); // -2
println!("{}", m.inv().unwrap()); // [[-2, 1], [3/2, -1/2]]
println!("{}", m.transpose());
println!("{}", m.trace().unwrap()); // 5
println!("{}", m.rank()); // 2
m[(0, 1)] = ctx.int(7); // IndexMut
println!("{} {}", m[(0, 1)], m.get(1, 0)); // 7 3
println!("{}", 2 * &m); // scalar on the left
println!("{}", m.clone() / 2);
println!("{}", &m * &m); // matrix product (also m.matmul(&m)?)
println!("{}", &m + &m);
println!("{}", -m.clone());
println!("{}", m.hadamard(&m).unwrap()); // element-wise product
println!("{}", Matrix::block_diag(&[&m, &Matrix::identity(&ctx, 1)]).unwrap());
println!("{} {}", m.minor(0, 0).unwrap(), m.minor_matrix(0, 0).unwrap()); // 4 [[4]]
println!("{:?}", m.eval_f64().unwrap()); // [[1.0, 7.0], [3.0, 4.0]]
println!("{:?}", m.equals(&m)); // Some(true)
assert!(Matrix::try_from(vec![vec![ctx.int(1)], vec![ctx.int(2), ctx.int(3)]]).is_err());
}
Element-wise helpers: map, map_indexed, subs, subs_map, eval, expand, simplify, diff, integrate, col, row, diagonal, submatrix, set, iter, to_vec, vec (column-major vectorisation), hstack/vstack, kronecker.
Selecting sub-matrices and exact conversion
New in 0.3: extract(&rows, &cols) (SymPy’s Matrix.extract; indices may repeat or reorder), select_rows, select_cols, delete_row, delete_col; the three-valued structure test is_integer_matrix (a companion to the existing is_zero); nnz (structurally non-zero entries); and lossless conversions to and from the num types — to_rational_rows, to_bigint_rows, Matrix::from_ratio, Matrix::from_bigint, Matrix::from_f64_rows (each f64 becomes the exact dyadic rational it represents) — which is how a Matrix is handed to the exact LP solver and the integer normal forms. (As elsewhere on this page, multi-row matrix output is compacted onto one line in the comments; a single-row matrix really does print as [[…]].)
use symplex::prelude::*;
use num_bigint::BigInt;
use num_rational::Ratio;
fn main() {
let ctx = Context::new();
let m = matrix![ctx, [1, 2, 3], [4, 5, 6], [7, 8, 9]];
println!("{}", m.extract(&[2, 0], &[0, 2]).unwrap()); // [[7, 9], [1, 3]]
println!("{}", m.select_rows(&[0, 2]).unwrap()); // [[1, 2, 3], [7, 8, 9]]
println!("{}", m.select_cols(&[1]).unwrap()); // [[2], [5], [8]]
println!("{}", m.delete_row(1).unwrap().delete_col(1).unwrap()); // [[1, 3], [7, 9]]
println!("{} {:?} {:?}", m.nnz(), m.is_zero(), m.is_integer_matrix()); // 9 Some(false) Some(true)
println!("{:?}", m.to_bigint_rows().unwrap()[2]); // [7, 8, 9]
println!("{}", m.extract(&[3], &[0]).unwrap_err());
// extract: invalid argument: row index 3 out of range for 3 rows
let f = Matrix::from_f64_rows(&ctx, &[vec![0.5, 0.1]]).unwrap();
println!("{f}"); // [[1/2, 3602879701896397/36028797018963968]]
println!("{:?} {:?}", f.is_integer_matrix(), f.to_bigint_rows()); // Some(false) None
let q = |n: i64, d: i64| Ratio::new(BigInt::from(n), BigInt::from(d));
let r = Matrix::from_ratio(&ctx, &[vec![q(1, 2), q(3, 1)]]).unwrap();
println!("{r} {:?}", r.to_rational_rows().unwrap()[0]);
// [[1/2, 3]] [Ratio { numer: 1, denom: 2 }, Ratio { numer: 3, denom: 1 }]
println!("{}", Matrix::from_bigint(&ctx, &[vec![BigInt::from(1), BigInt::from(-2)]]).unwrap()); // [[1, -2]]
symplex::syms!(ctx; x, y);
let s = Matrix::new(vec![vec![x.clone(), y.clone()]]).unwrap();
println!("{}", s.subs_map(&[(&x, &y), (&y, &x)])); // [[y, x]] (simultaneous)
println!("{:?} {:?} {}", s.is_zero(), s.is_integer_matrix(), s.nnz()); // None None 2
let z = Matrix::new(vec![vec![&(&x + 1).powi(2) - &(&x.powi(2) + &x * 2 + 1)]]).unwrap();
println!("{:?} {}", z.is_zero(), z.nnz()); // Some(true) 1
}
is_zero simplifies each entry, so it recognises (x + 1)² − x² − 2x − 1 as zero; nnz is purely structural and counts that entry. from_f64_rows is deliberately exact — use Context::from_f64_nice entry-wise when you want 0.1 read as 1/10.
Eigenvalues, eigenvectors, Jordan form
In 0.2 the eigen family takes no dummy variable. eigenvals returns eigenvalues with repetition, eigenvals_with_multiplicity returns (value, multiplicity) pairs, eigenvects returns (value, multiplicity, basis), diagonalize returns Diagonalization { p, d }, jordan_form returns JordanForm { p, j }, and is_diagonalizable is Option<bool>. Use char_poly(&λ) / char_poly_coeffs() when you want the polynomial itself.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let s = matrix![ctx, [2, 1], [1, 2]];
println!("{:?}", s.eigenvals().unwrap()); // [Ex(3), Ex(1)]
for (val, mult, vecs) in s.eigenvects().unwrap() {
println!("λ = {val} ×{mult}: {}", vecs[0].transpose()); // 3: [[1, 1]], 1: [[-1, 1]]
}
let Diagonalization { p, d } = s.diagonalize().unwrap();
assert_eq!(p.matmul(&d).unwrap().matmul(&p.inv().unwrap()).unwrap().equals(&s), Some(true));
let j = matrix![ctx, [5, 4, 2, 1], [0, 1, -1, -1], [-1, -1, 3, 0], [1, 1, -1, 2]];
println!("{:?}", j.eigenvals_with_multiplicity().unwrap()); // [(4, 2), (2, 1), (1, 1)]
println!("{:?}", j.is_diagonalizable()); // Some(false)
let jordan = j.jordan_form().unwrap().j;
println!("{jordan}"); // [[4,1,0,0],[0,4,0,0],[0,0,2,0],[0,0,0,1]]
}
RootOf eigenvalues
When the characteristic polynomial has an irreducible cubic or quartic factor without a compact radical form, 0.2 returns exact RootOf(poly, index) eigenvalues instead of Cardano/Ferrari expressions with nested complex cube roots (which made every downstream step swell). They evaluate numerically and are not counted as unevaluated. An EXPRESSION_BUDGET guard aborts computations whose intermediate expressions grow beyond bounds.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let c = matrix![ctx, [0, 1, 0], [0, 0, 1], [1, 1, 0]]; // char poly λ³ − λ − 1
for ev in c.eigenvals().unwrap() {
let Complex64 { re, im } = ev.eval_complex64().unwrap();
println!("{ev} ≈ {re:.6} {im:+.6}i"); // RootOf(λ^3 - λ - 1, k) ≈ …
}
}
Decompositions
Every factorisation returns a named struct from symplex::decompositions (all in the prelude) rather than a tuple, so qr.q/qr.r or let Qr { q, r } = … say which factor is which; each struct documents the identity it satisfies.
| Method | Returns | Preconditions (→ Err) |
|---|---|---|
lu() | Lu { l, u, perm } with P·A = L·U | square |
cholesky() | L with L·Lᵀ = A | symmetric, positive definite |
ldl() | Ldl { l, d } with A = L·D·Lᵀ | symmetric |
qr() | Qr { q, r } with A = Q·R, exact radicals | — |
matrix_decomp::gram_schmidt(&vectors, normalize) | orthogonal (or orthonormal) basis | linearly independent input |
rref() | (R, pivot_columns) | — |
pinv() | Moore–Penrose pseudo-inverse (any rank, 0.9) | — |
diagonalize() | Diagonalization { p, d } with A = P·D·P⁻¹ | square, diagonalizable |
jordan_form() | JordanForm { p, j } with A = P·J·P⁻¹ | square |
rank_decomposition() (0.9) | RankDecomposition { c, f } with A = C·F, rank A columns/rows | non-zero |
hessenberg() (0.9) | Hessenberg { h, p } with H = P⁻¹AP upper Hessenberg, no radicals | square |
use symplex::prelude::*;
use symplex::matrix_decomp::gram_schmidt;
fn main() {
let ctx = Context::new();
let spd = matrix![ctx, [4, 12, -16], [12, 37, -43], [-16, -43, 98]];
let l = spd.cholesky().unwrap();
println!("{l}"); // [[2,0,0],[6,1,0],[-8,5,3]]
assert_eq!(l.matmul(&l.transpose()).unwrap().equals(&spd), Some(true));
let Ldl { l, d } = spd.ldl().unwrap();
println!("{l} {d}");
assert!(matrix![ctx, [1, 2], [3, 4]].cholesky().is_err()); // not symmetric
let m = matrix![ctx, [1, 1, 0], [1, 0, 1], [0, 1, 1]];
let Qr { q, r } = m.qr().unwrap();
println!("{q}\n{r}"); // sqrt(1/2), sqrt(2/3), …
assert_eq!(q.is_orthogonal(), Some(true));
assert_eq!(q.matmul(&r).unwrap().simplify().equals(&m), Some(true));
let v1 = Matrix::col_vector(vec![ctx.int(1), ctx.int(1), ctx.int(0)]);
let v2 = Matrix::col_vector(vec![ctx.int(1), ctx.int(0), ctx.int(1)]);
for b in gram_schmidt(&[v1, v2], true).unwrap() {
println!("{}", b.transpose());
}
let Lu { l: lu_l, u: lu_u, perm } = matrix![ctx, [2, 1], [4, 3]].lu().unwrap();
println!("{lu_l} {lu_u} {perm:?}");
}
Matrix functions
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; t, n);
let rot = matrix![ctx, [0, -1], [1, 0]];
println!("{}", rot.matrix_exp().unwrap()); // [[cos(1), -sin(1)], [sin(1), cos(1)]]
println!("{}", rot.matrix_exp_t(&t).unwrap()); // [[cos(t), -sin(t)], [sin(t), cos(t)]]
println!("{}", matrix![ctx, [2, 1], [0, 2]].matrix_exp_t(&t).unwrap()); // [[e^(2t), t e^(2t)], [0, e^(2t)]]
let s = matrix![ctx, [2, 1], [1, 2]];
println!("{}", s.matrix_pow_symbolic(&n).unwrap()); // [[3^n/2 + 1/2, 3^n/2 - 1/2], …]
println!("{}", s.matrix_sqrt().unwrap()); // [[√3/2 + 1/2, √3/2 - 1/2], …]
println!("{}", s.powi(3).unwrap());
println!("{}", s.exp_series(6).unwrap()); // truncated Taylor series
}
Structure tests and norms
All structure tests return Option<bool> (None when a symbolic entry cannot be decided): is_symmetric, is_skew_symmetric, is_hermitian, is_orthogonal, is_unitary, is_upper_triangular, is_lower_triangular, is_diagonal, is_identity, is_zero, is_nilpotent, is_positive_definite, is_positive_semidefinite, is_diagonalizable. Norms: norm_1, norm_inf, norm_frobenius (= norm), norm_p(&p) for vectors.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; theta);
let r = Matrix::new(vec![vec![theta.cos(), -theta.sin()], vec![theta.sin(), theta.cos()]]).unwrap();
println!("{:?} {}", r.is_orthogonal(), r.det().unwrap().simplify()); // Some(true) 1
println!("{:?}", r.eigenvals().unwrap()); // [sin(theta)*I + cos(theta), -sin(theta)*I + cos(theta)]
let i = ctx.i_unit();
let h = Matrix::new(vec![vec![ctx.int(2), &ctx.int(1) + &i], vec![&ctx.int(1) - &i, ctx.int(3)]]).unwrap();
println!("{:?} {}", h.is_hermitian(), h.adjoint()); // Some(true) …
let m = matrix![ctx, [1, -2], [3, 4]];
println!("{} {} {}", m.norm_1(), m.norm_inf(), m.norm_frobenius()); // 6 7 sqrt(30)
}
Subspaces and least squares
rank, nullspace, columnspace, rowspace, left_nullspace return bases as column vectors; solve(&b) solves a square system; solve_least_squares(&b) solves the normal equations for over-determined systems; linsolve_matrix (see Solving) handles singular and inconsistent systems with free variables.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let a = matrix![ctx, [1, 1], [1, 2], [1, 3]];
let b = Matrix::col_vector(vec![ctx.int(1), ctx.int(2), ctx.int(2)]);
println!("{}", a.solve_least_squares(&b).unwrap().transpose()); // [[2/3, 1/2]]
let r1 = matrix![ctx, [1, 2], [2, 4]];
println!("{} {} {}", r1.rank(), r1.rowspace()[0], r1.left_nullspace()[0].transpose());
// 1 [[1, 2]] [[-2, 1]]
}
Exact matrices over ℚ and ℤ: QMatrix and ZMatrix
Matrix stores expressions, and every entry operation goes through the expression arena (canonicalisation, hash-consing, a write lock). That is what you want for symbolic matrices and pure overhead for numeric ones. 0.3.5 adds QMatrix (entries Ratio<BigInt>) and ZMatrix (entries BigInt) — both in the prelude and in symplex::matrix — as plain row-major Vec<T> matrices with the same shape rules, indexing, Display/Debug layout and Result-returning arithmetic as Matrix.
All rational eliminations are fraction-free: the rows are scaled to integers and reduced with Bareiss’s Gauss–Jordan variant, whose intermediate entries are minors of the input, so every division is exact and no gcd runs in the inner loop. On a 60×72 integer matrix that is about 40× faster than Gauss–Jordan over Ratio<BigInt> and two orders of magnitude faster than the same elimination over expressions.
use symplex::prelude::*;
use symplex::linprog::q; // exact rational literal: q(1, 2) = 1/2
fn main() {
let a = QMatrix::from_i64(&[&[2, 1], &[1, 3]]).unwrap();
let b = QMatrix::new(vec![vec![q(1, 2)], vec![q(1, 3)]]).unwrap();
println!("{}", a.solve(&b).unwrap().transpose()); // [[7/30, 1/30]]
println!("{}", a.det().unwrap()); // 5
println!("{}", a.inv().unwrap()); // [[3/5, -1/5], [-1/5, 2/5]]
// The 4×4 Hilbert matrix: det = 1/6048000, integral inverse.
let h = QMatrix::from_fn(4, 4, |i, j| q(1, (i + j + 1) as i64));
println!("{} {}", h.det().unwrap(), h.inv().unwrap().is_integer()); // 1/6048000 true
let s = QMatrix::from_i64(&[&[1, 2, 3], &[4, 5, 6], &[7, 8, 9]]).unwrap();
let (r, pivots) = s.rref();
println!("{r:?} {pivots:?}");
// QMatrix(3×3, [[1, 0, -1], [0, 1, 2], [0, 0, 0]]) [0, 1]
println!("{}", s.nullspace()[0].transpose()); // [[1, -2, 1]]
// ℤ: Bareiss determinant, Hermite and Smith forms, integer kernels.
let z = ZMatrix::from_i64(&[&[2, 4, 4], &[-6, 6, 12], &[10, -4, -16]]).unwrap();
let HermiteNormalForm { h: hnf, u } = z.hermite_normal_form_with_transform();
println!("{hnf:?} det U = {}", u.det().unwrap());
// ZMatrix(3×3, [[2, 4, 4], [0, 6, 0], [0, 0, 12]]) det U = -1
println!("{:?}", z.smith_normal_form().diagonal()); // [2, 6, 12]
println!("{:?}", ZMatrix::from_i64(&[&[2, 1, 1]]).unwrap().integer_nullspace());
// [ZMatrix(3×1, [[1], [0], [-2]]), ZMatrix(3×1, [[0], [1], [-1]])]
}
QMatrix has rref, rank, nullspace, columnspace, rowspace, det, inv, solve (square, several right-hand sides), clear_denominators ((Z, s) with Z = s·A integral) and to_zmatrix; ZMatrix has det, rank, content, hermite_normal_form[_with_transform], column_hermite_normal_form, smith_normal_form[_with_transforms], integer_nullspace, is_unimodular, lattice_determinant and to_qmatrix. Both have transpose, submatrix, hstack/vstack, map, scale, trace, the operators + − *, and is_zero/is_identity.
Conversions are explicit and lossless. ZMatrix::try_from(&m) / QMatrix::try_from(&m) accept a Matrix whose entries are all integer / rational literals (constant arithmetic such as 1/3 + 1/6 is folded first; a symbol is an InvalidArgument error, not an approximation), and to_matrix(&ctx) goes back:
use symplex::prelude::*;
use symplex::linprog::q;
fn main() {
let ctx = Context::new();
let m = matrix![ctx, [1, 2], [3, 4]];
let z = ZMatrix::try_from(&m).unwrap();
println!("{}", z.to_qmatrix().inv().unwrap().to_matrix(&ctx)); // [[-2, 1], [3/2, -1/2]]
let (zc, s) = QMatrix::new(vec![vec![q(1, 2), q(1, 3)], vec![q(2, 1), q(-1, 6)]])
.unwrap()
.clear_denominators();
println!("{zc:?} {s}"); // ZMatrix(2×2, [[3, 2], [12, -1]]) 6
let half = Matrix::new(vec![vec![ctx.rational(1, 2)]]).unwrap();
println!("{}", ZMatrix::try_from(&half).unwrap_err());
// ZMatrix::try_from: invalid argument: every entry must be an integer literal (fractions and symbolic entries are not allowed)
}
You rarely need to convert by hand: Matrix::{rref, rank, nullspace, columnspace, rowspace, left_nullspace, det, inv, solve, solve_least_squares, pinv}, linsolve / linsolve_matrix and every function in symplex::normalforms detect all-rational input and route through QMatrix/ZMatrix themselves, returning the same Matrix results as before (the RREF is unique, so pivots and entries are identical). A single symbolic entry sends the whole matrix down the expression path. Use the exact types directly when the data is numeric from the start — LP formulations, coefficient matrices from Poly::coefficient_matrix, lattices — to skip the arena round trip.
Integer normal forms
For a matrix of integer literals, 0.3 adds hermite_normal_form (row style, H = U·A), smith_normal_form (S = U·A·V, invariant factors) and integer_nullspace (a ℤ-basis of the integer kernel) as methods, with the transform-returning and column-convention variants in symplex::normalforms. Non-integer entries are an InvalidArgument error, not a rounding.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let a = matrix![ctx, [2, 4, 4], [-6, 6, 12], [10, -4, -16]];
println!("{}", a.hermite_normal_form().unwrap()); // [[2, 4, 4], [0, 6, 0], [0, 0, 12]]
println!("{}", a.smith_normal_form().unwrap()); // [[2, 0, 0], [0, 6, 0], [0, 0, 12]]
let ker: Vec<String> = matrix![ctx, [2, 1, 1]]
.integer_nullspace()
.unwrap()
.iter()
.map(|k| k.transpose().to_string())
.collect();
println!("{ker:?}"); // ["[[1, 0, -2]]", "[[0, 1, -1]]"]
}
Conventions, the SymPy-compatible column HNF, unimodularity tests and lattice determinants are covered in Integer Lattices and Normal Forms.
Calculus helpers
matrix::jacobian(&funcs, &vars), matrix_decomp::hessian(&f, &vars), matrix_decomp::wronskian(&funcs, &x), and Matrix::{diff, integrate} element-wise.
use symplex::prelude::*;
use symplex::matrix_decomp::{hessian, wronskian};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let f = &x.powi(3) * &y + &x * &y.powi(2);
println!("{}", hessian(&f, &[&x, &y])); // [[6xy, 3x²+2y], [3x²+2y, 2x]]
println!("{}", wronskian(&[&x.sin(), &x.cos()], &x).simplify()); // -1
println!("{}", symplex::matrix::jacobian(&[&f], &[&x, &y]));
}
Related types
- Quaternions (
symplex::quaternion::Quaternion, in the prelude): arithmetic operators,conjugate,norm,inverse,normalize,to_rotation_matrix/from_rotation_matrix,from_axis_angle/to_axis_angle,from_euler/to_euler,rotate_vector,slerp,exp/ln/pow. - Vector calculus (
symplex::vector):gradient,divergence,curl,laplacian, and their_in(&CoordinateSystem)variants for cylindrical and spherical coordinates;directional_derivative,line_integral_scalar,line_integral_vector,scalar_potential;is_conservative/is_irrotational/is_solenoidalreturnOption<bool>. - Control (
symplex::control):StateSpace(poles, stability, controllability, observability, ZOH discretisation,to_transfer_function) andTransferFunction(series/parallel/feedback algebra, Routh–Hurwitz,to_state_space).
See cargo run --example matrix_decompositions, matrix_algebra, exact_matrices, control_system and integer_lattices.
More decompositions and utilities (0.9)
0.9 fills in the remaining everyday SymPy matrix methods. As elsewhere, rational input is routed through QMatrix/ZMatrix and is exact; symbolic input follows the same structural pivoting rules as rref and lu (a symbolic pivot whose value cannot be decided is assumed non-zero, so the result holds generically).
Singular values and condition number
singular_values() returns the ncols square roots of the eigenvalues of AᵀA (computed from the smaller Gram matrix AᵀA or AAᵀ and padded with zeros), sorted in descending order whenever the values can be compared numerically. The result is exact whenever eigenvals can solve the Gram matrix’s characteristic polynomial — always for rational matrices, as radicals when the irreducible factors have degree ≤ 2 (or a compact radical form) and as RootOf values otherwise. condition_number() is σ_max/σ_min in the 2-norm; a singular matrix is a ComputationFailed error whose reason mentions “singular” (SymPy returns zoo).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let m = matrix![ctx, [1, 2], [3, 4]];
println!("{:?}", m.singular_values().unwrap());
// [Ex(sqrt(sqrt(221) + 15)), Ex(sqrt(-sqrt(221) + 15))] SymPy: [sqrt(sqrt(221) + 15), sqrt(15 - sqrt(221))]
println!("{}", m.condition_number().unwrap().eval_f64().unwrap()); // 14.93303437365925
println!("{:?}", matrix![ctx, [3, 0, 0], [0, 4, 0]].singular_values().unwrap()); // [Ex(4), Ex(3), Ex(0)]
println!("{}", matrix![ctx, [2, 0], [0, 3]].condition_number().unwrap()); // 3/2
assert!(matrix![ctx, [1, 2], [2, 4]].condition_number().is_err());
}
Pseudo-inverse for any rank, rank factorisation
pinv() no longer requires full column rank. It uses the full-rank factorisation A = C·F returned by rank_decomposition() — C holds the pivot columns of A, F the non-zero rows of rref(A) — and A⁺ = Fᵀ(FFᵀ)⁻¹(CᵀC)⁻¹Cᵀ; full-column-rank matrices still take the classical (AᵀA)⁻¹Aᵀ, and the zero matrix maps to the zero matrix of the transposed shape. rank_decomposition itself is an error only for the zero matrix (rank 0 has no non-empty factors).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let a = matrix![ctx, [1, 2], [2, 4]]; // rank 1
println!("{}", a.pinv().unwrap()); // [[1/25, 2/25], [2/25, 4/25]]
let RankDecomposition { c, f } = matrix![ctx, [1, 2, 3], [4, 5, 6], [7, 8, 9]].rank_decomposition().unwrap();
println!("{c} {f}"); // [[1, 2], [4, 5], [7, 8]] [[1, 0, -1], [0, 1, 2]]
symplex::syms!(ctx; x);
let s = Matrix::new(vec![vec![x.clone(), x.clone()], vec![x.clone(), x.clone()]]).unwrap();
println!("{}", s.pinv().unwrap()); // [[1/(4x), 1/(4x)], [1/(4x), 1/(4x)]]
}
Hessenberg form
hessenberg() returns Hessenberg { h, p } with H = P⁻¹AP upper Hessenberg (h_ij = 0 for i > j + 1), computed by Gaussian similarity transforms — row eliminations paired with the compensating column operations, with a symmetric row/column swap when the sub-diagonal entry is zero. Unlike SymPy’s Householder-based upper_hessenberg_decomposition the result stays in the field of the entries: exact rationals for rational input, no radicals.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let a = matrix![ctx, [1, 2, 3], [4, 5, 6], [7, 8, 10]];
let Hessenberg { h, p } = a.hessenberg().unwrap();
println!("{h}"); // [[1, 29/4, 3], [4, 31/2, 6], [0, -13/8, -1/2]]
println!("{p}"); // [[1, 0, 0], [0, 1, 0], [0, 7/4, 1]]
assert_eq!(&a * &p, &p * &h);
assert_eq!(h.char_poly(&ctx.symbol("λ")).unwrap(), a.char_poly(&ctx.symbol("λ")).unwrap());
}
Constructors and small utilities
| Method | SymPy | Notes |
|---|---|---|
Matrix::companion(&[c₀, …, c_{n−1}]) | Matrix.companion(Poly) | monic xⁿ + c_{n−1}xⁿ⁻¹ + … + c₀, coefficients ascending; ones on the sub-diagonal, −cᵢ in the last column |
Matrix::jordan_block(&λ, size) | Matrix.jordan_block(size, λ) | Err for size == 0 |
permanent() | Matrix.per() | Ryser on QMatrix for rational input, subset DP for symbolic; exponential, n ≤ 20 |
row_insert(pos, &rows), col_insert(pos, &cols) | row_insert, col_insert | pos == nrows/ncols appends; Err on bad position or shape |
permute_rows(&perm), permute_cols(&perm) | permute_rows, permute_cols | result row i is input row perm[i]; perm must be a genuine permutation |
row_del(i), col_del(j) | row_del, col_del | SymPy names for delete_row / delete_col |
Matrix::casoratian(&seqs, &n) | casoratian(seqs, n, zero=False) | det[fⱼ(n+i)]; SymPy’s default zero=True is .subs(&n, &ctx.int(0)) |
inv_mod(m) | Matrix.inv_mod(m) | integer matrices, adj(A)·det(A)⁻¹ mod m; Err unless gcd(det A, m) = 1 |
matrix_log() | Matrix.log() | via the Jordan form: P·log(J)·P⁻¹, log J_k(λ) = ln λ·I + Σ (−1)^{d+1}N^d/(dλ^d); Err for singular matrices |
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, n);
let c = Matrix::companion(&[ctx.int(4), ctx.int(3), ctx.int(2)]).unwrap(); // x³ + 2x² + 3x + 4
println!("{c}"); // [[0, 0, -4], [1, 0, -3], [0, 1, -2]]
println!("{}", -c.char_poly(&x).unwrap()); // x^3 + 2*x^2 + 3*x + 4 (char_poly is det(C − xI))
println!("{}", Matrix::jordan_block(&ctx.int(2), 3).unwrap()); // [[2, 1, 0], [0, 2, 1], [0, 0, 2]]
let m = matrix![ctx, [1, 2], [3, 4]];
println!("{}", m.permanent().unwrap()); // 10
println!("{}", matrix![ctx, [1, 2, 3], [4, 5, 6], [7, 8, 9]].permanent().unwrap()); // 450
println!("{}", m.row_insert(1, &matrix![ctx, [5, 6]]).unwrap()); // [[1, 2], [5, 6], [3, 4]]
println!("{}", m.col_insert(1, &matrix![ctx, [5], [6]]).unwrap()); // [[1, 5, 2], [3, 6, 4]]
println!("{}", matrix![ctx, [1], [2], [3]].permute_rows(&[2, 0, 1]).unwrap().transpose()); // [[3, 1, 2]]
println!("{}", m.inv_mod(5).unwrap()); // [[3, 1], [4, 2]]
let w = Matrix::casoratian(&[ctx.int(2).pow(&n), ctx.int(3).pow(&n)], &n).unwrap();
println!("{}", w.simplify()); // 6^n
println!("{}", matrix![ctx, [2, 0], [0, 3]].matrix_log().unwrap()); // [[ln(2), 0], [0, ln(3)]]
println!("{}", matrix![ctx, [1, 1], [0, 1]].matrix_log().unwrap()); // [[0, 1], [0, 0]]
}
LLL lattice reduction
ZMatrix::lll(delta) (delta a num_rational::Rational64; lll_default() uses δ = 3/4, lll_with_transform also returns the unimodular T with T·A = R as LllReduction { reduced, transform }) reduces the lattice basis formed by the rows, with exact rational Gram–Schmidt data. The output satisfies the size condition |μ_ij| ≤ 1/2 and the Lovász condition ‖b*_k‖² ≥ (δ − μ²_{k,k−1})‖b*_{k−1}‖² exactly, spans the same lattice (same Hermite normal form), and — because the reduction order and rounding follow SymPy’s DomainMatrix.lll — coincides with SymPy’s output. δ must lie in (1/4, 1) and the rows must be linearly independent (a lattice basis); anything else is an InvalidArgument error. The same is available on Matrix for integer literals (Matrix::lll, Matrix::lll_default, normalforms::lll, normalforms::lll_with_transform).
use symplex::prelude::*;
use symplex::num_rational::Ratio;
fn main() {
let ctx = Context::new();
let b = ZMatrix::from_i64(&[&[1, 1, 1], &[-1, 0, 2], &[3, 5, 6]]).unwrap();
let r = b.lll_default().unwrap();
println!("{r:?}"); // ZMatrix(3×3, [[0, 1, 0], [1, 0, 1], [-1, 0, 2]]) (= SymPy's .lll())
assert_eq!(r.hermite_normal_form(), b.hermite_normal_form()); // same lattice
let LllReduction { reduced: r2, transform: t } = b.lll_with_transform(Ratio::new(3, 4)).unwrap();
assert_eq!(&t * &b, r2);
assert!(t.is_unimodular());
println!("{}", matrix![ctx, [1, 0, 0, 1345], [0, 1, 0, 35], [0, 0, 1, 154]].lll_default().unwrap());
// [[0, 9, -2, 7], [1, 1, -9, -6], [1, -3, -8, 8]]
assert!(ZMatrix::from_i64(&[&[1, 2], &[2, 4]]).unwrap().lll_default().is_err()); // dependent rows
}
Integer Lattices and Normal Forms
symplex::normalforms works with matrices over ℤ: Hermite normal form (in both the row and the column convention), Smith normal form, integer kernels, unimodularity and lattice determinants. Everything is exact BigInt arithmetic. Inputs are ordinary Matrix values whose entries must be integer literals — a fraction, a symbol or an unevaluated constant is an InvalidArgument error, never a silent rounding — and results come back as integer Matrix values in the same context.
The most common operations are also available as methods: Matrix::hermite_normal_form, Matrix::smith_normal_form, Matrix::integer_nullspace.
Since 0.3.5 the algorithms live on ZMatrix, a plain BigInt matrix with no expression arena behind it; the normalforms functions convert a Matrix to a ZMatrix, run the same code and convert back. When your data is already integer, call ZMatrix::hermite_normal_form() and friends directly — they return ZMatrix values and need no Context, and the row/column conventions are identical.
Hermite normal form (row style): H = U·A
hermite_normal_form(&a) returns the row-style HNF: a row-echelon matrix with positive pivots, every entry above a pivot reduced into [0, pivot), and zero rows at the bottom. There is a unimodular U (det U = ±1) with H = U·A; hermite_normal_form_with_transform returns HermiteNormalForm { h, u }. Because this H is unique, the function is idempotent and HNF(V·A) = HNF(A) for every unimodular V — the rows of H are a canonical basis of the row lattice of A.
use symplex::prelude::*;
use symplex::normalforms::{hermite_normal_form, hermite_normal_form_with_transform, is_unimodular};
fn main() {
let ctx = Context::new();
let a = matrix![ctx, [2, 4, 4], [-6, 6, 12], [10, -4, -16]];
let h = hermite_normal_form(&a).unwrap();
println!("{h}");
let HermiteNormalForm { h: h2, u } = hermite_normal_form_with_transform(&a).unwrap();
assert_eq!(h, h2);
println!("{u}");
println!("{}", (&u * &a).eval() == h); // true — H = U·A, verified
println!("{} {}", u.det().unwrap(), is_unimodular(&u).unwrap()); // -1 true
println!("{}", h.hermite_normal_form().unwrap() == h); // true — idempotent
let v = matrix![ctx, [1, 3, 0], [0, 1, 0], [2, 0, 1]]; // unimodular
println!("{}", (&v * &a).eval().hermite_normal_form().unwrap() == h); // true
println!("{}", hermite_normal_form(&matrix![ctx, [1, 2], [2, 4]]).unwrap()); // rank 1
println!("{}", hermite_normal_form(&matrix![ctx, [3, 1], [1, 2]]).unwrap());
println!("{}", hermite_normal_form(&matrix![ctx, [0, 2, 3], [0, 4, 5]]).unwrap());
}
[
[2, 4, 4],
[0, 6, 0],
[0, 0, 12]
]
[
[ 1, 0, 0],
[-1, 3, 2],
[ 3, -4, -3]
]
true
-1 true
true
true
[
[1, 2],
[0, 0]
]
[
[1, 2],
[0, 5]
]
[
[0, 2, 0],
[0, 0, 1]
]
The number of nonzero rows of H is the rank, and for a square nonsingular A the product of the pivots is |det A| (2·6·12 = 144 = |−144| here). U is not unique when A is rank-deficient; the one returned is what the elimination produced.
Hermite normal form (column style): H = A·V
SymPy’s hermite_normal_form uses the column convention of Cohen’s Algorithm 2.4.5: column operations, H = A·V, the pivot of each nonzero column is its lowest nonzero entry, pivot rows increase strictly from left to right (so a square nonsingular matrix gives an upper-triangular H), pivots are positive, and entries to the right of a pivot in its row lie in [0, pivot). column_hermite_normal_form implements exactly this, with one difference: SymPy drops leading zero columns, symplex keeps them so that H = A·V holds with a square V.
use symplex::prelude::*;
use symplex::normalforms::{column_hermite_normal_form, hermite_normal_form};
fn main() {
let ctx = Context::new();
// SymPy: hermite_normal_form(Matrix([[12, 6, 4], [3, 9, 6], [2, 16, 14]]))
// == Matrix([[10, 0, 2], [0, 15, 3], [0, 0, 2]])
let m = matrix![ctx, [12, 6, 4], [3, 9, 6], [2, 16, 14]];
println!("{}", column_hermite_normal_form(&m).unwrap());
println!("{}", hermite_normal_form(&m).unwrap()); // the row form is a different matrix
println!("{}", column_hermite_normal_form(&matrix![ctx, [2, 4], [1, 2]]).unwrap()); // zero column kept
let half = Matrix::new(vec![vec![ctx.rational(1, 2), ctx.int(1)]]).unwrap();
println!("{}", hermite_normal_form(&half).unwrap_err());
}
[
[10, 0, 2],
[ 0, 15, 3],
[ 0, 0, 2]
]
[
[1, 23, 2],
[0, 30, 0],
[0, 0, 10]
]
[
[0, 2],
[0, 1]
]
hermite_normal_form: invalid argument: every entry must be an integer literal (fractions and symbolic entries are not allowed)
Which convention you want depends on what the rows and columns mean: the row form canonicalises the lattice spanned by the rows (ℤ-module generated by row vectors), the column form canonicalises the lattice spanned by the columns. Transposing alone does not turn one into the other — the row HNF of Aᵀ, transposed back, is lower-triangular with pivots at the top of each column, which is a third normalisation. column_hermite_normal_form handles the row/column reversal for you.
Smith normal form: S = U·A·V
smith_normal_form returns diag(d₁, …, dᵣ, 0, …) with dᵢ > 0 and dᵢ | dᵢ₊₁. The dᵢ (the invariant factors) are unique: d₁⋯dₖ is the gcd of the k×k minors of A, and for a square nonsingular matrix d₁⋯dₙ = |det A|. smith_normal_form_with_transforms returns SmithNormalForm { s, u, v } with S = U·A·V and both transforms unimodular.
use symplex::prelude::*;
use symplex::normalforms::{smith_normal_form, smith_normal_form_with_transforms};
fn main() {
let ctx = Context::new();
let m = matrix![ctx, [12, 6, 4], [3, 9, 6], [2, 16, 14]];
println!("{}", smith_normal_form(&m).unwrap());
let SmithNormalForm { s, u, v } = smith_normal_form_with_transforms(&m).unwrap();
println!("{}", (&(&u * &m) * &v).eval() == s); // true
println!("{} {}", u.det().unwrap(), v.det().unwrap()); // 1 1
println!("{}", m.det().unwrap()); // 300 = 1·10·30
let rect = matrix![ctx, [2, 4, 6, 8], [3, 6, 9, 15]];
println!("{}", rect.smith_normal_form().unwrap());
// The abelian group ℤ²/⟨(2, 4), (4, 2)⟩ is ℤ/2 ⊕ ℤ/6.
println!("{}", smith_normal_form(&matrix![ctx, [2, 4], [4, 2]]).unwrap());
}
[
[1, 0, 0],
[0, 10, 0],
[0, 0, 30]
]
true
1 1
300
[
[1, 0, 0, 0],
[0, 6, 0, 0]
]
[
[2, 0],
[0, 6]
]
The last example is the classical use: the Smith form of a relation matrix reads off the structure of a finitely generated abelian group (here ℤ/2 ⊕ ℤ/6, not ℤ/12 and not ℤ/3 ⊕ ℤ/4 — the divisibility chain matters).
Integer kernels
integer_nullspace(&a) returns a ℤ-basis of {x ∈ ℤⁿ : A·x = 0} as column vectors: n − rank(A) vectors that generate every integer solution. This is stronger than scaling the rational nullspace() to integers, which in general only spans a sublattice of finite index.
use symplex::prelude::*;
use symplex::normalforms::integer_nullspace;
fn main() {
let ctx = Context::new();
let k = matrix![ctx, [2, 1, 1]];
for b in integer_nullspace(&k).unwrap() {
println!("{} A·k = {}", b.transpose(), (&k * &b).eval());
}
// [[1, 0, -2]] A·k = [[0]]
// [[0, 1, -1]] A·k = [[0]]
for v in k.nullspace() {
println!("{}", v.transpose());
}
// [[-1/2, 1, 0]]
// [[-1/2, 0, 1]]
println!("{}", matrix![ctx, [1, 0], [0, 1], [1, 1]].integer_nullspace().unwrap().len()); // 0
for b in matrix![ctx, [1, 2, 3], [4, 5, 6]].integer_nullspace().unwrap() {
println!("{}", b.transpose()); // [[1, -2, 1]]
}
}
Clearing denominators in the rational basis gives (−1, 2, 0) and (−1, 0, 2), which generate only the even-coordinate part of the kernel — (0, 1, −1) is an integer solution they cannot reach. The ℤ-basis is saturated: it reaches all of them.
Unimodularity and lattice determinants
is_unimodular(&a) is true for a square integer matrix with det = ±1 (an automorphism of ℤⁿ; non-square gives false). lattice_determinant(&a) is the index [ℤᵐ : A·ℤⁿ] of the lattice spanned by the columns of an m×n matrix of full row rank — the product of the column-HNF pivots, equal to the gcd of all m×m minors, and |det A| in the square case. A rank-deficient matrix has a column lattice of infinite index and is an error.
use symplex::prelude::*;
use symplex::normalforms::{is_unimodular, lattice_determinant};
fn main() {
let ctx = Context::new();
println!("{} {} {}",
is_unimodular(&matrix![ctx, [2, 1], [1, 1]]).unwrap(),
is_unimodular(&matrix![ctx, [2, 0], [0, 1]]).unwrap(),
is_unimodular(&matrix![ctx, [1, 2, 3]]).unwrap()); // true false false
println!("{}", lattice_determinant(&matrix![ctx, [2, 0], [0, 3]]).unwrap()); // 6
println!("{}", lattice_determinant(&matrix![ctx, [2, 0, 1], [0, 3, 1]]).unwrap()); // 1
println!("{}", lattice_determinant(&matrix![ctx, [2, 0, 2], [0, 4, 2]]).unwrap()); // 4
println!("{}", lattice_determinant(&matrix![ctx, [1, 2], [2, 4]]).unwrap_err());
// lattice_determinant: invalid argument: matrix must have full row rank (rank 1 of 2 rows); the column lattice has infinite index otherwise
}
The columns (2, 0), (0, 3), (1, 1) generate all of ℤ² (index 1) even though no two of them do — the 2×2 minors are 6, 2, −3, whose gcd is 1.
Integer helpers
symplex::ntheory gained the list-valued gcd/lcm functions these algorithms need, which are also handy for clearing denominators in a certificate:
use num_bigint::BigInt;
use symplex::linprog::q;
use symplex::ntheory::{gcd_many, igcd, ilcm, lcm_many, rational_lcm_of_denominators};
fn main() {
let v: Vec<BigInt> = [12, 18, 30].iter().map(|&n| BigInt::from(n)).collect();
println!("{} {}", gcd_many(&v), lcm_many(&v)); // 6 180
println!("{} {}", igcd(&[-4i64, 6]), ilcm(&[4i64, 6, 10])); // 2 60
println!("{}", rational_lcm_of_denominators(&[q(1, 2), q(2, 3), q(5, 4)])); // 12
}
gcd_many(&[]) is 0 and lcm_many(&[]) is 1 (the empty product); igcd/ilcm accept any integer type convertible to BigInt.
See cargo run --example integer_lattices for the complete program.
Transforms
Laplace, Fourier, Mellin and Z transforms, Fourier series, and one-sided limits. Transforms that have an unevaluated node (LaplaceTransform, InverseLaplaceTransform) return Ex and have try_ twins; the others (fourier_transform, mellin_transform, z_transform) are Result-only because no unevaluated node exists for them.
Laplace
laplace(&t, &s) and inverse_laplace(&s, &t) are table-driven with linearity, shifting, scaling, differentiation, integration and f(t)/t rules. 0.2 extends the tables (Bessel J₀, tⁿe^(−at), sinh/cosh, erf where possible, 1/√s, delayed step functions) and adds the initial- and final-value theorems.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; t, s);
let a = ctx.symbol_with("a", &[Assumption::Positive]);
println!("{}", t.sin().laplace(&t, &s)); // 1/(s^2 + 1)
println!("{}", (&t.powi(2) * &(&t * -3).exp()).laplace(&t, &s)); // 2*(s + 3)^(-3)
println!("{}", ((&t * 2).sin() / &t).laplace(&t, &s)); // atan(2/s)
println!("{}", (&t - 2).heaviside().laplace(&t, &s)); // exp(-2*s)/s
println!("{}", t.bessel_j(&ctx.int(0)).laplace(&t, &s)); // 1/sqrt(s^2 + 1)
println!("{}", (&a * &t).sinh().laplace(&t, &s)); // a/(-a^2 + s^2)
println!("{}", t.erf().laplace(&t, &s)); // LaplaceTransform(erf(t), t, s) — not in table
println!("{}", (&s / (&s.powi(2) + &s * 2 + 5)).inverse_laplace(&s, &t)); // -1/2*sin(2*t)*exp(-t) + cos(2*t)*exp(-t)
println!("{}", ((&s * -2).exp() / &s).inverse_laplace(&s, &t)); // H(t - 2)
println!("{}", (1 / &s.sqrt()).inverse_laplace(&s, &t)); // t^(-1/2)/sqrt(pi)
let f = (&s + 1) / (&s * (&s.powi(2) + &s * 2 + 5));
println!("{}", f.laplace_initial_value(&s).unwrap()); // 0 (f(0⁺))
println!("{}", f.laplace_final_value(&s).unwrap()); // 1/5 (f(∞))
assert!(matches!((1 / (&s * (&s - 1))).laplace_final_value(&s), Err(SymplexError::Divergent { .. })));
}
Fourier transform
fourier_transform(&t, &w) uses the non-unitary angular convention F(ω) = ∫ f(t) e^(−iωt) dt; fourier_transform_with(&t, &w, FourierConvention::{NonUnitaryAngular, UnitaryAngular, Ordinary}) selects another. The table covers δ, constants, H(t), sign, 1/t, |t|, rectangular windows, e^(−a|t|), Gaussians, tⁿe^(−at)H(t), cos/sin, sinc, extended by linearity, shift, modulation, scaling, the derivative rule and t·f(t) → iF′(ω). Symbols other than t and ω are treated as real parameters; a required sign condition that cannot be proven is an error, not an assumption.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; t, w, nu);
let a = ctx.symbol_with("a", &[Assumption::Positive]);
let i = ctx.i_unit();
println!("{}", (-&a * t.abs()).exp().fourier_transform(&t, &w).unwrap()); // 2*a/(a^2 + w^2)
println!("{}", ((&t + 1).heaviside() - (&t - 1).heaviside()).fourier_transform(&t, &w).unwrap()); // 2*sin(w)/w
println!("{}", ((&t * -2).exp() * t.heaviside()).fourier_transform(&t, &w).unwrap()); // 1/(w*I + 2)
println!("{}", (-t.powi(2)).exp().fourier_transform(&t, &w).unwrap()); // sqrt(pi)*exp(-1/4*w^2)
println!("{}", (&t * 3).cos().fourier_transform(&t, &w).unwrap()); // DiracDelta(w - 3)*pi + DiracDelta(w + 3)*pi
println!("{}", (-(ctx.pi() * t.powi(2))).exp()
.fourier_transform_with(&t, &nu, FourierConvention::Ordinary).unwrap()); // exp(-nu^2*pi) (self-dual)
println!("{}", (1 / (&i * &w + 2)).inverse_fourier_transform(&w, &t).unwrap()); // exp(-2*t)*H(t)
let b = ctx.symbol("b");
assert!((-&b * t.abs()).exp().fourier_transform(&t, &w).is_err()); // sign of b unknown
}
Mellin transform
mellin_transform(&x, &s) returns (F(s), strip) where the strip is the BoolEx condition on re(s) for convergence; inverse_mellin_transform(&s, &x) inverts by table lookup (choosing the strip to the right of the pole when ambiguous).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, s);
for f in [(-&x).exp(), 1 / (1 + &x), 1 / (1 + &x).powi(3), x.powi(2) * (&x * -3).exp(), x.sin()] {
let (m, strip) = f.mellin_transform(&x, &s).unwrap();
println!("M{{{f}}} = {m} on {strip}");
}
// Gamma(s) on re(s) > 0
// pi/sin(s*pi) on re(s) > 0 & 1 > re(s)
// B(s, -s + 3) on re(s) > 0 & 3 > re(s)
// 3^(-s - 2)*Gamma(s + 2) on re(s) > -2
// sin(1/2*s*pi)*Gamma(s) on re(s) > -1 & 1 > re(s)
println!("{}", s.gamma().inverse_mellin_transform(&s, &x).unwrap()); // exp(-x)
}
Fourier series
fourier_series_on(&x, &lower, &upper, n_terms) computes exact coefficients as definite integrals, so sign, |x|, sawtooth and piecewise waves work (0.1 returned wrong coefficients for these). The FourierSeries exposes a0, coefficient_a(k), coefficient_b(k), complex coefficient_c(k), omega0(), n_terms() and truncate(n). fourier_series(&x, n) is the [−π, π] shorthand.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let pi = ctx.pi();
let square = x.sign().fourier_series_on(&x, &(-&pi), &pi, 5).unwrap();
println!("{}", square.truncate(5)); // 4*sin(x)/pi + 4*sin(3*x)/(3*pi) + 4*sin(5*x)/(5*pi)
println!("{} {}", square.coefficient_b(1), square.coefficient_b(2)); // 4/pi 0
let tri = x.abs().fourier_series_on(&x, &(-&pi), &pi, 3).unwrap();
println!("{} {}", tri.a0, tri.truncate(3)); // pi -4*cos(x)/pi - 4*cos(3*x)/(9*pi) + 1/2*pi
let parab = x.powi(2).fourier_series_on(&x, &ctx.int(-1), &ctx.int(1), 2).unwrap();
println!("{}", parab.truncate(2)); // -4*pi^(-2)*cos(x*pi) + pi^(-2)*cos(2*x*pi) + 1/3
}
Z-transform
z_transform(&n, &z) and inverse_z_transform(&z, &n) are table-driven (aⁿ, n, n², n·aⁿ, cos(ωn), 1/n!, …) with linearity and shift rules.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; n, z, w);
let a = ctx.symbol_with("a", &[Assumption::Positive]);
println!("{}", a.pow(&n).z_transform(&n, &z).unwrap()); // z/(-a + z)
println!("{}", n.powi(2).z_transform(&n, &z).unwrap()); // (z - 1)^(-3)*(z^2 + z)
println!("{}", (&w * &n).cos().z_transform(&n, &z).unwrap()); // z*(z - cos(w))/(z^2 - 2*z*cos(w) + 1)
println!("{}", (&z / (&z - 1).powi(2)).inverse_z_transform(&z, &n).unwrap()); // n
}
One-sided limits
limit_left, limit_right and limit_dir(&x, &a, Direction::{Left, Right}) were added in 0.2, with try_ twins. The two-sided limit returns an unevaluated Limit node when the one-sided limits disagree, rather than picking one.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let zero = ctx.int(0);
for f in [1 / &x, &x.abs() / &x, (-1 / &x).exp()] {
println!("{f}: {} | {} | {}", f.limit_left(&x, &zero), f.limit_right(&x, &zero), f.limit(&x, &zero));
}
// 1/x: -oo | oo | Limit(1/x, x, 0)
// abs(x)/x: -1 | 1 | Limit(abs(x)/x, x, 0)
// exp(-1/x): oo | 0 | Limit(exp(-1/x), x, 0)
println!("{}", (&x * &x.ln()).limit_right(&x, &zero)); // 0
println!("{}", x.tan().limit_left(&x, &(ctx.pi() / 2))); // oo
println!("{}", x.floor().limit_dir(&x, &ctx.int(1), Direction::Left)); // 0
}
See cargo run --example transforms and laplace_transforms.
Number Theory and Combinatorics
Integer functions live in symplex::ntheory, symplex::diophantine and symplex::combinatorics; they work on anything Into<BigInt> (i64, u64, BigInt, …) and return BigInt/Ratio<BigInt>/Option. Symbolic counterparts (n.fibonacci(), n.factorial(), n.binomial(&k), n.bell(), …) live on Ex and evaluate when the argument is a concrete integer.
Primality and factorization
0.2 replaces trial division with Pollard–Brent rho (Montgomery u128 arithmetic) plus ECM for BigInt, and deterministic Miller–Rabin with the BPSW test (no Carmichael false positives).
use num_bigint::BigInt;
use symplex::ntheory::*;
fn main() {
println!("{}", isprime(561)); // false (Carmichael number)
let m127 = BigInt::parse_bytes(b"170141183460469231731687303715884105727", 10).unwrap();
println!("{}", isprime(m127)); // true, well under a millisecond
println!("{:?}", factorint(1_099_532_599_387u64)); // [(1048583, 1), (1048589, 1)]
println!("{:?}", factorint(BigInt::from(2u128.pow(64) + 1))); // [(274177, 1), (67280421310721, 1)]
println!("{:?}", primepi(1_000_000)); // Some(78498)
println!("{} {:?}", nextprime(100), prevprime(100)); // 101 Some(97)
println!("{:?}", divisors(28)); // [1, 2, 4, 7, 14, 28]
println!("{} {} {}", totient(36), mobius(30), carmichael_lambda(8)); // 12 -1 2
println!("{:?}", perfect_power(1024)); // Some((2, 10))
}
Modular arithmetic
use symplex::ntheory::*;
fn main() {
println!("{:?}", mod_inverse(17, 43)); // Some(38)
println!("{}", mod_pow(3, 200, 1_000_003));
println!("{:?}", crt_i64(&[2, 3, 2], &[3, 5, 7])); // Some(23)
println!("{:?} {:?}", sqrt_mod(2, 7), sqrt_mod_all(2, 7)); // Some(3) [3, 4]
println!("{:?}", sqrt_mod(3, 7)); // None — not a quadratic residue
println!("{:?}", sqrt_mod_all(1, 15)); // [1, 4, 11, 14]
println!("{:?}", discrete_log(3, 13, 17)); // Some(4): 3⁴ ≡ 13 (mod 17)
println!("{:?}", primitive_root(17)); // Some(3)
println!("{:?}", multiplicative_order(2, 7)); // Some(3)
println!("{} {:?} {}", legendre_symbol(2, 7), jacobi_symbol(1001, 9907), kronecker_symbol(3, 8)); // 1 Ok(-1) -1
}
Continued fractions and Egyptian fractions
use num_bigint::BigInt;
use num_rational::Ratio;
use symplex::ntheory::*;
fn main() {
let r = Ratio::new(BigInt::from(415), BigInt::from(93));
println!("{:?}", continued_fraction(&r)); // [4, 2, 6, 7]
let cf = continued_fraction_periodic(23).unwrap(); // √23 = [4; (1, 3, 1, 8)]
println!("{:?} {:?}", cf.pre_period, cf.period); // [4] [1, 3, 1, 8]
let terms: Vec<BigInt> = [3, 7, 15, 1].iter().map(|&k| BigInt::from(k)).collect();
println!("{:?}", continued_fraction_convergents(&terms)); // 3, 22/7, 333/106, 355/113
println!("{:?}", egyptian_fraction(&Ratio::new(BigInt::from(4), BigInt::from(13)))); // Some([4, 18, 468])
}
Diophantine equations
use symplex::diophantine::*;
fn main() {
let sol = linear_diophantine(3, 5, 1).unwrap(); // x = 2 + 5k, y = −1 − 3k
println!("{} {} {} {}", sol.x, sol.y, sol.x_step, sol.y_step); // 2 -1 5 -3
println!("{:?}", pell(61)); // Some((1766319049, 226153980))
println!("{:?}", pell_solutions(2, 4)); // [(3, 2), (17, 12), (99, 70), (577, 408)]
println!("{:?}", pell_negative(5)); // x² − 5y² = −1
println!("{:?}", sum_of_two_squares(65)); // Some((4, 7))
println!("{:?}", sum_of_two_squares(2021)); // None (43·47, both ≡ 3 mod 4)
println!("{:?}", sum_of_four_squares(7));
println!("{:?}", pythagorean_triples(30)); // primitive triples with c ≤ 30
println!("{:?}", frobenius_number(&[6, 9, 20])); // Some(43) (Chicken McNugget)
}
Sequences and combinatorics
use symplex::combinatorics::*;
use symplex::ntheory;
use symplex::prelude::*;
fn main() {
println!("{}", ntheory::fibonacci(100)); // 354224848179261915075
println!("{:?}", ntheory::bernoulli(12)); // Some(-691/2730)
println!("{:?}", ntheory::euler_number(10)); // Some(-50521)
println!("{:?}", ntheory::harmonic(10)); // Some(7381/2520)
println!("{:?}", stirling2(10, 4)); // Some(34105)
println!("{:?}", stirling1(5, 2)); // signed Stirling numbers of the first kind
println!("{:?}", bell(10)); // Some(115975)
println!("{:?}", catalan(10)); // Some(16796)
println!("{:?}", derangements(10)); // Some(1334961)
println!("{:?}", partition_count(100)); // Some(190569292)
println!("{:?}", partitions(5).collect::<Vec<_>>()); // all partitions of 5
println!("{:?}", multinomial(6, &[2, 2, 2])); // Some(90)
// Symbolic: stays a node until the argument is concrete
let ctx = Context::new();
symplex::syms!(ctx; n);
println!("{} {}", n.fibonacci(), ctx.int(30).fibonacci().eval()); // fibonacci(n) 832040
println!("{}", ctx.int(10).bell().eval()); // 115975
}
Polynomial factoring and algebra
Factoring over ℤ (Berlekamp–Zassenhaus), multivariate factoring, resultants, discriminants, square-free decomposition, root isolation and the rest of the polynomial toolbox are covered in Algebra.
See cargo run --example factoring_and_ntheory, number_theory and crypto_rsa.
More number theory and discrete transforms (0.9.1)
0.9.1 fills in the rest of SymPy’s ntheory residue toolbox and adds an exact symplex::discrete module.
Higher power residues and polynomial congruences
nthroot_mod(a, n, m, all_roots) solves xⁿ ≡ a (mod m) for any modulus: m is factored, each prime is handled with Johnston’s generalised q-th root algorithm (a primitive root plus discrete logarithms only inside the Sylow subgroups for the primes dividing gcd(n, p−1), so p may be huge as long as those primes are moderate), roots are Hensel-lifted to prime powers and combined by CRT. n = 2 is sqrt_mod_all. The result is None when there is no root, otherwise the sorted roots (or just the smallest one).
use num_bigint::BigInt;
use symplex::ntheory::*;
fn main() {
println!("{:?}", nthroot_mod(11, 4, 19, true)); // Some([8, 11]) x⁴ ≡ 11 (mod 19)
println!("{:?}", nthroot_mod(68, 3, 109, false)); // Some([23])
println!("{:?}", nthroot_mod(2, 3, 7, true)); // None — 2 is not a cube mod 7
println!("{:?}", nthroot_mod(16, 4, 35, true)); // Some([2, 9, 12, 16, 19, 23, 26, 33])
let m127 = (BigInt::from(1) << 127) - 1;
println!("{:?}", nthroot_mod(8, 3, m127, false)); // Some([2]) cube roots modulo 2¹²⁷ − 1
println!("{:?}", quadratic_residues(7)); // [0, 1, 2, 4]
println!("{} {}", is_nthpow_residue(2, 4, 7), is_nthpow_residue(2, 3, 7)); // true false
// Roots of x⁶ − 2x⁵ − 35 modulo 6125 = 5³·7² (coefficients highest degree first)
let f: Vec<BigInt> = [1, -2, 0, 0, 0, 0, -35].iter().map(|&c| BigInt::from(c)).collect();
println!("{:?}", polynomial_congruence(&f, 6125)); // [3257]
let g: Vec<BigInt> = [1, 0, 0, -3, 5].iter().map(|&c| BigInt::from(c)).collect();
println!("{:?}", polynomial_congruence(&g, 1_000_003)); // [357940, 847957] (Cantor–Zassenhaus mod a large prime)
}
polynomial_congruence solves linear and quadratic congruences and monic binomials xⁿ − a for any factorable modulus; for other polynomials it finds the roots modulo each prime p | m (brute force for p ≤ 2¹⁶, gcd(f, xᵖ − x) plus Cantor–Zassenhaus splitting for 2¹⁶ < p < 2⁶³), Hensel-lifts them and combines them by CRT. A prime factor p ≥ 2⁶³ in that general case is not supported and gives an empty result.
Arithmetic functions
use symplex::ntheory::*;
fn main() {
println!("{} {} {}", multiplicity(2, 40), primenu(72), primeomega(72)); // 3 2 5
println!("{} {}", primorial(5), primorial_up_to(10)); // 2310 210
println!("{} {}", is_carmichael(561), is_carmichael(563)); // true false
println!("{}", is_amicable(220, 284)); // true
println!("{:?}", binomial_coefficients_list(4)); // [1, 4, 6, 4, 1]
println!("{:?}", binomial_coefficients(3)); // [((0, 3), 1), ((1, 2), 3), ((2, 1), 3), ((3, 0), 1)]
}
Continued fraction reduction
continued_fraction_reduce is the inverse of continued_fraction: a finite [a₀; a₁, …] back to a rational. The PeriodicContinuedFraction { pre_period, period } returned by continued_fraction_periodic is reduced by continued_fraction_reduce_periodic to a QuadraticSurd { p, q, d } meaning (p + √d)/q (q may be negative — that is how a negative radical coefficient is encoded); continued_fraction_reduce_periodic_ex builds the same value as an Ex, which canonicalises it.
use num_bigint::BigInt;
use symplex::ntheory::*;
use symplex::prelude::*;
fn main() {
let cf: Vec<BigInt> = [4, 2, 6, 7].iter().map(|&t| BigInt::from(t)).collect();
println!("{:?}", continued_fraction_reduce(&cf)); // Some(415/93)
let cf = continued_fraction_periodic(23).unwrap(); // [4; (1, 3, 1, 8)]
let surd = continued_fraction_reduce_periodic(&cf.pre_period, &cf.period).unwrap();
println!("({} + √{})/{}", surd.p, surd.d, surd.q); // (0 + √23)/1 = √23
let one: Vec<BigInt> = vec![BigInt::from(1)];
let phi = continued_fraction_reduce_periodic(&[], &one).unwrap();
println!("({} + √{})/{}", phi.p, phi.d, phi.q); // (1 + √5)/2
let ctx = Context::new();
let pre: Vec<BigInt> = [1, 2, 3].iter().map(|&t| BigInt::from(t)).collect();
let per: Vec<BigInt> = [4, 5].iter().map(|&t| BigInt::from(t)).collect();
println!("{}", continued_fraction_reduce_periodic_ex(&ctx, &pre, &per).unwrap()); // -1/52*sqrt(30) + 20/13 = (80 − √30)/52
}
Discrete transforms (symplex::discrete)
Everything in symplex::discrete is exact: sequences are Ratio<BigInt> (or BigInt residues for the NTT). There is deliberately no floating-point FFT and no symbolic DFT over Ex roots of unity — convolution is exact polynomial multiplication, and convolution_ex does the same on symbolic Ex coefficients. Power-of-two transforms zero-pad their input like SymPy.
use num_bigint::BigInt;
use num_rational::Ratio;
use symplex::discrete::*;
fn main() {
let q = |v: &[i64]| -> Vec<Ratio<BigInt>> { v.iter().map(|&t| Ratio::from_integer(BigInt::from(t))).collect() };
let b = |v: &[i64]| -> Vec<BigInt> { v.iter().map(|&t| BigInt::from(t)).collect() };
println!("{:?}", convolution(&q(&[1, 2, 3]), &q(&[4, 5, 6]))); // [4, 13, 28, 27, 18]
println!("{:?}", convolution_cyclic(&q(&[1, 2, 3]), &q(&[4, 5, 6]), 3)); // [31, 31, 28]
println!("{:?}", convolution_subset(&q(&[1, 2, 3, 4]), &q(&[5, 6, 7, 8]))); // [5, 16, 22, 60]
// Number-theoretic transform modulo 998244353 = 119·2²³ + 1 (root 3, as in SymPy)
let t = ntt(&b(&[1, 2, 3, 4]), 998_244_353).unwrap();
println!("{:?}", t); // [10, 173167434, 998244351, 825076915]
println!("{:?}", intt(&t, 998_244_353).unwrap()); // [1, 2, 3, 4]
println!("{:?}", convolution_ntt(&b(&[1, 2, 3]), &b(&[4, 5, 6]), 998_244_353).unwrap()); // [4, 13, 28, 27, 18]
println!("{}", ntt(&b(&[1, 2, 3, 4]), 7).is_err()); // true — 4 ∤ 7 − 1
println!("{:?}", fwht(&q(&[1, 2, 3, 4]))); // [10, -2, -4, 0]
println!("{:?}", ifwht(&q(&[10, -2, -4, 0]))); // [1, 2, 3, 4]
println!("{:?}", mobius_transform(&q(&[1, 2, 3, 4]))); // [1, 3, 4, 10] subset sums
println!("{:?}", inverse_mobius_transform(&q(&[1, 3, 4, 10]))); // [1, 2, 3, 4]
println!("{:?}", mobius_transform_superset(&q(&[1, 2, 3, 4]))); // [10, 6, 7, 4] superset sums
}
Probability and Statistics
New in 0.11. symplex::stats is the counterpart of SymPy’s sympy.stats: a random variable is a symbol together with a distribution, and the usual queries — mean, variance, moments, probabilities of events, density, CDF, moment generating function, quantile — are computed exactly, as expressions, wherever the distribution’s parameters are exact.
use symplex::prelude::*;
use symplex::stats::{Distribution, RandomVariable};
fn main() -> Result<(), SymplexError> {
let ctx = Context::new();
let x = RandomVariable::new(&ctx, "X", Distribution::normal(ctx.int(0), ctx.int(1)));
println!("{}", x.mean()); // 0
println!("{}", x.variance()); // 1
println!("{}", x.expectation(&(x.symbol().powi(2) + 3 * x.symbol()))); // 1 E[X² + 3X]
let y = RandomVariable::new(&ctx, "Y", Distribution::binomial(ctx.int(5), ctx.rational(1, 3)));
println!("{}", y.mean()); // 5/3
println!("{}", y.probability(&y.symbol().gt(&ctx.int(2)))?); // 17/81
Ok(())
}
Design
- Every distribution family knows its support, its density (or probability mass function) as an expression in a free variable, and closed forms for whatever moments it has. The generic machinery —
RandomVariable::expectation,probability,cdf,mgf— falls back to the crate’s exactintegrate_definite/summationover the support, soE[g(X)]works for anygthe integrator can handle. For a polynomialgthe closed-form raw moments are used directly (exact and cheap). - Parameters are expressions. Rational parameters give exact rational answers; symbolic parameters give symbolic answers (
E[X] = μ,Var[Gamma(k, θ)] = kθ²). Parameter validity (σ > 0,0 ≤ p ≤ 1,a < b) is checked for numeric parameters by thetry_constructors (Distribution::try_normal(…) -> Result); the unchecked constructors (Distribution::normal(…)) accept anything, and for symbolic parameters validity is the caller’s promise. - Nothing is numerical by default.
RandomVariable::sampleis the only place a random number generator appears, andstats::Rngis a seeded, reproducible SplitMix64 so Monte-Carlo sanity checks are deterministic. - A result that does not exist is never a number.
Cauchyhas no mean:x.mean()returns the divergent integral unevaluated (has_unevaluated()istrue), andtry_integrate_definiteonx·f(x)saysErr(Divergent).
| Query | SymPy | Returns |
|---|---|---|
mean(), variance(), std() | E(X), variance(X), std(X) | Ex — closed form, or the integral/sum |
moment(n), central_moment(n) | moment(X, n), cmoment(X, n) | Ex |
skewness(), kurtosis() | skewness(X), kurtosis(X) (not excess) | Ex |
expectation(&g) | E(g) | Ex — may contain an unevaluated Integral/Sum |
probability(&event) | P(cond) | Result<Ex> — NotImplemented for events that are not relations/conjunctions in X |
density(&x), cdf(&x) | density(X)(x), cdf(X)(x) | Ex |
mgf(&t), characteristic_function(&t) | moment_generating_function(X)(t), characteristic_function(X)(t) | Ex |
quantile(&p), median() | quantile(X)(p), median(X) | Option<Ex> — None when there is no closed inverse CDF |
sample(n, &mut rng) | sample(X, size=n) | Result<Vec<f64>> — exact-in-distribution sampling (inverse transform, cumulative sums, or the family’s own algorithm) |
Events are BoolEx conditions in the variable’s symbol: relations X < a, X ≤ a, X > a, X ≥ a, X = a with any (also symbolic) bound and their conjunctions, and — with numeric bounds — any boolean combination of relations in X (X² < 1, |X| > 2, X < −1 ∨ X > 1), which the crate’s inequality solver turns into a set. The event’s region is clipped to the support and measured through the closed-form CDF when the family has one, else by exact integration / summation, so P(X > 1) for Exponential(3) is exp(-3), P(0 < U < 1/4) for Uniform(0, 1) is 1/4, and P(N² < 1) for a standard normal is erf(√2/2).
cdf(&x) is the whole-line distribution function as SymPy prints it: a Piecewise that is 0 below the support and 1 above it (Uniform(0,1).cdf(3) = 1); the family’s own closed form on the support is distribution().family().cdf(&x).
Continuous families
Every family is a struct (stats::Normal, stats::Gamma, …) implementing the stats::Family trait — support, density, and the closed forms it has — wrapped in a Distribution by a Distribution::try_<name>(…) constructor (validates numeric parameters) or its Distribution::<name>(…) twin (unchecked). Parameters follow SymPy’s order and meaning. Distribution::downcast_ref::<Normal>() recovers the struct; Distribution::from_family(my_family) admits your own (implement Family with a support, a density and eq_family via stats::same_family, and every query below works).
| Family | Constructor | Support | Closed forms |
|---|---|---|---|
Normal(μ, σ) | normal(mean, std) | ℝ | mean, variance, all moments, cdf (erf), mgf, quantile (erfinv) |
Uniform(a, b) | uniform(lo, hi) | [a, b] | everything; mgf (e^{bt} − e^{at})/((b−a)t) |
Exponential(λ) | exponential(rate) | [0, ∞) | everything; E[Xⁿ] = n!/λⁿ |
Gamma(k, θ) | gamma(shape, scale) | [0, ∞) | moments θⁿ (k)ₙ, cdf γ(k, x/θ)/Γ(k) (elementary for integer/half-integer k), mgf (1 − θt)^{−k}; no quantile |
ChiSquared(k) | chi_squared(dof) | [0, ∞) | as Gamma(k/2, 2) |
Beta(α, β) | beta(alpha, beta) | [0, 1] | moments (α)ₙ/(α+β)ₙ; cdf and mgf by integration (polynomial cdf for integer α, β); no quantile |
Cauchy(x₀, γ) | cauchy(location, scale) | ℝ | cdf ½ + atan((x−x₀)/γ)/π, quantile x₀ + γ tan(π(p−½)); no moments |
Laplace(μ, b) | laplace(mean, scale) | ℝ | all moments (even central moments n! bⁿ), piecewise cdf, mgf, quantile |
Logistic(μ, s) | logistic(mean, scale) | ℝ | all moments (via Bernoulli numbers), cdf, mgf e^{μt} B(1−st, 1+st), quantile μ + s ln(p/(1−p)) |
LogNormal(μ, σ) | log_normal(mu, sigma) | (0, ∞) | moments e^{nμ + n²σ²/2}, cdf (erf), quantile (erfinv); no mgf |
StudentT(ν) | student_t(dof) | ℝ | mean 0 (ν > 1), variance ν/(ν−2) (ν > 2), even moments for n < ν; cdf by integration (elementary for odd ν); no mgf, no quantile |
Weibull(λ, k) | weibull(scale, shape) | [0, ∞) | moments λⁿ Γ(1 + n/k), cdf 1 − e^{−(x/λ)ᵏ}, quantile λ(−ln(1−p))^{1/k}; no mgf |
Pareto(x_m, α) | pareto(scale, shape) | [x_m, ∞) | moments α x_mⁿ/(α−n) for n < α, cdf 1 − (x_m/x)^α, quantile x_m (1−p)^{−1/α}; no mgf |
Triangular(a, b, c) | triangular(lo, hi, mode) | [a, b] | everything, piecewise density/cdf/quantile |
SymPy’s Weibull(alpha, beta) has alpha = scale λ and beta = shape k; symplex names them scale and shape.
Where a closed form is missing the generic route takes over: Beta(2, 3).cdf(x) integrates the density and returns 3x⁴ − 8x³ + 6x²; StudentT(5).cdf(1) simplifies to 7√5/(27π) + atan(√5/5)/π + ½; LogNormal.probability(…) stays an unevaluated integral because the exact integrator does not close ∫ e^{−(ln x−μ)²/2σ²}/x (the closed-form cdf is available instead). A moment that does not exist — E[X³] for Pareto(1, 3), E[X⁶] for StudentT(5) — is likewise returned as the (divergent) integral, never as a number.
use symplex::prelude::*;
use symplex::stats::{Distribution, RandomVariable};
fn main() -> Result<(), SymplexError> {
let ctx = Context::new();
symplex::syms!(ctx; v, t, p);
// Exponential(3): rate 3, mean 1/3.
let x = RandomVariable::new(&ctx, "X", Distribution::try_exponential(ctx.int(3))?);
println!("{}", x.mean()); // 1/3
println!("{}", x.variance()); // 1/9
println!("{}", x.moment(3)); // 2/9 E[X³] = 3!/3³
println!("{}", x.skewness()); // 2
println!("{}", x.cdf(&v)); // -exp(-3*v) + 1
println!("{}", x.mgf(&t)); // 3/(-t + 3)
println!("{}", x.quantile(&p).unwrap()); // -1/3*ln(-p + 1)
println!("{}", x.probability(&x.symbol().gt(&ctx.one()))?); // exp(-3)
// Gamma(3, 2): the CDF's incomplete gamma closes for integer shape.
let g = RandomVariable::new(&ctx, "G", Distribution::gamma(ctx.int(3), ctx.int(2)));
println!("{}", g.mean()); // 6
println!("{}", g.moment(3)); // 480 2³ · 3·4·5
println!("{}", g.cdf(&ctx.int(4))); // -5*exp(-2) + 1
println!("{}", g.mgf(&t)); // (-2*t + 1)^(-3)
// Beta(2, 3): no closed CDF on the family, but the integral is a polynomial.
let b = RandomVariable::new(&ctx, "B", Distribution::beta(ctx.int(2), ctx.int(3)));
println!("{}", b.mean()); // 2/5
println!("{}", b.cdf(&v).expand()); // 3*v^4 - 8*v^3 + 6*v^2
println!("{}", b.probability(&b.symbol().lt(&ctx.rational(1, 2)))?); // 11/16
// Cauchy(1, 2): a CDF and quantile, but no mean.
let c = RandomVariable::new(&ctx, "C", Distribution::cauchy(ctx.int(1), ctx.int(2)));
println!("{}", c.cdf(&ctx.int(3)).simplify()); // 3/4
println!("{}", c.median().unwrap().simplify()); // 1
println!("{}", c.mean().has_unevaluated()); // true — the divergent integral, unevaluated
Ok(())
}
Symbolic parameters
Parameters may be symbols; declare their assumptions, as everywhere else in the crate.
use symplex::prelude::*;
use symplex::stats::{Distribution, RandomVariable};
fn main() {
let ctx = Context::new();
let k = ctx.symbol_with("k", &[Assumption::Positive]);
let theta = ctx.symbol_with("theta", &[Assumption::Positive]);
let g = RandomVariable::new(&ctx, "G", Distribution::gamma(k.clone(), theta.clone()));
println!("{}", g.mean()); // k*theta
println!("{}", g.variance()); // k*theta^2
println!("{}", g.moment(2)); // theta^2*rising_factorial(k, 2) = θ² k(k+1)
let nu = ctx.symbol_with("nu", &[Assumption::Positive]);
let t = RandomVariable::new(&ctx, "T", Distribution::student_t(nu.clone()));
println!("{}", t.variance()); // nu/(nu - 2) (valid for ν > 2)
}
For StudentT and Pareto the family returns the closed form for a symbolic parameter (its validity — n < ν, n < α — is the caller’s promise) and None when a numeric parameter says the moment does not exist, so Distribution::student_t(ctx.int(2)).family().variance() is None and RandomVariable::variance falls through to the divergent integral.
Differential entropy
Distribution::entropy() gives the differential entropy −∫ f ln f — a closed form for every continuous family (Normal: ½ ln(2πeσ²), Uniform: ln(b − a), Exponential: 1 − ln λ, Gamma: k + ln θ + ln Γ(k) + (1−k)ψ(k), …), the expectation of −ln f otherwise.
use symplex::prelude::*;
use symplex::stats::Distribution;
fn main() {
let ctx = Context::new();
println!("{}", Distribution::uniform(ctx.int(2), ctx.int(5)).entropy()); // ln(3)
}
Sampling
sample(n, &mut rng) draws from every built-in family, each route exact in distribution (no normal approximations). A continuous family with a closed-form quantile (Normal, Uniform, Exponential, Cauchy, Laplace, Logistic, LogNormal, Weibull, Pareto, Triangular) is drawn by inverse transform through the compiled quantile; a family on a finite lattice or table (Bernoulli, Binomial, Hypergeometric, DiscreteUniform/Die, Finite) by cumulative sums of its pmf. The rest have algorithms of their own: Gamma by Marsaglia–Tsang (with the U^{1/k} boost for shape < 1), ChiSquared as Gamma(k/2, 2), Beta as X/(X+Y) of two gammas, StudentT as Z/√(V/ν), FDistribution as (U/d₁)/(V/d₂) of two χ²; Poisson by Knuth’s multiplication method below λ = 30 and Hörmann’s transformed rejection (PTRS) above; Geometric by the closed inversion ⌊ln U / ln(1−p)⌋ + 1; NegativeBinomial as the Poisson–Gamma mixture (so a non-integer r is fine). Parameters must be numeric: a symbolic parameter is Err(Unevaluable), a numeric one outside the family’s domain Err(InvalidArgument).
use symplex::prelude::*;
use symplex::stats::{Distribution, RandomVariable, Rng};
fn main() -> Result<(), SymplexError> {
let ctx = Context::new();
let x = RandomVariable::new(&ctx, "X", Distribution::exponential(ctx.int(3)));
let samples = x.sample(20_000, &mut Rng::new(1))?;
let mean = samples.iter().sum::<f64>() / samples.len() as f64;
println!("{mean:.3}"); // 0.329 — within three standard errors of the exact mean 1/3
Ok(())
}
Conditioning, transformations, mixtures
Distributions compose. x.given(&event) is SymPy’s given(X, cond): the same symbol with the Truncated distribution f / P(event) on the event’s region (E[N | N > 0] = √(2/π), E[B | B ≥ 2] = 325/131 for Binomial(5, ⅓)). x.transform("Y", &g) is the distribution of g(X): an affine aX + b transports every closed form exactly (2N + 1 ~ Normal(1, 2), with its mgf, quantile and moments); a strictly monotone g on the support (eˣ, ln x, 1/x, …) and the even shapes X², |X|, X^{2k} go through the change-of-variables formula (N² has the χ²(1) density e^{−y/2}/√(2πy); E[eᴺ] = √e by LOTUS); a finite table or finite integer range has its values mapped and merged (Die²). Distribution::mixture(&[(w₁, F₁), (w₂, F₂)]) is a finite mixture whose every query is the weighted sum of its components’. All of them sample: a truncation by the transported quantile (or rejection), a transformation by mapping inner samples, a mixture by choosing a component.
let n = RandomVariable::new(&ctx, "N", Distribution::normal(ctx.int(0), ctx.int(1)));
let half = n.given(&n.symbol().gt(&ctx.int(0)))?; // N | N > 0
half.mean(); // √(2/π)
let sq = n.transform("S", &n.symbol().powi(2))?; // N² ~ χ²(1)
sq.density(&y); // e^{−y/2}/√(2πy)
let m = Distribution::mixture(&[(ctx.rational(1, 4), a), (ctx.rational(3, 4), b)])?;
Discrete families and finite tables
Bernoulli(p), Binomial(n, p), Poisson(λ), Geometric(p) (support 1..), NegativeBinomial(r, p) (failures before the r-th success), Hypergeometric(N, m, n), DiscreteUniform(a, b) and Die(sides) — each with exact mean, variance, raw moments (Stirling-number and factorial-moment formulas, or derivatives of the moment generating function), pmf, cdf where it closes, and mgf. Probabilities of X ≤ a, X > a, a ≤ X ≤ b and X = a are exact rationals for rational parameters (Binomial(5, 1/3): P(Y > 2) = 17/81).
Distribution::try_finite(&ctx, vec![(value, probability), …]) is SymPy’s FiniteRV: an explicit table whose values need not be integers. Moments are sums over the table, probabilities are decided by exact comparison of each value with the event’s bounds, and sample draws by cumulative sums.
let coin = Distribution::try_finite(&ctx, vec![(ctx.int(1), ctx.rational(2, 3)), (ctx.int(0), ctx.rational(1, 3))])?;
let c = RandomVariable::new(&ctx, "C", coin);
c.mean(); // 2/3
c.probability(&c.symbol().eq_expr(&ctx.int(1)))?; // 2/3
Several variables
The joint model is independence: stats::expectation(&[&x, &y], &g) computes E[g(X, Y)] for a polynomial g from the marginals’ raw moments (a product per monomial), and stats::{variance, covariance, correlation} follow (cov(X, 2X) = 2, corr(X, 2X + 1) = 1 for a standard normal). stats::probability(&[&x, &y], &event) handles rectangles (a conjunction of per-variable relations → product of marginals) and the ordering X < Y of two independent normals exactly (1/2). stats::sum_distribution(&x, &y) returns the closed family of a sum when there is one — Normal + Normal, Binomial + Binomial (same p), Poisson + Poisson, NegativeBinomial + NegativeBinomial, Gamma + Gamma (same scale; Exponential and χ² included). stats::conditional_expectation(&x, &g, &event) is E[g · 1_event]/P(event) (E[X | X > 0] = √(2/π) for a standard normal), conditional_probability(&x, &event, &given) likewise, and x.entropy() is the differential (or Shannon) entropy with closed forms for every continuous family.
Analysis of variance on data
stats::anova holds every analysis of variance — anova_one_way (moved here from hypothesis in 0.18) and its extensions to factorial and repeated-measures designs — with the same contract as the rest of the data statistics: every quantity that is a rational function of the observations — sums of squares, F, η², the sphericity εs, Mauchly’s W — is an exact Q, and p-values are exact expressions (betainc_regularized for an F tail, uppergamma for a χ² tail) evaluated with eval_f64 when you ask. The reference implementations named in tests/v17/v17_anova.rs are statsmodels’ anova_lm / AnovaRM, pingouin’s rm_anova / epsilon / sphericity and scipy’s tukey_hsd; every number printed below is asserted there.
Two-way ANOVA. A TwoWayData holds the observations by cell (cells[a][b] = replicates; build it from nested vectors, from_i64, or long-form Observation { a, b, y } rows). Cell sizes may differ. A 2 × 3 design with three replicates per cell:
use symplex::stats::anova::{anova_two_way, anova_two_way_with, SsType, TwoWayData};
let data = TwoWayData::from_i64(&[
&[&[4, 5, 6], &[6, 7, 8], &[9, 10, 12]], // A = 0: cells for B = 0, 1, 2
&[&[5, 5, 7], &[8, 9, 11], &[13, 14, 16]], // A = 1
])?;
let r = anova_two_way(&ctx, &data)?; // Type II sums of squares
r.factor_a.ss; // 49/2 df 1 F 441/31 p 0.0026634776886835334
r.factor_b.ss; // 1339/9 df 2 F 1339/31 p 3.291990727040258e-06
r.interaction.ss; // 25/3 df 2 F 75/31 p 0.13098893805732253
r.residual.ss; // 62/3 df 12 MS 31/18
r.total.ss; // 3641/18 df 17
r.factor_b.partial_eta_squared; // 1339/1525
Each row is an AnovaRow { source, ss, df, ms, f, p_value, eta_squared, partial_eta_squared } (f/p_value are None on the residual and total rows; p_value_f64() rounds). Every sum of squares is the exact difference of the residual sums of squares of two nested least-squares fits on the dummy-coded design, so nothing is lost to floating point even when the design is unbalanced — which is where the type of sum of squares matters. With equal cell sizes the factors are orthogonal and Types I, II and III agree, as above. With unequal sizes they differ for the main effects: on the same layout with cell sizes 4, 2, 3 / 2, 4, 3, anova_two_way (Type II, SS(A | B), statsmodels anova_lm(typ=2)) gives SS_A = 24, while anova_two_way_with(&ctx, &data, SsType::TypeI) (sequential, SS(A) first) gives 338/9. SsType::TypeIII uses sum-to-zero contrasts, the SPSS / car::Anova(type=3) convention, and matches statsmodels on a model fit with C(A, Sum) * C(B, Sum); the interaction row is the same under every type.
Repeated measures. anova_repeated_measures(&ctx, &rows) takes one row per subject over the k conditions and returns the conditions / subjects / error / total rows, the exact F, and the sphericity machinery: the Greenhouse–Geisser ε̂ = (tr S̃)²/((k−1) tr S̃²) computed exactly from the double-centred covariance (no eigenvalues needed), the Huynh–Feldt ε̃, the corrected p-values (the F tail with both degrees of freedom scaled by ε), and Mauchly’s W with its χ² approximation.
use symplex::stats::anova::anova_repeated_measures;
let y = [from_i64(&[5, 7, 9]), from_i64(&[4, 5, 8]), from_i64(&[6, 8, 10]),
from_i64(&[3, 6, 4]), from_i64(&[7, 9, 13])]; // 5 subjects × 3 conditions
let r = anova_repeated_measures(&ctx, &y)?;
r.conditions.ss; // 542/15 df 2
r.subjects.ss; // 764/15 df 4
r.error.ss; // 178/15 df 8
r.f; // 1084/89 → 12.179775280898877 p 0.003735511033474317
r.epsilon_gg; // 7921/14597 → 0.5426457491265329
r.epsilon_hf; // Some(4168/7091) → 0.587787336059794
r.p_value_gg; // → 0.021264365858261566 (F on 2ε̂ and 8ε̂ degrees of freedom)
r.mauchly; // Some: W = 1245/7921, χ² 5.551145791696415 on 2 df, p 0.0623137671632364
Post hoc. tukey_hsd(&ctx, &groups, 0.95) returns one PairwiseComparison { i, j, diff, se, statistic, p_adj, ci } per pair of a one-way design: the difference, the Tukey–Kramer standard error √(MSE/2·(1/nᵢ + 1/nⱼ)) and the statistic are exact, while p_adj and the simultaneous interval come from the studentized range distribution, which has no closed form and is integrated numerically (studentized_range_cdf / _sf / _quantile, agreeing with scipy to about 1e-9). pairwise_t_tests(&ctx, &groups, Adjustment::Holm, 0.05) is the alternative when variances differ: Welch tests for every pair, adjusted by Holm or Bonferroni through the hypothesis module.
Tiny p-values
A p-value is an exact expression, so nothing is lost until you convert it — and p_value_f64() (that is, eval_f64) converts to an f64, which has nothing below about 1e-308. A χ² test on [[9000, 1000], [1000, 9000]] gives χ² = 12800 on one degree of freedom; scipy reports pvalue = 0.0, and so does p_value_f64(), but the expression uppergamma(1/2, 6400)/Gamma(1/2) still holds the value. Every result type with an exact p-value (TestResult, ChiSquareResult, AnovaResult, RepeatedMeasuresAnova, Mauchly; AnovaRow for its effect rows) implements the stats::PValue trait and offers the same methods inherently:
use symplex::stats::hypothesis::chi_square_independence;
let table = [from_i64(&[9000, 1000]), from_i64(&[1000, 9000])];
let r = chi_square_independence(&ctx, &table, false)?;
r.statistic; // 12800
r.p_value_f64()?; // 0.0 — underflow
r.p_value_log10()?; // -2781.636383026783 (mpmath: -2781.6363830267828509)
r.p_value_ln()?; // -6404.954469687346
r.p_value_decimal(20)?; // "2.3100265595063985852e-2782"
r.p_value_decimal(5)?; // "2.31e-2782"
p_value_log10 and p_value_ln evaluate the logarithm of the expression in arbitrary precision, so they are finite for any positive p (an exact 0, as from a perfectly correlated pearson_test, gives -∞; an exact 1 gives 0), and p_value_decimal(digits) prints the value itself with an exponent. Ols::p_values_log10(&ctx) does the same for every coefficient of a regression. The numbers above, and the same accessors on every other result type, are asserted against mpmath in tests/v17/v17_pvalues.rs.
Survival regression: Cox proportional hazards
stats::survival (Kaplan–Meier, Greenwood, Nelson–Aalen, the log-rank test — see Analysing Rater and Response Data) describes when events happen; stats::cox explains it with covariates. cox_ph(&obs, &x, &opts) fits h(t | x) = h₀(t)·exp(xᵀβ) by Newton–Raphson on Cox’s partial likelihood — the same Observation { time, event } rows, one f64 covariate row per observation, no intercept (the baseline hazard absorbs it) — with Efron’s tie correction by default (Ties::Breslow is the other), the analytic score and information matrix, and step-halving. Ten subjects, one covariate, three censored:
use symplex::stats::cox::{cox_ph, CoxOpts};
use symplex::stats::survival::Observation;
let obs = Observation::from_i64(&[4, 7, 2, 9, 12, 5, 15, 3, 11, 8],
&[true, true, true, false, true, true, false, true, true, false]);
let x: Vec<Vec<f64>> = [3.0, 1.0, 5.0, 2.0, 0.0, 4.0, 1.0, 6.0, 2.0, 3.0].iter().map(|&v| vec![v]).collect();
let fit = cox_ph(&obs, &x, &CoxOpts::default())?;
fit.coefficients; // [0.8759809887649096] statsmodels PHReg(..., ties='efron').fit().params
fit.hazard_ratios(); // [2.401229718322006] exp(β): each unit of x multiplies the hazard by 2.4
fit.standard_errors; // [0.3809028296542367] bse
fit.p_values; // [0.021462431348704628] two-sided normal
fit.log_likelihood; // -8.465216276861835 llf; fit.null_log_likelihood = -12.108680299521524
let lr = fit.llr_test(&ctx)?; // χ² 7.286928045319378 on 1 df, p 0.006945814500177098
fit.score_statistic(); // 7.355733707724492 the log-rank-type score test at β = 0
fit.concordance()?; // 31/38 — Harrell's C, exactly
fit.baseline_hazard()[0]; // BaselineHazardRow { stratum: 0, time: 2, hazard: 0.002858840843475405, cumulative: 0.002858840843475405 }
fit.baseline_hazard()[6]; // … { time: 12, hazard: 0.2940113085020759, cumulative: 0.4700301513413364 }
The fit is numerical (f64 coefficients, standard errors, residuals), so this is one of the places where the crate is not exact; what is exact is kept so: event times are Q, and the concordance index is a ratio of pair counts — (concordant + ½ tied) / usable over the pairs tᵢ < tⱼ with i an event — returned as a rational. llr_test, wald_test and score_test return TestResults (the trio summary(coxph) prints; df = p), so p_value_log10 and friends work on them; for a single 0/1 covariate under Breslow ties with no tied event times the score statistic is the log-rank statistic of log_rank_test, which the tests assert. conf_int(c) gives Wald intervals for β and hazard_ratio_conf_int(c) the same exponentiated; baseline_hazard() is the Breslow estimator dₜ / Σ_{Rₜ} exp(xⱼᵀβ̂) with its running sum; schoenfeld_residuals() (one row per event) and martingale_residuals() (one per subject, summing to zero) are the two diagnostics you plot; predict_partial_hazard(&x₀) is exp(x₀ᵀβ̂). cox_ph_stratified(&obs, &x, &strata, &opts) sums the partial likelihood over strata, each with its own baseline hazard. A monotone likelihood (every subject with the larger covariate value fails before every subject with the smaller one, so β̂ → ∞; coxph warns “beta may be infinite”) is a ComputationFailed that names the covariate, as logit does for separation. Every number above is asserted against statsmodels 0.15 in tests/v18/v18_cox.rs, including the Freireich 6-MP data (β̂ = −1.5721 Efron, −1.5092 Breslow, as in R).
What is exact and what is not
Everything above is symbolic: rational parameters give rational or closed-form answers, and symbolic parameters stay symbolic (E[X] = μ). Two honest gaps: the integrator does not close every density integral (LogNormal probabilities stay as an Integral although its closed-form cdf is available — probability uses the cdf first), and infinite sums with symbolic parameters may stay as a Sum. RandomVariable::sample is the only numerical routine, seeded through stats::Rng so results reproduce.
Analysing rater and response data
Many studies produce the same shape of data: several people (raters,
annotators, respondents, workers) each answer some of a set of items, and
the analyst wants to know how much they agree, what the “true” answer to
each item is, how reliable each rater is, whether two groups differ, and
which of many comparisons survive correction. symplex::stats answers
those questions exactly: every coefficient that is a rational function
of the counts is an exact rational, every test statistic is an exact
expression, and every p-value is an exact expression (or an exact rational
for the discrete exact tests) that is only rounded when you ask with
.eval_f64(). The oracles the tests cite are statsmodels, scipy,
the krippendorff package and Python’s statistics module on Fractions.
The walk-through below is tests/v13/v13_walkthrough.rs; every number in
it is produced by the library.
The data
Eight items rated by five raters into three categories 0, 1, 2, with two
missing ratings:
use symplex::stats::agreement::*;
let table = RatingTable::from_i64_missing(&[
&[Some(0), Some(0), Some(0), Some(0), Some(1)],
&[Some(1), Some(1), Some(1), Some(2), Some(1)],
&[Some(2), Some(2), Some(2), Some(2), Some(2)],
&[Some(0), Some(1), Some(0), None, Some(0)],
&[Some(1), Some(1), Some(2), Some(1), Some(1)],
&[Some(2), Some(1), Some(2), Some(2), None ],
&[Some(0), Some(0), Some(1), Some(0), Some(0)],
&[Some(1), Some(2), Some(1), Some(1), Some(1)],
])?;
RatingTable is items × raters with Option<Q> cells (Q is the exact
rational Ratio<BigInt>; symplex::linprog::{q, qi} build literals).
from_i64, from_rows, and the raters-first from_raters_i64 (the
krippendorff package’s layout) are the other constructors.
How much do the raters agree?
| Question | Function | Result on the data |
|---|---|---|
| Two raters, raw agreement | percent_agreement(&a, &b) | 5/8 |
| Two raters, chance-corrected (nominal) | cohen_kappa(&a, &b) → KappaResult { kappa, observed, expected } | κ = 3/7, observed 5/8, expected 11/32 |
| Two raters, ordinal categories | weighted_kappa(&a, &b, &Weights::Linear) (or Quadratic, Custom) | — |
| Many raters, nominal, complete rows | fleiss_kappa_ratings(&complete) | 23/48 |
| Many raters, missing data, any scale | krippendorff_alpha(&table, Level::Nominal) | 214/473 |
| … treating the categories as ordered | krippendorff_alpha(&table, Level::Ordinal) | 577/836 |
Interval-scale ratings, reliability of one rater / the mean of k | icc(&complete, IccForm::Icc2Single) (also Icc1, Icc3Single, *Average) | 133/183 |
| Rankings of items by several judges | kendall_w(&complete) | 577/745 |
| Alternatives to κ | scott_pi, gwet_ac1 | — |
Every one of these is an exact rational: the krippendorff package
reports 0.4524312896405921 for the nominal α, which is 214/473. The
coefficients that need complete tables (Fleiss, ICC, Kendall’s W) say so
in their errors; Krippendorff’s α is the one to reach for when cells are
missing. confusion_matrix, category_frequencies and
RatingTable::count_table expose the counts the coefficients are built
from.
What is the answer to each item?
use symplex::stats::aggregation::*;
let labels = LabelTable::from_rows(&rows, 3)?; // items × raters, Option<usize>
let votes = majority_votes(&labels); // Vote { winner, tied, counts }
let ds = dawid_skene(&labels, &DawidSkeneOpts::default())?;
ds.labels(); // one label per item (argmax posterior)
ds.confusion[j]; // rater j's estimated confusion matrix
ds.priors; // class prevalences
majority_vote / majority_votes / plurality(labels, &threshold) /
weighted_vote(labels, &weights) are exact. dawid_skene is the
Dawid–Skene (1979) EM algorithm in f64 — deterministic, with max_iter,
tol, smoothing and an initialisation choice — returning per-item
posteriors, per-rater confusion matrices, class priors and convergence
information; dawid_skene_counts takes replicate ratings. For pairwise
judgements (“is A better than B?”) bradley_terry(&wins, &opts) fits the
Bradley–Terry model by Hunter’s MM algorithm.
Priors on the raters: MAP Dawid–Skene and MACE
With few items per rater the maximum-likelihood confusion matrices
degenerate — a rater who only ever answered 0 gets the rows (1, 0, 0)
and then carries no information at all. Two Bayesian variants keep the
estimates in the interior:
// Dirichlet priors: α on the class prevalences, β on every confusion row
// ([true][observed], shared by all raters). The M-step is the posterior
// mode (count + α − 1) / Σ(count + α − 1), so every α ≥ 1; all ones is the MLE.
let priors = DawidSkenePriors::symmetric(3, 1.0, 3.0, 1.5); // K, class α, diagonal β, off-diagonal β
let ds = dawid_skene_map(&labels, &priors, &DawidSkeneOpts::default())?;
// MACE (Hovy et al. 2013): rater r copies the true label with probability θ_r,
// otherwise "spams" a label from its own distribution ξ_r.
let m = mace(&labels, &MaceOpts::default())?; // smoothing 0.1, majority-vote start
m.competence; // θ_r per rater — who to trust
m.spam_distribution; // ξ_r — what a spammer types
m.labels(); // argmax posterior per item
// Rank items by how undecided the raters left them, and check the models
// against gold labels where you have them.
posterior_entropy(&m.posteriors); // bits per item
rater_confusion_from_gold(&labels, &gold)?; // exact [gold][given] per rater
dawid_skene_map with symmetric(K, 1, 1 + s, 1 + s) is exactly
dawid_skene with smoothing = s; the priors matter when a rater’s rows
would otherwise be estimated from two or three items. MACE has one
parameter per rater instead of a K × K matrix, so it is the model to
reach for when raters are many and their labels few — it finds the
constant-answer spammer that a majority vote is fooled by. Both are
deterministic EM iterations in f64 (MaceInit::Posteriors gives a
start of your choosing in place of random restarts).
How good is each rater?
use symplex::stats::estimation::{proportion_interval, IntervalMethod};
let acc = worker_accuracy(&rater_labels, &gold)?; // Accuracy { correct: 5, answered: 8, accuracy: Some(5/8) }
category_metrics(&rater_labels, &gold, 3)?; // precision / recall / F₁ per category, exact
let ci = proportion_interval(5, 8, 0.95, IntervalMethod::ClopperPearson)?; // Interval<f64>
(ci.lower, ci.upper); // (0.2449, 0.9148)
proportion_interval(5, 8, 0.95, IntervalMethod::Wilson)?;
gold_screening(&labels, &gold, &q(2, 3))?; // pass / fail per rater on the gold items
The Clopper–Pearson interval is the exact one (statsmodels
proportion_confint(method='beta')); Wilson, Agresti–Coull and Wald are
the usual approximations. The proportion intervals are interval
estimates, so since 0.18 they live in stats::estimation beside the
mean intervals (the stats::aggregation paths still re-export them for
one release). A rater’s accuracy against chance is an exact binomial
test (below).
Exact intervals
The f64 function rounds; the same intervals exist with exact endpoints.
Wald, Wilson and Agresti–Coull are closed algebraic forms in the normal
quantile z, so they take any expression for z — a symbol for the
textbook formula, or z_for_confidence(&ctx, &q(95, 100)) for the exact
√2·erfinv(19/20) (1.959963984540054). Clopper–Pearson’s endpoints
are the roots in (0, 1) of the two binomial-tail polynomials
Σ_{j≥k} C(n,j) pʲ(1−p)ⁿ⁻ʲ − α/2 and Σ_{j≤k} … − α/2, which
proportion_interval_exact returns as RootOf algebraic numbers:
let z = ctx.symbol("z");
let ci = proportion_interval_symbolic(&ctx, 5, 8, &z, IntervalMethod::Wilson)?; // Interval<Ex> in z, contains z^2
let ci = proportion_interval_exact(&ctx, 5, 8, &q(95, 100), IntervalMethod::ClopperPearson)?;
ci.lower; // RootOf(…degree-8 polynomial in _p…, k)
ci.lower.eval_f64()?; // 0.2448632163665516 = scipy beta.ppf(0.025, 5, 4)
ci.upper.eval_f64()?; // 0.9147665858627464 = scipy beta.ppf(0.975, 6, 3)
ci.lower.eval_decimal(30)?; // as many digits as you like
proportion_interval_exact(&ctx, 1, 1, &q(9, 10), IntervalMethod::ClopperPearson)?; // [1/20, 1]: linear tail, rational root
The exact closed forms are not clipped to [0, 1] (Agresti–Coull at
k = 0 has a negative lower end that the f64 function clamps), and the
Clopper–Pearson roots need a Sturm isolation and a factorisation of a
degree-n polynomial — fine for tens of trials, not thousands. The
known-σ mean interval has the same pair,
confidence_interval_mean_z_symbolic / _exact, in the same module.
Do two groups differ?
Response times of two groups, in seconds:
use symplex::stats::{data, hypothesis::*};
let fast = data::from_i64(&[12, 15, 11, 14, 13, 16, 10, 17]);
let slow = data::from_i64(&[18, 22, 19, 25, 20, 21, 23, 24]);
data::mean(&fast)?; // 27/2
data::variance(&fast, data::Ddof::Sample)?; // 6
data::quantile(&slow, &q(3, 4), data::QuantileMethod::Inclusive)?; // 93/4
let t = t_test_two_sample(&ctx, &fast, &slow, false, Alternative::TwoSided)?; // Welch
t.statistic; // exact: −(…)·√(…) → −6.531972647421809
t.p_value; // exact expression through betainc_regularized → 1.3298737271301488e-05
cohens_d(&ctx, &fast, &slow, true)?;
let u = mann_whitney_u(&ctx, &fast, &slow, Alternative::TwoSided, RankMethod::Exact)?;
u.statistic; // 0
u.p_value; // 1/6435 — the exact null distribution of U, as a rational
TestResult { statistic, p_value, df, alternative } carries exact
expressions; p_value_f64() / statistic_f64() round them, and
p_value_exact() returns the rational when there is one (the discrete
exact tests). The family:
| Data | Tests |
|---|---|
| Two means | t_test_one_sample, t_test_two_sample (Student or Welch), t_test_paired; the t interval for a mean is estimation::confidence_interval_mean(&x, 0.95) |
| Several means | anova::anova_one_way → AnovaResult { f, df_between, df_within, p_value, ss_between, ss_within, eta_squared } (in stats::anova with the factorial and repeated-measures designs since 0.18) |
| Two proportions / one proportion | z_test_proportion, z_test_two_proportions (renamed from two_proportion_z_test in 0.18), binomial_test (exact) |
| Ranks / ordinal scores | mann_whitney_u (exact or asymptotic with tie correction), wilcoxon_signed_rank, kruskal_wallis, friedman, spearman_test, kendall_test |
| Categorical tables | chi_square_independence (with Yates), chi_square_goodness_of_fit, g_test, fisher_exact (exact up to a 2 000-point support, numeric above), mcnemar_test (exact or χ²), sign_test; counts(&[&[i64]]) / counts_usize(&[Vec<usize>]) build the table (the latter from a confusion_matrix) |
| Which cells drive a χ²? | expected_counts, chi2_contributions, standardized_residuals, adjusted_residuals (Haberman) |
| Correlation inference | pearson_test, pearson_t_statistic, compare_two_correlations; the Fisher-z interval is estimation::pearson_ci |
| Distribution fit | ks_one_sample(x, &Distribution) |
| Effect sizes | cohens_d, hedges_g, glass_delta, rank_biserial, cliffs_delta, eta_squared, cramers_v, phi_coefficient, odds_ratio, relative_risk, cohens_h |
Approval counts by group, [[30, 10], [18, 22]]: fisher_exact gives
p = 0.01150621201656047 exactly as a rational, and
chi_square_independence(…, true) p = 0.01205961617749023 as a
χ²-tail expression — both matching scipy to the last digit. Fisher’s
p-value is an exact rational while the hypergeometric support has at most
2 000 points; for larger tables (cells in the 10⁴–10¹¹ range) it is
numeric — the pmf walked from its mode in floating point, returned as
ctx.from_f64(p) and agreeing with scipy to 1e-9 — and a [[10⁶, 10⁶ + 7], [10⁶ − 3, 10⁶]] table takes milliseconds. Below 1e-308 the
expression is exp(ln p), so p_value_log10 still reads the tail.
Screening many raters at once
A rater who answered 14 of 20 gold questions correctly, against a chance
rate of ⅓: binomial_test(&ctx, 14, 20, &q(1, 3), Alternative::Greater)
has the exact p-value 1021403/1162261467 (≈ 8.79·10⁻⁴). Testing
many raters multiplies the false positives; bonferroni, holm,
benjamini_hochberg and benjamini_yekutieli return adjusted p-values
and reject flags (matching statsmodels.multipletests):
let adj = benjamini_hochberg(&[0.001, 0.02, 0.03, 0.2, 0.8], 0.05)?;
adj.reject; // [true, true, true, false, false]
bootstrap_ci and permutation_test (seeded through stats::Rng, so
reproducible) cover statistics without a known null distribution;
sample_size_for_proportion, sample_size_two_proportions,
power_t_test_two_sample and sample_size_t_test_two_sample plan a study
(the t-power uses the noncentral t by quadrature and matches statsmodels).
Estimating and modelling
stats::estimation fits distributions to data — fit_normal,
fit_exponential, fit_poisson, fit_bernoulli, fit_geometric,
fit_uniform, fit_log_normal by maximum likelihood (closed forms, exact
where the estimate is rational), fit_gamma_moments, fit_beta_moments,
fit_negative_binomial_moments by moments — and returns Distributions,
so everything from the previous chapter (probabilities, quantiles,
sampling) applies to the fitted model. log_likelihood, aic, bic
compare fits. Conjugate Bayesian updating returns distributions too:
beta_binomial_posterior(&ctx, &α, &β, successes, failures) is the
posterior for a rater’s accuracy after s right and f wrong answers,
credible_interval(&post, 0.95) its equal-tailed Interval<f64> (fields
lower, upper), and
posterior_predictive_beta_binomial the exact predictive table for the
next n answers.
Sequences of states — a respondent moving between “engaged”, “guessing”
and “gone”, say — are stats::markov::MarkovChain on an exact QMatrix:
stationary_distribution() is one exact linear solve ([2/7, 3/7, 2/7]
for the textbook 3-state chain), absorption_probabilities,
expected_steps_to_absorption, expected_hitting_time,
communication_classes, periods, and sample_path.
Descriptive statistics, exactly
stats::data has the everyday summaries on exact observations: mean,
variance(Ddof::{Population, Sample}), std, median, quantile /
quantiles (QuantileMethod::{Exclusive, Inclusive} — Python’s
statistics.quantiles default and method='inclusive'), iqr,
modes, frequencies, ranks (average ranks, as scipy.stats.rankdata),
covariance, pearson, spearman, kendall_tau, skewness, kurtosis,
median_abs_deviation, zscores, geometric_mean, harmonic_mean,
trimmed_mean, and the outlier screens iqr_outliers (Tukey’s fences) and
mad_outliers (modified z-scores) for response times. The ordinal
association measures live here too: goodman_kruskal_gamma,
somers_d(x, y, Dependent::{Y, X, Symmetric}), kendall_tau_c and the
concordance_counts they are built from. from_f64 converts floats
exactly (every f64 is a dyadic rational), to_f64 rounds back.
Is the questionnaire itself reliable?
When each item is meant to measure the same thing, stats::reliability
asks whether they do — exactly. With respondents as rows and items as
columns of a RatingTable:
| Question | Function |
|---|---|
| Internal consistency | cronbach_alpha (and cronbach_alpha_complete with list-wise deletion), standardized_alpha, kr20 for right/wrong items, guttman_lambda2, alpha_if_deleted |
| Split-half reliability | split_half(&table, &SplitHalf::{OddEven, FirstLast, Custom}) with the Spearman–Brown prophecy (spearman_brown(&r, k), in the context of r) |
| Item quality | item_difficulty, item_discrimination_index (upper vs lower third), point_biserial, item_total_correlation, corrected_item_total_correlation, all bundled by item_response_summary |
Every coefficient that is a rational function of the scores is an exact
rational (Cronbach’s α, KR-20); the ones with roots are exact
expressions. Since 0.18 stats::reliability holds only scale
reliability and item analysis; its former neighbours moved to the module
their rule names (the old paths re-export them for one release):
| Question | Function (0.18 home) |
|---|---|
| Agreement inference | agreement::{cohen_kappa_ci} (Fleiss–Cohen–Everitt variance, exact), kappa_test (H₀: κ = 0), cohen_kappa_maximum (the κ the marginals allow), cochrans_q (many raters, binary items) |
| Ordinal association | data::{goodman_kruskal_gamma, somers_d, kendall_tau_c, concordance_counts} |
| Which cells drive a χ²? | hypothesis::{expected_counts, chi2_contributions, standardized_residuals, adjusted_residuals} |
| Correlation inference | hypothesis::{pearson_test, pearson_t_statistic, compare_two_correlations}; estimation::{pearson_ci, fisher_z} |
Explaining accuracy or time by features
stats::regression fits models to the data you have about respondents:
use symplex::stats::regression::{ols, logit, LogitOpts, Design};
// Response time explained by two features, with an intercept — exactly.
let fit = ols(&y, &rows, true)?; // or Design::new().intercept().column(&x1).column(&x2).fit(&y)?
fit.coefficients; // Vec<Q>, exact (XᵀX)⁻¹Xᵀy
fit.r_squared; fit.adjusted_r_squared; // exact rationals
fit.standard_errors(&ctx)?; // exact expressions (√ of σ̂²(XᵀX)⁻¹)
fit.coefficient_tests(&ctx)?; // TestResult per coefficient, p through StudentT
fit.f_test(&ctx)?; fit.anova_table(); // overall F, exact
fit.conf_int(0.95)?; fit.prediction_interval(&x_new, 0.95)?; // Vec<Interval<f64>>, Interval<f64> — no ctx: the limits are f64
fit.leverage(); fit.cooks_distance(); fit.durbin_watson(); vif(&rows)?;
// Correct / incorrect explained by features: logistic regression (IRLS, f64).
let lg = logit(&correct, &features, true, &LogitOpts::default())?;
lg.coefficients; lg.odds_ratios(); lg.p_values; lg.pseudo_r_squared; lg.predict_proba(&x_new);
ols, wls, simple_linear_regression and polyfit are exact and match
statsmodels OLS attribute for attribute; logit matches Logit to
1e-6 and reports perfect separation as an error rather than as enormous
coefficients.
Several answers, or ordered answers
When the response has more than two categories, mnlogit fits a
multinomial logit (statsmodels MNLogit: category 0 is the reference,
one equation per other category) and ologit a proportional-odds model
(statsmodels OrderedModel(distr='logit'): P(y ≤ j | x) = σ(θ_j − xβ),
thresholds instead of an intercept). Both are Newton–Raphson in f64
with the same LogitOpts, and both refuse separated data with an error
that names the diverging coefficient.
use symplex::stats::regression::{mnlogit, ologit, LogitOpts};
// Which of three answers a respondent picks, explained by one feature x = 1..24.
let y = [0, 0, 1, 0, 0, 1, 2, 0, 1, 1, 2, 0, 1, 2, 1, 2, 1, 2, 2, 1, 2, 0, 2, 2];
let x: Vec<Vec<f64>> = (1..=24).map(|i| vec![f64::from(i)]).collect();
let mn = mnlogit(&y, &x, true, &LogitOpts::default())?;
println!("{:.4} {:.4}", mn.coefficients[1][0], mn.coefficients[1][1]); // -2.8750 0.2536 (answer 2 vs 0: intercept, slope)
println!("{:.4}", mn.relative_risk_ratios()[1][1]); // 1.2887 (× per unit of x for P(2)/P(0))
let p = mn.predict_proba(&[12.0])?;
println!("{:.3} {:.3} {:.3}", p[0], p[1], p[2]); // 0.272 0.406 0.322
println!("{}", mn.predict(&[12.0])?); // 1 (the modal answer)
println!("{:.4}", mn.pseudo_r_squared); // 0.1572
// A rating on an ordered scale 0 < 1 < 2, explained by x = 0..19: no intercept column.
let y = [0, 0, 1, 0, 1, 1, 2, 1, 2, 2, 0, 1, 2, 2, 1, 0, 0, 1, 2, 2];
let x: Vec<Vec<f64>> = (0..20).map(|i| vec![f64::from(i)]).collect();
let ol = ologit(&y, &x, &LogitOpts::default())?;
println!("{:.4}", ol.coefficients[0]); // 0.1140 (β)
println!("{:.4} {:.4}", ol.thresholds[0], ol.thresholds[1]); // 0.1212 1.7317 (θ₀ < θ₁, as thresholds)
println!("{:.4}", ol.odds_ratios()[0]); // 1.1208 (odds of a higher rating, per unit of x)
let p = ol.predict_proba(&[7.0])?;
println!("{:.3} {:.3} {:.3}", p[0], p[1], p[2]); // 0.337 0.381 0.282
println!("{:.3}", ol.llr_test(&ctx)?.p_value_f64()?); // 0.126 (H₀: β = 0, χ²₁)
statsmodels reports the ordinal thresholds as θ₀ followed by the
log-differences ln(θ_j − θ_{j−1}); ologit reports the thresholds
themselves (statsmodels’ transform_threshold_params), and its standard
errors are for those. Every printed number above is asserted in
tests/v19/v19_mnlogit.rs.
Screening while the answers arrive
stats::sequential::Sprt::bernoulli(&p0, &p1, α, β) is Wald’s sequential
probability ratio test: feed each gold-question outcome to update, and
the moment the exact log-likelihood ratio (log_likelihood_ratio(&ctx))
crosses a boundary the decision is AcceptH0 (the rater performs at the
chance rate p0) or AcceptH1 (at the competent rate p1); until then
Continue. The decision is sticky — once reached, every later update
returns it, is_decided() is true and stopped_at() gives the sample
size at which the test stopped; reset() starts over.
expected_sample_size_bernoulli says how many questions that takes on
average.
Comparing label distributions
stats::information measures, exactly, how two raters’ (or two
populations’) label distributions differ: kl_divergence,
js_divergence, total_variation, hellinger, bhattacharyya_distance,
cross_entropy; and from a joint table how much one variable tells about
another: mutual_information, conditional_entropy,
normalized_mutual_information (Norm::{Arithmetic, Geometric, Min, Max}), joint_from_counts. Logs are kept as exact ln expressions
over prime factors, so H(½, ¼, ¼) is exactly 3/2 bits.
Time to completion, time to attrition
stats::survival handles right-censored durations — how long until a
respondent finishes (or is still working when the study ends), how long a
worker stays active:
use symplex::stats::survival::{KaplanMeier, Observation, CiMethod, log_rank_test};
let obs = Observation::from_i64(&[3, 5, 6, 7, 8, 10, 12, 12], &[true, false, true, true, false, true, true, false]);
let km = KaplanMeier::fit(&obs)?;
km.survival_at(&q(6, 1)); // 35/48 — an exact step function
km.variance_at(&q(6, 1)); // Greenwood, 1505/55296
km.cumulative_hazard_at(&q(6, 1)); // Nelson–Aalen, 7/24
km.median(); // Some(10)
km.confidence_interval(&q(7, 1), 0.95, CiMethod::LogLog)?; // Interval<f64>
km.restricted_mean(&q(12, 1)); // 1279/144
let r = log_rank_test(&ctx, &all_obs, &groups)?; // exact χ² statistic 149059681/48496587 ≈ 3.0736, p ≈ 0.0796
The Kaplan–Meier steps, Greenwood variances and Nelson–Aalen hazards are
exact rationals matching statsmodels’ SurvfuncRight; the log-rank
statistic is an exact rational matching scipy’s logrank and statsmodels’
survdiff. exponential_rate is the censored MLE of a constant hazard,
and survival_function / hazard_function give S(t) and h(t) of any
Distribution as expressions.
Several measures at once, and extremes
stats::multivariate::MultivariateNormal (symbolic mean vector and
covariance Matrix) has an exact density, marginals, conditionals by the
Schur complement, Mahalanobis distance and sampling; covariance_matrix
and correlation_matrix summarise several columns of ratings at once
(exact), and pca finds their principal components exactly
(pca_f64 for larger matrices). stats::order::{order_statistic, minimum_of, maximum_of} give the distribution of the k-th smallest of n
independent draws as a Distribution in its own right — the fastest of
n responses, the worst of n ratings — exactly for both continuous
families and finite tables.
Code Generation
Once you have a symbolic result — a Jacobian, a controller, a filter — you want to run it fast. symplex offers three routes:
| Route | Method | When |
|---|---|---|
| Compiled closure | compile(&["x", …]) -> Result<CompiledFn> | Evaluate now, in this process; no source code |
| Rust source | to_rust_fn(name, &args) -> Result<String> | build.rs pipelines, no_std firmware |
| C99 source | to_c_fn(name, &args) -> Result<String> | C/C++ projects, other toolchains |
All three run constant folding and common subexpression elimination first, and all three return Err(SymplexError::FreeSymbol) for a symbol not in the parameter list and Err(NotImplemented) for a node with no numerical meaning (an unevaluated Integral, a set, I).
Compiled closures
CompiledFn is Clone + Send + Sync, callable directly (f(&[1.0, 2.0])), and has arity() and try_call() (arity-checked). compile_many compiles several expressions into one CompiledFnVec with a shared CSE pass — the right tool for gradients and Jacobians. Every numerically evaluable node is supported, including Γ, lnΓ, ψ, erf/erfc, Lambert W, Beta, factorials, Bessel J/Y/I/K, orthogonal polynomials, integer sequences, min/max/floor/sign/heaviside/atan2, and piecewise with boolean conditions.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y, z);
let f = &x.gamma() * &(&x.powi(2) + &y).erf() + &x.lambertw();
let cf = f.compile(&["x", "y"]).unwrap();
println!("{} {}", cf.arity(), cf(&[2.0, 1.0])); // 2 1.8526…
assert!(cf.try_call(&[1.0]).is_err()); // wrong arity
assert!(matches!((&x + &z).compile(&["x"]), Err(SymplexError::FreeSymbol { .. })));
let g = &x.sin().powi(2) + &(&x * 2 + &y).exp() * 3;
let grad = Ex::compile_many(&[&g.diff(&x), &g.diff(&y)], &["x", "y"]).unwrap();
println!("{:?}", grad.call_vec(&[0.5, 0.25])); // [21.97…, 10.59…]
}
Rust source
to_rust_fn emits a pub fn name(args: f64…) -> f64 with mul_add for a*b + c, powi for integer powers, and let tN = …; temporaries from CSE. Special functions call into an embedded mod symplex_rt { … } runtime that contains only the helpers the expression uses.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let g = &x.sin().powi(2) + &(&x * 2 + &y).exp() * 3;
println!("{}", g.to_rust_fn("g", &["x", "y"]).unwrap());
// #[must_use]
// pub fn g(x: f64, y: f64) -> f64 {
// 3_f64.mul_add(2_f64.mul_add(x, y).exp(), x.sin().powi(2))
// }
let f = &x.gamma() + &x.lambertw();
let code = f.to_rust_fn("f", &["x"]).unwrap();
assert!(code.starts_with("#[allow(dead_code, clippy::all)]\nmod symplex_rt {"));
assert!(code.contains("symplex_rt::gamma(x)"));
}
CodegenOptions
to_rust_fn_with_options(name, &args, &opts) takes a CodegenOptions:
| Field | Default | Effect |
|---|---|---|
precision | F64 | F32 emits f32 and f-suffixed literals |
math_backend | Std | Libm → libm::sin(x); CfgGated → math::sin(x) with a cfg-gated mod math that picks std or libm (for no_std) |
inline / must_use | false / true | attributes on the function |
cse | true | common subexpression elimination |
use_mul_add | true | fuse a*b + c into mul_add/fma (one rounding instead of two; disable for bit-exact unfused arithmetic) |
checked_domain | false | debug_assert! domain checks (ln(x) needs x > 0, sqrt needs x ≥ 0, lambertw needs x ≥ −1/e) |
emit_runtime | true | emit the mod symplex_rt preamble when needed |
unit_annotation, param_units, return_unit | none | uom type annotations at the function boundary |
Presets: CodegenOptions::no_std() (cfg-gated + #[inline]), CodegenOptions::embedded_f32().
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let h = &x.ln() + &x.sqrt();
let checked = CodegenOptions { checked_domain: true, ..Default::default() };
println!("{}", h.to_rust_fn_with_options("h", &["x"], &checked).unwrap());
// { debug_assert!(x >= 0.0_f64, "sqrt: argument {} outside domain", x); x.sqrt() } + …
println!("{}", h.to_rust_fn_with_options("h_nostd", &["x"], &CodegenOptions::no_std()).unwrap());
// #[cfg(feature = "std")] mod math { … } #[cfg(not(feature = "std"))] mod math { … }
// #[inline] #[must_use] pub fn h_nostd(x: f64) -> f64 { math::sqrt(x) + math::ln(x) }
}
Many functions in one file
Each to_rust_fn call embeds its own runtime. When you concatenate many functions into one file, set emit_runtime: false and paste CodegenOptions::runtime_module() (the complete mod symplex_rt for that backend) once at the top. symplex-build does this for you.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let shared = CodegenOptions { emit_runtime: false, ..Default::default() };
let mut file = CodegenOptions::default().runtime_module();
file.push('\n');
file.push_str(&x.gamma().to_rust_fn_with_options("g", &["x"], &shared).unwrap());
file.push('\n');
file.push_str(&x.lambertw().to_rust_fn_with_options("w", &["x"], &shared).unwrap());
assert_eq!(file.matches("mod symplex_rt {").count(), 1);
println!("{} lines", file.lines().count());
}
C99 source
to_c_fn emits #include <math.h>, the static inline symplex_* helpers the expression needs (Lambert W, digamma, Bessel functions, orthogonal polynomials, integer sequences — everything <math.h> lacks), then the function with const double tN = …; temporaries. tgamma, lgamma, erf, erfc, fma, expm1, log1p are used directly; integer powers |n| ≤ 4 become repeated multiplication; piecewise expressions become ternary chains ending in NAN.
to_c_fn_with_options honours precision (float + sinf/expf/fmaf), cse, inline (static inline), use_mul_add (fma), checked_domain (assert(...) with #include <assert.h>), and emit_runtime (pair with CodegenOptions::c_runtime() for a shared translation unit).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let g = &x.sin().powi(2) + &(&x * 2 + &y).exp() * 3 + &x.powi(3) * &y;
println!("{}", g.to_c_fn("g", &["x", "y"]).unwrap());
// /* Generated by symplex. */
// #include <math.h>
//
// double g(double x, double y) {
// return fma(y, (x * x * x), fma(3.0, exp(fma(2.0, x, y)), pow(sin(x), 2.0)));
// }
let opts = CodegenOptions { precision: Precision::F32, inline: true, checked_domain: true, ..Default::default() };
println!("{}", (&x.ln() + &x.sqrt()).to_c_fn_with_options("h32", &["x"], &opts).unwrap());
// static inline float h32(float x) {
// return (assert(x >= 0.0f), sqrtf(x)) + (assert(x > 0.0f), logf(x));
// }
let pw = Ex::piecewise(&[(&x.powi(2), &x.lt(&ctx.int(0))), (&x.sqrt(), &x.ge(&ctx.int(0)))]);
println!("{}", pw.to_c_fn("pw", &["x"]).unwrap());
// double pw(double x) { return (0.0 > x) ? (x * x) : ((x >= 0.0) ? sqrt(x) : NAN); }
assert!(x.lambertw().to_c_fn("w0", &["x"]).unwrap().contains("static inline double symplex_lambert_w0(double x)"));
}
cargo run --example c_codegen generates C for a special-function expression, compiles it with cc if available, and checks that the C program agrees with the compiled Rust closure to ~1e-13.
Matrices
Matrix::to_rust_fn(name, ¶ms) returns a flat row-major [f64; rows*cols] with CSE shared across entries; to_rust_fn_with_options takes the same options. This is what symplex-build uses for Jacobians and forward-kinematics transforms.
CSE
cse() returns (bindings, rewritten) for one expression, Ex::cse_many(&[&a, &b]) shares temporaries across several. Bindings are ordered post-order (deterministic; a binding only refers to earlier bindings); trivially cheap nodes are extracted only when used three or more times, and boolean nodes never are.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let g = &x.sin().powi(2) + &(&x * 2 + &y).exp() * 3;
let (bindings, exprs) = Ex::cse_many(&[&g.diff(&x), &g.diff(&y)]);
for (name, value) in &bindings {
println!("{name} = {value}"); // __cse_0 = exp(2*x + y)
}
println!("{:?}", exprs);
}
Build-time generation and no_std
The symplex-build crate runs the CAS in build.rs: register scalar and matrix functions, choose no_std, f32, companion tests, and write everything to $OUT_DIR. Its cfg-gated mod math covers every function the 0.2 Rust backend can emit (sin … atanh, powi/powf, atan2, min/max, expm1, log1p, log2, exp2, fma, sin_cos).
Other outputs
to_latex(), pretty()/pretty_ascii(), to_json()/Context::from_json, and plotting (textplot, to_svg, to_tikz, plot_data, eval_table — all Result in 0.2). See cargo run --example latex_output.
More targets and interchange (0.9.1)
Python, NumPy and Julia
to_python() prints a Python 3 expression over the math module (SymPy: pycode); to_numpy() the vectorised numpy. form (SymPy: NumPyPrinter); to_julia() base Julia (SymPy: julia_code). The *_fn(name, &args) twins wrap the expression in a function definition with CSE temporaries t0, t1, … and report a symbol that is not a parameter as Err(FreeSymbol). Numbers stay exact (2, (1/2)), integer powers are x**2/x^2, x^(1/2) is math.sqrt(x), relations and connectives print as x > 0 and 1 > x / numpy.logical_and(numpy.greater(x, 0), …) / x > 0 && 1 > x, and piecewise as (v if c else …), numpy.select([…], […], default=numpy.nan), (c ? v : …). Anything the target cannot express — Bessel functions, digamma, LambertW, unevaluated integrals, sets, I; for NumPy also gamma/erf/factorial (SciPy territory); for Julia gamma/erf (SpecialFunctions.jl) — is Err(NotImplemented), never a silently wrong formula. The expression forms print every symbol by name; the caller supplies import math / import numpy.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let g = &x.sin().powi(2) + &x.exp();
assert_eq!(g.to_python().unwrap(), "math.sin(x)**2 + math.exp(x)");
assert_eq!(g.to_numpy().unwrap(), "numpy.sin(x)**2 + numpy.exp(x)");
assert_eq!(g.to_julia().unwrap(), "sin(x)^2 + exp(x)");
let f = &x.sin().powi(2) + &x.sin() * &y;
println!("{}", f.to_python_fn("f", &["x", "y"]).unwrap());
// def f(x, y):
// t0 = math.sin(x)
// return t0**2 + t0*y
let pw = Ex::piecewise(&[(&x.powi(2), &x.lt(&ctx.int(0))), (&x.sqrt(), &x.ge(&ctx.int(0)))]);
assert_eq!(pw.to_python().unwrap(), "(x**2 if 0 > x else (math.sqrt(x) if x >= 0 else math.nan))");
assert!(matches!(x.bessel_j(&ctx.int(0)).to_python(), Err(SymplexError::NotImplemented(_))));
}
Presentation MathML
to_mathml() (on Ex and BoolEx; SymPy: mathml(expr, printer='presentation')) returns a <math xmlns="http://www.w3.org/1998/Math/MathML">…</math> element that browsers and MathJax render directly. Layout follows to_latex: <mfrac> for quotients and negative powers, <msqrt>/<mroot> for roots, <msup> for powers (sin(x)^2 as <msup><mi>sin</mi><mn>2</mn></msup>), <mi>sin</mi><mo>⁡</mo> (invisible apply) for function application, <mo>⁢</mo> (invisible times) between factors, Greek symbol names as character references (alpha → <mi>α</mi>), x_1 as <msub>, and explicit <mo>(</mo>…<mo>)</mo> wherever LaTeX would emit \left(…\right) — no <mfenced>. Every character reference is numeric, so the output is well-formed XML without a DTD. Series, DSolve, RootOf and RootSum have no standard presentation and return Err(NotImplemented).
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
assert_eq!(
(&x.powi(2) + 1).to_mathml().unwrap(),
"<math xmlns=\"http://www.w3.org/1998/Math/MathML\">\
<mrow><msup><mi>x</mi><mn>2</mn></msup><mo>+</mo><mn>1</mn></mrow></math>"
);
println!("{}", (&x.sin() / 2 + &x.sqrt()).to_mathml().unwrap());
println!("{}", x.gt(&ctx.int(0)).and(&x.lt(&ctx.int(1))).to_mathml().unwrap());
}
srepr and DOT
to_srepr() (SymPy: srepr) is the unambiguous constructor form of the exact tree — Add(Integer(1), Mul(Integer(2), Symbol('x'))), Pow(sin(Symbol('x')), Integer(2)), StrictGreaterThan(Symbol('x'), Integer(0)), Interval(a, b, false, true) — derived from to_tree(), so it is total (every node kind prints) and shows the arena’s canonical child order rather than display order. to_dot() (SymPy: dotprint) is a Graphviz digraph with one node per tree position (labelled with the node kind, plus the value for atoms), one edge per child, and ids n0, n1, … assigned in pre-order, so the output is deterministic; render it with dot -Tsvg.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
assert_eq!((2 * &x + 1).to_srepr(), "Add(Integer(1), Mul(Integer(2), Symbol('x')))");
println!("{}", (2 * &x + 1).to_dot());
// digraph {
// ordering=out;
// rankdir=TD;
// n0 [label="Add"];
// n1 [label="Integer(1)"];
// n2 [label="Mul"];
// n3 [label="Integer(2)"];
// n4 [label="Symbol('x')"];
// n0 -> n1;
// n0 -> n2;
// n2 -> n3;
// n2 -> n4;
// }
}
Parsing relations and implicit application
Context::parse already reads juxtaposition as multiplication (2x, 2 x, x y, 2(x+1), (x+1)(x-1), 2pi) and is otherwise strict: no relations, and an identifier followed by ( must be a known function. Two new entry points extend it without changing what parse accepts:
Context::parse_bool("x > 0 & x < 1") -> Result<BoolEx>(SymPy:sympify("x > 0")) adds<,<=,>,>=,==,!=, the connectives&/&&/and,|/||/or, the prefix negation~/!/not,True/False, and SymPy’s function formsEq(a, b),Ne,Lt,Le,Gt,Ge,And(…),Or(…),Not(a). Precedence is mathematical, loosest first:or<and< comparisons <+ -<* /<^;notapplies to the following relation; comparisons do not chain (0 < x < 1is an error — write0 < x & x < 1). Note that Python’ssympify("x > 0 & x < 1")fails because&binds tighter than>there. A numeric expression (x + 1) or a sort error ((x > 0) + 1,x & y) isErr.Context::parse_implicit("2 sin x") -> Result<Ex>(SymPy:parse_expr(s, transformations=implicit_multiplication_application)) additionally applies textbook function names without parentheses and treats an unknownf(…)as a product. The argument ofsin xis the juxtaposed product that follows, up to the next+,-, comparison, closing parenthesis or function name:2 sin xis2*sin(x),sin 2xissin(2*x),sin x^2issin(x^2),sin x/2issin(x/2),sin x + 1issin(x) + 1, andsin x cos yissin(x)*cos(y)(SymPy readssin(x*cos(y))). Only the textbook names (trigonometric/hyperbolic and inverses,exp,ln/log,sqrt,cbrt,abs,floor,ceil,sign,gamma,erf,erfc,factorial) are applied implicitly; short names that double as variables (re,im,arg,li,zeta, …) need parentheses, and the one-letter display aliasesC/B/Ware ordinary symbols (writebinomial,beta,lambertw).x(x+1)andf(x)arex*(x+1)andf*x.
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let p = ctx.parse_bool("x > 0 & x < 1").unwrap();
assert_eq!(p, x.gt(&ctx.int(0)).and(&x.lt(&ctx.int(1))));
assert_eq!(p.to_string(), "x > 0 & 1 > x");
assert_eq!(p.to_lean().unwrap(), "0 < x ∧ x < 1");
assert_eq!(ctx.parse_implicit("2x + 3(y-1)").unwrap(), 2 * &x + 3 * (&y - 1));
assert_eq!(ctx.parse_implicit("2 sin x cos y").unwrap(), 2 * &x.sin() * &y.cos());
assert!(ctx.parse("x > 0").is_err());
}
Dimensional Analysis
symplex::units provides compile-time dimensional analysis: a quantity’s dimension is part of its Rust type, so adding a Mass to a Length does not compile, and differentiating a Length with respect to a Time yields a Velocity. Underneath, every quantity is an Ex in SI base units, so all the symbolic machinery still applies.
Quantity types
Qty<D> is generic over a type-level dimension Dim<L, M, T, I, Θ, N, J> (powers of length, mass, time, current, temperature, amount, luminous intensity, as typenum integers). Thirty named aliases cover the common cases: Length, Mass, Time, Current, Temperature, Area, Volume, Velocity, Acceleration, AngularVelocity, Frequency, Force, Energy, Torque, Power, Momentum, AngularMomentum, MomentOfInertia, Pressure, Stiffness, Damping, Voltage, Resistance, Inductance, Capacitance, Charge, MagneticFlux, Dimensionless, Angle, ….
use symplex::prelude::*;
use symplex::units::*;
fn main() {
let ctx = Context::new();
let m = Mass::symbol(&ctx, "m");
let a = Acceleration::symbol(&ctx, "a");
// The `: Force` annotation is checked by the compiler.
let f = dim!(ctx, Force: m * a);
println!("F = {f}"); // a*m [N]
let d = Length::symbol(&ctx, "d");
let w = dim!(ctx, Energy: f * d);
println!("W = {w}"); // a*d*m [J]
// Substitute and evaluate like any Ex
let f_num = f.subs(&m, &ctx.int(10)).subs(&a, &ctx.rational(981, 100)).eval();
println!("{f_num}"); // 981/10 [N]
println!("{}", f_num.eval_f64().unwrap()); // 98.1
}
Mass + Length is a type error; Mass * Acceleration is a Force; Energy / Time is a Power. Multiplication and division of quantities compute the resulting dimension at the type level.
Typed calculus
diff_wrt(&Time) divides the dimension by time; integrate_wrt multiplies. Build the formula with expr! on raw symbols and wrap it with from_ex:
use symplex::prelude::*;
use symplex::units::*;
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; g, t); // raw symbols for expr!
let t_var = Time::symbol(&ctx, "t");
let x = Length::from_ex(expr!(ctx, 1 / 2 * g * t ^ 2));
let v: Velocity = x.diff_wrt(&t_var); // d(Length)/d(Time)
let acc: Acceleration = v.diff_wrt(&t_var);
println!("{x}\n{v}\n{acc}"); // 1/2*g*t^2 [m] g*t [m/s] g [m/s²]
println!("{}", acc.inner()); // the underlying Ex: g
}
Exact conversions
Named constructors convert from other units with exact rational factors: Length::inches(&x), Length::miles(&x), Force::pound_force(&x), Pressure::psi(&x), Energy::btu(&x), Volume::us_gallons(&x), … (about 100 in total). The result is in SI.
use symplex::prelude::*;
use symplex::units::*;
fn main() {
let ctx = Context::new();
let one = ctx.int(1);
println!("{}", Length::inches(&one).eval()); // 127/5000 [m]
println!("{}", Force::pound_force(&one).eval()); // exact rational newtons
println!("{}", Pressure::psi(&one).eval());
println!("{}", Energy::btu(&one).eval());
println!("{:.6}", Volume::us_gallons(&one).eval_f64().unwrap() * 1000.0); // 3.785412 L
}
Physical constants
symplex::units::constants provides typed constants (speed_of_light, standard_gravity, planck_constant, boltzmann_constant, gravitational_constant, …) as exact PhysicalConstant nodes that display by name and evaluate on demand. cargo run --example physics_constants shows them.
Compile-time assertions
symplex::const_assert_dim! asserts a dimensional identity at compile time (for example that Energy equals Force × Length); symplex::units::inference infers dimensions of an untyped expression from a DimMap of symbol dimensions. See examples/units_physics.rs.
Code generation with uom
CodegenOptions::with_uom() (plus param_units / return_unit) annotates generated Rust functions with uom quantity types at the boundary (raw f64 inside). cargo run --example units_engineering shows a motor-design workflow ending in typed generated code.
Examples
cargo run --example units_physics # Newton, Ohm, pendulum, compile-time assertions
cargo run --example units_electrical # Circuit analysis with units
cargo run --example units_engineering # Motor design, imperial conversions, uom codegen
cargo run --example units_kinematics # Kinematics with typed calculus
cargo run --example units_lagrangian # Lagrangian mechanics with units
PID Controller Design
Problem
You have a DC motor modeled as a second-order transfer function. You need to:
- Design a PID controller with symbolic gains
- Derive the closed-loop characteristic polynomial
- Verify stability using the Routh-Hurwitz criterion
- Choose specific gains and confirm all closed-loop poles are in the left half-plane
- Generate optimized Rust code for the controller update function
Background
A PID controller computes the control signal as:
u(t) = Kp·e(t) + Ki·∫e(t)dt + Kd·de/dt
where e(t) is the tracking error. In the Laplace domain, the controller transfer function is:
C(s) = Kp + Ki/s + Kd·s = (Kd·s² + Kp·s + Ki) / s
The closed-loop system is stable when all roots of the characteristic polynomial have negative real parts. For a third-order polynomial s³ + a₂s² + a₁s + a₀, the Routh-Hurwitz conditions are:
a₂ > 0a₀ > 0a₂·a₁ > a₀
Solution
The complete solution is in examples/pid_controller.rs. Run it with:
cargo run --example pid_controller
Setup
The plant is a normalized DC motor model with moment of inertia J=1, damping b=10, and torque constant K=20:
#![allow(unused)]
fn main() {
use symplex::prelude::*;
let ctx = Context::new();
symplex::syms!(ctx; s, Kp, Ki, Kd);
// Plant: G(s) = 20 / (s² + 10s)
// Closed-loop characteristic polynomial with PID:
let char_poly = expr!(ctx,
s^3 + (10 + 20*Kd)*s^2 + 20*Kp*s + 20*Ki
);
}
Symbolic Stability Analysis
The Routh-Hurwitz conditions are derived directly from the coefficients:
#![allow(unused)]
fn main() {
let a2 = expr!(ctx, 10 + 20*Kd);
let a1 = expr!(ctx, 20*Kp);
let a0 = expr!(ctx, 20*Ki);
// Stability requires: a2 > 0, a0 > 0, a2·a1 > a0
let routh_product = (&a2 * &a1).expand();
// → 400*Kd*Kp + 200*Kp
}
Gain Selection and Verification
Substituting Kp=5, Ki=2, Kd=0.5 gives P(s) = s³ + 20s² + 100s + 40. symplex finds the three closed-loop poles numerically and confirms all have negative real parts:
Closed-loop poles:
p1 = -0.4374 ✓
p2 = -11.8382 ✓
p3 = -7.7244 ✓
Stability: STABLE — all poles in left half-plane
The Routh conditions are also verified: a₂·a₁ = 2000 > a₀ = 40.
Code Generation
The PID update equation with the chosen gains is compiled to an optimized Rust function:
#![allow(unused)]
fn main() {
symplex::syms!(ctx; error, integral, derivative);
let pid_output = &ctx.rational(5, 1) * &error
+ &ctx.rational(2, 1) * &integral
+ &ctx.rational(1, 2) * &derivative;
let code = pid_output.eval().to_rust_fn(
"pid_update", &["error", "integral", "derivative"]
).unwrap();
}
This produces:
#![allow(unused)]
fn main() {
pub fn pid_update(error: f64, integral: f64, derivative: f64) -> f64 {
5_f64.mul_add(error, 2_f64.mul_add(integral, (0.5_f64 * derivative)))
}
}
The generated function can be dropped directly into an embedded control loop. It uses mul_add for numerical stability and contains no allocations, branches, or function calls beyond basic arithmetic.
Key symplex Features Used
expr!macro for building polynomial expressions with symbolic gains.expand()for multiplying out the Routh product.subs()and.eval()for substituting concrete gain values.solve()for finding closed-loop poles (cubic equation).eval_f64()and.eval_complex64()for numerical pole evaluation.to_rust_fn()for generating deployable Rust code.compile()for creating a callable closure for verification
Polynomial Inequality Certificates
Problem
You want to prove — not numerically check — that a polynomial is non-negative on a box. Concretely, for
0 ≤ r ≤ 1/2, 0 ≤ f ≤ 1
show that
goal(r, f) = 1/4 − (r − f/2)² ≥ 0.
The inequality is true (|r − f/2| ≤ 1/2 on the box) and tight at the corners (1/2, 0) and (0, 1), so sampling cannot prove it and any floating-point slack would be suspicious.
Background: Handelman certificates
The four hypothesis polynomials
g₁ = r, g₂ = 1/2 − r, g₃ = f, g₄ = 1 − f
are non-negative on the box by definition, and so is every product of them. If we can find non-negative rationals λᵢ with
goal = Σ λᵢ · hᵢ, hᵢ ∈ { 1, gⱼ, gⱼ·gₖ, … }
then goal ≥ 0 follows immediately, and the identity can be checked by expanding both sides — a proof that fits on one line. Handelman’s theorem says such a representation always exists for a polynomial that is strictly positive on a polytope, if you allow products of high enough degree.
Finding λ is a linear feasibility problem: match coefficients monomial by monomial, and require λ ≥ 0. That is exactly what Poly::coefficient_matrix builds and linprog::feasible_nonneg solves — over ℚ, so the certificate is exact.
Solution
Build the family of products of degree ≤ 2 (fifteen polynomials), lay them out as a coefficient matrix, ask for a non-negative solution, and verify the identity two independent ways.
use num_rational::Ratio;
use num_traits::{Signed, Zero};
use symplex::linprog::{LpProblem, Q, feasible_nonneg};
use symplex::ntheory::rational_lcm_of_denominators;
use symplex::poly_ex::Poly;
use symplex::prelude::*;
fn show(v: &[Q]) -> String {
format!("({})", v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(", "))
}
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; r, f);
let gens: [&Ex; 2] = [&r, &f];
// ── Hypotheses: each is ≥ 0 on the box 0 ≤ r ≤ 1/2, 0 ≤ f ≤ 1 ─────────────
let g: Vec<(String, Ex)> = vec![
("r".into(), r.clone()),
("(1/2 - r)".into(), ctx.rational(1, 2) - &r),
("f".into(), f.clone()),
("(1 - f)".into(), ctx.int(1) - &f),
];
// Family: 1, gᵢ, gᵢ·gⱼ (i ≤ j) — every product of at most two hypotheses.
let mut family: Vec<(String, Ex)> = vec![("1".into(), ctx.int(1))];
family.extend(g.iter().cloned());
for i in 0..g.len() {
for j in i..g.len() {
family.push((format!("{}·{}", g[i].0, g[j].0), &g[i].1 * &g[j].1));
}
}
println!("{} hypothesis polynomials", family.len());
let hyps: Vec<Poly> = family.iter().map(|(_, e)| e.as_poly(&gens).unwrap()).collect();
let hyp_refs: Vec<&Poly> = hyps.iter().collect();
// ── Goal ──────────────────────────────────────────────────────────────────
let goal_ex = ctx.rational(1, 4) - (&r - &f / 2).powi(2);
let goal = goal_ex.as_poly(&gens).unwrap();
println!("goal = {}", goal.to_ex());
// ── Coefficient matrix: rows = monomials, columns = hypotheses ───────────
let mut all = hyp_refs.clone();
all.push(&goal);
let basis = Poly::monomial_basis(&all).unwrap();
println!("basis: {basis:?}");
let m = Poly::coefficient_matrix(&hyp_refs, &basis).unwrap();
println!("M is {}×{}", m.nrows(), m.ncols());
let b: Vec<Q> = basis
.iter()
.map(|mono| goal.coeff_monomial(mono).unwrap().as_rational().unwrap())
.collect();
println!("b = {}", show(&b));
// ── Solve M·λ = b, λ ≥ 0 exactly ──────────────────────────────────────
let a_rows = m.to_rational_rows().unwrap();
let lambda = feasible_nonneg(&a_rows, &b).unwrap().expect("certificate exists");
for ((name, _), l) in family.iter().zip(&lambda) {
if !l.is_zero() {
println!("λ = {l:>4} · {name}");
}
}
// ── Verify 1: Poly arithmetic ─────────────────────────────────────────────
let mut acc = Poly::zero(&ctx, &gens).unwrap();
for (h, l) in hyps.iter().zip(&lambda) {
acc = acc.add(&h.scale(&ctx.from_ratio(l.clone())).unwrap()).unwrap();
}
println!("Σ λᵢhᵢ == goal (Poly::equals): {}", acc.equals(&goal));
// ── Verify 2: ratsimp on the unexpanded certificate ──────────────────────
let cert: Ex = ctx.sum(
&family
.iter()
.zip(&lambda)
.filter(|(_, l)| !l.is_zero())
.map(|((_, e), l)| ctx.from_ratio(l.clone()) * e)
.collect::<Vec<_>>(),
);
println!("certificate = {cert}");
println!("(goal − certificate).ratsimp() = {}", (&goal_ex - &cert).ratsimp());
// ── An integer certificate ───────────────────────────────────────────────
let n = rational_lcm_of_denominators(&lambda);
let ints: Vec<String> = family
.iter()
.zip(&lambda)
.filter(|(_, l)| !l.is_zero())
.map(|((name, _), l)| format!("{}·{name}", l * Ratio::from_integer(n.clone())))
.collect();
println!("{n}·goal = {}", ints.join(" + "));
// ── Infeasible: a false inequality and its Farkas certificate ────────────
let bad_ex = ctx.rational(1, 8) - (&r - &f / 2).powi(2);
let bad = bad_ex.as_poly(&gens).unwrap();
println!("\nbad goal = {}", bad.to_ex());
println!("bad(1/2, 0) = {}", bad.eval(&[&ctx.rational(1, 2), &ctx.int(0)]).unwrap());
let b_bad: Vec<Q> = basis
.iter()
.map(|mono| bad.coeff_monomial(mono).unwrap().as_rational().unwrap())
.collect();
// Same equalities through LpProblem so that we get the certificate back.
let mut lp = LpProblem::minimize(vec![Q::zero(); hyps.len()]);
for (row, rhs) in a_rows.iter().zip(&b_bad) {
lp = lp.eq(row.clone(), rhs.clone());
}
let sol = lp.solve().unwrap();
println!("status = {:?}", sol.status);
let y = sol.farkas.clone().unwrap();
for (mono, yi) in basis.iter().zip(&y) {
println!("y[r^{} f^{}] = {yi}", mono[0], mono[1]);
}
// y defines a linear functional L(p) = Σ_m y_m · coeff_m(p) on polynomials.
let functional = |p: &Poly| -> Q {
basis
.iter()
.zip(&y)
.map(|(mono, yi)| p.coeff_monomial(mono).unwrap().as_rational().unwrap() * yi)
.sum()
};
let mins: Vec<Q> = hyps.iter().map(|h| functional(h)).collect();
println!("min over hypotheses of L(hᵢ) = {}", mins.iter().min().unwrap());
println!("L(bad goal) = {}", functional(&bad));
assert!(mins.iter().all(|v| !v.is_negative()));
// ── Infeasible but true: an interior zero ────────────────────────────────
let touch_ex = (&r - ctx.rational(1, 4)).powi(2);
let touch = touch_ex.as_poly(&gens).unwrap();
let b_touch: Vec<Q> = basis
.iter()
.map(|mono| touch.coeff_monomial(mono).unwrap().as_rational().unwrap())
.collect();
println!("\n(r − 1/4)² : {:?}", feasible_nonneg(&a_rows, &b_touch).unwrap().map(|v| show(&v)));
println!("(r − 1/4)² ≥ 0 on [0, 1/2]: {:?}",
touch_ex.poly_is_nonnegative_on(&r, &ctx.int(0), &ctx.rational(1, 2)));
}
Output
15 hypothesis polynomials
goal = -1/4*f^2 + f*r - r^2 + 1/4
basis: [[2, 0], [1, 1], [1, 0], [0, 2], [0, 1], [0, 0]]
M is 6×15
b = (-1, 1, 0, -1/4, 0, 1/4)
λ = 1 · r·(1/2 - r)
λ = 1/2 · r·f
λ = 1/2 · (1/2 - r)·(1 - f)
λ = 1/4 · f·(1 - f)
Σ λᵢhᵢ == goal (Poly::equals): true
certificate = 1/2*f*r + 1/4*f*(-f + 1) + r*(-r + 1/2) + 1/2*(-r + 1/2)*(-f + 1)
(goal − certificate).ratsimp() = 0
4·goal = 4·r·(1/2 - r) + 2·r·f + 2·(1/2 - r)·(1 - f) + 1·f·(1 - f)
bad goal = -1/4*f^2 + f*r - r^2 + 1/8
bad(1/2, 0) = -1/8
status = Infeasible
y[r^2 f^0] = 1
y[r^1 f^1] = 0
y[r^1 f^0] = 2
y[r^0 f^2] = 1
y[r^0 f^1] = 1
y[r^0 f^0] = 5
min over hypotheses of L(hᵢ) = 0
L(bad goal) = -5/8
(r − 1/4)² : None
(r − 1/4)² ≥ 0 on [0, 1/2]: Some(true)
Reading the result
The certificate. The solver found
1/4 − (r − f/2)² = r(1/2 − r) + ½·r·f + ½·(1/2 − r)(1 − f) + ¼·f(1 − f)
Every term on the right is a product of factors that are non-negative on the box, multiplied by a non-negative rational, so the left side is non-negative on the box. That is the whole proof. Multiplying through by the lcm of the denominators (rational_lcm_of_denominators) gives the integer form 4·goal = 4·r(1/2 − r) + 2·rf + 2·(1/2 − r)(1 − f) + f(1 − f), which a reader can expand by hand. (For this goal the multipliers are in fact unique — the LP polytope is a single point — but in general feasible_nonneg returns some vertex of the feasible set, and any one of them is a valid certificate.)
Two verifications. Poly::equals compares the normalised coefficient lists of Σ λᵢhᵢ and goal — a structural check that uses no simplification. (goal − certificate).ratsimp() starts from the unexpanded certificate (products of linear factors) and reduces the difference to rational normal form; 0 is the only normal form of the zero function. Agreeing by two different routes is cheap insurance against a bug in either.
The Farkas certificate. Lowering the constant to 1/8 makes the inequality false (bad(1/2, 0) = −1/8), and the LP is Infeasible. Instead of a witness λ, LpProblem returns a vector y with one entry per monomial row. Because all our constraints are equalities with λ ≥ 0, the module’s certificate condition reduces to Mᵀy ≥ 0 and yᵀb < 0. Read y as a linear functional on polynomials, L(p) = Σₘ yₘ·coeffₘ(p): the first condition says L(hᵢ) ≥ 0 for every hypothesis product (the smallest value is 0), and the second says L(bad) = −5/8 < 0. Any non-negative combination of the hᵢ would have L ≥ 0, so bad is not one — a proof of non-existence that is just as checkable as the certificate itself. (A point evaluation p ↦ p(r₀, f₀) is one such functional; the LP’s y is generally not a point, which is why the argument works even when the inequality is true but the family is too small.)
Infeasible does not mean false. (r − 1/4)² is non-negative on [0, 1/2] — poly_is_nonnegative_on confirms it exactly via a Sturm sequence — yet no Handelman certificate exists at any degree, because the polynomial has a zero in the interior of the box and products of the gⱼ vanish only on its boundary. Handelman’s theorem needs strict positivity. When a search comes back None for an inequality you believe, the options are: raise the degree of the products (for strictly positive goals this eventually works, at the price of O(dⁿ) columns), add squares such as (r − 1/4)² to the family (moving toward a Positivstellensatz / sum-of-squares certificate), or shrink the box away from the zero.
Key symplex features used
Ex::as_poly/Poly— the polynomial view with exact coefficients (Polynomials as Data)Poly::monomial_basis,Poly::coefficient_matrix,Poly::coeff_monomial— from polynomials to a linear systemMatrix::to_rational_rows—Matrix→Vec<Vec<Ratio<BigInt>>>for the LPlinprog::feasible_nonneg,LpProblem::eq+LpSolution::farkas— exact feasibility and certificates (Exact Linear Programming)Poly::scale,Poly::add,Poly::equalsandEx::ratsimp— two independent verificationsntheory::rational_lcm_of_denominators— the integer form of the certificateEx::poly_is_nonnegative_on— exact sign of a univariate polynomial on an interval
The one-call version, and a proof Lean can check
Everything above is what symplex::certificates::prove_nonnegative_on_box does for you: it enumerates the products of the box inequalities up to a degree, solves the exact LP (preferring few, low-degree products), re-verifies the identity with exact polynomial arithmetic, and — when the claim is false — returns an exact counterexample instead. Certificate::to_lean then writes the result as a Mathlib theorem whose proof is nlinarith over precisely the products of the certificate, so Lean only has to check linear arithmetic.
use symplex::certificates::{prove_nonnegative_on_box, BoxBound, BoxOutcome};
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let (r, f) = (ctx.symbol("r"), ctx.symbol("f"));
let goal = ctx.rational(1, 4) - (&r - &f / 2).powi(2);
let bounds = [
BoxBound { var: r.clone(), lo: ctx.int(0), hi: ctx.rational(1, 2) },
BoxBound { var: f.clone(), lo: ctx.int(0), hi: ctx.int(1) },
];
match prove_nonnegative_on_box(&goal, &bounds, 2).unwrap() {
BoxOutcome::Proved(cert) => {
println!("{cert}");
// -1/4*f^2 + f*r - r^2 + 1/4 = 1/2*f*r + 1/4*f*(-f + 1) + r*(-r + 1/2) + 1/2*(-r + 1/2)*(-f + 1), 0 ≤ r ≤ 1/2, 0 ≤ f ≤ 1
assert!(cert.verify());
print!("{}", cert.to_lean("quarter_bound").unwrap());
}
BoxOutcome::Refuted { point, value, .. } => println!("false: goal = {value} at {point:?}"),
BoxOutcome::Unknown(u) => println!("no certificate of degree {}", u.degree),
}
}
The emitted theorem:
theorem quarter_bound (r f : ℝ) (h_r_lo : (0 : ℝ) ≤ r) (h_r_hi : r ≤ (1 / 2 : ℝ))
(h_f_lo : (0 : ℝ) ≤ f) (h_f_hi : f ≤ (1 : ℝ)) :
0 ≤ -(f ^ 2 / 4) + f * r - r ^ 2 + (1 / 4 : ℝ) := by
nlinarith [mul_nonneg (sub_nonneg.mpr h_r_hi) (sub_nonneg.mpr h_f_hi),
mul_nonneg (sub_nonneg.mpr h_f_lo) (sub_nonneg.mpr h_f_hi),
mul_nonneg (sub_nonneg.mpr h_r_lo) (sub_nonneg.mpr h_r_hi),
mul_nonneg (sub_nonneg.mpr h_r_lo) (sub_nonneg.mpr h_f_lo)]
This compiles against Mathlib (Lean 4.30.0) without errors or warnings — including with linter.style.longLine on, since every emitter wraps at 100 columns (lean::wrap_lean); cargo run --example certificates_to_lean out.lean writes a file with several such theorems that you can check with lake env lean out.lean inside any Mathlib project. The (r − ¼)²-style case from the previous section comes back as BoxOutcome::Unknown at every degree — exactly the interior-zero limitation of Handelman’s theorem — and a false claim such as xy − ½ ≥ 0 on the unit square is Refuted { point: [(x, 0), (y, 0)], value: -1/2, .. }.
Half-lines, interior double zeros, and the whole real line
Handelman needs a compact box and a goal that stays strictly positive inside it. Two extensions cover the cases that come up in practice:
- Half-lines
x ≥ a(orx ≤ a): withk = x − a, if every coefficient ofp(a + k)is non-negative that is already a proof, and when it is not, a Pólya multiplier(1 + k)^Nmakes it so (guaranteed for a strictly positive goal).prove_nonnegative_on_halflinedoes both, refutes false claims with an exact point, andprove_nonnegative_on_realsglues two half-lines into a proof for all of ℝ. - Even-multiplicity zeros inside the domain:
goal = g²·his split off by exact factoring andhgets the certificate; in Lean every hint becomesmul_nonneg (sq_nonneg g) (…). This works for boxes and half-lines alike.
use symplex::certificates::{prove_nonnegative_on_halfline, HalfLineOutcome, Ray};
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let j = ctx.symbol("j");
// (j − 5)²·(j² + 1) ≥ 0 for j ≥ 3: the double zero at 5 is inside the half-line.
let goal = (&j - 5).powi(2) * (&j.powi(2) + 1);
match prove_nonnegative_on_halfline(&goal, &j, &ctx.int(3), Ray::AtLeast, 12).unwrap() {
HalfLineOutcome::Proved(cert) => {
println!("{cert}");
// j^4 - 10*j^3 + 26*j^2 - 10*j + 25 = (j - 5)^2*(6*j + (j - 3)^2 - 8), j ≥ 3
println!("Pólya exponent {}, square {}", cert.polya_power(), cert.square().unwrap());
// Pólya exponent 0, square Poly(j - 5, j)
print!("{}", cert.to_lean("square_inside").unwrap());
}
HalfLineOutcome::Refuted { point, value, .. } => println!("false at {}: {value}", point[0].1),
HalfLineOutcome::Unknown(u) => println!("no certificate up to N = {}", u.max_polya_power),
}
}
theorem square_inside (j : ℝ) (h_j_lo : (3 : ℝ) ≤ j) :
0 ≤ j ^ 4 - 10 * j ^ 3 + 26 * j ^ 2 - 10 * j + 25 := by
have hk : 0 ≤ j - (3 : ℝ) := sub_nonneg.mpr h_j_lo
nlinarith [sq_nonneg (j - 5), mul_nonneg (sq_nonneg (j - 5)) (hk),
mul_nonneg (sq_nonneg (j - 5)) (pow_nonneg hk 2)]
When the shift alone is not enough — j² − j + 1 on j ≥ 0 has a negative coefficient — the certificate carries the multiplier: (j + 1)·(j² − j + 1) = j³ + 1, and the Lean proof shows 0 ≤ (1 + (j - 0)) ^ 1 * (j ^ 2 - j + 1) with nlinarith [pow_nonneg hk 3] and divides by the positive factor with nonneg_of_mul_nonneg_right. A tight minimum costs a larger exponent (4j² − 6j + 3 needs N = 12), which is Pólya’s theorem being honest about how close to zero the goal gets.
Parametric polyhedra: a multiplier on the goal
A decision procedure over a polytope whose facets move with a real parameter j ≥ j₀ has to show, cell by cell, that a goal holds on the cell for every j — or that the cell is empty for every j. The certificate is again an exact identity with non-negative weights, but with one twist: the Farkas multipliers of j-dependent facets are rational functions of j, so a polynomial identity only exists after the goal is multiplied by a polynomial λ(j):
λ(j)·g = Σ μ · jᵃ (j − j₀)ᵇ · hₖ + Σ μ · jᵃ (j − j₀)ᵇ + μ₀ (+ Σ μ · hₖ hₗ), λ(j) = 1 + Σ νₐ jᵃ, μ, ν ≥ 0.
prove_nonnegative_on_polyhedron (0.4) runs the search staged — degree-1 multipliers with λ = 1 first, then higher degrees, pairwise products last — and returns the first certificate, re-verified by polynomial arithmetic. prove_polyhedron_empty is the same call with the goal −1.
use symplex::prelude::*;
use symplex::certificates::{ParamBound, PolyhedronOpts, PolyhedronOutcome, prove_nonnegative_on_polyhedron};
fn main() {
let ctx = Context::new();
let (j, r, t) = (ctx.symbol("j"), ctx.symbol("r"), ctx.symbol("t"));
// On { t ≥ r, t + j·r ≥ j + 1 } the goal t − 1 ≥ 0 holds for every j ≥ 0,
// but its multipliers are 1/(1 + j) and j/(1 + j): λ(j) = 1 + j is needed.
let hyps = [&t - &r, &t + &j * &r - &j - 1];
let param = ParamBound { var: j.clone(), lower: ctx.int(0) };
match prove_nonnegative_on_polyhedron(&(&t - 1), &hyps, Some(¶m), &PolyhedronOpts::default()).unwrap() {
PolyhedronOutcome::Proved(c) => {
println!("{c}");
// (j + 1)*(t - 1) = j*h0 + h1; h0 = -r + t, h1 = j*r - j + t - 1; j ≥ 0
print!("{}", c.to_lean("needs_lambda").unwrap());
}
PolyhedronOutcome::Refuted { point, value, .. } => println!("false: {value} at {point:?}"),
PolyhedronOutcome::Unknown(u) => println!("no certificate: {u}"),
}
}
theorem needs_lambda (r t j : ℝ) (hj : (0 : ℝ) ≤ j) (h0 : 0 ≤ -r + t) (h1 : 0 ≤ j * r - j + t - 1) :
0 ≤ t - 1 := by
have hJ0 : (0 : ℝ) ≤ j := by linarith
have h0J := mul_nonneg hJ0 h0
have hg : (0 : ℝ) ≤ (j + 1) * (t - 1) := by
linarith only [h0J, h1]
have hg' := nonneg_of_mul_nonneg_right hg (by linarith only [hJ0])
linarith only [hg']
The proof shape is the one a person writes: one have … := mul_nonneg … per product the certificate uses (h0J is j·h₀; h0K would be (j − j₀)·h₀, h0xh1 a pairwise product, pJJ the pure power j²), then linarith only […] over exactly those facts; with λ ≠ 1, 0 ≤ λ·g is shown first and divided out with nonneg_of_mul_nonneg_right. An emptiness certificate concludes False. When the theorem statement is not yours to write — the goal lives inside a larger lemma — cert.lean_steps(&PolyhedronLeanNames { hyps: &["e0", "e1"], param_nonneg: "hJ0", shift_nonneg: "hK0" }, &opts) gives the same have lines, hint names and closing block with your hypothesis names, and to_block(" ") indents and re-flows them to Mathlib’s width. If the proof’s parameter is a cast natural, LeanOpts::default().with_symbol_text("j", "(j : ℝ)") renders it that way everywhere (0.5). To certify many goals against the same hypotheses, build a PolyhedronProver once and call .prove(&goal) per facet — or .prove_poly(&poly) when the goal is already an exact polynomial (a MultiPoly through Poly::from_multipoly, in any generator order), which skips the expression round trip (0.6.1). A goal polynomial may carry generators that occur in none of its terms — a tool’s ring has the parameter J on every row whether or not the row mentions it — and prove_poly ignores those even when the prover does not know them; only a generator that actually occurs and is not a variable of the hypotheses is an InvalidArgument (0.9.1). cert.used_hyps() names the hypotheses the identity really uses, so a generated lemma’s signature can list exactly those instead of scanning the emitted text for names.
Budgets (0.9.1). A deep cell with a couple of dozen j-dependent hypotheses can make the degree-3 pairwise stage run for minutes, and nothing in a tree builder should be able to do that unbounded. PolyhedronOpts::default().with_time_limit(Duration::from_secs(5)) gives every prove/prove_poly/prove_empty call five seconds from the moment it starts (so one PolyhedronProver built once gets a fresh allowance per goal; with_deadline(Instant) is the absolute form, and the earlier of the two applies), and with_max_pivots(n) caps the total simplex pivots across all of a call’s LPs — the stage LPs and the refutation’s sample LPs share one meter. When the budget runs out the answer is Unknown with u.budget_exhausted == Some(BudgetHit::Deadline | BudgetHit::MaxPivots) and a Display ending budget exhausted: deadline; a budget the search fits inside never changes a Proved or Refuted answer, and the certificate is byte-identical to the unbudgeted one. Underneath, LpProblem::with_budget(Budget::within(d).with_max_pivots(n)) is the same mechanism on a single exact LP: the simplex checks the budget at every pivot (the count is shared by the i64 → i128 → 256-bit → BigInt attempts, so an overflowed attempt does not get its pivots back) and reports LpStatus::BudgetExhausted — an answer with empty x/objective, not an error. Without a budget every pivot sequence is exactly what it was.
Assembling a whole proof: lean::Block
A generator that stitches many certificates into one lemma — a refine frame_lemma … ?_ ?_ followed by one bullet per facet, inside rcases case splits — should not concatenate strings with hand-counted spaces: Lean’s tactic blocks are column-sensitive (the tactics of a by block must sit strictly right of the tactic that opened it, and a · bullet moves that column by two), and a mis-indented line silently changes which block a tactic belongs to. symplex::lean::{Block, Tactic, Proof, Decl} (0.8) is a small structured model of exactly this: Tactic::have(name, Some(ty), Proof::by(block)), Tactic::bullet(block), Tactic::raw("linarith only […]"), and Block::render(indent) places every line from its tactic column and wraps past it. steps.block() gives a certificate’s closing steps as such a block, so a leaf is
use symplex::lean::{Block, Tactic};
let mut leaf = Block::new(vec![Tactic::raw(format!("refine {call}\n {}", vec!["?_"; facets.len()].join(" ")))]);
for steps in &facet_steps {
leaf.push(Tactic::bullet(steps.block()));
}
lemma_body.push_str(&leaf.render(" "));
and the dispatcher’s rcases le_or_gt (0 : ℝ) (g) with h | h with its two bullets is Tactic::raw(…) followed by two Tactic::bullet(…), each bullet starting with Tactic::have("e6", Some("(0 : ℝ) ≤ …"), Proof::term("h6")). Decl::new(DeclKind::Lemma, name, statement, body).with_binders(…).with_doc(…) renders the header in Mathlib’s style (binders packed, : at the end of the binder lines, the statement on its own line, := by). lean::lean_ident quotes a name with «…» when it is not a plain identifier. The renderer’s output for the generator’s leaf shape is pinned to text that compiled against Mathlib.
Two refinements for generated files (0.9.1). The refine … ?_ ?_ … line above is better written as Tactic::apply("refine leafG346_single_poly", args) with the arguments — (20 * (j : ℝ) + 10), ρ, hx, twenty-eight ?_ — as a Vec<String>: the renderer packs them greedily onto the head’s line and onto continuation lines two columns past the tactic column, and because each argument is an atom it never breaks inside a parenthesised term the way a generic re-flow of a long string might; the result is a fixed point of wrap_lean, so a file that mixes both stays stable. And Decl has a preamble: Vec<String> (.with_preamble(vec!["set_option maxHeartbeats 400000 in".into(), "-- generated".into()])): lines emitted verbatim, unwrapped, before the doc comment — the place for set_option … in, open … in and a provenance comment. Adding the field means a Decl { … } struct literal from 0.8 now needs preamble: vec![]; Decl::new and the builders avoid the question.
Every shape the emitter produces (λ = 1, λ of degree 1 and 2, j₀ > 0, j₀ = 0, j₀ < 0, mixed J/K chains, pairwise products, emptiness with and without λ, pure parameter powers, no parameter at all) was compiled against Mathlib with the long-line linter on, and the emitted text is pinned to that compiled file in the test suite.
PolyhedronOutcome::Refuted carries an exact point of the set where the goal is negative (for the emptiness question: a point in the set), found by sampling j and minimising the goal over the cell with the exact LP when everything is affine in the free variables. The staged search costs a few milliseconds per facet for a cell with a dozen j-dependent hypotheses in a release build, which is what makes it usable inside a tree builder that asks thousands of times.
Where a leaf’s time goes. Certifying the ~50 facets of one leaf against one prover was profiled on the floor generator: assembling each LP from the stage columns is negligible (under 3 % of the certificate time), and the simplex itself was dominated not by pivot count but by arithmetic width — the fraction-free tableau’s entries are minors of the scaled system, so a 16-row certificate LP peaks around 70–120 bits and a 20-row one around 130–190, which sent almost every LP to i128 and the heaviest few percent to BigInt, where they cost ten times as much per pivot as all the others together. Three changes, each leaving every pivot sequence exactly as it was: the exact divisions of the fraction-free update are now a multiplication by the inverse of the pivot’s odd part modulo the word size, checked by one multiplication (Jebelean), instead of a long division; a 256-bit fixed-width cell type sits between i128 and BigInt, so those LPs never touch the heap; and a PolyhedronProver builds each stage’s product basis on the first goal that reaches it (most goals settle in the first stage, so the pairwise basis with its ½·m² products is usually never built). The certificate pipeline of a class run is 2.3× faster and the emitted Lean is byte-identical. A warm-started simplex (dual simplex from the previous facet’s basis) was measured and set aside: the LPs it could serve — those without λ columns, which change with the goal — are the cheap ones, and it would land on a different, equally optimal vertex when the optimum is degenerate (it always is here), changing certificates for a few percent of the remaining time. Since a PolyhedronProver now shares nothing mutable beyond those once-built bases, one prover may be used from several threads at once (&prover in a std::thread::scope); the certificates are the same as a sequential run’s.
See cargo run --example polyhedron_certificates and, for the exact geometry of the cells themselves (vertices, volume, cuts), symplex::polytope.
Sums of squares: interior zeros without a square factor
Everything above multiplies non-negative hypotheses. A polynomial that is non-negative on all of ℝⁿ with no hypotheses at all — (x − 1)² + (y − 1)², or x⁴ + y⁴ + z⁴ + 1 − 4xyz — needs a different certificate: a sum of squares g = Σ dₖ·pₖ². prove_sos (0.6) finds one exactly:
use symplex::prelude::*;
use symplex::certificates::{prove_sos, SosOpts, SosOutcome};
fn main() {
let ctx = Context::new();
let (x, y, z) = (ctx.symbol("x"), ctx.symbol("y"), ctx.symbol("z"));
let amgm = x.powi(4) + y.powi(4) + z.powi(4) - &x * &y * &z * 4 + 1;
match prove_sos(&amgm, &[x.clone(), y.clone(), z.clone()], &SosOpts::default()).unwrap() {
SosOutcome::Proved(c) => {
println!("{c}");
// x^4 + y^4 + z^4 - 4*x*y*z + 1 = (-1/3*x^2 - 1/3*y^2 - 1/3*z^2 + 1)^2 + 2/3*(-y*z + x)^2
// + 2/3*(-x*z + y)^2 + 2/3*(-x*y + z)^2 + 2/3*(-x^2 + y^2)^2 + 8/9*(-1/2*x^2 - 1/2*y^2 + z^2)^2
print!("{}", c.to_lean("amgm3").unwrap());
}
SosOutcome::Refuted { point, value, .. } => println!("negative: {value} at {point:?}"),
SosOutcome::Unknown(u) => println!("no decomposition found: {u}"),
}
}
theorem amgm3 (x y z : ℝ) : 0 ≤ x ^ 4 + y ^ 4 + z ^ 4 - 4 * x * y * z + 1 := by
have h : x ^ 4 + y ^ 4 + z ^ 4 - 4 * x * y * z + 1 = (-(x ^ 2 / 3) - y ^ 2 / 3 - z ^ 2 / 3 + 1) ^
2 + (2 / 3 : ℝ) * (-(x * y) + z) ^ 2 + (2 / 3 : ℝ) * (-(x * z) + y) ^ 2 + (2 / 3 : ℝ) *
(-(y * z) + x) ^ 2 + (8 / 9 : ℝ) * (-(x ^ 2 / 2) - y ^ 2 / 2 + z ^ 2) ^ 2 + (2 / 3 : ℝ) *
(-x ^ 2 + y ^ 2) ^ 2 := by ring
rw [h]
positivity
The search is the Peyrl–Parrilo pipeline made exact: write g = mᵀ Q m over the monomials of half the degree, solve the semidefinite program for Q numerically (a small dense interior-point method is built in — no external solver), round the solution to rationals, project it back onto the coefficient constraints exactly, and test positive semidefiniteness with the rational L·D·Lᵀ of QMatrix::ldl_psd — whose factorisation is the decomposition. The Lean proof is two deterministic steps: ring checks the identity, positivity closes a sum of non-negative terms.
A goal with real zeros — every certificate the tree builder cares about touches zero somewhere — has only singular Gram matrices, which rounding cannot hit. prove_sos then does facial reduction: it reads the kernel off the numerical solution, makes it exact (directly when the kernel is rational, otherwise through its integer relations, found by LLL after Newton-refining the zeros of the goal to double precision), restricts the search to that face and solves again. Sums of two random squares whose common zeros are irrational algebraic points come back as exactly those two squares.
What it cannot do, it says so: Refuted carries an exact point where the goal is negative; Motzkin’s polynomial x⁴y² + x²y⁴ − 3x²y² + 1 (non-negative but not a sum of squares) is Unknown, never Proved. Certificates round-trip through JSON with re-verification like the others, and lean_hints gives the sq_nonneg (pₖ) terms for an nlinarith skeleton of your own.
The SDP has a budget too (0.9.1): SosOpts::default().with_time_limit(Duration::from_secs(10)) (or with_deadline(Instant)) is checked between interior-point iterations and between facial-reduction rounds, and when it passes the answer is Unknown with a reason starting budget exhausted: deadline that names the round it was in. The cheap answers that come before the SDP — an exact counterexample, a constant, odd degree — are unaffected, and a roomy limit gives the same certificate as none.
Gradient Descent
Run the complete example:
cargo run --example gradient_descent
Full walkthrough for this cookbook entry is under development. The example file contains extensive comments explaining each step.
See examples/gradient_descent.rs for the complete source.
RSA Encryption
Run the complete example:
cargo run --example crypto_rsa
Full walkthrough for this cookbook entry is under development. The example file contains extensive comments explaining each step.
See examples/crypto_rsa.rs for the complete source.
Digital Filter Design
Run the complete example:
cargo run --example signal_filter
Full walkthrough for this cookbook entry is under development. The example file contains extensive comments explaining each step.
See examples/signal_filter.rs for the complete source.
Robot Arm Kinematics
Run the complete example:
cargo run --example robotics_codegen
Full walkthrough for this cookbook entry is under development. The example file contains extensive comments explaining each step.
See examples/robotics_codegen.rs for the complete source.
Lagrangian Mechanics
Run the complete example:
cargo run --example dynamics
Full walkthrough for this cookbook entry is under development. The example file contains extensive comments explaining each step.
See examples/dynamics.rs for the complete source.
API Patterns
Every public operation in symplex follows one of a small number of patterns. Once you know which pattern a method uses, you know its return type and what a “failure” looks like.
Pattern 1 — Always returns Ex
Operations for which “unchanged” or “unevaluated” is a legitimate answer never fail:
expr.simplify() expr.expand() expr.eval()
expr.factor(&x) expr.subs(&x, &v) expr.rewrite(&rules)
expr.diff(&x) // Derivative(f, x) if it cannot differentiate
expr.integrate(&x) // Integral(f, x) if no closed form
expr.integrate_definite(&x, &a, &b) // Integral node if undecided
expr.limit(&x, &a) // Limit(f, x, a)
expr.summation(&k, &a, &b) // Sum node
expr.laplace(&t, &s) // LaplaceTransform node
expr.solve_ode(&y, &x) // DSolve node
Check with has_unevaluated(). Note that RootOf and RootSum are exact algebraic answers and are not counted.
Pattern 2 — try_ twin returns Result<Ex>
Every Pattern-1 method that can produce an unevaluated form has a try_ twin that returns Err instead. The twin calls the base method and checks has_unevaluated(), so there is no behavioural drift between the two.
let anti = expr.try_integrate(&x)?; // Err(ComputationFailed) if unevaluated
let val = expr.try_integrate_definite(&x, &a, &b)?; // Err(Divergent) if proven divergent
let lim = expr.try_limit_right(&x, &a)?;
let sum = expr.try_summation(&k, &lo, &hi)?;
Available twins: try_diff, try_integrate, try_integrate_definite, try_limit, try_limit_left, try_limit_right, try_limit_dir, try_series, try_maclaurin, try_series_at_infinity, try_summation, try_product_over, try_laplace, try_inverse_laplace, try_residue, try_gosper_sum, try_solve_ode, try_solve_gt/ge/lt/le.
Pattern 3 — Numeric boundary → Result
Crossing from symbols to numbers can fail (free symbols, unsupported node, precision exhausted, non-convergence):
expr.eval_f64() // Result<f64>
expr.eval_complex64() // Result<Complex64> (num_complex; in the prelude)
expr.eval_decimal(50) // Result<String>
expr.compile(&["x"]) // Result<CompiledFn>
Ex::compile_many(&[&a, &b], &["x"]) // Result<CompiledFnVec>
expr.to_rust_fn("f", &["x"]) // Result<String>
expr.to_c_fn("f", &["x"]) // Result<String>
expr.integrate_numeric(&x, &a, &b) // Result<f64>
expr.nroots(&x, 12) // Result<Vec<Complex64>>
expr.textplot(&x, a, b) // Result<String> (all plotting methods)
Pattern 4 — Queries → Option
Three-valued questions return Option<bool> (yes / no / cannot decide) and structural queries return Option<T>:
expr.is_positive() expr.is_real() expr.is_integer() expr.equals(&other)
expr.is_convergent(&k) expr.is_absolutely_convergent(&k) expr.is_real_valued()
set.contains(&e) set.is_subset(&t) set.is_disjoint(&t) set.is_empty() set.is_open()
matrix.is_symmetric() matrix.is_orthogonal() matrix.is_positive_definite() matrix.is_diagonalizable()
bool_ex.is_tautology() bool_ex.satisfiable()
vector::is_conservative(&f, &vars)
expr.degree(&x) expr.coeff(&x, 2) expr.resultant(&g, &x) expr.discriminant(&x)
expr.hypergeometric_ratio(&k) expr.as_i64() expr.as_rational() set.inf() set.measure()
None is a real answer — do not unwrap() it. A symbolic entry usually means the question cannot be decided without assumptions.
Pattern 5 — Structural preconditions → Result
Operations whose input must have a particular shape:
matrix.det() matrix.inv() matrix.matmul(&other)
matrix.cholesky() matrix.lu() matrix.minor(i, j)
matrix.eigenvals() matrix.jordan_form() matrix.qr()
Matrix::new(rows) Matrix::from_i64(&ctx, rows)
Rule::try_new(...) bool_ex.truth_table(&atoms)
Pattern 6 — Mathematical outcomes as Err or enum variants
Solvers distinguish “no method” from “the answer is: none” or “the answer is: all”:
| Call | Outcome | Representation |
|---|---|---|
solve | identity | Err(SymplexError::InfiniteSolutions { .. }) |
solve | contradiction / range violation | Err(SymplexError::NoSolution { .. }) |
solve_system_ex | positive-dimensional | Err(InfiniteSolutions) |
linsolve | contradictory system | Ok(LinearSolution::Inconsistent) |
linsolve | under-determined | Ok(LinearSolution::Parametric { .. }) |
try_integrate_definite | divergent | Err(SymplexError::Divergent { .. }) |
laplace_final_value | unstable pole | Err(Divergent) |
fourier_transform, mellin_transform, z_transform | not in table / missing sign assumption | Err(ComputationFailed) (no unevaluated node exists for these) |
Ownership and references
Ex is Clone (cheap: an Arc bump and a u32) but not Copy. Operators are implemented on references and values (&x + &y, &x * 2, x.clone() / 3, 2 * &x, x += 1), and scalars of type i32, i64, u32, u64, i128, f64, BigInt, Ratio<BigInt> are accepted through the Scalar/ToEx traits. Methods take &Ex arguments. Collections use Context::sum(iter) / Context::product(iter) or Option<Ex> — iter.sum::<Ex>() panics on an empty iterator because there is no context to build 0 in.
Contexts
Everything belongs to a Context. Mixing expressions from different contexts panics with a clear message (the only panic in the symbolic layer, treated as a logic error like indexing out of bounds). Context is Clone; clones share the arena. Context::compact(&roots) garbage-collects into a fresh context.
Naming conventions
| Suffix / prefix | Meaning | Example |
|---|---|---|
try_ | Result twin of a Pattern-1 method | try_integrate |
_with | same operation with an options struct | simplify_with(&SimplifyOpts), rewrite_with(&rules, &RewriteOpts), integrate_numeric_with(…, &QuadOpts) |
_traced | also returns Vec<Step> | simplify_traced, rewrite_traced |
_or_empty | swallow the error into an empty Vec | solve_or_empty |
_general | complete solution family | solve_general |
_ivp | with initial conditions | solve_ode_ivp |
_all | all variables (multivariate) | factor_all, sqrt_mod_all |
is_* | three-valued query | is_positive, is_symmetric |
as_* | cheap structural view | as_rational, as_numer_denom, as_intervals |
from_* | constructor on Context/types | from_f64, from_ratio, from_coefficients |
Error Handling
symplex has one error type, SymplexError (in the prelude; #[non_exhaustive], implements std::error::Error via thiserror). The symbolic layer never panics except for the cross-context guard; every other failure is either an unevaluated node (see API Patterns) or one of the variants below.
Variants
| Variant | Fields | Raised by |
|---|---|---|
FreeSymbol { name } | the unbound symbol | eval_f64, compile, to_rust_fn, to_c_fn, integrate_numeric, eval_f64_with when a symbol is not supplied |
Unevaluable { reason } | numeric evaluation of a node with no finite value (oo, zoo, a set, …), a non-real integration bound | |
PrecisionExhausted { requested, achieved } | eval_decimal when the working precision cannot deliver the requested digits | |
NotImplemented(String) | names the node | compile/codegen on a node without numerical meaning (unevaluated Integral, Apply, Bessel with symbolic order, …) |
ComputationFailed { operation, reason } | which operation, why | every try_* method when the result is unevaluated; fourier_transform/mellin_transform/z_transform when no rule applies; solve when no method applies; solve_ode_ivp when constants cannot be fitted; integrate_numeric when quadrature does not converge |
Divergent { operation, reason } | try_integrate_definite when the integral is proven divergent; laplace_final_value for a pole in the closed right half-plane | |
NoSolution { operation, reason } | solve / solve_general on a contradiction or range violation (sin x = 2); solve_ode_ivp with contradictory initial conditions | |
InfiniteSolutions { operation, reason } | solve on an identity; polysys::solve_system_ex on a positive-dimensional system | |
InvalidArgument { operation, reason } | malformed input: a non-symbol variable, duplicate parameter names, wrong shapes (cholesky on a non-symmetric matrix, norm_p on a non-vector), linsolve with a non-linear equation, Rule::try_new with an unbound wildcard, CompiledFn::try_call with the wrong arity | |
ContradictoryAssumptions { symbol, a, b } | declaring a symbol both Positive and Negative, etc. |
Because the enum is #[non_exhaustive], always include a wildcard arm when matching.
Matching on outcomes
use symplex::prelude::*;
fn main() {
let ctx = Context::new();
let x = ctx.symbol("x");
for eq in [&x.powi(2) - 4, &x - &x, &x.sin() - 2, &x.exp().ln().exp() - &x.exp()] {
match eq.solve(&x) {
Ok(roots) => println!("{eq} = 0 → {roots:?}"),
Err(SymplexError::InfiniteSolutions { reason, .. }) => println!("{eq} = 0 → identity: {reason}"),
Err(SymplexError::NoSolution { reason, .. }) => println!("{eq} = 0 → no solution: {reason}"),
Err(SymplexError::ComputationFailed { reason, .. }) => println!("{eq} = 0 → could not solve: {reason}"),
Err(e) => println!("{eq} = 0 → {e}"),
}
}
match x.powi(-2).try_integrate_definite(&x, &ctx.int(-1), &ctx.int(1)) {
Err(SymplexError::Divergent { reason, .. }) => println!("divergent: {reason}"),
Err(SymplexError::ComputationFailed { .. }) => println!("undecided"),
other => println!("{other:?}"),
}
}
Unevaluated nodes vs. errors
The base methods (integrate, limit, summation, …) return an unevaluated node and never fail; the try_ twin turns that into Err(ComputationFailed). Choose based on the caller:
- Interactive / exploratory code: use the base method and print the result; an
Integral(…)node is informative. - Pipelines and code generation: use
try_so that a missing closed form stops the pipeline instead of producing a function that callsIntegral.
Option is not an error
Three-valued queries (is_positive, equals, SetEx::contains, Matrix::is_symmetric, …) return None for “cannot decide”. That is an answer, not a failure — typically it means a symbol needs an assumption (ctx.symbol_with("a", &[Assumption::Positive])).
Panics
The symbolic layer panics in exactly two situations, both programming errors:
- Cross-context mixing — combining expressions from different
Contexts. The message names the operation. - Empty
Sum/Productiterators —iter.sum::<Ex>()on an empty iterator has no context to build0in. Usectx.sum(iter)/ctx.product(iter), or collect intoOption<Ex>(which yieldsNone).
Library code never uses unwrap/expect on user data; if you find a panic elsewhere, it is a bug — please report it with the expression that triggered it.
Configuration limits
EvalConfig { max_pow_exponent, max_result_digits, max_evalf_precision } (via Context::with_config) caps the size of intermediate results. Exceeding a cap leaves the expression unevaluated (2^5000 stays a Pow node) rather than consuming unbounded memory. Simplification, rewriting and eigenvalue computations have their own internal budgets (MAX_REWRITE_OPS, MATCH_BUDGET, EXPRESSION_BUDGET, the Gruntz work budget) that make them return the best result so far instead of hanging.
Migrating from 0.11 to 0.12
0.12 redesigns the representation of distributions in symplex::stats;
the query API on RandomVariable is unchanged. Every break has a one-line
fix.
Distribution is a struct, not an enum
Distribution::Continuous(ContinuousFamily::Normal { mean, std }) and
Distribution::Discrete(DiscreteFamily::Binomial { n, p }) are gone. A
Distribution is an opaque handle to a Family (a trait); the families
are structs.
| 0.11 | 0.12 |
|---|---|
match d { Distribution::Continuous(ContinuousFamily::Normal { mean, std }) => … } | if let Some(n) = d.downcast_ref::<Normal>() { n.mean, n.std } |
Distribution::Continuous(f) => f.entropy(&ctx) | d.family().entropy() (closed form) or d.entropy() (always an answer) |
d.mean(&ctx), d.variance(&ctx), d.raw_moment(n, &ctx) → Option<Ex> | d.family().mean(), .variance(), .raw_moment(n) → Option<Ex>; d.mean(), d.variance(), d.moment(n) → Ex (closed form or generic route) |
d.cdf(&x), d.mgf(&t), d.quantile(&p) → Option<Ex> | d.family().cdf(&x) … → Option<Ex> (closed form on the support); d.cdf(&x), d.mgf(&t) → Ex |
d.is_continuous() | unchanged (d.kind() == Kind::Continuous) |
Support is a typed region
Support::Continuous { lo: Option<Ex>, hi: Option<Ex> },
Support::Discrete { … } and Support::Finite(Vec<Ex>) are replaced by a
struct with a Kind and Pieces.
| 0.11 | 0.12 |
|---|---|
Support::Continuous { lo: Some(a), hi: Some(b) } | Support::interval(a, b); unbounded ends are ctx.neg_infinity() / ctx.infinity() |
Support::Discrete { lo: Some(a), hi: None } | Support::integers(&ctx, Some(a), None) |
Support::Finite(values) | Support::points(values) |
match support { Support::Continuous { lo, hi } => … } | let iv = support.as_interval()?; then iv.lower, iv.upper, iv.kind (an Interval<Ex>) |
matches!(s, Support::Discrete { .. }) | s.kind() == Kind::Discrete |
Distribution::finite takes the context
Distribution::try_finite(table) → Distribution::try_finite(&ctx, table)
(likewise finite): an empty table has no parameter to take a context from.
cdf is clamped to the support
RandomVariable::cdf(&x) / Distribution::cdf(&x) return the whole-line
distribution function — 0 below the support, the closed form on it, 1
above it — as SymPy’s cdf(X)(x) does (Uniform(0, 1).cdf(3) is 1, not
3; Geometric(p).cdf(k) is a Piecewise that is 0 for k < 1). A
test that pinned the unclamped formula should compare
d.family().cdf(&x) instead.
Events that used to be NotImplemented now have answers
P(X² < 1), P(X < −1 ∨ X > 1), E[X | X² > 1] go through the
inequality solver when the bounds are numeric. P(X = 3 ∧ X > 5) is 0
(it was P(X = 3)), P(X > 1 ∧ X ≥ 2) is P(X ≥ 2) (the strict bound no
longer wins), and P(X = ½) for an integer-valued variable is 0.
Migrating from 0.6 to 0.7
Every breaking change in 0.7.0, with the one-line fix. Code that only pattern-matches Proved(c) and Refuted { point, value, .. } on PolyhedronOutcome / SosOutcome, builds options with ::default() and the with_* builders, and calls the inherent certificate methods compiles unchanged.
Outcomes
The four outcome enums are aliases of one generic certificates::Outcome<C, U>.
| 0.6 | 0.7 |
|---|---|
BoxOutcome::Refuted { point, value } with point: Vec<Q> | Refuted { point, value, .. } with point: Vec<(Ex, Q)> — the value of x is point[i].1 |
HalfLineOutcome::Refuted { point, value } with point: Q | Refuted { point, value, .. } with point: Vec<(Ex, Q)> of length 1 — point[0].1 |
PolyhedronOutcome::Refuted { point, value, param_value } | unchanged fields; the variant is #[non_exhaustive], so write .. |
SosOutcome::Refuted { point, value } | Refuted { point, value, .. } (param_value is None) |
BoxOutcome::Unknown { farkas, degree } | Unknown(u) with u: BoxUnknown { farkas, degree, .. } |
HalfLineOutcome::Unknown { max_polya_power } | Unknown(u) with u: HalfLineUnknown { max_polya_power, .. } |
PolyhedronOutcome::Unknown { degree, lambda_degree, pairwise } | Unknown(u) with u: PolyhedronUnknown { … , .. } |
SosOutcome::Unknown { reason } | Unknown(u) with u: SosUnknown { reason, .. }; u.to_string() is the reason |
// 0.6
match prove_nonnegative_on_halfline(&g, &j, &ctx.int(3), Ray::AtLeast, 10)? {
HalfLineOutcome::Refuted { point, value } => println!("false at j = {point}: {value}"),
HalfLineOutcome::Unknown { max_polya_power } => println!("gave up at N = {max_polya_power}"),
HalfLineOutcome::Proved(c) => …,
}
// 0.7
match prove_nonnegative_on_halfline(&g, &j, &ctx.int(3), Ray::AtLeast, 10)? {
HalfLineOutcome::Refuted { point, value, .. } => println!("false at j = {}: {value}", point[0].1),
HalfLineOutcome::Unknown(u) => println!("gave up: {u}"),
HalfLineOutcome::Proved(c) => …,
}
The helpers is_proved() and certificate() exist on every outcome as before; is_refuted(), is_unknown(), into_certificate(), refutation(), unknown() and map_certificate() are new.
The box certificate type
| 0.6 | 0.7 |
|---|---|
certificates::Certificate (struct) | certificates::BoxCertificate |
certificates::CertificateData | certificates::BoxCertificateData |
| — | certificates::Certificate is now the trait implemented by all five certificate types |
A use symplex::certificates::Certificate; that meant the struct must become BoxCertificate; one that is only used for method calls can be deleted (the inherent methods do not need the trait in scope).
Option structs
LeanOpts, PolyhedronOpts and SosOpts are #[non_exhaustive]: a struct literal, including one ending in ..Default::default(), no longer compiles outside the crate.
// 0.6
let opts = PolyhedronOpts { max_lambda_degree: 0, ..Default::default() };
let lean = LeanOpts { real_type: "ℚ".into(), ..Default::default() };
// 0.7
let opts = PolyhedronOpts::default().with_max_lambda_degree(0);
let lean = LeanOpts::default().with_real_type("ℚ");
// or
let mut opts = PolyhedronOpts::default();
opts.max_lambda_degree = 0;
PolyhedronOpts::single(degree, lambda_degree) is unchanged. New builders: PolyhedronOpts::{with_max_degree, with_max_lambda_degree, with_pairwise, with_staged}, SosOpts::{with_max_basis, with_max_iterations, with_rounding_digits, with_max_facial_reductions}.
Functions that could panic on their arguments now return Result
| 0.6 | 0.7 |
|---|---|
StateSpace::controllability_matrix() -> Matrix | -> Result<Matrix, SymplexError> |
StateSpace::observability_matrix() -> Matrix | -> Result<Matrix, SymplexError> |
StateSpace::discretize_zoh(dt, order) -> StateSpace | -> Result<StateSpace, SymplexError> |
StateSpace::riccati_residual(p, q, r) -> Option<Matrix> | -> Result<Matrix, SymplexError> (singular R and shape mismatches are errors) |
StateSpace::ackermann(poles) -> Option<Matrix> | -> Result<Matrix, SymplexError> (InvalidArgument for multi-input / wrong pole count, ComputationFailed if uncontrollable) |
robotics::homogeneous(rotation, position) -> Matrix | -> Result<Matrix, SymplexError> |
dynamics::{total_time_derivative, euler_lagrange, mass_matrix, christoffel_symbols, coriolis_matrix, manipulator_equation} | each returns Result<_, SymplexError> |
Behavioural, not signature, changes: StateSpace::char_poly and matrix_decomp::wronskian return NaN instead of panicking on an ill-shaped model / empty list (use try_char_poly / try_wronskian for the error); is_controllable / is_observable return false for an ill-shaped model; ode::solve_ode_system{,_nonhomogeneous} return None where they previously could panic.
Nothing else
Polytope, ParametricPolytope, MultiPoly, PolyhedronProver, the Lean emitters and every certificate’s inherent methods are unchanged (and faster); the pinned Mathlib-compiled fixtures are byte-identical to 0.6.1.
Migrating from 0.3 to 0.4
symplex 0.4 is a minor release with two source-level breaking changes, both mechanical. Everything else is additive; results of existing operations are unchanged. The full list is in the CHANGELOG, and cargo semver-checks check-release --baseline-version 0.3.5 --release-type minor reports exactly these two.
roots_count_real → count_real_roots_in
The 0.3 alias was kept without a deprecation warning so that -D warnings builds were not broken by a patch release; 0.4 removes it as announced.
// 0.3
let n = f.roots_count_real(&x, &lo, &hi);
// 0.4
let n = f.count_real_roots_in(&x, &lo, &hi); // Ex
let n = Poly::new(&f, &[&x]).unwrap().count_real_roots_in(&lo, &hi); // Poly
LeanOpts has a new field
LeanOpts gained prefer_subtraction, and more fields may follow in minor releases. A struct literal that names every field no longer compiles; use functional update or the builders.
// 0.3
let opts = LeanOpts { real_type: "ℚ".into(), ascribe_integers: false };
// 0.4 — either
let opts = LeanOpts { real_type: "ℚ".into(), ..Default::default() };
// or
let opts = LeanOpts::default().with_real_type("ℚ").with_prefer_subtraction(true);
LeanOpts::default() renders exactly as 0.3 did; prefer_subtraction is opt-in.
Migrating from 0.1 to 0.2
symplex 0.2 is a breaking release. Most changes are mechanical (a Result where there was an Option, a dropped dummy argument); a few change results because 0.1 was wrong. The full list is in the CHANGELOG; this page shows the code.
compile returns Result
// 0.1
let f = expr.compile(&["x"]).expect("unsupported node"); // Option<Box<dyn Fn>>
// 0.2
let f = expr.compile(&["x"])?; // Result<CompiledFn, SymplexError>
f(&[2.0]); // still callable
f.arity(); // new
f.try_call(&[2.0])?; // new: arity-checked
CompiledFn is Clone + Send + Sync. The error tells you why: FreeSymbol { name } or NotImplemented(node). Coverage grew to every numerically evaluable node (special functions, Bessel, orthogonal polynomials, piecewise), so expressions that were None in 0.1 now compile.
definite_integral → integrate_definite
// 0.1 — computed F(b) − F(a) blindly: ∫₋₁¹ dx/x² gave −2
let v = expr.definite_integral(&x, &a, &b);
// 0.2
let v = expr.integrate_definite(&x, &a, &b); // Ex; Integral node if undecided
let v = expr.try_integrate_definite(&x, &a, &b)?; // Err(Divergent) / Err(ComputationFailed)
If you relied on F(b) − F(a) for a proper integral, results are unchanged. For integrals across a pole you now get Err(Divergent) (or an unevaluated node), which is the correct answer.
solve semantics
// 0.1: identities and contradictions both gave Ok(vec![]) (or a guess)
// 0.2:
match expr.solve(&x) {
Ok(roots) => …,
Err(SymplexError::InfiniteSolutions { .. }) => …, // x − x = 0
Err(SymplexError::NoSolution { .. }) => …, // sin x = 2, eˣ = −1, |x| = −1
Err(e) => …,
}
Roots are now eval’d: asin(1/2) comes back as π/6. If you matched on strings, update the expected text. solve_or_empty still returns Vec<Ex> and swallows every error.
Matrices
// 0.1 // 0.2
m.eigenvals(&lam)? m.eigenvals()?
m.eigenvects(&lam)? m.eigenvects()?
m.diagonalize(&lam)? m.diagonalize()?
m.jordan_form(&lam)? m.jordan_form()?
m.matrix_exp(&t)? m.matrix_exp_t(&t)? // or matrix_exp() for e^A
m.is_diagonalizable(&lam) -> bool m.is_diagonalizable() -> Option<bool>
m.is_symmetric() -> bool m.is_symmetric() -> Option<bool>
m.cholesky() -> Option<Matrix> m.cholesky() -> Result<Matrix>
m.lu() -> (L, U, perm) m.lu() -> Result<(L, U, perm)>
m.minor(i, j) -> Matrix m.minor_matrix(i, j)? // sub-matrix
m.minor(i, j)? // Result<Ex>: its determinant
Matrix::from_i64(&ctx, rows) -> Matrix Matrix::from_i64(&ctx, rows)?
m.add_elementwise(&n) / sub_elementwise m.add(&n)? / m.sub(&n)? (or &m + &n)
Matrix::try_identity / try_zeros Matrix::identity / zeros
char_poly(&lam) still takes the variable you want in the output. Structure tests on symbolic matrices return None when undecidable — replace if m.is_symmetric() with if m.is_symmetric() == Some(true).
Linear systems
// 0.1
let values: Vec<Ex> = ctx.solve_system(&eqs, &vars);
// 0.2
match ctx.solve_system(&eqs, &vars)? {
LinearSolution::Unique(pairs) => …,
LinearSolution::Parametric { solution, free } => …,
LinearSolution::Inconsistent => …,
}
// or: let sol = linsolve(&eqs, &vars)?; sol.get(&x)
polysys::solve_system_ex now returns algebraic solutions (radicals) where 0.1 returned only rational ones, and Err(InfiniteSolutions) for positive-dimensional systems.
Complex parts
let z = ctx.symbol("z");
z.re() // 0.1: z (assumed real — wrong)
// 0.2: re(z) (unevaluated until z is known real)
z.conjugate() // 0.1: z // 0.2: conjugate(z)
Declare ctx.symbol_with("z", &[Assumption::Real]) to recover the 0.1 behaviour where it was intended.
has_unevaluated and RootOf
RootOf / RootSum no longer count as unevaluated, so try_integrate, try_solve_ode, … succeed on results containing them. If you used has_unevaluated() to detect degree-≥5 roots, check for the node instead: expr_type() still reports ExprType::Unevaluated for a RootOf at the root of an expression, so root.expr_type() == ExprType::Unevaluated keeps working for the solutions returned by solve.
Other signature changes
| 0.1 | 0.2 |
|---|---|
StateSpace::poles(&s) | StateSpace::poles() |
vector::is_conservative(…) -> bool | -> Option<bool> (also is_irrotational, is_solenoidal) |
SetEx::contains(&e) structural | set membership, Option<bool> |
expr.textplot(…) -> String | -> Result<String> (all plotting methods) |
Ex::differentiate_finite(...) | differentiate_finite(&var, &points, order) |
FormalPowerSeries over Ratio | over Ex (coefficient(k) -> Ex, coefficient_rational(k) -> Option<Ratio>) |
finite_diff::* over Ratio | over Ex |
iter.sum::<Ex>() on empty → 0 | panics; use ctx.sum(iter) or Option<Ex> |
Debug for Ex prints ids | prints Ex(x^2 + 1) |
expand() splits (x·y)^a | no longer for unknown-sign symbols; expand_power_base(true) |
Assumption enum | new variants ExtendedReal, NotPositive, NotZero, … — add a _ => arm |
OdeType enum | new variants — add a _ => arm |
d/dx digamma(x) → formal derivative | polygamma(1, x) |
Digamma(5) stays | folds to -EulerGamma + 25/12 |
Results that changed because 0.1 was wrong
fourier_seriesof|x|,sign(x)and piecewise inputs (coefficients are now exact definite integrals).- One-sided limits:
limitreturns aLimitnode when the two one-sided limits differ, instead of one of them. - Several Gruntz limits of
exp/lntowers. matrix_expwith numeric complex eigenvalues (asin(−1)parity error).- Factoring is no longer truncated at small degrees:
factormay now split polynomials that 0.1 left whole. - Shifted alternating half-integer p-series had a sign error.
New things worth adopting
Context::from_f64(exact dyadic) /from_f64_approx(v, max_denominator)for ingesting floats —symplex-buildandsymplex-wasmnow use these for DH parameters (0.3→3/10).solve_generalfor periodic equations;linsolvefor linear systems;solve_ode_ivpfor initial-value problems.to_c_fnfor C targets;compile_manyfor gradients.simplify_traced/rewrite_tracedwhen a simplification surprises you.
Migrating from SymPy
This page is a reference for developers who know SymPy and want to find the equivalent operations in symplex. It is organized by task, with SymPy on the left and symplex on the right.
Key Differences
Before the translation table, a few structural differences to be aware of:
| Concept | SymPy | symplex |
|---|---|---|
| State | Global implicit state; symbols are free-standing objects | Every expression belongs to a Context; no global state |
| Types | Everything is an Expr at runtime | Ex (numeric), BoolEx (boolean), SetEx (set-valued) — distinct at compile time |
| Arithmetic | Python operators on SymPy objects | Rust operators on &Ex references (or use expr! macro) |
| Evaluation | simplify() is the catch-all | eval() for exact reduction, simplify() for multi-strategy simplification to a fixpoint, simplify_traced() to see what fired |
| Failure | Returns unevaluated or raises exception | Returns unevaluated Ex (Pattern 1) or Result (Patterns 2–6); never raises |
| Realness | Symbols are complex unless real=True; re(z) stays symbolic | Same in 0.2: z.re() is re(z) unless z is declared Real |
| Floats | Float type exists alongside exact | No float type in expressions; floats only via eval_f64() |
| Printing | pprint(), latex(), str() | println!("{expr}"), expr.to_latex() |
Setup
| SymPy | symplex |
|---|---|
from sympy import * | use symplex::prelude::*; |
x, y, z = symbols('x y z') | symplex::syms!(ctx; x, y, z); |
x = Symbol('x') | let x = ctx.symbol("x"); |
x = Symbol('x', positive=True) | let x = sym!(ctx; x, Positive); |
x = Symbol('x', integer=True) | let x = sym!(ctx; x, Integer); |
Building Expressions
| SymPy | symplex |
|---|---|
x**2 + 2*x + 1 | &x.powi(2) + &x * 2 + 1 |
x**2 + 2*x + 1 | expr!(ctx, x^2 + 2*x + 1) |
Rational(1, 3) | ctx.rational(1, 3) |
r.p, r.q (numerator / denominator of a Rational) | e.as_ratio_i128() → Option<(i128, i128)>, e.as_ratio_parts() → Option<(BigInt, BigInt)>; the full Ratio<BigInt> via e.as_rational() (types re-exported as symplex::num_rational / symplex::num_bigint) |
Integer(42) | ctx.int(42) |
pi | ctx.pi() |
E | ctx.e() |
I | ctx.i_unit() |
oo | ctx.infinity() |
zoo | ctx.complex_infinity() |
EulerGamma, Catalan, GoldenRatio | ctx.euler_gamma(), ctx.catalan(), ctx.golden_ratio() |
Float(0.3) | no float type — ctx.from_f64_approx(0.3, 1_000_000) → 3/10, ctx.from_f64(0.3) → exact dyadic |
Rational("22/7"), S("0.125") | ctx.rational_str("22/7"), ctx.decimal_str("0.125") |
sin(x) | x.sin() |
cos(x) | x.cos() |
exp(x) | x.exp() |
log(x) | x.ln() |
sqrt(x) | x.sqrt() |
Abs(x) | x.abs() |
re(z), im(z), conjugate(z), arg(z) | z.re(), z.im(), z.conjugate(), z.arg() |
z.as_real_imag() | z.as_real_imag() |
expand_complex(z) | z.expand_complex() |
x**y | x.pow(&y) |
x**5 | x.powi(5) |
factorial(n) | n.factorial() |
binomial(n, k) | n.binomial(&k) |
gamma(x) | x.gamma() |
erf(x) | x.erf() |
Piecewise((a, cond1), (b, cond2)) | Ex::piecewise(&[(&a, &cond1), (&b, &cond2)]) |
Matrix([[1, 2], [3, 4]]) | matrix![ctx, [1, 2], [3, 4]] |
Calculus
| SymPy | symplex |
|---|---|
diff(f, x) | f.diff(&x) |
diff(f, x, 3) | f.diff_n(&x, 3) |
diff(f, x, y) | f.diff(&x).diff(&y) |
integrate(f, x) | f.integrate(&x) |
integrate(f, (x, 0, 1)) | f.integrate_definite(&x, &ctx.int(0), &ctx.int(1)) (unevaluated node if undecided) or f.try_integrate_definite(…)? (Err(Divergent) when proven divergent) |
integrate(f, (x, 0, oo)) | f.integrate_definite(&x, &ctx.int(0), &ctx.infinity()) |
Integral(f, (x, 0, 1)).evalf() | f.integrate_numeric(&x, &ctx.int(0), &ctx.int(1))? (Gauss–Kronrod) |
limit(f, x, 0) | f.limit(&x, &ctx.int(0)) |
limit(f, x, 0, '+'), limit(f, x, 0, '-') | f.limit_right(&x, &ctx.int(0)), f.limit_left(&x, &ctx.int(0)) |
limit(f, x, oo) | f.limit(&x, &ctx.infinity()) |
series(f, x, 0, 5) | f.series(&x, &ctx.int(0), 5) |
f.series(x, 0, 5).removeO() | f.maclaurin(&x, 5) |
series(f, x, oo, 4) | f.series_at_infinity(&x, 4) |
fps(f, x) | f.fps_maclaurin(&x) → FormalPowerSeries (coefficient(k), general_term(&k), truncate(n)) |
residue(f, z, a) | f.residue(&z, &a) |
summation(f, (k, 0, n)) | f.summation(&k, &ctx.int(0), &n) (or try_summation) |
product(f, (k, 1, n)) | f.product_over(&k, &ctx.int(1), &n) |
Sum(f, (k, 1, oo)).is_convergent() | f.is_convergent(&k) → Option<bool> |
finite_diff_weights(1, [x-h, x, x+h], x) | finite_diff::finite_diff_weights(1, &[…], &x) |
differentiate_finite(f, x, points=[…]) | f.differentiate_finite(&x, &points, 1) |
Simplification
| SymPy | symplex |
|---|---|
simplify(expr) | expr.simplify() (simplify_with(&SimplifyOpts::single_pass()) for one pass) |
expand(expr) | expr.expand() |
factor(expr) | expr.factor(&x); multivariate expr.factor_all() |
factor_list(expr) | expr.factor_list(&x) → (content, Vec<(factor, mult)>) |
sqf_list(expr) | expr.sqf_list(&x) |
resultant(f, g, x), discriminant(f, x) | f.resultant(&g, &x), f.discriminant(&x) → Option<Ex> |
div(f, g, x), gcdex(f, g, x) → (s, t, h) | f.poly_div(&g, &x), f.poly_gcdex(&g, &x) → ExtendedGcd { x: s, y: t, gcd: h } |
decompose(f, x), interpolate(points, x) | f.decompose(&x), Ex::poly_interpolate(&points, &x) |
Poly(f).nroots(), real_roots(f), count_roots(f) | f.nroots(&x, digits) → Vec<Complex64>, f.real_roots_isolate(&x), f.count_real_roots(&x) |
Poly(f).count_roots(inf, sup) | f.count_real_roots_in(&x, &lo, &hi) / p.count_real_roots_in(&lo, &hi) on a Poly (endpoints rational or ±∞) |
cancel(expr) (all variables) / ratsimp(expr) | expr.ratsimp() — rational normal form, opaque non-rational subexpressions treated as indeterminates |
cancel(expr, x) | expr.cancel(&x) |
expr.as_numer_denom(), fraction(expr) | expr.as_numer_denom() — same semantics: 3/31 → (3, 31), x/2 + 1/3 → (3*x + 2, 6), sums combined at every depth, nothing cancelled |
fraction(together(expr)), fraction(cancel(expr)) | expr.as_numer_denom() (already deep), expr.ratsimp().as_numer_denom() |
apart(expr, x) | expr.partial_fractions(&x) |
together(expr) | expr.together() |
collect(expr, x) | expr.collect(&x) |
trigsimp(expr) | expr.simplify_trig() |
expand_trig(expr) | expr.expand_trig() |
logcombine(expr) | expr.log_combine() |
expand_log(expr) | expr.expand_log() |
powsimp(expr) | expr.simplify_powers() |
combsimp(expr) | expr.simplify_combinatorial() |
radsimp(expr) | expr.rationalize_denom() |
sqrtdenest(expr) | expr.sqrtdenest() |
signsimp(expr) | expr.signsimp() |
powdenest(expr, force=True) | expr.powdenest(true) |
expand(expr, deep=False) | expr.expand_with(&ExpandOpts { deep: false, ..Default::default() }) |
expand_power_base(expr, force=True) | expr.expand_power_base(true) |
nsimplify(expr) | expr.nsimplify(tol); nsimplify(expr, [pi]) → expr.nsimplify_with_constants(&[&ctx.pi()], tol) |
rcollect(expr, x, y) | expr.rcollect(&[&x, &y]) |
separatevars(expr) | expr.separate_vars(&[&x, &y]), separate_vars_dict |
expr.subs(x**2, u) (algebraic) | expr.subs_algebraic(&x.powi(2), &u) |
expr.replace(pattern, repl) with Wild | Rule::new(name, &lhs_with_a_, &rhs) + expr.rewrite(&RuleSet::from_rules(vec![rule])) — see The Rule Engine |
Polynomials (Poly)
See Polynomials as Data. Generators are explicit; anything else becomes a (possibly symbolic) coefficient.
| SymPy | symplex |
|---|---|
Poly(e, x, y) | e.as_poly(&[&x, &y]) → Option<Poly> (also Poly::new(&e, &[&x, &y])) |
Poly(e, x, y).as_dict() / .terms() | e.as_poly(&[&x, &y]).unwrap().terms() → Vec<(Vec<u32>, Ex)>, lex-descending |
p.monoms(), p.coeffs() | p.monoms(), p.coeffs() |
p.coeff_monomial(x*y) | p.coeff_monomial(&[1, 1])? |
p.LC(), p.LM(), p.LT() | p.leading_coeff(), p.leading_monomial(), p.leading_term() |
p.degree(x), p.total_degree(), p.degree_list() | p.degree_in(&x), p.total_degree(), p.degree_list() |
p.all_coeffs() (univariate, highest first) | p.all_coeffs() → Option<Vec<Ex>> |
degree(e, x), Poly(e, x).coeffs(), e.coeff(x, 2), LC(e, x) | e.degree(&x), e.coeffs(&x) (ascending), e.coeff(&x, 2), e.leading_coeff(&x) — symbolic coefficients allowed since 0.3 |
p.eval({x: 1, y: 2}), p.eval(x, 2) | p.eval(&[&one, &two])?, p.eval_gen(&x, &two)? |
p.as_expr() | p.to_ex() |
p + q, p * q, p ** 3, p.diff(x) | p.add(&q)?, p.mul(&q)?, p.pow(3)?, p.derivative(&x)? |
p.primitive(), p.monic() | p.content_and_primitive(), p.monic() |
Poly(e, x).nroots() | e.as_poly(&[&x]).unwrap().nroots(digits)? (or e.nroots(&x, digits)?) → Vec<Complex64> |
Poly(e, x).count_roots(a, b), real_roots | p.count_real_roots_in(&a, &b), p.count_real_roots(), p.real_roots_isolate() |
Poly(e, x).shift(a) | p.shift(&x, &a)? — p(x + a); all coefficients ≥ 0 after shifting by a certifies p ≥ 0 on [a, ∞) |
| (no equivalent) | p.is_nonnegative_on(&lo, &hi), p.is_positive_on(&lo, &hi) → Option<bool> (exact, Sturm) |
Interval(lo, hi).is_subset(solve_univariate_inequality(e >= 0, x)) | e.poly_is_nonnegative_on(&x, &lo, &hi) (and poly_is_positive_on) → Option<bool>, exact Sturm-based decision |
groebner([f, g], x, y) | groebner::groebner_basis(&[f.to_multipoly()?, g.to_multipoly()?]), back with Poly::from_multipoly |
Matrix of coefficients by hand | Poly::monomial_basis(&polys)?, Poly::coefficient_matrix(&polys, &basis)? |
Solving
| SymPy | symplex |
|---|---|
solve(f, x) | f.solve(&x) → Result<Vec<Ex>>; identities are Err(InfiniteSolutions), contradictions Err(NoSolution) |
solve(f, x) (ignoring errors) | f.solve_or_empty(&x) → Vec<Ex> |
solveset(sin(x) - 1/2, x) (with ImageSet) | f.solve_general(&x)? → GeneralSolution { solutions, parameters } |
solveset(f > 0, x) | f.solve_gt(&x) → SetEx |
solveset(f >= 0, x) | f.solve_ge(&x) → SetEx |
reduce_inequalities([x > 0, x <= 5], x) | reduce_inequalities(&[x.gt(&zero), x.le(&five)], &x)? → SetEx |
linsolve([eq1, eq2], [x, y]) | linsolve(&[eq1, eq2], &[x, y])? → LinearSolution::{Unique, Parametric, Inconsistent} |
linsolve((A, b), x1, x2) | linsolve_matrix(&a, &b)? (unknowns are named x1, x2, …; singular and inconsistent systems handled) |
solve(a*x**2 + b*x + c, x) (parametric) | (a x² + b x + c).solve(&x)? — results are ratsimp’d since 0.3 |
sympy.solvers.simplex.lpmax(f, constraints) / lpmin | LpProblem::maximize(c).le(row, rhs)….solve()? / LpProblem::minimize — exact over ℚ, see Exact Linear Programming |
sympy.solvers.simplex.linprog(c, A, b, A_eq, b_eq, bounds) | linprog::linprog(&c, &a_ub, &b_ub, &a_eq, &b_eq, &bounds)? |
scipy.optimize.linprog(c, A_ub, b_ub, A_eq, b_eq) | linprog::linprog(…) (exact) or linprog_matrix(Objective::Minimize, &c, Some(&a_ub), Some(&b_ub), None, None)? |
“is b a non-negative combination of these vectors?” | linprog::feasible_nonneg(&rows, &b)? → Option<Vec<Q>> |
| dual values / infeasibility certificate | LpSolution::duals, LpSolution::farkas |
solve([eq1, eq2], [x, y]) (polynomial) | symplex::polysys::solve_system_ex(&[eq1, eq2], &[x, y])? (algebraic solutions) |
nsolve(f, x, x0) | f.solve_numeric(&x, x0, max_iter, tol) |
nsolve([f1, f2], [x, y], [x0, y0]) | solve_numeric_system(&[f1, f2], &[x, y], &[x0, y0])? |
dsolve(ode, y(x)) | ode.solve_ode(&y, &x) |
dsolve(ode, y(x), ics={y(0): 0, y(x).diff(x).subs(x, 0): 1}) | ode.solve_ode_ivp(&y, &x, &[InitialCondition { order: 0, x: x0, value: v0 }, InitialCondition { order: 1, x: x0, value: v1 }])? |
rsolve(a(n+2) - a(n+1) - a(n), a(n), {a(0): 0, a(1): 1}) | rsolve::rsolve_linear(&[c0, c1, c2], forcing, &n, &[a0, a1])? |
Linear Algebra
| SymPy | symplex |
|---|---|
M = Matrix([[1,2],[3,4]]) | let m = matrix![ctx, [1, 2], [3, 4]]; |
M.det() | m.det().unwrap() |
M.inv() | m.inv().unwrap() |
M.eigenvals() | m.eigenvals_with_multiplicity()? (m.eigenvals()? lists with repetition) |
M.eigenvects() | m.eigenvects()? → Vec<(value, multiplicity, Vec<Matrix>)> |
M.charpoly(x) | m.char_poly(&x)? |
M.T | m.transpose() |
M.H | m.adjoint() |
M * N | &m * &n or m.matmul(&n)? |
2 * M, M / 2 | 2 * &m, m.clone() / 2 |
M[i, j], M[i, j] = v | m[(i, j)], m[(i, j)] = v |
M.trace() | m.trace()? |
M.rank() | m.rank() → usize |
M.nullspace() | m.nullspace() → Vec<Matrix> (also rowspace, columnspace, left_nullspace) |
| integer kernel (no direct SymPy API) | m.integer_nullspace()? — a ℤ-basis, see Integer Lattices |
hermite_normal_form(M) (sympy.matrices.normalforms, column style H = A·V) | normalforms::column_hermite_normal_form(&m)? (leading zero columns kept); row style H = U·A is m.hermite_normal_form()? / hermite_normal_form_with_transform |
smith_normal_form(M) | m.smith_normal_form()?, normalforms::smith_normal_form_with_transforms(&m)? → SmithNormalForm { s, u, v } |
abs(M.det()) == 1 | normalforms::is_unimodular(&m)? |
M.extract(rows, cols) | m.extract(&rows, &cols)? |
M[rows, :], M[:, cols] | m.select_rows(&rows)?, m.select_cols(&cols)? |
M.row_del(i), M.col_del(j) | m.delete_row(i)?, m.delete_col(j)? (returns a new matrix) |
M.is_zero | m.is_zero() → Option<bool> |
all(e.is_integer for e in M) | m.is_integer_matrix() → Option<bool> |
M.subs({x: y, y: x}) (simultaneous) | m.subs_map(&[(&x, &y), (&y, &x)]) |
Matrix(rows) from Rational/int/float data | Matrix::from_ratio(&ctx, &rows)?, Matrix::from_bigint, Matrix::from_f64_rows (exact dyadic) |
[[e for e in row] for row in M.tolist()] as numbers | m.to_rational_rows(), m.to_bigint_rows() → Option<Vec<Vec<_>>> |
M.diagonalize() | m.diagonalize()? → Diagonalization { p, d } |
M.is_diagonalizable() | m.is_diagonalizable() → Option<bool> |
M.jordan_form() | m.jordan_form()? → JordanForm { p, j } |
M.exp() | m.matrix_exp()?; (t*M).exp() → m.matrix_exp_t(&t)? |
M**n (symbolic n) | m.matrix_pow_symbolic(&n)? |
M.sqrt() / M**Rational(1,2) | m.matrix_sqrt()? |
M.LUdecomposition() | m.lu()? → Lu { l, u, perm } |
M.cholesky() | m.cholesky()? |
M.LDLdecomposition() | m.ldl()? → Ldl { l, d } |
M.QRdecomposition() | m.qr()? → Qr { q, r } |
GramSchmidt(vecs, True) | matrix_decomp::gram_schmidt(&vecs, true)? |
M.is_symmetric(), M.is_positive_definite | m.is_symmetric(), m.is_positive_definite() → Option<bool> |
M.norm(), M.norm(1), M.norm(oo) | m.norm_frobenius(), m.norm_1(), m.norm_inf() |
M.solve_least_squares(b) | m.solve_least_squares(&b)? |
hessian(f, [x, y]), wronskian([f, g], x) | matrix_decomp::hessian(&f, &[&x, &y]), matrix_decomp::wronskian(&[&f, &g], &x) |
Evaluation and Substitution
| SymPy | symplex |
|---|---|
expr.subs(x, 3) | expr.subs(&x, &ctx.int(3)) |
expr.subs(x, 3) (integer shorthand) | expr.subs_i64(&x, 3) |
expr.subs([(x, 1), (y, 2)]) | expr.subs_map_i64(&[(&x, 1), (&y, 2)]) or expr.eval_at(&[(&x, &a), (&y, &b)]) |
expr.evalf() | expr.eval_f64() → Result<f64> |
expr.evalf(50) | expr.eval_decimal(50) → Result<String> |
expr.is_number | expr.is_constant() / expr.as_rational().is_some() |
expr.free_symbols | expr.free_symbols() |
expr.equals(other) | expr.equals(&other) → Option<bool>; expr.probably_equal(&other, samples) |
expr1 < expr2 (numeric) | expr1.compare_numeric(&expr2), expr1.is_less_than(&expr2) → Option |
Code Generation
| SymPy | symplex |
|---|---|
sstr(expr) then hand-edit for Lean / Mathlib | expr.to_lean()? — Mathlib spacing (2 * j + 1, j ^ 2), (3 / 31 : ℝ), (j - 1) / (2 * j), Real.sin x, 0 < x ∧ x < 1 for a BoolEx; to_lean_with(&LeanOpts::default().with_real_type("ℚ")) for the carrier type / ascribing every integer |
| (wrap Lean output to ≤ 100 columns by hand) | lean::wrap_lean(&text, lean::MATHLIB_LINE_WIDTH); the certificate emitters already do this |
ask(Q.positive(3*u**2 + 2*u + 1)) with u declared positive — usually None | e.is_positive() → Some(true) for a rational-coefficient polynomial in one real-assumed symbol (exact Sturm decision); is_nonnegative, is_negative, is_nonpositive likewise |
lambdify([x], expr) | expr.compile(&["x"]) → Result<CompiledFn> (Clone + Send + Sync, arity(), try_call()) |
lambdify([x, y], [f1, f2]) | Ex::compile_many(&[&f1, &f2], &["x", "y"]) → Result<CompiledFnVec> (shared CSE) |
rust_code(expr) | expr.to_rust_fn("name", &["x"]) → Result<String> (to_rust_fn_with_options for f32, no_std, checked_domain, …) |
ccode(expr) / codegen(("f", expr), "C99") | expr.to_c_fn("f", &["x"]) → Result<String> (self-contained C99 with helpers) |
cse([expr1, expr2]) | Ex::cse_many(&[&expr1, &expr2]) → (Vec<(Ex, Ex)>, Vec<Ex>); single: expr.cse() |
latex(expr) | expr.to_latex() → String |
| — | expr.to_json() → Result<String> |
Number Theory
| SymPy | symplex |
|---|---|
isprime(n) | symplex::ntheory::isprime(n) |
factorint(n) | symplex::ntheory::factorint(n) |
nextprime(n) | symplex::ntheory::nextprime(n) |
prevprime(n) | symplex::ntheory::prevprime(n) |
divisors(n) | symplex::ntheory::divisors(n) |
totient(n) | symplex::ntheory::totient(n) |
mobius(n) | symplex::ntheory::mobius(n) |
mod_inverse(a, m) | symplex::ntheory::mod_inverse(a, m) |
crt([r1,r2], [m1,m2]) | symplex::ntheory::crt_i64(&[r1,r2], &[m1,m2]) |
pow(a, e, m) (3-arg pow) | symplex::ntheory::mod_pow(a, e, m) |
igcd(a, b, c), ilcm(a, b, c) | symplex::ntheory::igcd(&[a, b, c]), ilcm(&[a, b, c]) (any integer type); gcd_many(&[BigInt]), lcm_many |
ilcm(*[r.q for r in rationals]) | symplex::ntheory::rational_lcm_of_denominators(&rationals) |
primerange(2, 50) | symplex::ntheory::primerange(2, 50) / primes_up_to(50) |
primepi(n), prime(n) | symplex::ntheory::primepi(n), prime(n) |
legendre_symbol(a, p) | symplex::ntheory::legendre_symbol(a, p) |
jacobi_symbol(a, n), kronecker_symbol(a, n) | symplex::ntheory::jacobi_symbol(a, n)?, kronecker_symbol(a, n) |
sqrt_mod(a, p), sqrt_mod(a, p, all_roots=True) | symplex::ntheory::sqrt_mod(a, p), sqrt_mod_all(a, p) |
discrete_log(n, a, b) | symplex::ntheory::discrete_log(b, a, n) (base, target, modulus) |
primitive_root(p), n_order(a, n) | symplex::ntheory::primitive_root(p), n_order(a, n) |
continued_fraction(x), continued_fraction_periodic(0, 1, d) | symplex::ntheory::continued_fraction(&ratio), continued_fraction_periodic(d) → PeriodicContinuedFraction { pre_period, period } |
egyptian_fraction(r) | symplex::ntheory::egyptian_fraction(&r) |
diophantine(x**2 - 61*y**2 - 1) | symplex::diophantine::pell(61), pell_solutions(61, k) |
diophantine(3*x + 5*y - 1) | symplex::diophantine::linear_diophantine(3, 5, 1) → LinearDiophantine { x, y, x_step, y_step } |
sum_of_squares(n, 2) | symplex::diophantine::sum_of_two_squares(n) |
Combinatorics
| SymPy | symplex |
|---|---|
stirling(n, k, kind=2) | symplex::combinatorics::stirling2(n, k) → Option<BigInt> |
stirling(n, k, kind=1, signed=True) | symplex::combinatorics::stirling1(n, k) → Option<BigInt> |
npartitions(n) / partition(n) | symplex::combinatorics::partition_count(n) → Option<BigInt> |
multinomial_coefficients(n, k) | symplex::combinatorics::multinomial(n, &ks) → Option<BigInt> |
bell(n) | symplex::combinatorics::bell(n) or ctx.int(n).bell().eval() |
catalan(n) | symplex::combinatorics::catalan(n) or ctx.int(n).catalan_number().eval() |
subfactorial(n) | symplex::combinatorics::derangements(n) or ctx.int(n).subfactorial().eval() |
fibonacci(n), lucas(n) | symplex::ntheory::fibonacci(n), lucas(n) or ctx.int(n).fibonacci().eval() |
bernoulli(n), euler(n), harmonic(n) | symplex::ntheory::bernoulli(n), euler_number(n), harmonic(n) |
partitions(n) (iterator) | symplex::combinatorics::partitions(n) |
Special Functions
| SymPy | symplex |
|---|---|
gamma(x) | x.gamma() |
erf(x) | x.erf() |
erfc(x) | x.erfc() |
beta(a, b) | a.beta(&b) |
besselj(n, x) | x.bessel_j(&n) |
bessely(n, x) | x.bessel_y(&n) |
LambertW(x) | x.lambertw() |
DiracDelta(x) | x.dirac_delta() |
Heaviside(x) | x.heaviside() |
digamma(x) | x.digamma() |
polygamma(n, x) | x.polygamma(&n) |
loggamma(x) | x.log_gamma() |
zeta(s) | s.zeta() |
Si(x), Ci(x), Ei(x), li(x) | x.si(), x.ci(), x.ei(), x.li() |
KroneckerDelta(i, j) | i.kronecker_delta(&j) |
besseli(n, x), besselk(n, x) | x.bessel_i(&n), x.bessel_k(&n) |
legendre(n, x) | x.legendre(&n) |
chebyshevt(n, x) | x.chebyshev_t(&n) |
hermite(n, x) | x.hermite(&n) |
laguerre(n, x) | x.laguerre(&n) |
Sets and Logic
| SymPy | symplex |
|---|---|
Interval(0, 5), Interval.Lopen(3, 10) | ctx.interval(&zero, &five, IntervalKind::Closed), ctx.interval(&three, &ten, IntervalKind::LeftOpen) |
FiniteSet(1, 2, 3) | ctx.finite_set(&[one, two, three]) |
S.Reals, S.EmptySet | ctx.reals(), ctx.empty_set() |
A.union(B), A.intersect(B) | a.union(&b).simplify(), a.intersection(&b).simplify() |
A - B, A.symmetric_difference(B), A.complement(S.Reals) | a.difference(&b), a.symmetric_difference(&b), a.absolute_complement() |
A.contains(x), x in A | a.contains(&x) → Option<bool>, x.is_in(&a) |
A.is_subset(B), A.is_disjoint(B) | a.is_subset(&b), a.is_disjoint(&b) → Option<bool> |
A.inf, A.sup, A.measure, A.boundary, A.closure, A.interior | a.inf(), a.sup(), a.measure(), a.boundary(), a.closure(), a.interior() → Option |
A.as_relational(x) | a.to_condition(&x)? |
And(p, q), Or(p, q), Not(p), Implies(p, q) | p.and(&q), p.or(&q), p.not(), p.implies(&q) |
to_nnf, to_cnf, to_dnf | b.to_nnf(), b.to_cnf(), b.to_dnf() |
satisfiable(b) | b.satisfiable() → Option<bool>; b.is_tautology(), b.is_contradiction() |
truth_table(b, [p, q]) | b.truth_table(&[p, q])? |
piecewise_fold / Piecewise.simplify() | expr.piecewise_simplify() |
Transforms
| SymPy | symplex |
|---|---|
fourier_transform(f, t, w) (ordinary frequency) | f.fourier_transform_with(&t, &w, FourierConvention::Ordinary)? |
fourier_transform with 2π angular frequency | f.fourier_transform(&t, &w)? (non-unitary angular) |
inverse_fourier_transform(F, w, t) | F.inverse_fourier_transform(&w, &t)? |
mellin_transform(f, x, s) → (F, (a, b), cond) | f.mellin_transform(&x, &s)? → (F, strip: BoolEx) |
inverse_mellin_transform(F, s, x, (a, b)) | F.inverse_mellin_transform(&s, &x)? |
fourier_series(f, (x, -pi, pi)) | f.fourier_series_on(&x, &(-ctx.pi()), &ctx.pi(), n)? |
s.truncate(n), s.an, s.bn | s.truncate(n), s.coefficient_a(k), s.coefficient_b(k) |
| Z-transform (not in SymPy core) | f.z_transform(&n, &z)?, F.inverse_z_transform(&z, &n)? |
Numerical Optimisation (SciPy / NumPy)
SymPy defers to SciPy and NumPy here; symplex ships equivalents in symplex::optimize (see Numerical Optimisation). All are deterministic and return Result.
| SciPy / NumPy | symplex |
|---|---|
scipy.optimize.brentq(f, a, b) | optimize::brent_root(f, a, b, &RootOpts::default())?; on an Ex: e.find_root_bracket(&x, a, b)? |
scipy.optimize.bisect(f, a, b) | optimize::bisect(f, a, b, &opts)? |
scipy.optimize.newton(f, x0, fprime) | optimize::newton_root(f, df, x0, &opts)? (derivative from e.diff(&x).compile(..)) |
scipy.optimize.minimize(f, x0, method="Nelder-Mead") | optimize::nelder_mead(f, &x0, &MinimizeOpts::default())?; on an Ex: e.minimize_numeric(&[&x, &y], &x0)? → MinimizeResult { x, fun, iterations, evaluations, converged } |
scipy.optimize.minimize_scalar(f, bounds=(a, b), method="bounded") | optimize::minimize_scalar(f, a, b, &opts)? (Brent), golden_section; on an Ex: e.minimize_scalar_numeric(&x, a, b)? → ScalarMinimum { x, value } |
scipy.optimize.differential_evolution(f, bounds, seed=0) | optimize::differential_evolution(f, &bounds, &DeOpts { seed, .. })? with bounds: &[Interval<f64>] (closed, Interval::closed(lo, hi)); on an Ex: e.minimize_global_numeric(&vars, &bounds, &opts)? |
numpy.polyfit(x, y, deg) (highest degree first) | optimize::poly_fit(&xs, &ys, deg)? (ascending: [c₀, c₁, …]); eval_poly(&c, x) evaluates |
numpy.polyfit with exact rationals (no NumPy equivalent) | optimize::poly_fit_exact(&points, deg)?, stats::regression::polyfit(&x, &y, deg)? (both ascending), Ex::poly_fit_points(&ctx, &points, &x, deg)? → Ex |
scipy.stats.linregress(x, y) (slope, intercept) | optimize::linear_fit(&xs, &ys)? → LinearFit { slope, intercept } |
numpy.trapz(y, x) / scipy.integrate.trapezoid(y, x) | optimize::trapezoid(&ys, &xs)? |
scipy.optimize.fsolve(F, x0) | solve_numeric_system(&eqs, &vars, &x0)? (damped Newton, symbolic Jacobian) |
Dimensional Analysis
SymPy does not have a built-in compile-time unit system. symplex provides one:
#![allow(unused)]
fn main() {
use symplex::units::*;
let ctx = Context::new();
let m = Mass::symbol(&ctx, "m");
let a = Acceleration::symbol(&ctx, "a");
// Type-safe: the compiler verifies the dimension
let force = dim!(ctx, Force: m * a);
// Typed calculus: d(Length)/d(Time) → Velocity
symplex::syms!(ctx; g, t);
let t_var = Time::symbol(&ctx, "t");
let pos = Length::from_ex(expr!(ctx, 1/2 * g * t^2));
let vel: Velocity = pos.diff_wrt(&t_var);
}
There is no SymPy equivalent for this. The closest is SymPy’s physics.units module, which performs dimensional analysis at runtime rather than compile time.
ODE Solving
| SymPy | symplex |
|---|---|
dsolve(Eq(f(x).diff(x), f(x)), f(x)) | (see below) |
classify_ode(ode) | ode_expr.classify_ode(&y, &x) |
ODE solving in symplex uses a different interface from SymPy. Instead of wrapping the ODE in Eq() and using Function('f'), you build the ODE as an expression involving y.formal_diff(&x):
#![allow(unused)]
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
let y = ctx.symbol("y");
let dy = y.formal_diff(&x);
// y' + 2y = 0
let ode = &dy + &y * 2;
let solution = ode.solve_ode(&y, &x);
println!("{solution}"); // y = C1*exp(-2*x)
}
symplex supports 16 ODE classes: simple separable, full separable, first-order linear (constant and variable coefficient), exact, integrating factor, Bernoulli, Riccati (solve_riccati with a particular solution), Euler–Cauchy, homogeneous-coefficient, second-order constant-coefficient (homogeneous and non-homogeneous), nth-order constant-coefficient, reduction of order, variation of parameters, and Clairaut. Initial-value problems use solve_ode_ivp; linear systems x' = Ax use ode::solve_ode_system_ivp.
Things That Don’t Have Direct Equivalents
In SymPy but not symplex
Permutation,PermutationGroup, and abstract algebrageometrymodule (Point, Line, Circle, Polygon)statsmodule (probability distributions)tensormodule (indexed tensors, Einstein summation)physics.quantummodulepdsolve(PDE solving)- General
diophantine()(symplex haslinear_diophantine,pell,sum_of_two_squares,pythagorean_triples, not the general classifier) rsolvefor polynomial/rational/hypergeometric coefficients (symplex has constant-coefficient linear and first-order recurrences)hyper,meijergand the Meijer-G integration engineEqas a first-class expression in every API (symplex hasEquationwithsolve,solve_for, arithmetic, andeq!, accepted bylinsolve)- Pretty-printing with Unicode box drawing (
pprint) — symplex haspretty()/pretty_ascii() O()notation for series remainders
In symplex but not SymPy
- Compile-time dimensional analysis (
dim!macro, quantity types) - Compile-time expression type safety (
Ex/BoolEx/SetEx) - Thread-safe contexts (
Send + Sync, no GIL) - Optimized Rust and C99 code generation with CSE and an embedded special-function runtime (
to_rust_fn,to_c_fn) - Compiled closures for fast numerical evaluation (
compile,compile_many) - Exact
RootOfeigenvalues for irreducible cubic/quartic characteristic polynomials - Exact linear programming over ℚ with shadow prices and Farkas certificates (
symplex::linprog) - Exact sign of a polynomial on an interval (
poly_is_nonnegative_on, Sturm-based) - Row-style Hermite normal form with its unimodular transform, integer kernels, lattice determinants
- Deterministic numerical optimisation (
symplex::optimize) in the same crate as the CAS Err(Divergent)/Err(NoSolution)/Err(InfiniteSolutions)as first-class outcomesEvalConfigfor user-controllable computation limitsbuild.rscode generation pipeline for embedded targets- WASM compilation target (
symplex-wasm)