Skip to content

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 and the API refinements shipped in the 3.0.x bug-fix releases. It describes the current 3.0 API, matching the website's 3.0.7 browser build. 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. 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. Replace legacy license keys when prompted and update direct Rust licensing imports and worker authorization.

  11. Review custom printers and notebook display code for bounded MathML output.

  12. When using Community tensors, preserve their tensor interface and update imports to the current Community namespaces.

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.

Testing a zero identity

Structural equality still does not prove an algebraic identity. Use check_zero() when simplification and sampling are needed, and handle all three results:

from symbolica import E

identity = E('(x+1)^2-x^2-2*x-1')
assert identity.check_zero(numerical_samples=0) is True

The keyword options control finite-field samples, numerical samples, maximum precision, exact fallback, and whether floating coefficients are treated as exact stored values. Numerical nonzero tests are heuristic; set numerical_samples=0 to disable them. A result of None means undecidable, and a small numerical value is not treated as zero by a tolerance.

Slicing children and mixed arithmetic

Expression indexing accepts negative indices, and slicing returns a Python list of immediate children in iteration order:

from symbolica import S

f, x, y, z = S('f', 'x', 'y', 'z')
assert f(x, y, z)[-1] == z
assert f(x, y, z)[1:] == [y, z]

This does not recursively traverse an expression. Arithmetic with a custom right operand can dispatch to its reflected methods, such as __radd__ and __rmul__. Extension types should implement those methods, return NotImplemented for unsupported inputs, and preserve their own return types.

Exact Equation Solving

See Solving equations for a runnable introduction to branches, free variables, and coverage.

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.

Algebraic Coefficient Fields

Ordinary .to_polynomial() conversion still treats radicals as independent variables. In 3.0, use extensions=[] to discover algebraic coefficients, or supply generators to include a larger field:

from symbolica import E, P

p = E('x+sqrt(2)').to_polynomial(extensions=[])
q = E('x^2-2').to_polynomial(extensions=[E('sqrt(2)')])
formal = P('x^2-2', minimal_poly=P('t^2-2'))

Radicals and selected root(...) expressions specify embedded algebraic numbers. A formal minimal polynomial defines a generator without selecting a particular root. Existing polynomials can also be converted with to_number_field; adjoin explicitly composes formal fields. See the polynomial tutorial for all routes.

For direct expression factorization, the keyword is singular: E('x^2-2').factor(extension=[E('sqrt(2)')]).

Selecting algebraic roots

Root indices now put real roots first in ascending order, followed by complex roots ordered by real part and then imaginary part. For example, index zero of (x**3-x-1).root(0) selects its real root. If code previously stored root indices, check that each still selects the intended embedding rather than assuming the old enumeration is unchanged.

Polynomial root isolation

Polynomial.isolate_roots() now returns (IsolatedRoot, multiplicity) pairs for distinct roots in the complex plane. Use isolate_real_roots() for only real roots. Code that expects rational interval endpoints should use isolate_real_root_intervals(), which returns (lower, upper, multiplicity).

An IsolatedRoot provides a certified disk's center and radius, refine(tolerance), and to_expression() for an exact symbolic root. The center is a pair of exact real and imaginary parts, not the root itself.

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 return NumPy arrays when NumPy is installed and nested Python lists otherwise. Install symbolica[numpy] when downstream code requires array methods, dtypes, or broadcasting; otherwise consume the results as nested sequences.

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

Function symbols and tags in the instruction stream are Expression objects, not strings. In particular, EvaluatorFunction.tags is a list of expressions. Do not assume that an instruction object can be serialized directly as JSON.

Registered constants and fixed-argument calls, such as pi and exp(1), have entries in exported.constant_functions. Each EvaluatorConstantFunction provides read-only index, function, tags, and arguments properties. Code generators must compute these entries at their target precision and fill the corresponding constant slots; their exact placeholders are not the values of the constants.

Use Evaluator.from_instructions() to reconstruct a validated evaluator:

from symbolica import E, S, Evaluator

x = S('x')
original = E('x^2+1').evaluator([x], jit_compile=False, n_cores=1)
restored = Evaluator.from_instructions(original.get_instructions())
assert restored.evaluate([[3.0]])[0][0] == 10

Retained bodies and registered constant definitions are preserved. External callback implementations must still be registered on their symbols. For transfer to another thread or process, Evaluator.save() and Evaluator.load() include and restore the necessary symbolic state; callbacks are not serialized. Regenerate older saved artifacts rather than relying on private binary formats remaining identical between builds.

Notebook Output and Custom Printers

formatted() now uses bounded, interactive MathML by default. To retain the highlighted text presentation, use expr.formatted(mathml=False). max_terms controls the legacy text view; use page_size to bound the number of entries requested in a MathML page.

from symbolica import S

x, y, z = S('x', 'y', 'z')
expr = ((x+y+z)**20).expand()
viewer = expr.mathml_viewer(page_size=25)
page = viewer.page()  # Bounded HTML and metadata; no widget import required.
assert page['end'] <= 25
viewer.close()

Omitting page_size allows automatic previews to include a compact complete product when it fits. An explicit size remains a maximum, and rendering budgets can show fewer entries. Custom integrations can use the shared MathMLViewer with a MathMLPageSource; javascript() supplies the standalone browser renderer. The website and Gallery provide this integration without loading AnyWidget. Other notebook environments need the display integration appropriate to their frontend.

Custom print callbacks may now receive PrintMode.MathML. Return a bounded MathML fragment for that mode, or None to use the native renderer; plaintext, LaTeX, and Typst strings are not interchangeable with MathML. Symbol printers can also use a dictionary mapping mode names or PrintMode members to strings.

Community Tensor and Graph APIs

Use symbolica.community.tensor for tensor algebra and symbolica.community.hepkit for particle-physics tools. The older spenso import forwards to tensor, and the older Community vakint import forwards to hepkit.vakint. Former Idenso simplifications are methods on TensorExpression.

Tensor-aware transformations preserve the ordered external-axis interface. Use outer() to keep axes independent and contract_ports() or compose() to choose explicit contractions or matrix channels. Use to_expression() when deliberately dropping the tensor interface. Inherited scalar transformations can return ordinary expressions, so check the return type when continuing with tensor operations.

For interactive tensor output, use tensor_expr.paged(settings=...) rather than the inherited scalar viewer factory. It retains tensor notation and index labels. The direct MathML pager does not accept custom Typst notation_source; use to_typst_project() or to_svg() for that output. Graph objects can be displayed directly in the Gallery, where their interactive SVG controls are isolated from the notebook's reactive output tree.

Series Coefficients

See Series expansions for runnable examples of the coefficient boundary and fractional powers.

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.

Rust builds that expose the C bindings must enable the separate c_api feature; those bindings are no longer compiled unconditionally.

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.

Shared extensions and bounded computations

Optional Python implementations can register through Rust's ExtensionPoint and ExtensionImplementation in symbolica::api::python. Keep participating components in one native library with one Symbolica kernel and shared PyO3 class identities. Use the actual Rust types to pass objects between components instead of importing another component's Python module to find its implementation. The owning class still defines each public method and its stub; registration supplies the implementation.

The Rust API also adds tracked numerical evaluation, polynomial and rational reconstruction, and shared cooperative memory and elapsed-time budgets. Tracked values retain uncertainty separately from working precision; increasing precision does not make an uncertain input exact. Fallible reconstruction oracles and nested resource scopes propagate failures rather than silently continuing with a partial result.

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.

Legacy keys are processed with an outdated-key warning instead of being rejected solely for their old format. Replace them to use the current signed format and offline validation. 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.

Dependent Rust libraries can propagate an authorized operation to their workers with InheritedLibraryUnlock. Capture on the calling thread and keep an activation guard alive inside each worker:

use symbolica::license::InheritedLibraryUnlock;

let unlock = InheritedLibraryUnlock::capture();
std::thread::scope(|threads| {
    threads.spawn(|| {
        let _guard = unlock.activate();
        // Run this library's Symbolica operations here.
    });
});

Keep the captured handle within the licensed library operation. Rust workers without Python thread state no longer try to acquire the GIL just to inspect Python authorization frames; dependent libraries still need to propagate their caller's authorization at the worker boundary.

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
Instruction constants and tags Placeholder slots / string assumptions Resolve constant_functions; names and tags are expressions
Machine-precision batches NumPy assumed Arrays with NumPy; nested lists without it
Notebook formatting Highlighted text settings Bounded MathML by default; mathml=False for text
Root indices Previous enumeration Real roots first, then complex roots
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