Migrating from Symbolica 2.2 to 3.0

Update Symbolica 2.2 code for 3.0, including Python conditions, exact solving, Numerica scalars, evaluators, series, and Rust backends and domains.

This guide covers the main changes needed when upgrading Symbolica 2.2 to 3.0, including Numerica 3.0. See the release post for new features and improvements. If upgrading from an earlier version, first follow the 2.0 and 2.2 migration guides.

Upgrade Checklist

  1. When 3.0 is available, upgrade the Python package:

    pip install --upgrade 'symbolica>=3,<4'

    For Rust, use Rust 1.89 or newer and update the dependency:

    cargo add symbolica@3

    Update direct Numerica and Graphica dependencies to version 3 as well.

  2. Replace symbolic equality conditions using == or != with .eq() or .ne(). Handle None from undecidable conditions and property queries.

  3. Replace solve_linear_system() with solve() and read assignments from solution branches.

  4. Update arbitrary-precision result handling and numeric callbacks for Float and ComplexFloat.

  5. Review antisymmetric function patterns: matching no longer permutes their arguments.

  6. Replace level_range with min_level and max_level; update custom match callback return values.

  7. Re-export and recompile saved native evaluator libraries. Remove dimension arguments from compiled evaluator load() calls, and update consumers of get_instructions().

  8. Handle unknown series coefficients explicitly.

  9. For Rust, update numeric backend features, algebraic-domain imports, sampling code, and integration grid construction.

  10. Renew old offline license keys and update direct Rust licensing imports.

Python Comparisons and Conditions

Equality tests and symbolic equations

== and != return Python Booleans. Expressions compare structurally, with exact-value equality for scalar numbers. Transformers and held expressions compare by object identity; comparison does not execute them.

Use .eq() and .ne() when you need a deferred Condition, including solver equations, pattern restrictions, and Transformer branches:

from symbolica import Expression, S, T

x, y, f, w_ = S("x", "y", "f", "w_")
assert (x == x) is True
assert (x == y) is False

# 2.2: cond=(w_ == x)
assert f(x).replace(f(w_), y, cond=w_.eq(x)) == y

# 2.2: T().if_then(T() == x, ...)
transform = T().if_then(T().eq(x), T().replace(x, y))
assert transform(x) == y

held = x.hold(T().replace(x, y))
assert held.eq(y).eval() is True
assert (held == y) is False

# Preserve the equation for the solver.
assert Expression.solve(x.eq(1), [x])[0][x] == 1

Ordered operators <, <=, >, and >= also return conditions. Combine parenthesized relations with &, |, and ~. Python and, or, not, and chained comparisons request Boolean conversion instead of building a symbolic formula.

from symbolica import S

x, y = S("x", "y")
condition = (x >= 0) & (x <= 1) & y.ne(0)
assert (x <= 1).eval() is None
assert x.eq(1).eval() is None

Condition.eval() returns True, False, or None; converting an undecidable condition to bool raises TypeError. A Transformer if_then with an unknown condition raises ValueError. Pass equations directly to solve() without calling eval() or bool() first. Creating an inequality condition does not imply that solve() supports solving inequalities.

Expression truth, hashing, and sorting

if x == 3: remains a structural test. In contrast, bool(x) raises TypeError for a general symbolic expression. Numeric expression truth follows the numeric value: bool(N(0)) is false.

Equal scalar numbers have compatible Python hashes. For example, N(1) == N(1.0) == 1, and {N(1): "one"}[1] works. Equality uses the exact stored value: a binary float created with N(0.1) is not the exact rational N(1) / 10. Symbolic equality does not attempt an algebraic proof.

Ordered comparisons now mean mathematical real ordering. For internal structural sorting, use compare_structure() explicitly:

from functools import cmp_to_key
from symbolica import S

x, y = S("x", "y")
ordered = sorted([y, x], key=cmp_to_key(lambda a, b: a.compare_structure(b)))

This structural order can change between versions. Powers now have length two, matching the base and exponent yielded by iteration.

Property queries can be unknown

is_real(), is_integer(), is_scalar(), is_positive(), and is_nonnegative() return bool | None. Use is False when a branch requires a proven negative result; not expr.is_real() also accepts an unknown result.

from symbolica import S

x = S("unknown_property")
r = S("real_property", is_real=True)
assert x.is_real() is None
assert r.is_real() is True
assert (r + 1j).is_real() is False
assert (r**2).is_positive() is None  # It could be zero.
assert (r**2).is_nonnegative() is True

Setting is_real=False leaves the symbol without a realness assumption; it does not assert that the symbol is non-real. is_finite() and is_constant() remain Boolean queries.

Exact Equation Solving

Replace Expression.solve_linear_system() with Expression.solve(). An expression is interpreted as equal to zero; use .eq(rhs) for an explicit right-hand side. The result is a SolutionSet, whose branches map variables directly to expressions:

from symbolica import Expression, S, Reals

x, y = S("x", "y")

# 2.2: x_value, y_value = Expression.solve_linear_system(equations, [x, y])
result = Expression.solve([x + y - 3, x - y - 1], [x, y])
branch = result[0]
x_value, y_value = branch[x], branch[y]
assert dict(branch) == {x: 2, y: 1}

roots = Expression.solve(x**2 - 1, [x], domain=Reals)
assert {branch[x] for branch in roots} == {-1, 1}

family = Expression.solve(x + y - 1, [x, y])[0]
assert family[x] == 1 - y
assert family.free_variables() == [y]
assert family.get(y) is None

The default domain is Complexes; select Reals, Rationals, or Integers when needed. Branch order is not guaranteed. len(result) counts branches, which may represent entire families. Free variables are omitted from the assignment mapping, and indexing a free variable raises KeyError.

For parameter-dependent systems, inspect result.coverage, result.coverage_guard, and branch.conditions(). A "generic" answer applies under the coverage guard; substitute excluded parameter values into the original equations and solve those cases separately. Converting a branch to a dictionary extracts assignments only, so retain its conditions alongside them. bool(result) and result.is_empty() can raise IncompleteCoverage if emptiness cannot be established. Unsupported problems raise UnsupportedProblem rather than returning a complete empty solution set.

Numerica Scalars in Python

Constructing and converting numbers

Symbolica exports Numerica’s immutable Float and ComplexFloat types. Arbitrary-precision APIs return these instead of Decimal values or pairs of Decimal values. Existing Decimal and component-pair inputs remain accepted.

from decimal import Decimal
from symbolica import Float, ComplexFloat, S

x = Float("1.234567890123456789", decimal_digits=80)
y = Float.from_ratio(1, 3, precision=256)
z = ComplexFloat(x, y)

exact = x.to_decimal()           # Exact stored binary value as Decimal.
rounded = x.to_decimal(20)       # 20 significant digits, ties to even.
real, imag = z.as_tuple()        # Float components; also z.real and z.imag.
decimal_parts = z.to_decimal_tuple()

v = S("v")
result = (v*v).evaluate({v: z}, decimal_digit_precision=80)
assert isinstance(result, ComplexFloat)
assert isinstance(result.real.to_decimal(), Decimal)

Use precision for bits or decimal_digits for decimal working precision; specifying both raises ValueError. Copies preserve precision unless an explicit precision is requested. Native floats use 53 bits; decimal strings infer precision from significant digits, with a minimum of 53 bits. Construct high-precision inputs from strings, integers, or exact ratios to avoid first rounding them through a native float.

Arithmetic tracks accuracy and can change result precision. with_precision() returns a rounded copy; increasing precision cannot recover lost digits. Decimal conversion is exact by default and independent of Python’s global decimal context. Both scalar classes are unhashable.

API Return type in 3.0
Evaluator.evaluate_with_prec(...) list[Float]
Evaluator.evaluate_complex_with_prec(...) list[ComplexFloat]
Expression.evaluate(..., decimal_digit_precision=...) ComplexFloat
Expression.nsolve(...) Float, even with a native float initial guess
Expression.nsolve_system(...) list[Float]
Polynomial.approximate_roots(..., decimal_digit_precision=...) list[tuple[ComplexFloat, int]]

The 32-digit evaluator path also returns these scalars. Machine-precision expression evaluation and polynomial roots still return native complex numbers; machine-precision batch evaluators still return NumPy arrays.

Replace re, im = result with re, im = result.as_tuple() for Float components or result.to_decimal_tuple() for Decimal components. Use to_decimal() where a downstream library expects Decimal, and float() or complex() when deliberately converting to machine precision.

Numeric callbacks

The decimal and decimal_complex callback keys remain, but callback arguments are now sequences of Float and ComplexFloat, respectively:

from symbolica import S

f = S("numeric_callback", eval={
    "decimal": lambda args: args[0].sin(),
    "decimal_complex": lambda args: args[0].sin(),
})

Tagged callbacks follow the same convention. A constant callback can return the new scalars without losing precision. Decimal and component-pair callback results remain accepted. Convert arguments explicitly with to_decimal() or to_decimal_tuple() when using Decimal-specific operations.

Both types provide elementary functions, including inverse trigonometric and hyperbolic functions, plus constants such as Float.pi(decimal_digits=80) and ComplexFloat.i(decimal_digits=80). Instance methods zero(), one(), and nan() preserve precision. sample_unit(random.Random(seed)) samples at the working precision; complex samples have zero imaginary part.

Pattern Matching

Matching levels

Replace level_range=(minimum, maximum) with min_level=minimum and max_level=maximum in expressions, Transformers, and replacements:

from symbolica import S

x, f = S("x", "f")
expr = x * f(x, f(x))
assert expr.replace(x, 1, min_level=1, max_level=1) == x * f(1, f(x))

Bounds are inclusive. min_level defaults to zero and max_level=None removes the upper bound. Levels count function nesting by default; level_is_tree_depth=True counts expression tree depth.

Callback decisions

PatternRestriction.req_matches(callback), wildcard.req(callback), and wildcard.req_cmp(other, callback) share this contract:

Old match-stack decision 3.0 return value Meaning
-1 False Reject
0 None Undecidable
1 True Accept

Return None while required wildcards are missing from the match dictionary. A returned Condition is evaluated explicitly. Only a true condition accepts a completed match; negating an unknown condition still yields unknown. Restrictions on multiple wildcards wait until the values are available.

Antisymmetric function matching

This is a breaking change for rules that rely on argument permutations. In 2.2, the matcher considered permutations of an antisymmetric function’s arguments. In 3.0, it matches arguments position by position, just as it does for an ordinary function. This affects both Python and Rust matching APIs.

Calls without wildcards still normalize using antisymmetry: arguments are sorted, odd permutations introduce a minus sign, and repeated arguments yield zero. Calls containing wildcards, including wildcards nested inside arguments, retain their argument order. A pattern’s wildcard positions therefore refer to the target function’s stored, normalized argument order.

from symbolica import S

f = S("antisymmetric_example", is_antisymmetric=True)
w_, a_, b_ = S("w_", "a_", "b_")
expr = f(1, 2)

# Antisymmetric normalization still applies to concrete calls.
assert f(2, 1) == -expr
assert f(1, 1) == 0

# Matching does not move the second argument into the first position.
assert not expr.matches(f(w_, 1))
assert expr.replace(f(w_, 1), 9) == expr

# Put the fixed argument and wildcard in the target's stored order.
assert expr.replace(f(1, w_), 9) == 9
assert list(expr.match(f(a_, b_))) == [{a_: 1, b_: 2}]

Review rules that put a fixed argument in a particular slot, conditions that expect a wildcard to receive any argument, and code that counts or iterates over matches. If a rule must cover several argument orders, provide those patterns explicitly. When a rule expresses an antisymmetric identity, carry the appropriate permutation sign into its replacement; swapping arguments alone is not sufficient. Avoid assuming that the internal canonical order of symbolic arguments is stable across versions.

Evaluators

Loading compiled libraries

Compiled libraries now carry input and output dimensions. Remove input_len and output_len from Python load() calls:

from symbolica import CompiledRealEvaluator

# 2.2: CompiledRealEvaluator.load("test.so", "my_fun", input_len, output_len)
compiled = CompiledRealEvaluator.load("test.so", "my_fun")

This applies to real, complex, SIMD, and CUDA evaluators. CUDA still accepts number_of_evaluations and block_size as runtime settings. Re-export and recompile older libraries once to include the metadata. Loading a library without it raises an error with rebuild instructions.

Precision evaluators raise ValueError for invalid input counts or unsupported precision, and remain usable after a failed call.

Exported instructions and function definitions

get_instructions() now returns EvaluatorInstructions:

from symbolica import S, FunctionDefinition

x, y, f = S("x", "y", "f")
evaluator = f(x).evaluator([x], functions=[
    FunctionDefinition(f, [y], y**2, inlining="never"),
])

# 2.2: instructions, temporary_count, constants = evaluator.get_instructions()
exported = evaluator.get_instructions()
instructions = exported.instructions
temporary_count = exported.temporary_count
constants = exported.constants
assert exported.input_count == exported.output_count == 1
assert len(exported.sub_evaluators) == 1

Custom instruction consumers must handle sub_evaluators, whose entries carry the function, tags, and nested evaluator body. Function-call instructions can refer to those bodies. Choose inlining="always" to request inlining, "never" to retain calls, or "auto" for the default policy. Pass FunctionDefinition objects to functions=....

Series Coefficients

Accessing an unknown coefficient now differs from accessing a known zero. Python indexing and get_coefficient() raise IndexError at or beyond the series’ absolute order:

from symbolica import N, S

x = S("x")
series = x.exp().series(x, 0, 2)
assert series[2] == N(1) / 2
assert series[-1] == 0
assert series.get_absolute_order() == (3, 1)
try:
    series[3]
except IndexError:
    pass  # The coefficient of x**3 has not been computed.
else:
    raise AssertionError("Expected an unknown coefficient")

Negative indices denote negative powers, not offsets from the end. Rational exponents are supported, and powers refer to the variable minus the expansion point. Requested depth is inclusive; get_absolute_order() is the exclusive boundary of known coefficients. For fractional expansions, use that reported boundary instead of assuming depth + 1.

Use to_expression() to deliberately discard the remainder before extracting coefficients from an ordinary expression.

Rust and Numerica API Changes

Numeric backends and WebAssembly

The old gmp and no_gmp features are replaced by independent backend choices:

2.2 feature selection 3.0 feature selection
gmp integer-gmp, float-mpfr
no_gmp integer-malachite, float-astro

Select exactly one integer backend and one floating-point backend. GMP/MPFR remain the defaults. Disable defaults when choosing alternatives:

[dependencies]
symbolica = { version = "3", default-features = false, features = [
    "integer-malachite", "float-astro", "native_code_generation"
] }

The same backend names apply to direct Numerica dependencies. Cargo unifies features across dependencies, so ensure all dependencies select compatible backends. Native evaluator compilation and JIT APIs require native_code_generation, which is enabled by default in Symbolica.

For WebAssembly, use default-features = false, features = ["wasm"]. This selects Malachite/Astro backends and the smaller polynomial-code mode; native compilation and JIT are unavailable in that configuration.

Solving and algebraic domains

Replace the legacy linear-system entry point with the solve builder:

use symbolica::prelude::*;

let x = parse!("x");
let y = parse!("y");
let equations = [parse!("x+y-3"), parse!("x-y-1")];
let solutions = AtomView::solve(&equations)
    .over(Reals)
    .wrt(&[x, y])?;

for (variable, value) in &solutions[0] {
    println!("{variable} = {value}");
}

Use .wrt_with_exponent::<E, _>() when selecting polynomial exponent storage explicitly. The same solution-coverage and free-variable considerations as in Python apply.

The symbolica::domains::algebraic_number module moved to symbolica::domains::algebraic. Update explicit imports of AlgebraicExtension and AlgebraicNumber, or use the prelude. The new module also exposes Root, AlgebraicContext, AlgebraicEmbedding, and AlgebraicQuotient for root-aware and formal algebraic computations.

Property checks, matching, series, and compiled evaluation

  • Mathematical expression property methods now return ConditionResult. Use .is_true() or .is_false() to require a proof, or convert to Option<bool> to handle the unknown case explicitly.
  • Replacement, ReplaceBuilder, and MatchSettings use .min_level(1).max_level(1) instead of .level_range(...). .max_level(None) clears the upper bound.
  • Series::coefficient(exponent) returns Option<F::Element>: None means the coefficient is unknown, whereas Some(0) means it is known to vanish. This includes missing terms below the leading term. Propagate None with ? in custom callbacks when appropriate, or use to_atom() to explicitly discard the remainder.
  • Compiled evaluators expose get_input_len() and get_output_len() per evaluation. Safe evaluate() calls check slice lengths before entering native code, and evaluate_batch() returns an error for incompatible shapes.

Ring ordering and sampling

Random sampling moved out of Ring into SampleableRing, with an associated SamplingPolicy. Generic code that samples ring elements should require that trait and pass a policy by reference. To embed a uniformly sampled integer in any ring, use sample_small_integer() or sample_integer() instead:

use symbolica::prelude::*;

let mut rng = symbolica::rand::rng();

// 2.2: Q.sample(&mut rng, (-4, 7))
let small = Q.sample_small_integer(&mut rng, -4..=7);

let policy = Integer::from(5)..=Integer::from(9);
let integer = Z.sample(&mut rng, &policy);

These ranges are inclusive. RNG traits and generators are re-exported through symbolica::rand and numerica::rand; Symbolica’s prelude includes Rng, RngCore, and SeedableRng.

Use OrderedRing::cmp() for mathematical total ordering or RealEmbedding::try_cmp() / try_sign() for a comparison that may fail. InternalOrdering::internal_cmp() provides an internal structural order and should not be used to infer mathematical signs.

Custom rings can expose optimized bulk operations through Ring::kernels(); the default provides no specialized kernels. Owned division helpers include try_div_owned() and quot_rem_owned(). Use exact_div_owned() only when divisibility is already established.

Integer representation

Integer::Double now contains DoubleInteger instead of a bare i128. Code that directly constructs or matches that variant must convert the payload:

use symbolica::domains::integer::Integer;

let value = Integer::Double((1_i128 << 80).into());
if let Integer::Double(inner) = value {
    let raw: i128 = inner.get();
    assert_eq!(raw, 1_i128 << 80);
}

Prefer Integer::from(value) when constructing an integer so that Numerica chooses its representation.

Integration grids and matrices

ContinuousGrid::new() and DiscreteGrid::new() now return Result<_, String>. Propagate or handle construction errors:

use symbolica::numerical_integration::{ContinuousGrid, DiscreteGrid, Grid};

let continuous = ContinuousGrid::<f64>::new(2, 128, 100, None, false)?;
let discrete = DiscreteGrid::new(
    vec![Some(Grid::Continuous(continuous))], 0.01, false,
)?;

Continuous dimensions and bin counts must be positive. A supplied bin-number evolution must be nonempty and contain only positive counts. Invalid discrete grid settings are also rejected.

Sparse matrix constructors validate CSR structure, including ordered, unique column indices within each row. Use SparseMatrix::try_from_csr() to receive an error; from_csr() and from_csr_slices() panic on invalid input. Dense matrix shape validation and singular-matrix inversion checks are stricter. Matrix::row_iter() now returns an iterator of rows instead of a concrete std::slice::Chunks type; update explicit type annotations.

Licensing

Symbolica 3.0 includes the Symbolica Source-Available License 1.0; Numerica remains MIT licensed. See the license information and the license supplied with the package.

Old offline keys must be renewed. The new format is S-<user-id>-YYYY.MM.DD-<signature>; the visible date expires at midnight UTC on that date. Obtain a replacement through the license page. Activation still uses Python’s set_license_key(), Rust’s LicenseManager::set_license_key(), or SYMBOLICA_LICENSE.

Rust licensing APIs now live in symbolica::license:

use symbolica::license::LicenseManager;

The root-level symbolica::LicenseManager path was removed; symbolica::prelude::* still includes it. ExecutionCapabilities, LibraryUnlock, and LibraryUnlockGuard also live in symbolica::license. The activate_oem_license! and register_library_unlock! macros are exported at the crate root. Library authors can use signed library keys for scoped multicore unlocking; Python packages register them with register_library_unlock() from the package named by the key.

Common Before/After Summary

Area 2.2 3.0
Deferred equality w_ == x, T() == x w_.eq(x), T().eq(x)
Deferred inequality w_ != x w_.ne(x)
Unknown properties Boolean checks Handle None in Python / ConditionResult in Rust
Linear solving solve_linear_system(...) solve(...), then read a branch’s assignments
High-precision results Decimal or Decimal pairs Float or ComplexFloat; convert explicitly
Match levels level_range=(a, b) min_level=a, max_level=b
Antisymmetric matching Argument permutations considered Positional matching; wildcard patterns retain argument order
Match-stack decisions -1, 0, 1 False, None, True
Compiled evaluator loading Caller supplies dimensions Dimensions loaded from rebuilt library
Instruction export Three-item tuple EvaluatorInstructions with nested bodies
Unknown series coefficient Indistinguishable from zero IndexError in Python / None in Rust
Rust backend features gmp, no_gmp Independent integer and float features
Rust grid construction Grid value Result
Rust algebraic module domains::algebraic_number domains::algebraic
Rust licensing import symbolica::LicenseManager symbolica::license::LicenseManager