Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 Mass to a Length is a compiler error, and differentiating Length with respect to Time produces Velocity.

  • 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

CategoryCapabilities
CalculusDifferentiation (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
SummationFaulhaber, Gosper, telescoping, binomial sums, p-series (ζ(2m) exact), power-series recognition, infinite products, convergence tests
AlgebraExpansion, 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
PolynomialsPoly 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)
SolvingPolynomials 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 programmingExact simplex over ℚ with shadow prices and Farkas infeasibility certificates; feasible_nonneg for non-negative combinations
Sets & logicInterval algebra with a normal form, three-valued membership/subset queries, reduce_inequalities, NNF/CNF/DNF, DPLL satisfiability, truth tables
Linear algebraDeterminant, 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 optimisationBrent/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
TransformsLaplace (forward/inverse, initial/final value), Fourier (three conventions), Mellin (with fundamental strip), Z, Fourier series on arbitrary intervals
Complex analysisre/im/conjugate/arg honest about unknown realness, as_real_imag, polar form, complex infinity
Number theoryPollard–Brent rho + ECM factorization, BPSW primality, modular square roots, discrete logarithms, primitive roots, continued fractions, Pell and other Diophantine equations, CRT
CombinatoricsStirling numbers (both kinds), Bell, Catalan, derangements, Fibonacci/Lucas, Bernoulli/Euler numbers, multinomial coefficients, integer partitions
Special functionsGamma, 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
OutputRust and C99 code generation with CSE, compiled closures, LaTeX, JSON serialization, plots (text/SVG/TikZ)
Units30 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), and symplex-wasm exposes a Session API 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:

  1. Exact arithmetic. All numbers are Ratio<BigInt>. The expression 1/3 is the exact rational one-third, not 0.33333.... Floating-point numbers appear only when explicitly requested via eval_f64(), compile(), or integrate_numeric().

  2. 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.

  3. Type safety. Numeric expressions (Ex), boolean expressions (BoolEx), and set-valued expressions (SetEx) are distinct Rust types. Passing a boolean to sin() is a compile error.

  4. Thread safety. Context is Clone (Arc-based) and Ex is Send + Sync. Multiple threads can work with the same context without data races.

  5. Honest failure. Operations that cannot produce a closed-form result return unevaluated symbolic forms. ∫x^x dx returns Integral(x^x, x) — a truthful representation of the problem — rather than an incorrect value or a panic. ∫₋₁¹ dx/x² is Err(Divergent), not −2. re(z) stays re(z) unless z is known to be real. Numerical operations return Result.

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 marked ignore are 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 / the krippendorff package 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 exact QMatrix transition 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 + b with every closed form transported (Affine), strictly monotone maps and /|X| by the change-of-variables formula (Transformed), finite ranges mapped and merged.
  • Distribution::mixture(&[(w, F), …]).
  • Your own families: implement Family, wrap with Distribution::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 + √3x⁴ − 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::Certificategoal, 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::verticesRatio<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.

Cookbook: sums of squares

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, λ, the hg line — without touching hypothesis names like e1J. 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 that expand leaves.
  • PolyhedronLeanSteps::to_block(indent) re-flows to Mathlib’s width with the indent included, and wrap_lean never starts a continuation line with :=, so have hg : … := by keeps 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_newPoly::new with 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) and Poly::coeffs_rational().
  • LeanOpts::prefer_subtraction ((1 / 2 : ℝ) - r instead of -r + (1 / 2 : ℝ)) and the with_* builders.
  • HalfLineCertificate::lean_hints(hk, &opts) — the hint list alone, for a proof skeleton.
  • linsolve documents that an over-determined but consistent system is Unique.

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.20.3
#[test] functions10,17711,021
Lines in src/~163K~174K
New public modulespoly_ex, linprog, normalforms, optimize
Signature-breaking changesmany (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.

Polynomials as Data

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.

Numerical Optimisation

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]:

Operation0.3.40.3.5
Matrix::rref, 30×36208 ms4.2 ms
Matrix::det, 30×3042 ms0.55 ms
Matrix::solve, 30×30138 ms1.5 ms
Matrix::inv, 30×30470 ms10 ms
linsolve_matrix, 30×30135 ms1.5 ms
Matrix::inv, 60×60118 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_coeff and is_polynomial now succeed for polynomials whose coefficients are symbolic parameters (a·x² + (a + 1)·x + 3 in x). In 0.2 they returned None / false. Code that used None from these methods to mean “has parameters” should test Poly::has_rational_coeffs or free_symbols instead.
  • simplify_rational and the results of solve with symbolic coefficients are now put into rational normal form with ratsimp. 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 with equals or 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.10.2
ExprNode variants~8091
#[test] functions~6,00010,177
Lines in src/~103K~163K
ODE classes1316
Code generation targetsRustRust, 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.

Summation and Series

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.

Solving Equations

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.

Sets and Logic

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.

The Rule Engine

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.

Transforms

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.

Code Generation

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 persistent Session (define names, evaluate, differentiate, solve) plus a full stateless API including integrate_definite and to_c_fn.
  • symplex-build: exact DH parameters (0.33/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 the symplex-wasm crate)

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

CratePurpose
symplex-macrosThe expr!, matrix!, eq!, dim!, rule! procedural macros (a dependency of symplex; you do not add it yourself)
symplex-buildBuild-time code generation: run the CAS in build.rs and emit no_std Rust for firmware
symplex-wasmwasm-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:

CratePurpose
num-bigint, num-rational, num-traits, num-integerArbitrary-precision arithmetic
smallvecStack-allocated small vectors for expression nodes
parking_lotFast reader-writer locks for thread-safe contexts
astro-floatArbitrary-precision floating-point for numerical evaluation
serde, serde_jsonJSON serialization of expressions
tracingOptional structured logging (enable with RUST_LOG=symplex=debug)
typenumCompile-time type-level integers for dimensional analysis
thiserrorError 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:

  1. Isolation. Different contexts have independent symbol tables and assumptions. A server handling multiple users can give each one a separate context without interference.

  2. Safety. Expressions from different contexts cannot be mixed. If you try to add an expression from ctx_a to one from ctx_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:

NodeMeaning
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.

MethodSymPyReturns
singularities(&x, domain)singularitiesSetEx of points where the expression is undefined
stationary_points(&x, domain)stationary_pointsSetEx of real zeros of the derivative
maximum(&x, &domain) / minimummaximum / minimumsupremum / infimum as an Ex (oo allowed)
is_increasing, is_decreasing, is_strictly_increasing, is_strictly_decreasing, is_monotonicsameOption<bool>
is_convex(&x, &domain)is_convexOption<bool>
periodicity(&x)periodicityOption<Ex> (Some(0) for a constant)
function_range(&x, &domain)function_rangeSetEx, 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 equals and 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 is None or Err(NotImplemented) — never a guess.
  • Periodic families. Zeros of sin, cos, tan are 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 as ConditionSet(x, cos(x) == 0) rather than truncated to the principal branches.
  • Continuity is required by maximum, minimum and function_range: singularities inside the domain, or discontinuous / opaque nodes (floor, sign, Piecewise, unknown functions), give Err(NotImplemented). abs kinks are fine and are included among the candidates.
  • Deliberate differences from SymPy. is_strictly_increasing(x³, ℝ) is Some(true) (SymPy tests ℝ ⊆ {f' > 0} and answers None); is_monotonic is the three-valued or of increasing and decreasing (SymPy asks whether f' has no zeros, so is False there); periodicity(sin(x)²) is the fundamental period pi (SymPy: 2*pi).

Where to go next

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.

MethodReturnsUse 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

  1. Evaluates the integrand and locates its real singularities inside [a, b].
  2. 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.
  3. Handles infinite bounds the same way.
  4. Applies symmetry shortcuts (odd integrand on a symmetric interval, periodicity).
  5. Resolves Abs, Sign, Heaviside, DiracDelta and Piecewise integrands by splitting at their breakpoints.
  6. 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

ConstantContext methodNotes
γ (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).

FunctionConstructorExact values (eval)Derivative
erfi, erf⁻¹, erfc⁻¹x.erfi(), x.erfinv(), x.erfcinv()erfi(0) = 0, odd; erfinv(±1) = ±∞; erfcinv(1) = 02e^{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, Chix.shi(), x.chi()Shi(0) = 0, odd; Chi(0) = −∞sinh x/x, cosh x/x
Fresnel S, Cx.fresnels(), x.fresnelc()S(0) = 0, S(±∞) = ±1/2, oddsin(π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) foldsformal
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:

MethodDoes
simplify_trig, fu, trig_power_linearize, trig_half_angleTrigonometric identities (Fu’s algorithm)
expand_trig, trig_combinesin(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
signsimpCanonicalise signs: −t·(−x − y)t·(x + y)
rationalize_denom, simplify_rationalRadical and rational denominators
simplify_combinatorialFactorials, binomials, Gamma
nsimplify(tol), nsimplify_with_constants(&[&pi], tol)Recognise a float as a rational or a rational multiple of a constant
refine, refine_withApply assumptions (√(x²)x for x ≥ 0)
rewrite_as_exp, rewrite_as_trigChange representation
separate_vars, separate_vars_additive, separate_vars_dictSplit products/sums by variable groups
subs_algebraic(&old, &new)Substitute u inside 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 + 3 is a perfectly good polynomial in x.
  • 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 symbolMatches
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.

selfoldsubs_algebraic
x^4x^2u^2
x^3x^2u*x
1/x^2x^21/u
x^6 + 3x^2 + 1x^2u^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:

SituationResult
Finitely many solutionsOk(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 methodErr(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 an Err (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:

statusSet fieldsMeaning
Optimalx, objective, dualsfinite optimum
Infeasiblefarkas (Some unless the bounds alone contradict)no feasible point
Unboundedthe 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ᵢ ≤ 0 on rows and yᵢ ≥ 0 on rows (the signs flip for a maximisation), yᵢ is free on = rows, and with the reduced costs r = c − Aᵀy:

  • complementary slackness: yᵢ·(aᵢ·x* − bᵢ) = 0 for every row;
  • rⱼ = 0 unless x*ⱼ sits at a finite bound (for a minimisation rⱼ ≥ 0 at a lower bound and rⱼ ≤ 0 at an upper bound; reversed for a maximisation);
  • strong duality: cᵀx* = yᵀb + Σⱼ rⱼ x*ⱼ, which reduces to cᵀx* = yᵀb under the default bounds x ≥ 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ᵢ ≥ 0 on rows, yᵢ ≤ 0 on rows, free on = rows, such that, with g = Aᵀy,

Σⱼ  inf { gⱼ·xⱼ : lⱼ ≤ xⱼ ≤ uⱼ }   >   yᵀb

where every infimum is finite (gⱼ > 0 ⇒ lⱼ finite, gⱼ < 0 ⇒ uⱼ finite, gⱼ = 0 contributes 0). Any feasible x would satisfy (Aᵀy)·x ≤ yᵀb, so the inequality proves that none exists. With no finite bounds this is the textbook form Aᵀ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_evolution draws its random numbers from a local SplitMix64 generator seeded by DeOpts::seed, so identical inputs give bit-identical results.
  • Nothing panics. Bad input (a bracket without a sign change, degree ≥ len, reversed bounds) is InvalidArgument; running out of iterations or meeting a non-finite value is ComputationFailed. The minimisers that return a MinimizeResult report an exhausted budget through converged == false instead of an error, so the best point found is never thrown away.
  • Polynomial coefficients are ascending: [c₀, c₁, …, c_d] means c₀ + c₁x + … + c_d xᵈ. (NumPy’s polyfit is 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 wantNumericExact
Roots of a polynomialfind_root_bracket (one root, needs a bracket)solve (all roots, radicals/RootOf), real_roots_isolate, nroots
Roots of a transcendental equationfind_root_bracket, newton_rootsolve (Lambert W, inversion), solve_general for families
Systems of equationssolve_numeric_systemlinsolve, polysys::solve_system_ex
A minimum of a differentiable functionnelder_mead, minimize_scalardiff + solve, hessian for classification
A global minimum in a boxdifferential_evolutionpoly_is_nonnegative_on for proving a bound in 1-D; LP certificates in several
A feasible point / optimum of a linear programlinprog (Exact Linear Programming)
A polynomial through pointspoly_fitpoly_fit_exact, poly_interpolate
An integraltrapezoid (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

ConstructorResult
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 BoolExx ∈ 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.

MethodReturnsPreconditions (→ Err)
lu()Lu { l, u, perm } with P·A = L·Usquare
cholesky()L with L·Lᵀ = Asymmetric, 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) basislinearly 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/rowsnon-zero
hessenberg() (0.9)Hessenberg { h, p } with H = P⁻¹AP upper Hessenberg, no radicalssquare
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]));
}
  • 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_solenoidal return Option<bool>.
  • Control (symplex::control): StateSpace (poles, stability, controllability, observability, ZOH discretisation, to_transfer_function) and TransferFunction (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

MethodSymPyNotes
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_insertpos == nrows/ncols appends; Err on bad position or shape
permute_rows(&perm), permute_cols(&perm)permute_rows, permute_colsresult row i is input row perm[i]; perm must be a genuine permutation
row_del(i), col_del(j)row_del, col_delSymPy 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·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 exact integrate_definite / summation over the support, so E[g(X)] works for any g the integrator can handle. For a polynomial g the 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 the try_ 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::sample is the only place a random number generator appears, and stats::Rng is a seeded, reproducible SplitMix64 so Monte-Carlo sanity checks are deterministic.
  • A result that does not exist is never a number. Cauchy has no mean: x.mean() returns the divergent integral unevaluated (has_unevaluated() is true), and try_integrate_definite on x·f(x) says Err(Divergent).
QuerySymPyReturns
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).

FamilyConstructorSupportClosed 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 (, ln x, 1/x, …) and the even shapes , |X|, X^{2k} go through the change-of-variables formula ( 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?

QuestionFunctionResult on the data
Two raters, raw agreementpercent_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 categoriesweighted_kappa(&a, &b, &Weights::Linear) (or Quadratic, Custom)
Many raters, nominal, complete rowsfleiss_kappa_ratings(&complete)23/48
Many raters, missing data, any scalekrippendorff_alpha(&table, Level::Nominal)214/473
… treating the categories as orderedkrippendorff_alpha(&table, Level::Ordinal)577/836
Interval-scale ratings, reliability of one rater / the mean of kicc(&complete, IccForm::Icc2Single) (also Icc1, Icc3Single, *Average)133/183
Rankings of items by several judgeskendall_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:

DataTests
Two meanst_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 meansanova::anova_one_wayAnovaResult { 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 proportionz_test_proportion, z_test_two_proportions (renamed from two_proportion_z_test in 0.18), binomial_test (exact)
Ranks / ordinal scoresmann_whitney_u (exact or asymptotic with tie correction), wilcoxon_signed_rank, kruskal_wallis, friedman, spearman_test, kendall_test
Categorical tableschi_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 inferencepearson_test, pearson_t_statistic, compare_two_correlations; the Fisher-z interval is estimation::pearson_ci
Distribution fitks_one_sample(x, &Distribution)
Effect sizescohens_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:

QuestionFunction
Internal consistencycronbach_alpha (and cronbach_alpha_complete with list-wise deletion), standardized_alpha, kr20 for right/wrong items, guttman_lambda2, alpha_if_deleted
Split-half reliabilitysplit_half(&table, &SplitHalf::{OddEven, FirstLast, Custom}) with the Spearman–Brown prophecy (spearman_brown(&r, k), in the context of r)
Item qualityitem_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):

QuestionFunction (0.18 home)
Agreement inferenceagreement::{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 associationdata::{goodman_kruskal_gamma, somers_d, kendall_tau_c, concordance_counts}
Which cells drive a χ²?hypothesis::{expected_counts, chi2_contributions, standardized_residuals, adjusted_residuals}
Correlation inferencehypothesis::{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:

RouteMethodWhen
Compiled closurecompile(&["x", …]) -> Result<CompiledFn>Evaluate now, in this process; no source code
Rust sourceto_rust_fn(name, &args) -> Result<String>build.rs pipelines, no_std firmware
C99 sourceto_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:

FieldDefaultEffect
precisionF64F32 emits f32 and f-suffixed literals
math_backendStdLibmlibm::sin(x); CfgGatedmath::sin(x) with a cfg-gated mod math that picks std or libm (for no_std)
inline / must_usefalse / trueattributes on the function
csetruecommon subexpression elimination
use_mul_addtruefuse a*b + c into mul_add/fma (one rounding instead of two; disable for bit-exact unfused arithmetic)
checked_domainfalsedebug_assert! domain checks (ln(x) needs x > 0, sqrt needs x ≥ 0, lambertw needs x ≥ −1/e)
emit_runtimetrueemit the mod symplex_rt preamble when needed
unit_annotation, param_units, return_unitnoneuom 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, &params) 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 (sinatanh, 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>&#x2061;</mo> (invisible apply) for function application, <mo>&#x2062;</mo> (invisible times) between factors, Greek symbol names as character references (alpha<mi>&#x3B1;</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 forms Eq(a, b), Ne, Lt, Le, Gt, Ge, And(…), Or(…), Not(a). Precedence is mathematical, loosest first: or < and < comparisons < + - < * / < ^; not applies to the following relation; comparisons do not chain (0 < x < 1 is an error — write 0 < x & x < 1). Note that Python’s sympify("x > 0 & x < 1") fails because & binds tighter than > there. A numeric expression (x + 1) or a sort error ((x > 0) + 1, x & y) is Err.
  • 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 unknown f(…) as a product. The argument of sin x is the juxtaposed product that follows, up to the next +, -, comparison, closing parenthesis or function name: 2 sin x is 2*sin(x), sin 2x is sin(2*x), sin x^2 is sin(x^2), sin x/2 is sin(x/2), sin x + 1 is sin(x) + 1, and sin x cos y is sin(x)*cos(y) (SymPy reads sin(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 aliases C/B/W are ordinary symbols (write binomial, beta, lambertw). x(x+1) and f(x) are x*(x+1) and f*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:

  1. Design a PID controller with symbolic gains
  2. Derive the closed-loop characteristic polynomial
  3. Verify stability using the Routh-Hurwitz criterion
  4. Choose specific gains and confirm all closed-loop poles are in the left half-plane
  5. 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₂ > 0
  • a₀ > 0
  • a₂·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 system
  • Matrix::to_rational_rowsMatrixVec<Vec<Ratio<BigInt>>> for the LP
  • linprog::feasible_nonneg, LpProblem::eq + LpSolution::farkas — exact feasibility and certificates (Exact Linear Programming)
  • Poly::scale, Poly::add, Poly::equals and Ex::ratsimp — two independent verifications
  • ntheory::rational_lcm_of_denominators — the integer form of the certificate
  • Ex::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 (or x ≤ a): with k = x − a, if every coefficient of p(a + k) is non-negative that is already a proof, and when it is not, a Pólya multiplier (1 + k)^N makes it so (guaranteed for a strictly positive goal). prove_nonnegative_on_halfline does both, refutes false claims with an exact point, and prove_nonnegative_on_reals glues two half-lines into a proof for all of ℝ.
  • Even-multiplicity zeros inside the domain: goal = g²·h is split off by exact factoring and h gets the certificate; in Lean every hint becomes mul_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(&param), &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 ), 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”:

CallOutcomeRepresentation
solveidentityErr(SymplexError::InfiniteSolutions { .. })
solvecontradiction / range violationErr(SymplexError::NoSolution { .. })
solve_system_expositive-dimensionalErr(InfiniteSolutions)
linsolvecontradictory systemOk(LinearSolution::Inconsistent)
linsolveunder-determinedOk(LinearSolution::Parametric { .. })
try_integrate_definitedivergentErr(SymplexError::Divergent { .. })
laplace_final_valueunstable poleErr(Divergent)
fourier_transform, mellin_transform, z_transformnot in table / missing sign assumptionErr(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 / prefixMeaningExample
try_Result twin of a Pattern-1 methodtry_integrate
_withsame operation with an options structsimplify_with(&SimplifyOpts), rewrite_with(&rules, &RewriteOpts), integrate_numeric_with(…, &QuadOpts)
_tracedalso returns Vec<Step>simplify_traced, rewrite_traced
_or_emptyswallow the error into an empty Vecsolve_or_empty
_generalcomplete solution familysolve_general
_ivpwith initial conditionssolve_ode_ivp
_allall variables (multivariate)factor_all, sqrt_mod_all
is_*three-valued queryis_positive, is_symmetric
as_*cheap structural viewas_rational, as_numer_denom, as_intervals
from_*constructor on Context/typesfrom_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

VariantFieldsRaised by
FreeSymbol { name }the unbound symboleval_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 nodecompile/codegen on a node without numerical meaning (unevaluated Integral, Apply, Bessel with symbolic order, …)
ComputationFailed { operation, reason }which operation, whyevery 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 calls Integral.

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:

  1. Cross-context mixing — combining expressions from different Contexts. The message names the operation.
  2. Empty Sum/Product iteratorsiter.sum::<Ex>() on an empty iterator has no context to build 0 in. Use ctx.sum(iter) / ctx.product(iter), or collect into Option<Ex> (which yields None).

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.110.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.110.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.60.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: QRefuted { 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.60.7
certificates::Certificate (struct)certificates::BoxCertificate
certificates::CertificateDatacertificates::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.60.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_realcount_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_integralintegrate_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.10.2
StateSpace::poles(&s)StateSpace::poles()
vector::is_conservative(…) -> bool-> Option<bool> (also is_irrotational, is_solenoidal)
SetEx::contains(&e) structuralset membership, Option<bool>
expr.textplot(…) -> String-> Result<String> (all plotting methods)
Ex::differentiate_finite(...)differentiate_finite(&var, &points, order)
FormalPowerSeries over Ratioover Ex (coefficient(k) -> Ex, coefficient_rational(k) -> Option<Ratio>)
finite_diff::* over Ratioover Ex
iter.sum::<Ex>() on empty → 0panics; use ctx.sum(iter) or Option<Ex>
Debug for Ex prints idsprints Ex(x^2 + 1)
expand() splits (x·y)^ano longer for unknown-sign symbols; expand_power_base(true)
Assumption enumnew variants ExtendedReal, NotPositive, NotZero, … — add a _ => arm
OdeType enumnew variants — add a _ => arm
d/dx digamma(x) → formal derivativepolygamma(1, x)
Digamma(5) staysfolds to -EulerGamma + 25/12

Results that changed because 0.1 was wrong

  • fourier_series of |x|, sign(x) and piecewise inputs (coefficients are now exact definite integrals).
  • One-sided limits: limit returns a Limit node when the two one-sided limits differ, instead of one of them.
  • Several Gruntz limits of exp/ln towers.
  • matrix_exp with numeric complex eigenvalues (a sin(−1) parity error).
  • Factoring is no longer truncated at small degrees: factor may 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-build and symplex-wasm now use these for DH parameters (0.33/10).
  • solve_general for periodic equations; linsolve for linear systems; solve_ode_ivp for initial-value problems.
  • to_c_fn for C targets; compile_many for gradients.
  • simplify_traced / rewrite_traced when 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:

ConceptSymPysymplex
StateGlobal implicit state; symbols are free-standing objectsEvery expression belongs to a Context; no global state
TypesEverything is an Expr at runtimeEx (numeric), BoolEx (boolean), SetEx (set-valued) — distinct at compile time
ArithmeticPython operators on SymPy objectsRust operators on &Ex references (or use expr! macro)
Evaluationsimplify() is the catch-alleval() for exact reduction, simplify() for multi-strategy simplification to a fixpoint, simplify_traced() to see what fired
FailureReturns unevaluated or raises exceptionReturns unevaluated Ex (Pattern 1) or Result (Patterns 2–6); never raises
RealnessSymbols are complex unless real=True; re(z) stays symbolicSame in 0.2: z.re() is re(z) unless z is declared Real
FloatsFloat type exists alongside exactNo float type in expressions; floats only via eval_f64()
Printingpprint(), latex(), str()println!("{expr}"), expr.to_latex()

Setup

SymPysymplex
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

SymPysymplex
x**2 + 2*x + 1&x.powi(2) + &x * 2 + 1
x**2 + 2*x + 1expr!(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)
pictx.pi()
Ectx.e()
Ictx.i_unit()
ooctx.infinity()
zooctx.complex_infinity()
EulerGamma, Catalan, GoldenRatioctx.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**yx.pow(&y)
x**5x.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

SymPysymplex
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

SymPysymplex
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 WildRule::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.

SymPysymplex
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_rootsp.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 handPoly::monomial_basis(&polys)?, Poly::coefficient_matrix(&polys, &basis)?

Solving

SymPysymplex
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) / lpminLpProblem::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 certificateLpSolution::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

SymPysymplex
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.Tm.transpose()
M.Hm.adjoint()
M * N&m * &n or m.matmul(&n)?
2 * M, M / 22 * &m, m.clone() / 2
M[i, j], M[i, j] = vm[(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()) == 1normalforms::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_zerom.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 dataMatrix::from_ratio(&ctx, &rows)?, Matrix::from_bigint, Matrix::from_f64_rows (exact dyadic)
[[e for e in row] for row in M.tolist()] as numbersm.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_definitem.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

SymPysymplex
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_numberexpr.is_constant() / expr.as_rational().is_some()
expr.free_symbolsexpr.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

SymPysymplex
sstr(expr) then hand-edit for Lean / Mathlibexpr.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 Nonee.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

SymPysymplex
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

SymPysymplex
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

SymPysymplex
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

SymPysymplex
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.EmptySetctx.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 Aa.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.interiora.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_dnfb.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

SymPysymplex
fourier_transform(f, t, w) (ordinary frequency)f.fourier_transform_with(&t, &w, FourierConvention::Ordinary)?
fourier_transform with angular frequencyf.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.bns.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 / NumPysymplex
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

SymPysymplex
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 algebra
  • geometry module (Point, Line, Circle, Polygon)
  • stats module (probability distributions)
  • tensor module (indexed tensors, Einstein summation)
  • physics.quantum module
  • pdsolve (PDE solving)
  • General diophantine() (symplex has linear_diophantine, pell, sum_of_two_squares, pythagorean_triples, not the general classifier)
  • rsolve for polynomial/rational/hypergeometric coefficients (symplex has constant-coefficient linear and first-order recurrences)
  • hyper, meijerg and the Meijer-G integration engine
  • Eq as a first-class expression in every API (symplex has Equation with solve, solve_for, arithmetic, and eq!, accepted by linsolve)
  • Pretty-printing with Unicode box drawing (pprint) — symplex has pretty() / 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 RootOf eigenvalues 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 outcomes
  • EvalConfig for user-controllable computation limits
  • build.rs code generation pipeline for embedded targets
  • WASM compilation target (symplex-wasm)