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

Alkahest

Alkahest is a high-performance computer algebra system with a Rust core and Python API.

Install: pip install alkahest from PyPI (Python 3.9–3.13). Optional RL environments: pip install "alkahest[rl]" (Python ≥ 3.10). Default wheels include vendored egglog (egraph), the Gröbner solver (groebneralkahest.solve and related APIs work out of the box), the Cranelift JIT (cranelift) and the multi-core paths (parallel). Only the LLVM JIT is omitted; Linux linux_x86_64 +jit / +full wheels ship on GitHub Releases. The Getting started chapter has full install notes (venv, LD_LIBRARY_PATH, building from source). See Reinforcement learning for verifiable RL environments.

What it is

A general-purpose symbolic math library designed around three axes:

Performance. The Rust kernel uses hash-consed directed acyclic graphs so structural equality is a pointer comparison and subexpression sharing is automatic. FLINT backs polynomial arithmetic. An LLVM JIT compiles symbolic expressions to native or GPU code at runtime. Common operations run orders of magnitude faster than SymPy.

Correctness. Every simplification and transformation produces a derivation log — the exact sequence of rewrite rules applied, with arguments and side conditions. A subset of operations can export Lean 4 proof terms verifiable by an independent checker.

Ergonomics. The Python API uses operator overloading for natural expression construction. Results are rich objects with .value, .steps, and .certificate attributes. Error messages carry structured codes, location information, and suggested remediations.

Agent loops. Budgets and cancellation, batch APIs that never abort on one bad candidate, versioned compact result envelopes, and session-level claim graphs are first-class — see Autoresearch / agent loops.

Design principles

Explicit representations. The type system distinguishes UniPoly (FLINT-backed univariate polynomial), MultiPoly (sparse multivariate), RationalFunction, and the generic Expr tree. Converting between them is an explicit call. There are no silent representation changes hiding performance cliffs.

Stateless by design. No global assumption contexts. No hidden caches that change behavior. All context (domains, simplification policy, precision, budgets) is passed explicitly or scoped through context(...). This makes results deterministic and parallelism safe.

Composable transformations. trace, grad, jit, and certify operate on a shared traced representation and stack freely: jit(grad(f)) compiles a derivative, jit(grad(grad(f))) compiles a second derivative.

A small primitive set. Each primitive (sin, exp, add, mul, …) registers a full bundle: simplification rule, forward- and reverse-mode differentiation, MLIR lowering, Lean theorem tag, numerical evaluation. New operations are added by registering a primitive, not by adding code paths across the system.

Compared to alternatives

SymPySageMathSymbolics.jlAlkahest
PerformanceSlowModerateFastFast
GPU codegenNoNoNoYes
Lean proofsNoNoNoYes
Python APIYesYesNo (Julia)Yes
Open sourceYesYesYesYes

This guide

The guide covers the Rust-level design concepts. For the generated Python API reference (Sphinx), see the API documentation.

Getting started

Install

PyPI (default)

Alkahest is on the Python Package Index. Supported interpreters are Python 3.9 through 3.13 (requires-python on PyPI).

python -m pip install -U pip
pip install alkahest

Use a virtual environment when you also build from source or test multiple Python versions:

python3 -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
python -m pip install -U pip
pip install alkahest

Default PyPI wheels are built with egraph, groebner, cranelift and parallel: the vendored egglog e-graph backend, the Gröbner solver (so alkahest.solve, Diophantine and homotopy work out of the box), the pure-Rust Cranelift JIT (so alkahest.jit_is_available() is True without a system LLVM), and the Rayon-backed multi-core paths. They do not include the LLVM JIT (llvm_jit) or cuda.

parallel is on in the default wheel — but still check it

capabilities()["features"]["parallel"] is True on every wheel published to PyPI, on Linux, macOS and Windows alike. The parallel Cargo feature is what powers the sharded ExprPool, the parallel F4 reduction, and every *_par entry point (numpy_eval_par, simplify_par), so those are genuinely multi-core out of the box.

It is still the one feature worth probing rather than assuming, because its absence is silent. parallel is not a Cargo default: a source build that does not pass --features parallel still gets working numpy_eval_par and simplify_par — they transparently fall back to their single-threaded counterparts, with no error, no warning, and no speedup. Benchmarking numpy_eval_par against numpy_eval on such a build measures the same number twice.

Never infer parallelism from the function existing; ask:

import alkahest as ak
if not ak.capabilities()["features"]["parallel"]:
    ...  # a source build without --features parallel; *_par is a no-op alias here

There is no pip install alkahest[jit] / alkahest[full] that swaps the native extension: pip extras only add Python dependencies, not alternate binaries.

For native LLVM CPU JIT use an opt-in +jit or +full Linux wheel from GitHub Releases (below), or build from source with --features jit. See the repository README.md for the same policy in short form.

Optional Linux wheels (+jit / +full)

Tagged releases attach linux_x86_64 wheels on GitHub Releases (CI builds them on ubuntu-22.04; these are not the manylinux wheels published as the default PyPI binaries). Pick the .whl whose tags match your Python (cp311, etc.) and linux_x86_64.

Local versionCargo featuresWhen to use
+jitegraph groebner jit parallelLLVM CPU JIT in place of Cranelift; everything else matches the default PyPI wheel.
+fullegraph groebner jit cranelift parallelThe only wheel with both JIT backends, and a strict superset of the default wheel. Use it when you want LLVM without giving up Cranelift.

Example direct installs (replace <version> and the wheel name using the release asset list):

pip install "https://github.com/alkahest-cas/alkahest/releases/download/v<version>/alkahest-<version>+full-cp311-cp311-linux_x86_64.whl"
pip install "https://github.com/alkahest-cas/alkahest/releases/download/v<version>/alkahest-<version>+jit-cp311-cp311-linux_x86_64.whl"

These wheels vendor LLVM and related .so files under site-packages/alkahest.libs/. If import alkahest fails with a missing libLLVM-*.so or libffi-*.so, prepend that directory to LD_LIBRARY_PATH (or install matching system packages).

If your downloader rejects + in the URL, percent-encode it in the filename segment (e.g. 2.0.2%2Bfull).

After +jit or +full, alkahest.jit_is_available() should be True. Gröbner-backed APIs such as alkahest.solve are available in all wheels (including the default PyPI wheel) since groebner became a default Cargo feature in 2.3.1.

macOS and Windows +jit / +full wheels are not produced in CI yet; use building from source there.

Roadmap: a small PEP 503 extras index URL hosting only +jit / +full wheels (PyTorch-style --extra-index-url). Until then, use PyPI for the default wheel or direct URLs / asset downloads from Releases.

Build-profile verification

Every published wheel runs an import-and-capability smoke test in release CI. After installing, inspect the exact native build rather than inferring features from available Python functions:

import alkahest as ak

features = ak.capabilities()["features"]
print(features)
DistributionTested platformsNative feature profileparallel
Default PyPI wheelLinux x86_64, macOS arm64, Windows x86_64egraph, groebner, cranelift_jit, parallelTrue
Release +jitLinux x86_64egraph, groebner, llvm_jit (no Cranelift), parallelTrue
Release +fullLinux x86_64same as +jitTrue
Source buildanywhatever you pass to --featuresTrue only with --features parallel

parallel is called out separately because it is the one feature whose absence is silent: numpy_eval_par and simplify_par exist and work in every build, and simply stop being parallel when it is off. Every published wheel now enables it, so the row that can surprise you is the last one — parallel is not a Cargo default, so a source build has to ask for it.

jit and cranelift remain compatibility names in this mapping. Prefer llvm_jit and cranelift_jit when selecting a backend explicitly. cuda indicates that the extension was compiled with NVPTX codegen — it guarantees that ak.compile_cuda and ak.CudaCompiledFn exist, but not that a usable GPU is present at runtime, and it is in no published wheel. There is no groebner_cuda bit: the GPU Gröbner kernel has no Python binding, so the bit was unfalsifiable from Python and was removed in contract v3. Read GPU support before branching on cuda.

Optional: RL environments (alkahest[rl])

Reinforcement-learning environments (symbolic integration, Prime Intellect Hub) are an optional extra. Requires Python ≥ 3.10 (verifiers does not support 3.9).

pip install "alkahest[rl]"

This adds verifiers and datasets. Environment code ships in the main wheel under alkahest.rl. See the RL guide for API details, veRL integration, and Environments Hub publishing.

From source

For optional Cargo features (jit, parallel, cuda, …), GPU/NVPTX, or development, build the PyO3 extension with maturin. The groebner and egraph features are default and included automatically.

Prerequisites (typical): Rust stable (≥ 1.76) and nightly, LLVM 15 (only for --features jit), FLINT (≥ 2.9, 3.x recommended; pulls in GMP/MPFR). See the repository README for distro-specific package names.

FLINT is a hard requirement of every source build. There is no FLINT-free configuration: UniPoly is a FLINT polynomial, and factorization, resultants, normal forms and number_theory call FLINT directly with no pure-Rust fallback. The flint3 Cargo feature selects which FLINT version’s API to use — it does not make FLINT optional. Without it the build stops early with an install hint (sudo apt-get install libflint-dev, dnf install flint-devel, brew install flint, conda install -c conda-forge libflint, …).

Without root, build FLINT into a user-local prefix and point the build at it:

FLINT_LIB_DIR=$PREFIX/lib FLINT_INCLUDE_DIR=$PREFIX/include \
  maturin develop --manifest-path alkahest-py/Cargo.toml --release
export LD_LIBRARY_PATH=$PREFIX/lib      # DYLD_LIBRARY_PATH on macOS

Both variables also feed FLINT version detection, so a locally built FLINT 3 is recognised as FLINT 3. ALKAHEST_SKIP_FLINT_CHECK=1 bypasses the presence probe. If you only need a working install, the PyPI wheels already bundle FLINT.

pip install maturin
git clone https://github.com/alkahest-cas/alkahest.git
cd alkahest
maturin develop --manifest-path alkahest-py/Cargo.toml --release

The default build already includes egraph and groebner. Additional optional features:

# LLVM JIT for native compiled evaluation
maturin develop --manifest-path alkahest-py/Cargo.toml --release --features jit

# Pure-Rust Cranelift JIT (fast compile, no system LLVM required)
maturin develop --manifest-path alkahest-py/Cargo.toml --release --features cranelift

# Parallel simplification and parallel F4 (sharded ExprPool + numpy_eval_par)
maturin develop --manifest-path alkahest-py/Cargo.toml --release --features parallel

# CUDA / NVPTX codegen (requires CUDA toolkit and LLVM with NVPTX target)
maturin develop --manifest-path alkahest-py/Cargo.toml --release --features cuda

# Full native build (JIT + parallel; egraph and groebner are already default)
maturin develop --manifest-path alkahest-py/Cargo.toml --release --features "parallel cranelift jit"

Rust crate

alkahest-cas is also published on crates.io (docs.rs) for use directly from Rust:

[dependencies]
alkahest-cas = "2"

# groebner is included by default; add other optional features as needed:
# alkahest-cas = { version = "2", features = ["parallel", "egraph"] }

System prerequisites (same libraries as the Python build — must be installed before cargo build):

# Debian / Ubuntu
sudo apt-get install -y libflint-dev libgmp-dev libmpfr-dev

# macOS (Homebrew)
brew install flint

The jit feature additionally requires LLVM 15 dev headers (llvm-15-dev / brew install llvm@15).

A self-contained runnable example is in examples/rust_quickstart/.

First steps

Every computation starts with an ExprPool. It owns all expressions; you create symbols and integers from it.

import alkahest
from alkahest import ExprPool, diff, simplify, integrate, sin, exp, cos

pool = ExprPool()
x = pool.symbol("x")
y = pool.symbol("y")

Building expressions

Python operators build expression trees:

expr = x**2 + pool.integer(2) * x + pool.integer(1)
print(expr)  # x^2 + 2*x + 1

Math functions accept expressions:

f = sin(x**2) + exp(x * y)

Parsing expressions from strings

Use parse when the expression comes from user input or a config file:

from alkahest import parse

e = parse("x^2 + 2*x + 1", pool, {"x": x})
print(e)   # x^2 + 2*x + 1

Identifiers not in the symbols dict are auto-created as symbols in pool. Both ^ and ** denote exponentiation. See Parsing from strings for the full syntax reference.

Simplification

r = simplify(x + pool.integer(0))
print(r.value)  # x
print(r.steps)  # [RewriteStep(rule='add_zero', ...)]

Differentiation

dr = diff(sin(x**2), x)
print(dr.value)  # 2*x*cos(x^2)

Integration

r = integrate(exp(x), x)
print(r.value)   # exp(x)

r = integrate(sin(x), x)
print(r.value)   # -cos(x)

Polynomial arithmetic

from alkahest import UniPoly, RationalFunction

# Convert to FLINT-backed univariate polynomial
p = UniPoly.from_symbolic(x**3 + pool.integer(-1), x)
q = UniPoly.from_symbolic(x + pool.integer(-1), x)
print(p.gcd(q))          # x - 1
print(p // q)            # x^2 + x + 1

Compiled evaluation

from alkahest import compile_expr, eval_expr

# Scalar evaluation via a dict binding
result = eval_expr(x**2 + y, {x: 3.0, y: 1.0})
print(result)  # 10.0

# JIT-compiled callable
f = compile_expr(x**2 + pool.integer(1), [x])
print(f([3.0]))  # 10.0

Vectorized evaluation over NumPy arrays

import numpy as np
from alkahest import compile_expr, numpy_eval

f = compile_expr(sin(x) * exp(pool.integer(-1) * x), [x])
xs = np.linspace(0, 10, 1_000_000)
ys = numpy_eval(f, xs)  # vectorised; much faster than a Python loop

Context manager

with alkahest.context(pool=pool, simplify=True):
    z = alkahest.symbol("z")  # uses the active pool
    expr = z**2 + alkahest.sin(z)

Agent / autoresearch loops

For a fan-out of candidates under a wall-clock or step budget, with results that survive context compaction:

import alkahest as ak

pool = ak.ExprPool()
x = pool.symbol("x")

with ak.context(pool=pool, budget=ak.Budget(wall_ms=100, seed=1)):
    outs = ak.integrate_many([x**2, ak.sin(x)], x)
    for item in outs:
        if item.ok:
            print(item.value.to_dict(mode="compact")["verification"]["status"])
        else:
            print(item.error["code"])  # e.g. E-INT-001 or E-BUDGET-001

Full picture: Autoresearch / agent loops, Budgets, Batch, Claim graphs.

Running the examples

The examples/ directory in the Git repository has runnable end-to-end scripts. With alkahest installed (pip install alkahest or maturin develop as above), from the repository root run:

python examples/calculus.py
python examples/polynomials.py
python examples/jit_eval.py
python examples/ball_arithmetic.py
python examples/ode_modeling.py

If you are developing without installing the extension into the active environment, set PYTHONPATH=python so the pure-Python package is importable alongside your build.

Python API reference

The Sphinx-generated Python API is published alongside this guide:

Open the Python API documentation

It includes ExprPool, simplification, calculus, polynomials, numerics, transforms, matrices, ODE/DAE, solvers, codegen, error types, and the search / workload surface (Budget, batch_map, DerivedResult.to_dict, …).

Conceptual chapters for agent-facing plumbing:

TopicGuide
Budgets, cancellation, seeds, pool lifetimeBudgets
Batch / streaming fan-outBatch
Compact machine-parseable resultsDerivation logs
Session provenanceClaim graphs
OverviewAutoresearch / agent loops

Submodules

Not everything lives on the top-level namespace as a function. These are reached as alkahest.<name> and documented in their own chapters:

ModuleWhat it isGuide
alkahest.ansatzParametric families (polynomial, rational, exponential_polynomial, linear_combination, quadratic_form) plus fit, enumerate_family, certify_nonnegAnsatz families
alkahest.crosscheckDifferential testing against an external CAS oracle: check, sweep, run_frozen_corpus, to_sympy, register_oracleCross-CAS testing
alkahest.smtSMT-LIB 2 export and z3/cvc5 bridge: to_smtlib, solve, supported, solversSMT bridge
alkahest.researchSession claim graphs and provenanceClaim graphs
alkahest.experimentalTransforms, dsolve, asymptotics, residue, Fps, to_jax — may change in a minor release. Must be imported explicitly (from alkahest import experimental as ex); it is not an attribute of the top-level module until thenStability policy
alkahest.rlVerifiable RL environmentsReinforcement learning
alkahest.number_theory, alkahest.modular, alkahest.latticeFLINT-backed integer and lattice routines

alkahest.ansatz, alkahest.crosscheck and alkahest.smt are new in 3.8. They are in alkahest.__all__ and resolve on attribute access without a separate import, as do their error classes AnsatzError, CrossCheckError and SmtError.

Kernel design

The expression kernel is the foundation everything else builds on. It lives in alkahest-core/src/kernel/.

Hash-consed DAG

Every expression is represented as a directed acyclic graph stored in an ExprPool. Nodes are interned: before inserting a new node, the pool checks whether a structurally identical node already exists. If it does, the existing ExprId is returned instead of allocating a new node.

This gives three properties:

  1. Structural equality is a pointer comparison. id_a == id_b iff the expressions are structurally identical. No tree traversal required.
  2. Automatic subexpression sharing. If sin(x²) appears in ten different expressions, there is only one sin(x²) node in memory.
  3. Hash-based memoization is cheap. Caching the result of a transformation keyed by ExprId is O(1) and correct. Hot recursive paths (simplify, differentiation, integration guards, JIT interpreter) use per-call HashMap<ExprId, T> memo tables so shared DAG nodes are processed once, not once per tree occurrence.

ExprPool

ExprPool is the intern table. It owns all expressions in a session.

pool = ExprPool()
x = pool.symbol("x")       # intern a Symbol node
n = pool.integer(42)       # intern an Integer node

Multiple pools are independent. An ExprId from one pool must not be mixed into another — the pool validates this in debug builds.

Persistent pool (V1-14). A pool can be serialized to disk and reopened, preserving all ExprIds across sessions:

pool.save_to("session.alkp")
pool2 = ExprPool.load_from("session.alkp")

Sharded pool. With --features parallel, the intern table uses a sharded concurrent hashmap (DashMap), allowing multiple threads to insert expressions without contention.

ExprData variants

Each interned node is one of:

VariantDescription
Symbol(name, domain)Named variable with a domain annotation
Integer(n)Exact arbitrary-precision integer
Rational(p, q)Exact rational number
Add(children)N-ary addition
Mul(children)N-ary multiplication
Pow(base, exp)Exponentiation
Call(primitive, args)Application of a registered primitive
Piecewise(cases)Conditional expression
Predicate(kind, args)Boolean condition (inequality, equality)

Add and Mul are n-ary: a + b + c is one Add node with three children, not two nested Add nodes. Children are sorted at construction time so that commutativity is structural — a + b and b + a produce the same interned node.

Domains

Every symbol carries a domain as part of its structural identity:

x_real = pool.symbol("x", "real")
x_complex = pool.symbol("x", "complex")
# x_real and x_complex are distinct expressions — different ExprIds

The domain is not a global assumption; it is part of what the symbol is. Simplification rules can query a symbol’s domain to decide whether a rewrite is valid (e.g. sqrt(x²) → x requires x to be non-negative).

Available domains: real, positive, nonnegative, integer, complex. The default when no domain is specified is real.

ExprId and memory

ExprId is a 32-bit index into the pool’s internal arena. It is Copy, Send, and Sync. Cloning an ExprId is free. No reference counting is needed because the pool owns all nodes; expressions are not freed until the pool is dropped.

That last clause is a hard limit, not an implementation detail. The arena is append-only: there is no clear, no truncate, no refcount and no GC, so nothing is ever reclaimed while the pool is alive, and the storage cannot shrink. A distinct expression costs roughly 200 bytes of resident memory per node, permanently. A loop that builds a module-scope pool once and then calls into it forever grows linearly and without bound, at flat per-call latency — so it OOMs with no slowdown to warn you first. Every Expr, Matrix, Series and DerivedResult holds a strong reference to its pool, so retaining one result retains the whole history.

The supported pattern for unattended work is one pool per problem, dropped when the problem is done: see Budgets → ExprPool never reclaims.

The kernel is designed with parallelism as a first-class property. All kernel types are Send + Sync. The simplification and differentiation passes can run concurrently on disjoint ExprIds from the same pool.

Interning cost model

Interning a new node requires:

  1. Hash the ExprData.
  2. Look up in the concurrent hash map.
  3. On miss: allocate the node in the arena and insert into the map.
  4. On hit: return the existing ExprId.

Step 4 (the common case in a running computation) is a single hash lookup plus a pointer load. The arena uses bump allocation, so step 3 is also fast.

The memory benchmark group in alkahest-core/benches/alkahest_bench.rs verifies that rebuilding an identical expression tree does not grow the pool. Note the scope of that guarantee: it covers repeated work, not new work. A stream of distinct inputs grows the pool by every node it interns, and none of it comes back — see the warning under ExprId and memory.

One documented exception to “identical input does not grow the pool”: Matrix.eigenvals() interns a fresh gensym per call, so it grows by about 1.9 KB per call even on the same matrix. Cache its result rather than recomputing.

Expression representations

Alkahest exposes multiple representation types rather than hiding everything behind a single Expr. This is a deliberate design decision: the representation is visible, conversions are explicit, and performance characteristics are predictable.

Choosing a representation

If you need…Use
General symbolic computationExpr
Fast univariate polynomial arithmeticUniPoly
Sparse multivariate polynomial algebraMultiPoly
Sparse polynomial over 𝔽ₚ (modular / interpolation)MultiPolyFp
Rational functions with automatic cancellationRationalFunction
Rigorous enclosures with error boundsArbBall

Conversion to a specialized type is always an explicit opt-in:

expr = x**3 + pool.integer(-2) * x + pool.integer(1)
p = UniPoly.from_symbolic(expr, x)   # explicit conversion

If the expression cannot be represented in the target type (e.g. sin(x) as a polynomial), a ConversionError is raised with a remediation hint.

Expr

The generic symbolic expression. All other types convert to and from Expr. Built by operator overloading on the Python side:

expr = x**2 + pool.integer(3) * x * y - pool.integer(1)

Operations like diff, simplify, and integrate work on Expr and return DerivedResult objects wrapping an Expr.

UniPoly

Dense univariate polynomial backed by FLINT. Coefficients are exact integers or rationals stored in a FLINT polynomial object.

from alkahest import UniPoly

# x^3 - 2x + 1
p = UniPoly.from_symbolic(x**3 + pool.integer(-2) * x + pool.integer(1), x)

print(p.degree)          # 3
print(p.coefficients())  # [1, -2, 0, 1]  (constant first)
print(p.leading_coeff)   # 1  (a property, and an exact Python int)

# Arithmetic — all FLINT-backed, exact
q = UniPoly.from_symbolic(x + pool.integer(-1), x)
print(p * q)             # x^4-x^3-2*x^2+3*x-1
print(p.gcd(q))          # x-1
print(p // q)            # x^2+x-1
print(p % q)             # 0

# Powers
r = UniPoly.from_symbolic(x + pool.integer(1), x)
print(r ** 3)            # x^3+3*x^2+3*x+1

degree, is_zero and leading_coeff are properties (zero-argument O(1) accessors); coefficients() is a method because it allocates a list. Note that coefficients() is i64 and truncates coefficients that do not fit, whereas leading_coeff is exact at any size.

UniPoly is the right choice when you are doing heavy univariate polynomial arithmetic (GCD chains, resultants, factorization) because FLINT applies highly optimized algorithms with exact arithmetic.

MultiPoly

Sparse multivariate polynomial over ℤ (integers). Terms are stored as a map from exponent vectors to coefficients.

from alkahest import MultiPoly

# x^2*y + x*y^2 - 1
expr = x**2 * y + x * y**2 + pool.integer(-1)
mp = MultiPoly.from_symbolic(expr, [x, y])

print(mp.total_degree)       # 3
print(mp.integer_content())  # 1

# Arithmetic
mp2 = MultiPoly.from_symbolic(x * y, [x, y])
print(mp + mp2)              # -1 + xy + xy^2 + x^2y
print(mp * mp2)              # -xy + x^2y^3 + x^3y^2

Terms print in the polynomial’s internal (ascending exponent-vector) order, not in descending degree.

Variable order matters for the exponent-vector key. Pass variables in a consistent order when constructing MultiPoly objects that will be combined.

MultiPolyFp

Sparse multivariate polynomial over 𝔽ₚ = ℤ/pℤ. Used as the working type for black-box sparse interpolation and sparse modular GCD.

from alkahest import sparse_interp_univariate, sparse_interp, gcd_sparse, MultiPoly

p = 32749  # prime

# Recover a sparse univariate from 2·term_bound black-box evaluations
# (Ben-Or/Tiwari).  Returns a list of (coefficient, exponent) pairs.
f = sparse_interp_univariate(lambda v: (v**5 + 3*v**3 + 7) % p, term_bound=3, prime=p)
print(f)   # [(7, 0), (3, 3), (1, 5)]   i.e. x^5 + 3*x^3 + 7

# Recover a sparse multivariate via Zippel's algorithm — this one is a MultiPolyFp,
# printed over positional variables x0, x1, ... in the order given by `vars`
f2 = sparse_interp(
    lambda vals: (vals[0]**3 * vals[1]**2 + vals[0] * vals[1]**4) % p,
    vars=[x, y], term_bound=2, degree_bound=5, prime=p,
)
print(f2)  # 1*x0*x1^4 + 1*x0^3*x1^2 (mod 32749)

# Sparse modular GCD over ℤ[x₁,...,xₙ] — substrate for exact GCD algorithms
a = MultiPoly.from_symbolic((x + y) * (x - y), [x, y])
b = MultiPoly.from_symbolic((x + y) * (x + pool.integer(1)), [x, y])
h = gcd_sparse(a, b, term_bound=4, degree_bound=4)
print(h)   # y + x

sparse_interp_univariate uses Berlekamp–Massey + BSGS discrete-log + Vandermonde solve and requires exactly 2 * term_bound oracle calls. sparse_interp uses Zippel’s variable-by-variable algorithm with batched Vandermonde lifting.

RationalFunction

Quotient of two MultiPoly objects, automatically reduced by their GCD.

from alkahest import RationalFunction

# (x^2 - 1) / (x - 1) → normalized to x + 1
numer = x**2 + pool.integer(-1)
denom = x + pool.integer(-1)
rf = RationalFunction.from_symbolic(numer, denom, [x])
print(rf)   # 1 + x

# Arithmetic preserves the rational form
rf_x = RationalFunction.from_symbolic(x, pool.integer(1), [x])
rf_inv = RationalFunction.from_symbolic(pool.integer(1), x, [x])
print(rf_x + rf_inv)   # (1 + x^2) / (x)

GCD normalization runs at construction, so every RationalFunction is in lowest terms.

ArbBall

A real interval [midpoint ± radius] backed by FLINT’s Arb library. Arithmetic on ArbBall values produces guaranteed enclosures of the true result.

from alkahest import ArbBall, ExprPool, interval_eval, sin

# ArbBall(midpoint, radius, precision_bits=53)
a = ArbBall(2.0, 0.5)    # [1.5, 2.5]
b = ArbBall(3.0, 0.0)    # exactly 3

# A ball prints as midpoint ± radius, not as an interval
print(a + b)   # ArbBall(5.000000 ± 5.00e-1)   i.e. [4.5, 5.5]
print(a * b)   # ArbBall(6.000000 ± 1.50e0)    i.e. [4.5, 7.5]

# Evaluate a symbolic expression rigorously
pool = ExprPool()
x = pool.symbol("x")
result = interval_eval(sin(x), {x: ArbBall(1.0, 1e-10)})
print(result.lo, result.hi)   # tight enclosure of sin(1)

The output ball is guaranteed to contain the true value for any input in the input balls. This is useful for:

  • Certified numerical evaluation
  • Proving bounds on symbolic expressions
  • Verification workflows alongside Lean certificate export

See Ball arithmetic for more detail.

Converting back to Expr

Conversion back to Expr is per-type, not universal. GbPoly — the Gröbner representation — round-trips:

from alkahest import expr_to_gbpoly, diff

g = expr_to_gbpoly(x**2 + pool.integer(1), [x])
expr_again = g.to_expr()   # (x^2 + 1)
dr = diff(expr_again, x)   # DerivedResult(value=(x * 2))

UniPoly, MultiPoly and RationalFunction do not currently expose a symbolic conversion. Read their coefficients (UniPoly.coefficients(), MultiPolyFp.terms) or keep the original Expr alongside the polynomial — from_symbolic does not consume it.

Parsing expressions from strings

alkahest.parse converts a human-readable math string into an Expr node using a Pratt (top-down operator precedence) recursive-descent parser.

import alkahest
from alkahest import ExprPool, parse, diff, simplify

pool = ExprPool()
x = pool.symbol("x")

e = parse("x^2 + 2*x + 1", pool, {"x": x})
print(e)                    # x^2 + 2*x + 1

dr = diff(e, x)
print(dr.value)             # 2*x + 2

Syntax

FormMeaning
42, 3.14, 1.5e-3Integer or float literal
x, alpha, x_1Symbol (created in pool on first use)
a + b, a - bAddition / subtraction
a * b, a / bMultiplication / division
a ^ b, a ** bExponentiation (right-associative)
-a, +aUnary negation / identity
(expr)Grouping
sin(x), atan2(y, x)Function call (one or two arguments)

Whitespace (spaces, tabs, newlines) is ignored everywhere.

Operator precedence

From lowest to highest:

LevelOperators
10+ - (infix)
20* /
25Unary - +
30^ ** (right-associative)

So -x^2 parses as -(x^2), not (-x)^2, and x^2^3 parses as x^(2^3) = x^8.

Supported functions

abs, acos, asin, atan, atan2, ceil, cos, cosh, erf, erfc, exp, floor, gamma, log, round, sign, sin, sinh, sqrt, tan, tanh

The symbols map

By default, every new identifier is interned as a fresh pool.symbol(name). Pass a pre-built symbols dict to bind identifiers to existing Expr objects, or to collect the symbols that were created:

# Pre-bind x to an existing symbol
x = pool.symbol("x")
e = parse("sin(x)^2 + cos(x)^2", pool, {"x": x})

# Collect auto-created symbols after parsing
sym_map: dict = {}
e = parse("a*x^2 + b*x + c", pool, sym_map)
print(sym_map.keys())   # dict_keys(['a', 'x', 'b', 'c'])

Identifiers not in the map are created and then added to the map, so the same string name always resolves to the same Expr within a single parse call.

Error handling

parse raises ParseError (code E-PARSE-001) on any lexical or syntax error. The exception’s .span attribute gives the (start, end) byte range of the offending token, and .remediation provides a hint:

from alkahest import ParseError

try:
    parse("sin(x) @ 2", pool, {"x": x})
except ParseError as e:
    print(e)           # unexpected character '@' at offset 7
    print(e.span)      # (7, 8)

try:
    parse("zeta(x)", pool, {"x": x})
except ParseError as e:
    print(e.remediation)  # known functions: abs, acos, asin, ...

Round-trip with pretty-printing

parse is the inverse of str() for expressions built from the operators and functions listed above:

from alkahest import latex, unicode_str

e = parse("sin(x)^2 + cos(x)^2", pool, {"x": x})
print(latex(e))        # \sin\!\left(x\right)^{2} + \cos\!\left(x\right)^{2}
print(unicode_str(e))  # sin(x)² + cos(x)²

Simplification

Alkahest provides two complementary simplification engines that operate on the same expression pool.

Rule-based simplification

simplify applies a fixed set of algebraic rewrite rules until no more apply (fixpoint). It is fast, predictable, and always terminates.

from alkahest import simplify

r = simplify(x + pool.integer(0))   # → x
r = simplify(x * pool.integer(1))   # → x
r = simplify(pool.integer(2) * pool.integer(3))  # → 6  (constant folding)

The default rule set covers:

  • Identity and absorbing elements (x + 0 → x, x * 1 → x, x * 0 → 0)
  • Constant folding (integer and rational arithmetic)
  • Basic polynomial simplification (x + x → 2*x, x² * x → x³)
  • Commutativity and associativity (normalized at construction)

Domain-specific rule sets

from alkahest import simplify_trig, simplify_log_exp, simplify_expanded

# Pythagorean identity and double-angle formulas
r = simplify_trig(sin(x)**2 + cos(x)**2)  # → 1

# Conservatively leaves branch-sensitive identities unchanged
r = simplify_log_exp(exp(log(x)))

# Expand products and collect like terms
r = simplify_expanded((x + pool.integer(1))**3)

Customizing the rule set

from alkahest import simplify_with, make_rule

# Add a custom rule: sin²(x) → 1 - cos²(x)
my_rule = make_rule("sin_sq_to_cos", lhs=sin(x)**2, rhs=pool.integer(1) - cos(x)**2)
r = simplify_with(expr, rules=[my_rule])

Conditional simplification (colored e-graphs)

Branch-sensitive rewrites are opt-in. In Python, create an explicit experimental context tied to one expression pool; x > 0 then enables sqrt(x²) → x:

from alkahest.experimental import Assumptions

assumptions = Assumptions(pool)
assumptions.refine(pool.gt(x, pool.integer(0)))
r = assumptions.simplify(sqrt(x**2))  # → x

The current fact language recognizes conjunctions of positive and non-zero predicates. Unsupported predicates are retained for contradiction detection but do not authorize a rewrite; a definitive contradiction raises AssumptionError with code E-SIMPLIFY-001. Contexts never modify the thread-local context() helper or global simplifier state.

Without a proven fact, branch-cut identities such as sqrt(x²) → x, exp(log(x)) → x, and log(a*b) → log(a) + log(b) remain unchanged. Algebraic cancelations such as x/x → 1 and x^0 → 1 still fire in the default simplifier and record NonZero side conditions in the derivation log. The colored pass runs after ordinary rule simplification and preserves repeated terms and factors.

The literal-zero carve-out

b · b⁻¹ → 1 is a documented convention for a symbolic base, but it is not a convention anybody holds when the base is the literal integer 0: 0⁻¹ is division by zero, so 0 · 0⁻¹ is the indeterminate form 0 · ∞ and has no value. Through 3.7 simplify returned 1 for it, simplify_egraph returned 0, and simplify(5 · 0⁻¹ · 0) returned 0 — three answers that were their own proof that at least two were wrong. As of 3.8 all of them decline:

import alkahest as ak

pool = ak.ExprPool()
Z = pool.integer(0)
undefined = Z * Z ** pool.integer(-1)

ak.simplify(undefined).value          # (0 * 0^-1) — left alone
ak.simplify_egraph(undefined).value   # (0 * 0^-1) — same
ak.simplify(pool.integer(5) * Z ** pool.integer(-1) * Z).value   # (0 * 0^-1 * 5)

The guard tests for a literal zero base. Because the rule engine normalises strictly bottom-up, that also covers every base the simplifier can itself reduce to zero — x - x included, so diff(2/(x - x), x) no longer reports 1 for a function whose domain is empty. A base that is zero but not provably so keeps the b · b⁻¹ → 1 convention: deciding it would need a three-valued zero test on the Mul rewrite path, which costs several 128-bit ball evaluations per node.

Unaffected controls, so you can see the boundary: x · x⁻¹ → 1, 0 · x → 0, and 2x − 2x → 0 all still fire.

Parallel simplification

from alkahest import simplify_auto, simplify_par, simplify_redex, simplify_strategy

simplify_par(expr)      # fork-join: best on wide expressions
simplify_redex(expr)    # level-scheduled: best on deep ones, deterministic log
simplify_auto(expr)     # picks one of the two from the expression's shape
simplify_strategy(expr) # "fork_join" | "level_scheduled" | "sequential"

Each takes a single expression and returns the same result as simplify; only the schedule differs. Published wheels are built with --features parallel, so all three schedulers are real there. parallel is not a Cargo default, though: a source build that omits it falls back to the sequential path, so all four remain callable and simplify_strategy then reports "sequential" — which is the way to tell the two situations apart.

Neither parallel scheduler dominates:

simplify_parsimplify_redex
Strategyfork-join mirroring the sequential traversalexpression bucketed by height, one par_iter per level
Forks onAdd/Mul with ≥ 4 childrenevery node, regardless of type
Memosharded DashMapflat Vec<AtomicU32> indexed by ExprId
Traversalrecursive (stack-refill trampoline for deep inputs)iterative
Derivation logorder varies with thread countdeterministic

Measured on 32 cores, best time over 1–32 threads:

shapesequentialsimplify_parsimplify_redex
deep chain (2000 levels, width 1)38.7 ms23.1 ms5.5 ms
wide sum, independent terms28.4 ms5.1 ms10.3 ms
many medium chains115.6 ms19.2 ms36.6 ms
wide sum over a shared DAG2.48 ms0.89 ms0.83 ms

Fork-join keeps each chain on one worker and wins on wide expressions through cache locality. Level scheduling wins on deep ones, where fork-join finds no wide node to fork on and runs essentially sequentially. At one thread the level scheduler is faster on every shape measured. Reproduce with cargo run --release --features parallel --example simplify_three_way.

Both are experimental: alkahest_cas::experimental::{simplify_par, simplify_redex}.

E-graph simplification

simplify_egraph uses equality saturation via egglog to explore many equivalent forms simultaneously before committing to the best one via a cost function.

from alkahest import simplify_egraph

# The e-graph can discover non-obvious equivalences
r = simplify_egraph(x * x - pool.integer(1))  # may factor or simplify

E-graph saturation is more powerful than rule-based simplification for some inputs but slower and has non-deterministic performance for complex expressions. See E-graph saturation for configuration options (EgraphConfig, including disjoint_schedule for match-disjoint rule groups).

User-defined PatternRule sets can use discrimination-net indexing on the Rust side (PatternRuleSet, simplify_with_pattern_rules); the built-in RewriteRule engine still scans rules linearly.

Choosing between the two

Criterionsimplifysimplify_egraph
SpeedFast, predictableSlower, variable
CompletenessFixed rule setEquality saturation
TerminationAlwaysConfigurable limits
Side conditionsRespectedRespected
Best forHot paths, cleanupDifficult equalities

For most workflows: use simplify (or a domain-specific variant) first. Reach for simplify_egraph when you need the system to discover a non-obvious equivalence.

Collect and normalize

Two utility passes that sit between the two engines:

from alkahest import collect_like_terms, poly_normal

# 2*x + 3*x → 5*x
r = collect_like_terms(pool.integer(2) * x + pool.integer(3) * x)

# Normalize to canonical polynomial form over given variables
r = poly_normal(x**2 + pool.integer(2) * x * y + y**2, [x, y])

Complex constructors (experimental)

alkahest.experimental exposes symbolic conjugate, re, im, and principal arg. These are symbolic-only: they are not registered for f64, ball, or JIT evaluation.

Safe simplifications include involution of conjugate, real/integer literals for re/im, arg of a strictly positive literal or Domain.Positive symbol, and exact arg(I) / arg(-I). Branch-sensitive cases such as conjugate(log(z)), arg(0), negative reals, and generic complex inputs stay unevaluated. Principal Arg uses the conventional range (−π, π] with a cut on the negative real axis; do not rewrite through atan2, log, or sqrt yet.

Rule engine

The rule engine underlies both simplify and the e-graph backend. Rules are the atomic units of algebraic knowledge.

Anatomy of a rule

A RewriteRule has:

  • A name — stable string identifier (used in derivation logs and Lean certificate output)
  • A LHS pattern — an expression template with pattern variables
  • A RHS template — the replacement
  • Optional side conditions — predicates that must hold for the rule to fire

Pattern syntax

Patterns are regular expressions with a subset of ExprData nodes used as wildcards. From the Python side, pattern variables are Expr objects whose names start with ?:

from alkahest import make_rule, match_pattern

pool = ExprPool()
x = pool.symbol("x")

# Pattern variable — matches any subexpression
pv = pool.symbol("?a")

# Rule: ?a + 0 → ?a
add_zero = make_rule("add_zero", lhs=pv + pool.integer(0), rhs=pv)

Pattern variables capture any subexpression and must bind consistently: if ?a appears twice in the LHS it must match the same expression in both positions.

Matching

match_pattern applies a pattern to an expression and returns all match substitutions:

matches = match_pattern(sin(x)**2 + cos(x)**2, pattern)
for subst in matches:
    print(subst)  # dict mapping pattern variable → matched expr

The matcher is associative-commutative (AC): a + b matches b + a, and a + b + c matches any ordering.

Built-in rule sets

The rule sets loaded by simplify and the domain-specific simplifiers are:

FunctionRules
simplifyArithmetic identities, constant folding, polynomial normalization — with one carve-out: no rule folds a product that contains a literal zero raised to a negative power, because 0 · 0⁻¹ has no value (details)
simplify_trigPythagorean identity, double-angle and half-angle formulas
simplify_log_expLog/exp cancellation (branch-cut safe subset)
simplify_expandedDistributive expansion, like-term collection

Defining custom rules

from alkahest import make_rule, simplify_with

pool = ExprPool()
x = pool.symbol("x")
a = pool.symbol("?a")
b = pool.symbol("?b")

# Commutativity of subtraction rewrite: a - a → 0
self_cancel = make_rule(
    "self_cancel",
    lhs=a + pool.integer(-1) * a,
    rhs=pool.integer(0),
)

# Apply the custom rule alongside the default set
r = simplify_with(expr, rules=[self_cancel])

Custom rules are recorded in derivation logs with the name you provide. If you tag the rule with a Lean theorem name (via the Rust PrimitiveRegistry API), the corresponding step can be exported as a Lean proof term.

Rule execution model

simplify applies rules in a fixpoint loop:

  1. For each node in the expression (post-order traversal):
    • Try each rule in the rule set.
    • If a rule matches and its side conditions are satisfied, apply it, emit a RewriteStep, and restart the loop for the modified subtree.
  2. Repeat until no rule fires in a full pass.

This is an inner-outer loop strategy rather than exhaustive bottom-up application. It is fast but not complete — some sequences of rewrites require rules to be applied in a specific order. The e-graph engine removes this ordering dependency.

Side conditions

A rule can carry a side condition checked against the matched substitution:

# sqrt(x^2) → x  only when x is non-negative
sqrt_sq = make_rule(
    "sqrt_sq_nonneg",
    lhs=sqrt(a**2),
    rhs=a,
    condition="nonnegative",   # checked against the domain of ?a
)

Side conditions that reference symbol domains are sound: sqrt_sq_nonneg will only fire when ?a is bound to a symbol with domain positive or nonneg. They propagate into the derivation log as SideCondition entries and into Lean output as assumptions.

E-graph saturation

The e-graph backend exposes a fundamentally different approach to simplification: rather than applying rules one at a time in a fixed order, it builds a structure that represents many equivalent expressions simultaneously, then extracts the best one.

What is an e-graph?

An e-graph partitions expressions into equivalence classes (e-classes). When a rewrite rule fires, it does not replace the LHS — it adds the RHS to the same e-class as the LHS. At the end of saturation, an extraction step picks the cheapest representative from each e-class according to a cost function.

This eliminates the phase-ordering problem: rules can fire in any order without risk of committing to a suboptimal form. The e-graph remembers all explored forms and chooses among them at the end.

Using the e-graph

from alkahest import simplify_egraph, simplify_egraph_with

# Default configuration
r = simplify_egraph(expr)

# With explicit config
from alkahest import EgraphConfig, simplify_egraph_with

cfg = EgraphConfig(node_limit=10_000, iter_limit=20)
r = simplify_egraph_with(expr, cfg)

Cost functions

The extraction step minimizes a cost function over e-class representatives. Three built-in cost functions:

NameBehavior
SizeCostPrefers the expression with the fewest AST nodes
DepthCostPrefers the shallowest expression tree
OpCostAssigns per-operation costs; penalizes expensive ops
StabilityCostPenalizes patterns that cause catastrophic cancellation

StabilityCost is aware of numerical stability issues: it penalizes subtractive cancellation patterns and prefers numerically stable rearrangements.

Configuration

The e-graph runs until saturation (no new e-class merges) or until a limit is hit:

  • node_limit — maximum number of e-nodes. Once reached, saturation stops and extraction runs on the current state.
  • iter_limit — maximum number of saturation rounds.

For large or complex expressions, saturation can be expensive. The rule-based simplify is often sufficient and should be preferred on hot paths.

Rule sets in the e-graph

The e-graph uses the same RewriteRule objects as the rule-based engine. By default it loads the arithmetic rules. Domain-specific rules (trig, log/exp) are kept separate to avoid e-class explosions on expressions that do not involve those operations.

The default e-graph rule set includes trig identities (sin²+cos²→1) and safe log/exp cancellation. Disable per domain via EgraphConfig(include_trig_rules=False) or include_log_exp_rules=False.

Match-disjoint scheduling

By default (disjoint_schedule=True), shrink and explore rules are split into match-disjoint egglog rulesets (shrink-add, shrink-mul, shrink-pow, explore-trig, explore-log, explore-mul) and run as separate (run …) steps within each phase. This reduces cross-rule interference during saturation. Set disjoint_schedule=False to use the legacy single-ruleset schedule.

When e-graphs help

The e-graph is especially powerful when:

  • Multiple non-obvious rewrites must be combined in a specific order that is hard to predict.
  • The “right” form is not syntactically similar to the input (e.g. factoring followed by cancellation).
  • You want the globally cheapest form under a custom cost function, not just any simplified form.

It is less useful when:

  • The expression is already in near-canonical form and only identity cleanup is needed.
  • You need predictable performance on a hot path.
  • The expression is large and associative-commutative, where the e-graph can grow combinatorially.

Colored e-graphs (conditional rewrites)

Separate from the egglog saturation backend, Alkahest implements a native colored e-graph (simplify/colored_egraph.rs) for conditional simplification under explicit assumptions (e.g. x > 0 ⊢ sqrt(x²) → x). When SimplifyConfig::assumptions is non-empty, simplify_with runs this pass before the rule engine. See Simplification — conditional simplification.

AC matching in the e-graph

The egglog backend handles associativity and commutativity structurally: Add and Mul children are sorted at pool-insertion time, so there is a single canonical ordering. The e-graph does not need to enumerate permutations.

This is more efficient than classical AC-completion but requires that the canonical ordering is established at construction, which the kernel enforces.

Calculus

Alkahest supports symbolic differentiation and integration with full derivation logging.

Differentiation

diff(expr, var) computes the symbolic derivative of expr with respect to var.

from alkahest import diff, sin, cos, exp, log

pool = ExprPool()
x = pool.symbol("x")

# Polynomial
dr = diff(x**3 + pool.integer(2) * x, x)
print(dr.value)   # 3*x^2 + 2

# Chain rule
dr = diff(sin(x**2), x)
print(dr.value)   # 2*x*cos(x^2)

# Product rule
dr = diff(x * exp(x), x)
print(dr.value)   # exp(x) + x*exp(x)

# Logarithm
dr = diff(log(x**2 + pool.integer(1)), x)
print(dr.value)   # 2*x / (x^2 + 1)

Registered primitives

Every primitive in the registry has a differentiation rule. The 23 currently registered primitives include:

sin, cos, tan, asin, acos, atan, atan2, sinh, cosh, tanh, exp, log, sqrt, abs, sign, erf, erfc, gamma, floor, ceil, round, min, max

Derivation log

The DerivedResult returned by diff records every rule application:

dr = diff(sin(x**2), x)
for step in dr.steps:
    print(f"  {step['rule']:25s}  {step['before']}  →  {step['after']}")

Forward-mode automatic differentiation

diff_forward computes the derivative using forward-mode AD (dual numbers). It produces the same result as diff but through a different computational path:

from alkahest import diff, diff_forward

sym = diff(x**3, x)
fwd = diff_forward(x**3, x)
# fwd.value == sym.value

Forward mode is useful for checking that the symbolic rules agree with dual-number evaluation.

Symbolic gradient (symbolic_grad)

symbolic_grad(expr, vars) returns a list of Expr — one partial derivative per variable. It does not use @trace and is not composable with jit directly.

APIInputOutput
diff(expr, var)one variableDerivedResult with .steps
symbolic_grad(expr, vars)many variableslist[Expr]
grad(traced_fn)TracedFn from @traceGradTracedFn (numeric; see Transformations)
from alkahest import symbolic_grad

pool = ExprPool()
x = pool.symbol("x")
y = pool.symbol("y")

expr = x**2 * y + sin(x * y)
grads = symbolic_grad(expr, [x, y])
# grads[0] = ∂/∂x = 2*x*y + y*cos(x*y)
# grads[1] = ∂/∂y = x^2 + x*cos(x*y)

For the JAX-style gradient of a traced Python function (compose with jit), use alkahest.gradnot symbolic_grad. See Transformations.

Integration

integrate(expr, var) computes the symbolic antiderivative of expr with respect to var.

from alkahest import integrate, sin, cos, exp

# Polynomials
r = integrate(x**3, x)
print(r.value)    # x^4/4

# Known functions
r = integrate(sin(x), x)
print(r.value)    # -cos(x)

r = integrate(exp(x), x)
print(r.value)    # exp(x)

r = integrate(x**pool.integer(-1), x)
print(r.value)    # log(x)

Integration rules

The integration engine applies a rule table for common forms, then escalates to the Risch decision procedure for harder cases:

Rule table (fast path)

  • Power rule: ∫ xⁿ dx = xⁿ⁺¹/(n+1) for integer n ≠ -1
  • Logarithm: ∫ 1/x dx = log(x)
  • Exponential tower: ∫ exp(a*x + b) dx, ∫ xⁿ·exp(x) dx (poly × exp)
  • Linear substitution: ∫ f(a*x + b) dx
  • Trigonometric: ∫ sin(x) dx, ∫ cos(x) dx, etc.
  • Standard table entries for erf, inverse trig, etc.

Risch algorithm (escalation)

  • Rational functions A(x)/D(x): Hermite reduction (repeated factors → rational part), then Rothstein–Trager (rational residues → log), irreducible quadratics (negative discriminant → arctan; positive discriminant → log with √Δ coefficients), and irreducible factors of degree ≥ 3 via a RootSum node (Lazard–Rioboo–Trager over the number field ℚ[t]/Q(t)).
  • Exp tower with rational coefficient: ∫ f(x)·exp(η) dx where f ∈ ℚ(x) — solved via the rational Risch DE (Bronstein §6.1).
  • Polynomial × exp / log towers: poly-RDE and known-table rules.
# Rational functions
r = integrate(pool.integer(1) / (x**2 - pool.integer(1)), x)  # → ½·log((x-1)/(x+1))

r = integrate(pool.integer(1) / (x**2 + pool.integer(1)), x)  # → arctan(x)

# Rational coefficient × exp
r = integrate((x - pool.integer(1)) / x**2 * exp(x), x)       # → exp(x)/x

# Degree-≥3 denominator → RootSum
r = integrate(pool.integer(1) / (x**3 - pool.integer(3)*x + pool.integer(1)), x)
# r.value contains a RootSum node (sum over algebraic residues)

Non-elementary certification: when the integrand is provably non-elementary (Liouville’s theorem — e.g. sin(x)/x, exp(x)/x, exp(x²)), integrate raises IntegrationError with code E-INT-004 (NonElementary) rather than a generic “not implemented”:

from alkahest import IntegrationError

try:
    integrate(exp(x) / x, x)
except IntegrationError as e:
    print(e.code)         # E-INT-004
    print(e.remediation)  # "no elementary antiderivative (NonElementary)"

For integrands outside the supported classes (e.g. sqrt(P(x)), mixed algebraic+transcendental), integrate raises IntegrationError with code E-INT-001 (NotImplemented).

Verification

A common pattern is to verify an antiderivative by differentiating it back:

antideriv = integrate(expr, x).value
check = simplify(diff(antideriv, x).value)
# check.value should equal expr

Higher derivatives

Chain calls to diff:

d2 = diff(diff(sin(x), x).value, x)
print(d2.value)   # -sin(x)

The derivation log of the outer diff does not include the inner steps. If you need the full trace, concatenate dr1.steps + dr2.steps.

Asymptotics at scale

series and Gruntz limit expand a function; asymptotic_expand handles the power/log/exp scales of a function at infinity. What a loop working on combinatorics and analysis needs on top of that is the asymptotics of a sum — how Σ_{k=a}^{n} f(k) behaves as n → ∞. That is how a conjecture about a growth rate actually gets settled.

from alkahest import ExprPool
from alkahest.experimental import euler_maclaurin

pool = ExprPool()
k, n = pool.symbol("k"), pool.symbol("n")

r = euler_maclaurin(pool.integer(1) / k, k, 1, n, corrections=2)
r.leading           # log(n)
r.terms            # [log(n), γ, 1/(2n), −1/(12n²)] — most significant first
r.rigor            # "numerically_consistent"

The formula

For a smooth summand,

Σ_{k=a}^{n} f(k) = ∫_a^n f(t) dt + (f(a) + f(n))/2
                   + Σ_{j=1}^{m} B_{2j}/(2j)! · (f^{(2j-1)}(n) − f^{(2j-1)}(a))
                   + R_m

Read as n → ∞, the a-endpoint pieces collapse into one additive constant and what remains is an asymptotic expansion in n. For f(k) = 1/k that is the classical H_n ~ log n + γ + 1/(2n) − 1/(12n²) + ….

The constant is fitted, and the report says so

Euler–Maclaurin does not determine the additive constant from the n-side terms. For the harmonic numbers that constant is Euler’s γ, and no amount of boundary algebra at k = a produces it.

This module therefore obtains it numerically, from the exactly computed sum at large n, and is explicit about that: rigor comes back as "numerically_consistent" rather than "proved", and the fitted constant appears in hypotheses with status "assumed". The shape of the expansion is derived symbolically — only that one scalar is empirical.

r.all_hypotheses_checked      # False
[s for s, _ in r.hypotheses]  # ["checked", "assumed", "assumed"]

This distinction is the whole reason the result is an AsymptoticReport rather than a bare list of terms. A loop that records “γ ≈ 0.5772 was fitted, not proved” can revisit it; one handed an undifferentiated expansion cannot.

Ordering and the verification gate

Terms are returned most-significant first, and the ordering is by magnitude at the check points, not by where they came from in the formula. This matters: the constant sits below every growing term and above every decaying one, so for Σ k it lands after n²/2 and n/2, while for H_n it lands right after log n.

Every term then goes through the same o()-gate as asymptotic_expand: the truncated expansion is compared against the exactly computed sum at increasing n, and a term is kept only if it genuinely refines its predecessor against the true value. Terms that fail are dropped; if nothing survives, the call refuses rather than emitting an unverified expansion. r.verification carries the evidence and r.max_relative_error summarises it.

Refusals

SituationBehaviour
Summand has no symbolic antiderivative (e.g. exp(−k²))Refuse — the integral term cannot be formed
Summand not numerically evaluable at the check pointsRefuse — the gate has no oracle
No term survives the gateRefuse — emit nothing rather than something unverified
corrections above the supported maximumRefuse

Singularity analysis of generating functions

For a rational f(z), the growth of [zⁿ] f(z) is governed entirely by the singularity of smallest modulus:

from alkahest.experimental import coefficient_asymptotics

gf = pool.integer(1) / (pool.integer(1) - z - z*z)   # Fibonacci
r = coefficient_asymptotics(gf, z, n)
r.terms[0]      # ~ C·φⁿ

A pole ρ of multiplicity m contributes C · n^{m-1} · ρ^{-n}. The shape — which power of n, which exponential base — is exact; the single leading constant is obtained from the exact power series by Richardson extrapolation, and the report says so.

That extrapolation is not a nicety. Reading the constant off a single finite index absorbs the subleading term: for 1/(1-z)², where [zⁿ] = n+1 exactly, taking C = a₄₈/48 = 49/48 leaves a permanent 2% bias, so the relative error stops shrinking as n grows — which is precisely what an asymptotic statement must not do. One Richardson step cancels the 1/k term and recovers C = 1.

What it refuses

The transfer theorem needs a unique dominant singularity. 1/(1-z²) has poles at both +1 and −1; its coefficients oscillate (1,0,1,0,…) and no single power-law term describes them. Rather than reporting one pole as if it won, the routine declines — as it does for a complex dominant pole (necessarily one of a conjugate pair) and for non-rational input.

From a recurrence: Poincaré–Perron

A certified recurrence already determines how fast its sequence grows, so after zeilberger or guess_holonomic the growth law is one call away:

from alkahest import ExprPool
from alkahest.experimental import asymptotics_from_recurrence

pool = ExprPool()
n = pool.symbol("n")

# (n+1)·u(n+1) − (4n+2)·u(n) = 0 — the central binomial coefficients.
r = asymptotics_from_recurrence([(-2, -4), (1, 1)], n, terms=[1])

r.growth_rate_exact          # 4          — derived
r.polynomial_exponent_exact  # -1/2       — derived
r.connection_constant        # 0.5641895… — fitted; this is 1/√π
r.verdict                    # "single_dominant_root"

rec may be a ZeilbergerCertificate, a GuessedRecurrence, or a plain list of coefficient polynomials [p_0, …, p_J] for Σ_i p_i(n)·u(n+i) = 0; each p_i is an Expr in n or a tuple of ascending integer coefficients.

What is derived and what is fitted

Write D = max_i deg p_i, take the coefficient of n^D in each p_i to build the characteristic polynomial χ(t) = Σ_i a_i tⁱ, and the coefficient of n^{D-1} to build χ₁. Poincaré’s theorem says the sequence grows like a root of χ; Perron’s refinement pins the polynomial factor:

u(n) ~ C · ρⁿ · n^α,     α = −χ₁(ρ) / (ρ · χ'(ρ))

ρ and α are functions of the recurrence and of nothing else, and when ρ is rational both are available exactly. C is not. It is determined by the initial conditions, so it is extrapolated numerically from the exact terms — run forward from the recurrence in exact rational arithmetic — and reported on its own, with connection_constant_converged and connection_constant_drift from a second extrapolation over a smaller range of indices. evidence() splits the two:

r.evidence()["derived"]["growth_rate"]         # 4.0
r.evidence()["fitted"]["connection_constant"]  # 0.5641895…
r.evidence()["fitted"]["relative_drift"]       # 2.5e-10

This is the same discipline euler_maclaurin applies to its additive constant, for the same reason: no amount of algebra on the recurrence produces 1/√π.

What it refuses to answer

The theorem needs the roots of χ to have distinct moduli and the leading coefficient to be eventually non-zero. When they do not hold, verdict says so and growth_rate is None — a growth rate is never invented:

verdictwhat went wrong
equal_modulus_rootsu(n+2) = 4u(n) has roots ±2; the solutions oscillate
repeated_dominant_rootχ'(ρ) = 0, so the exponent formula does not apply
degenerate_leading_coefficientdeg χ < J — a root at infinity, outside the theorem
eventually_zerothe sequence is zero from some index on

Multiplicity is exact — it comes from the squarefree decomposition of χ over , not from clustering the numeric roots. That is not fussiness: A359643’s characteristic polynomial is (t−1)³·(27t−283), whose triple root is real and sits well below the dominant one, and a tolerance that merged them would refuse a case the theory handles perfectly.

One more hypothesis is easy to miss. Poincaré’s conclusion is that u(n+1)/u(n) tends to some root, not necessarily the largest. u(n+2) = 3u(n+1) − 2u(n) with u(0) = u(1) = 1 is the constant sequence, and its component along the dominant root 2 is zero. With terms supplied that is detected and reported as follows_dominant_root == False; without them it is an explicitly assumed hypothesis in report().hypotheses.

Scope of this release

Shipped: the Euler–Maclaurin route for Σ_{k=a}^{n} f(k), with Bernoulli corrections, magnitude ordering, the numeric gate, and the checked-versus- assumed hypothesis ledger in AsymptoticReport.

Also shipped: singularity analysis for rational generating functions, and Poincaré–Perron growth from a P-recursive recurrence (asymptotics_from_recurrence).

Not shipped, and tracked as follow-up (the shared scaffolding — AsymptoticReport, the gate, exact Bernoulli numbers, rational-function extraction and a complex root finder — is already in place for them):

  • Algebraic and log-type generating functions — the transfer theorem beyond poles (√(1-4z) for the Catalan numbers, log singularities). Only the rational case ships here.
  • Sequence asymptotics from a closed form — Stirling-based expansions of a ratio of factorials. The recurrence route ships (above); the closed-form one does not.
  • Full Birkhoff–Trjitzinsky asymptotics — the cases asymptotics_from_recurrence reports and declines to answer: equal-modulus roots, a repeated dominant root, and the degenerate leading coefficient that produces ρⁿ·n^{cn} growth.
  • Laplace / saddle-point / stationary-phase asymptotics of parameter integrals.

Note that Σ log k gives Stirling’s formula for log n! through this route already, so the factorial case is reachable today.

Transformations

Transformations are higher-order operations that take a function and return a new function. They compose freely and operate on traced symbolic representations.

Tracing

trace symbolically executes a Python function by replacing its inputs with symbolic variables and recording the computation as an expression DAG.

import alkahest
from alkahest import ExprPool

pool = ExprPool()

@alkahest.trace(pool)
def f(x, y):
    return x**2 + alkahest.sin(y)

print(f.expr)    # x^2 + sin(y)
print(f.symbols) # [x, y]

The decorator takes the pool as an argument. Variable names are inferred from the function signature.

Numeric evaluation

TracedFn objects are callable with numeric values:

print(f(3.0, 0.0))   # 9.0

import numpy as np
xs = np.linspace(0, 1, 1000)
ys = np.zeros(1000)
result = f(xs, ys)   # vectorised automatically

Gradient (grad, not symbolic_grad)

alkahest.grad applies to a TracedFn from @trace. For partial derivatives of a bare Expr, use symbolic_grad instead.

grad differentiates a traced function symbolically with respect to all (or a subset of) its inputs:

df = alkahest.grad(f)
# df(x, y) returns [∂f/∂x, ∂f/∂y] = [2*x, cos(y)]

grads = df(1.0, 0.0)   # [2.0, 1.0]

Differentiate with respect to a subset:

df_x = alkahest.grad(f, wrt=[f.symbols[0]])  # ∂f/∂x only

JIT compilation

jit wraps a traced function in the LLVM JIT backend. The first call triggers compilation; subsequent calls run the compiled code directly.

fast_f = alkahest.jit(f)
print(fast_f(3.0, 0.0))  # 9.0, via LLVM-compiled code

Vectorised evaluation is automatic when array inputs are detected:

xs = np.linspace(0, 10, 1_000_000)
ys = np.zeros_like(xs)
result = fast_f(xs, ys)   # zero-copy batch path

Composing transformations

Transformations stack:

# Compiled gradient
fast_df = alkahest.jit(alkahest.grad(f))
grads = fast_df(xs, ys)   # compiled, vectorised gradient

# Second derivative: grad of grad
d2f = alkahest.grad(alkahest.grad(f))

Note that grad returns a GradTracedFn, not a TracedFn. jit can be applied to GradTracedFn when it wraps a single scalar output. For multi-output cases, compile each gradient expression individually with compile_expr.

trace_fn

Functional (non-decorator) version of trace:

from alkahest import trace_fn

fn = trace_fn(lambda x, y: x * alkahest.exp(y), pool)

PyTrees

Transformations work over nested data structures (lists, dicts, tuples, dataclasses). The Python layer flattens and unflattens them automatically:

from alkahest import flatten_exprs, unflatten_exprs, map_exprs

# A system of equations as a list
eqs = [x**2 + y - pool.integer(1), x - y**2 + pool.integer(1)]

# Map simplification over the list
simplified = map_exprs(simplify, eqs)

# Flatten to a list of ExprIds and the structure descriptor
flat, treedef = flatten_exprs(eqs)
restored = unflatten_exprs(flat, treedef)

This follows the JAX pytree pattern. The Rust kernel sees only flat sequences; structure reconstruction is a Python-layer concern.

Context manager

alkahest.context sets a default pool and configuration for a block:

with alkahest.context(pool=pool, simplify=True):
    z = alkahest.symbol("z")          # uses the active pool
    expr = z**2 + alkahest.sin(z)     # auto-simplified

Inside the context, alkahest.symbol(name) creates a symbol in the active pool without passing the pool explicitly. This is a convenience wrapper — the pool is still explicit at the structural level.

Code generation

Alkahest can compile symbolic expressions to fast native or GPU code. Compiled code bypasses Python entirely during evaluation.

The compilation pipeline

Expressions lower through multiple IR levels:

ExprPool (hash-consed DAG)
    ↓  e-graph extraction + canonicalization
Canonical expression form
    ↓  alkahest MLIR dialect
High-level MLIR (math-aware ops: horner, poly_eval, interval_eval)
    ↓  lowering passes
Standard MLIR (arith, math, linalg, gpu)
    ↓
LLVM IR / PTX / StableHLO (depending on target)
    ↓
Native machine code / GPU kernel / XLA

The custom alkahest MLIR dialect is where math-aware optimizations happen: Horner’s method for polynomials, fused multiply-add emission, numerically stable rearrangements via StabilityCost.

compile_expr

compile_expr produces a callable from a symbolic expression and a list of input variables:

from alkahest import ExprPool, compile_expr, sin, cos

pool = ExprPool()
x = pool.symbol("x")
y = pool.symbol("y")

f = compile_expr(x**2 + sin(y), [x, y])
print(f([3.0, 0.0]))   # 9.0

The callable takes a list of floats (one per variable) and returns a float. For batch evaluation see numpy_eval below.

CPU compilation uses a three-tier dispatch chosen by CompileConfig (expression size and expected_evals, not feature-flag order):

  1. Interpreter — small DAGs and few planned evaluations (zero compile latency)
  2. Cranelift (--features cranelift) — pure Rust, ~10× faster compile than LLVM; no system LLVM required
  3. LLVM (--features jit) — inkwell / LLVM 15 MCJIT; best throughput for large batch sweeps

Default PyPI wheels use the interpreter only. +jit / +full release wheels enable LLVM. Add cranelift to a from-source build for the fast-compile tier without LLVM.

Native JIT backends also emit a bulk entry point (alkahest_eval_bulk) for column-major batch evaluation; CompiledFn.call_batch / call_bulk use the same layout as numpy_eval.

CompileCache

Repeated compilation of the same expression is expensive. CompileCache memoizes by (ExprId, input variables) — hash-consing makes ExprId a stable content key:

from alkahest import ExprPool, compile_expr, CompileCache

pool = ExprPool()
x = pool.symbol("x")
cache = CompileCache()
f = cache.compile(x**2, [x], pool)   # JIT compiles on first call
g = cache.compile(x**2, [x], pool)   # cache hit — O(1)
print(cache.stats())                 # hits, compiles, hit_rate

eval_expr

For one-off evaluation without compiling:

from alkahest import eval_expr

result = eval_expr(x**2 + sin(y), {x: 3.0, y: 0.0})
print(result)   # 9.0

eval_expr is slower than a compiled function for repeated evaluation but has no compilation overhead.

numpy_eval

numpy_eval vectorises a compiled function over NumPy arrays via the batch path:

import numpy as np
from alkahest import numpy_eval

f = compile_expr(sin(x) * cos(x), [x])
xs = np.linspace(0, 2 * 3.14159, 1_000_000)
ys = numpy_eval(f, xs)   # vectorised, zero-copy

Also accepts PyTorch CPU tensors and JAX arrays via DLPack.

Parallel batch evaluation

With --features parallel, numpy_eval_par distributes evaluation across CPU cores (Rayon), releasing the GIL during computation:

from alkahest import numpy_eval_par

ys = numpy_eval_par(f, xs)   # same API as numpy_eval; multi-core

numpy_eval_par is only parallel when the extension was built with parallel. Every published wheel is — but parallel is not a Cargo default, so a source build that omits it gets a silent no-op alias: numpy_eval_par falls back to numpy_eval with the same correct result, the same single-threaded throughput, and no warning. Timing the two against each other on such a build measures one code path twice.

import alkahest as ak
ak.capabilities()["features"]["parallel"]   # True on PyPI wheels

If that prints False, rebuild with --features parallel — see Getting started.

A CompiledFn belongs to the thread that compiled it

numpy_eval_par fans out internally, so you do not need threads of your own to use it. If you add them anyway, compile inside each thread: the object returned by compile_expr owns JIT code pages and a backend handle, and is pinned to its creating thread. Touching it from another one is refused:

f = compile_expr(expr, [x])          # thread A
threading.Thread(target=lambda: numpy_eval(f, xs)).start()
# pyo3_runtime.PanicException: alkahest::PyCompiledFn is unsendable,
#                              but sent to another thread

Two things to know about that refusal. It has nothing to do with parallel — plain numpy_eval is refused identically. And PanicException derives from BaseException, not Exception, so a worker wrapped in a bare except Exception: will not catch it and the thread will die silently.

ExprPool and Expr have no such restriction: share the pool, build expressions anywhere, and call compile_expr per thread.

Horner-form emission

horner rewrites a polynomial expression into Horner’s form, which is numerically better conditioned and faster to evaluate:

from alkahest import horner

# x^3 + 2x^2 + 3x + 4 → x*(x*(x + 2) + 3) + 4
h = horner(x**3 + pool.integer(2)*x**2 + pool.integer(3)*x + pool.integer(4), x)

emit_c emits a C function string for embedding in other projects:

from alkahest import emit_c

c_code = emit_c(expr, [x, y], fn_name="f")
# → "double f(double x, double y) { return ...; }"

MLIR dialect

The alkahest-mlir crate exposes the custom MLIR dialect. The dialect ops are:

OpDescription
alkahest.symSymbolic variable reference
alkahest.constConstant value
alkahest.add, alkahest.mulArithmetic
alkahest.powExponentiation
alkahest.hornerHorner polynomial evaluation
alkahest.poly_evalGeneric polynomial evaluation
alkahest.series_taylorTaylor series evaluation
alkahest.interval_evalBall arithmetic evaluation
alkahest.rational_fnRational function evaluation

Three lowering targets are available:

  • ArithMath — lowers to arith + math MLIR dialects; uses math.fma for Horner chains
  • StableHlo — lowers to StableHLO ops for XLA/JAX integration
  • Llvm — lowers to llvm dialect for LLVM IR / PTX emission
from alkahest import to_stablehlo

# Emit textual MLIR in the StableHLO dialect
mlir_text = to_stablehlo(expr, [x, y], fn_name="my_fn")
print(mlir_text)  # valid input to mlir-opt / XLA

GPU codegen (NVPTX)

With --features cuda and an LLVM 15 installation with NVPTX support — not in any published wheel, so pip install alkahest never has this. Full detail, including build prerequisites, the supported node set, error codes and the state of testing, is in GPU support (CUDA).

from alkahest import compile_cuda

f_gpu = compile_cuda(expr, [x, y])
result = f_gpu.call_batch(inputs)   # runs on CUDA device 0

Guard on the capability bit before reaching for it, since the name does not exist at all without the feature:

if alkahest.capabilities()["features"]["cuda"]:
    ...

The GPU compiler:

  1. Lowers the expression through inkwell to NVPTX LLVM IR for sm_86 (Ampere)
  2. Links libdevice.10.bc for transcendental functions (__nv_sin, etc.)
  3. Emits PTX via LLVM’s target machine
  4. Loads the PTX via the CUDA driver (cudarc)

The benchmark nvptx/nvptx_polynomial_1M shows 16.2× speedup over the CPU JIT on a 1M-point polynomial evaluation on an RTX 3090.

Upcoming (v1.1): AMD ROCm / amdgcn target (hardware-blocked pending RDNA3 availability).

Caching

Use CompileCache for explicit per-session memoization of compiled functions (see above). The persistent ExprPool (V1-14) can serialize expression DAGs across sessions; combine with CompileCache to avoid recompilation after reload.

Tier dispatch always falls back to the interpreter when native JIT features are unavailable or compilation fails.

GPU support (CUDA)

Alkahest has two independent CUDA features. Neither is in the wheel published to PyPI, so pip install alkahest gives you no GPU support at all — a source build is required, and each feature has different build prerequisites.

Cargo featureWhat it providesBuild prerequisitesReachable from Python?
cudaNVPTX codegen: compile_cuda turns an expression into a GPU kernelLLVM 15 with the NVPTX target (cuda implies alkahest-core/jit, i.e. inkwell), plus libcuda.so.1 at runtimeYescompile_cuda, CudaCompiledFn
groebner-cudaA Macaulay-matrix mod-p row reduction kernel used by the Rust function compute_groebner_basis_gpuOnly cudarcno LLVM, because the kernel is a static PTX string rather than LLVM outputNo — see below

The two do not imply each other. cuda = ["jit", "dep:cudarc"] and groebner-cuda = ["groebner", "dep:cudarc"] (alkahest-core/Cargo.toml).

Building

# NVPTX expression codegen. Needs LLVM 15 built with NVPTX:
#   llc --version | grep nvptx     # must list nvptx64
maturin develop --manifest-path alkahest-py/Cargo.toml --release --features cuda

# GPU Gröbner kernel (Rust-only; nothing changes at the Python surface)
maturin develop --manifest-path alkahest-py/Cargo.toml --release --features groebner-cuda

# Both
maturin develop --manifest-path alkahest-py/Cargo.toml --release \
    --features "cuda groebner-cuda"

cudarc uses dynamic loading, so the extension builds on a machine with no CUDA installed; the driver is only needed when a kernel actually launches. LLVM 15 with NVPTX, by contrast, is needed at build time for cuda — a build without it fails or produces E-CUDA-001 at compile time.

libdevice.10.bc (from the CUDA toolkit) is linked into every generated module so that sin, cos, … resolve to __nv_*. If it is not found automatically, point at it explicitly:

export ALKAHEST_LIBDEVICE_PATH=/usr/local/cuda/nvvm/libdevice/libdevice.10.bc

What capabilities() reports

import alkahest as ak

features = ak.capabilities()["features"]
features["cuda"]           # `--features cuda` was compiled in
features["llvm_jit"]       # True on any `cuda` build: `cuda` implies the LLVM backend

Read these bits precisely — each says what was linked, and nothing more:

  • cuda == True guarantees ak.compile_cuda and ak.CudaCompiledFn exist and that PTX can be emitted on the host. It does not promise a GPU: the driver is loaded lazily, so a machine with no device compiles happily and fails at call_batch with E-CUDA-003. Ask ak.cuda_device_count() — it reports 0 when there is no usable device, and never raises.
  • llvm_jit == True on a cuda build even when alkahest-py’s own jit feature was never named, because alkahest-core’s cuda feature turns on jit. Cranelift and LLVM are not mutually exclusive; a CUDA build can link both.

There is no groebner_cuda bit (contract v3 and later — capabilities()["features"] raises KeyError for it). It was removed rather than wired up because it was unfalsifiable: no Python observation distinguished True from False. See below.

ak.CudaError is importable on every build, CUDA or not — it is an exception class, not an entry point, and code that writes except ak.CudaError around a compile step must keep working when moved between wheels. compile_cuda and CudaCompiledFn genuinely do not exist without the feature, and are appended to __all__ only when they do.

compile_cuda

import alkahest as ak

pool = ak.ExprPool()
x, y = pool.symbol("x"), pool.symbol("y")
expr = ak.sin(x) * ak.cos(y) + (x * x + y * y) * pool.rational(1, 100)

fn = ak.compile_cuda(expr, [x, y])   # -> CudaCompiledFn
fn.n_inputs                          # 2
fn.ptx                               # generated PTX assembly (str), `.target sm_86`

out = fn.call_batch([xs, ys])        # list[float], one output per point

call_batch takes one column per symbolic input (structure-of-arrays: xs is every x value, not the first point), all of equal length, and returns a Python list with one float per point. It copies host → device, launches on device 0, and copies back; a mismatched column count or ragged columns raise ValueError before anything touches the GPU. fn.call_batch_on(ordinal, inputs) is the same call on a chosen device.

Pipeline: expression → LLVM IR via inkwell → link libdevice.10.bc → internalize and DCE → PTX for sm_86 (Ampere) → loaded through the CUDA driver by cudarc.

Discovering the valid device ordinals

n = ak.cuda_device_count()          # 0 when there is no GPU here
for dev in range(n):
    fn.call_batch_on(dev, [[0.0]] * fn.n_inputs)

The valid arguments to call_batch_on are exactly range(ak.cuda_device_count()). Both directions are pinned by tests that run on hardware (tests/test_cuda.py::test_device_count_agrees_with_the_ordinals_that_launch and nvptx_gpu::cuda_device_count_matches_the_ordinals_that_launch): every ordinal below the count launches, and the ordinal at the count is refused.

cuda_device_count() never raises. Every “no GPU here” shape — no driver, no device, driver too old — reports 0, because that is the single answer a caller acts on. This matters more than it looks: cudarc panics rather than returning Err when libcuda.so cannot be dlopen’d at all, so a naive binding would abort the process on precisely the machines a capability probe exists to report on. The same bug bit groebner_cuda.rs::gpu_available and nvptx_gpu.rs::device_available.

Earlier releases documented a workaround here — launch on an ordinal and catch E-CUDA-003 — because a cuda_device_count binding could not be verified by anything on an ordinary dev box: cuda implies LLVM 15 with NVPTX, so it could not even be compiled, and no CI job built the Python extension with the feature. It shipped once both could be done on real hardware, which is the standard the capability overclaims this page documents were failing.

Limits worth knowing before you reach for it

  • sm_86 is hard-coded. Newer or older architectures rely on the driver’s PTX JIT.
  • f64 only, one output value per point. There is no vector or complex return.
  • Supported nodes: integer/rational/float constants, +, *, **, and the unary functions sin, cos, tan, exp, log, sqrt, abs. Integer exponents in 0..=16 are unrolled to multiplies; anything else goes through __nv_pow. Any other function — atan, sinh, erf, … — is refused with E-CUDA-002 rather than approximated, as is any symbol you forgot to pass in inputs.
  • Host lists in, host list out. The zero-copy device-pointer entry point (call_device_ptrs) exists in the Rust crate only; it has no PyO3 binding, so a CuPy or Torch CUDA tensor is round-tripped through host memory today.

Errors

All are CudaError, a subclass of AlkahestError, each carrying .code and .remediation (see Error handling).

CodeMeaning
E-CUDA-001LLVM has no NVPTX target — rebuild LLVM with nvptx64 in LLVM_TARGETS_TO_BUILD
E-CUDA-002PTX generation failed: unbound symbol, unsupported node, or a verifier complaint
E-CUDA-003CUDA driver error — no device, context creation, module load, or a memcpy
E-CUDA-004Not implemented
E-CUDA-005libdevice bitcode not found — install the CUDA toolkit or set ALKAHEST_LIBDEVICE_PATH
E-CUDA-006Kernel launch failed

groebner-cuda is not reachable from Python

The feature compiles a real, tested CUDA kernel — MacaulayMatrix::reduce_gpu plus a multi-prime CRT lift — and exports compute_groebner_basis_gpu from the Rust crate. But no shipped code path calls it. GroebnerBasis.compute, solve, and triangularize all go through compute_buchberger_basis on the CPU, and alkahest-py never references the GPU entry point at all. So on a --features groebner-cuda build:

  • no Python name appears or disappears,
  • no Python call gets faster,
  • and, since 3.8, no capability bit claims otherwise.

This is deliberate rather than accidental — the crossover policy in docs/symbolic-gpu-benchmarks.md says production dispatch must not prefer the GPU until the benchmark harness says it wins, and that wiring does not exist yet. Rust users can call alkahest_cas::poly::groebner::compute_groebner_basis_gpu directly.

Why the bit was removed rather than wired up

capabilities()["features"]["groebner_cuda"] used to report True here. It was the only occurrence of the string groebner_cuda anywhere in alkahest-py — there was no binding to go with it, no *gpu* name in the public or the private module, and GroebnerBasis exposing only CPU methods. That made it strictly worse than the cuda overclaim fixed in d139a46, which at least had a private route in.

An unreachable True is the same class of defect as a silent wrong answer: it makes a caller trust something it should not. The two ways out are to add a binding or to drop the claim, and dropping it was the right one days before a release — a binding would have been new public API that no CI job can build (no job builds the Python extension with either CUDA feature; see below) and that nobody without a GPU can run. Adding unverifiable surface is how the original defect got in. The bit is gone; the kernel is unchanged and still Rust-reachable. If dispatch ever prefers the GPU, the binding lands first and a bit follows it.

compute_groebner_basis_gpu now reports where it ran

The Rust entry point falls back to CPU row reduction when no device is present, and it used to say nothing about having done so — a device_id: None run, a run whose driver calls all failed, and a real GPU run returned identical, indistinguishable values. A function named ..._gpu that quietly runs on the CPU is a footgun of exactly the kind this release has spent its time eliminating, so both it and reduce_batch now return a GpuBackendReport alongside the polynomials:

#![allow(unused)]
fn main() {
use alkahest_cas::poly::groebner::{compute_groebner_basis_gpu, MonomialOrder};

let (basis, backend) = compute_groebner_basis_gpu(gens, MonomialOrder::Lex, Some(0))?;
assert!(backend.ran_on_gpu(), "fell back to the CPU: {backend:?}");
}

ran_on_gpu() is true only when at least one mod-p row reduction executed on a device and none fell back; fell_back_to_cpu() is its counterpart; reductions_on_gpu, reductions_on_cpu and first_gpu_error carry the detail. The stderr warning on fallback is still emitted, but it is no longer the only channel. This is a breaking change to the Rust signature — a compile error on upgrade, which is the correct failure mode for a caller who was reading a result as a GPU result.

State of testing — read this before trusting the feature

Rust, on hardware. alkahest-core/tests/nvptx_gpu.rs and alkahest-core/tests/groebner_cuda.rs run under .github/workflows/cuda_nightly.yml on a self-hosted dual-RTX-3090 runner: 17 CUDA-gated tests, plus compute-sanitizer memcheck and racecheck. The last full run was green and both sanitizers clean. --target-processes all is load-bearing in that workflow — without it the sanitizer instruments cargo, a process that makes no CUDA calls, and reports success having inspected nothing.

Python. tests/test_cuda.py covers the binding: the capability/namespace contract, PTX emission, the CudaError refusals, call_batch argument validation, and — the point of the exercise — GPU-versus-CPU numerical agreement on polynomial, transcendental, compile_expr and numpy_eval comparisons. Only the contract tier runs without the feature; everything else skips, which is what happens in CI and on the wheel. Setting ALKAHEST_GPU_TESTS=1 (as the nightly does for Rust) turns those skips into a hard error, so a job that promises hardware cannot quietly report success without reaching it.

The honest gap: no CI job has ever built the Python extension with cuda or groebner-cuda. The nightly runs cargo, never maturin, so the Python tier is only exercised when someone builds with the feature and runs pytest by hand on a GPU box. Until a maturin develop --features cuda + pytest tests/test_cuda.py step is added to the nightly, treat the Python GPU surface as verified by hand and not by CI — which is precisely how the compile_cuda export gap survived three releases while capabilities() advertised the feature.

A second gate that was inspecting nothing. cargo test --features groebner-cuda could not pass on a machine with no NVIDIA driver at all, contradicting the header comment of alkahest-core/tests/groebner_cuda.rs. cudarc panics rather than returning Err when libcuda.so cannot be dlopened, so gpu_available() — whose whole job is to answer “should the GPU tier run?” — aborted the three GPU tests instead of skipping them. It now treats a missing library and a missing device alike (both mean not available), while still failing hard when ALKAHEST_GPU_TESTS=1 asserted a device that is not usable. The ALKAHEST_GPU_TESTS=1 tier additionally asserts GpuBackendReport::ran_on_gpu(), so a “GPU test” that silently reduced every matrix on the CPU now fails rather than passing on identical results.

See also

  • Code generation — the CPU JIT tiers, emit_c, StableHLO
  • Error handling — the full code registry
  • examples/gpu_batch_eval.py — CPU/GPU batch comparison, degrades cleanly to CPU-only on a wheel without the feature

Ball arithmetic

Unified evaluation (experimental)

alkahest.experimental.evaluate provides one result contract for exact rational, f64, and rigorous interval evaluation. It returns an EvaluationResult; mathematically unsupported inputs return status == "unsupported" and a stable E-EVAL-* reason code rather than raising. Invalid API input, such as an invalid mode or zero precision, still raises ValueError.

from fractions import Fraction
import alkahest as ak
from alkahest.experimental import evaluate

p = ak.ExprPool()
x = p.symbol("x")
result = evaluate(x + p.rational(1, 3), {x: Fraction(1, 6)})
assert result.value == Fraction(1, 2)
assert result.backend == "exact_rational"

Use mode="f64" for ordinary floating-point evaluation and mode="interval" with ArbBall bindings for an enclosure. In interval mode, result.value and result.enclosure are the same ArbBall; its lo and hi bound the true result. mode="auto" selects intervals for ArbBall bindings (or an explicit precision), exact rationals when possible, and otherwise falls back to f64.

Ball arithmetic provides rigorous enclosures: every operation produces an interval guaranteed to contain the true result. Alkahest uses FLINT’s Arb library as the backend.

ArbBall

An ArbBall represents the real interval [midpoint ± radius]:

from alkahest import ArbBall

a = ArbBall(2.0, 0.5)    # [1.5, 2.5]
b = ArbBall(3.0, 0.0)    # exactly 3.0

print(a.mid)   # 2.0
print(a.rad)   # 0.5
print(a.lo)    # 1.5
print(a.hi)    # 2.5

An ArbBall can also carry a precision (in bits) for the midpoint:

a = ArbBall(2.0, 1e-30, prec=128)  # 128-bit midpoint

Ball arithmetic operations

All arithmetic on ArbBall values produces a guaranteed enclosure. The radius grows to account for rounding and operation error:

a = ArbBall(2.0, 0.1)
b = ArbBall(3.0, 0.1)

print(a + b)    # [4.8, 5.2]  — radius grows by sum of input radii
print(a * b)    # guaranteed enclosure of [1.9, 2.1] * [2.9, 3.1]
print(a ** 2)   # [3.24, 4.41]  (squares the interval)

interval_eval

interval_eval evaluates a symbolic expression with ArbBall inputs:

from alkahest import ExprPool, ArbBall, interval_eval, sin, exp

pool = ExprPool()
x = pool.symbol("x")

# sin(1 ± 1e-10) — guaranteed enclosure
result = interval_eval(sin(x), {x: ArbBall(1.0, 1e-10)})
print(result.lo, result.hi)

# Multivariate
y = pool.symbol("y")
expr = sin(x) * exp(y)
result = interval_eval(expr, {
    x: ArbBall(1.0, 0.01),
    y: ArbBall(0.0, 0.01),
})

interval_eval guarantees that the output ball contains the true value for any input in the given input balls, accounting for all rounding in the intermediate computation.

AcbBall

Complex ball arithmetic for expressions over ℂ:

from alkahest import AcbBall

z = AcbBall(1.0, 0.0, 1.0, 0.0)  # 1 + i, exact

Use cases

Certified numerical evaluation. Compute a value and prove it lies within a tight bound without symbolic proof:

# Prove sin(1) ∈ [0.841, 0.842]
r = interval_eval(sin(x), {x: ArbBall(1.0, 0.0)})
assert r.lo > 0.841 and r.hi < 0.842

Numerical verification of symbolic results. After deriving a symbolic simplification, verify it numerically with rigorous bounds:

# Verify sin²(x) + cos²(x) = 1 at x = 1
lhs = sin(x)**pool.integer(2) + cos(x)**pool.integer(2)
r = interval_eval(lhs, {x: ArbBall(1.0, 0.0)})
assert 1.0 in r  # ball contains 1

Sensitivity analysis. Pass an input ball representing parameter uncertainty and observe how the output uncertainty grows:

# x = 1 ± 0.1 (10% uncertainty)
r = interval_eval(x**pool.integer(3), {x: ArbBall(1.0, 0.1)})
print(r)  # output uncertainty

Relationship to Lean certificates

Ball arithmetic and Lean certificate export are complementary:

  • Ball arithmetic gives numerical certainty within floating-point computation.
  • Lean certificates give symbolic/logical certainty for the rewrite steps applied.

Combining them: certify(interval(differentiate(f))) gives a derivative, evaluated with rigorous interval bounds, with a machine-checkable proof of the symbolic differentiation step.

Rigorous global bounds (Taylor models)

Ball arithmetic gives rigorous pointwise enclosures: evaluate f at a ball and the true value is inside the ball you get back. A research loop routinely needs rigorous global statements instead:

  • the maximum of f on [a,b]×[c,d] is at most M;
  • ∫_a^b f dx lies in [I₁, I₂];
  • f has no root anywhere in this box.

These turn a numeric observation into a theorem, which is exactly the step that takes a candidate from “survived the sweep” to “established”.

import alkahest as ak

pool = ak.ExprPool()
x = pool.symbol("x")

r = ak.bound_on_box(x * (pool.integer(1) - x), [(x, 0.0, 1.0)])
r.lower, r.upper        # encloses the true range [0, 1/4]
r.budget_exhausted      # False — converged within the work budget

ak.verified_integral(ak.sin(x), x, 0.0, 3.14159)   # Enclosure containing ~2
ak.verified_no_roots(x*x + pool.integer(1), [(x, -5.0, 5.0)])   # "true"
ak.verified_sign(ak.exp(x), [(x, -5.0, 5.0)], "positive")       # "true"

The soundness contract

Every returned enclosure is a rigorous outer bound. Rounding is outward at every step, and any operation whose remainder cannot be bounded rigorously refuses rather than returning something plausible.

The consequence worth internalising: an enclosure may be wide, but it is never wrong. A wide-but-true bound is a fine answer for a loop — it just means “not settled yet”. A tight-but-false one is a poisoned lemma that everything downstream inherits.

Running out of work budget is therefore not an error. The enclosure is returned anyway, with budget_exhausted = True, still sound.

Why not just use interval arithmetic

Naive interval evaluation is rigorous but suffers the dependency problem: it forgets that the two xs in x - x are the same number.

Expression, boxNaive intervalsTaylor model
x - x, x ∈ [-1,1][-2, 2]{0}
x(1-x), x ∈ [0,1][0, 1][0, 1/4]

A Taylor model carries a polynomial in the box’s normalised coordinates plus a rigorously enclosing remainder interval, so cancellation happens symbolically in the polynomial part and only the genuinely uncertain residue stays in the interval.

Branch-and-bound

bound_on_box runs Moore–Skelboe branch-and-bound: one pass for the minimum, one for the maximum. Each keeps a rigorous bound on the extremum and prunes sub-boxes whose enclosure proves they cannot contain it, so work concentrates where the extremum actually is.

This pruning is not an optimisation detail, it is what makes the module usable. A stopping rule that instead required every sub-box’s enclosure to be narrower than tol would be demanding a pointwise-tight model everywhere, which no finite budget achieves for a function with a wide range — exp on [-5,5] would exhaust the budget and still return an enclosure loose enough to straddle zero, so even exp > 0 would come back undecided.

Three-valued predicates

verified_no_roots and verified_sign return one of "true", "false", "undecided". The third is never collapsed into the other two.

VerdictMeaning
"true"Certified: the property holds everywhere on the box
"false"Certified: it fails — proved by the enclosure, or by a rigorously evaluated witness point
"undecided"Neither could be established within the budget and precision

A sign predicate is a universally quantified claim, so a single point where it provably fails disproves it. verified_sign uses that: it evaluates the expression rigorously at the box centre, the per-axis endpoints and (in low dimension) the corners, and reports "false" when one of those point enclosures lies strictly on the wrong side. Without it, x > 0 on [-1,1] — plainly false — could only ever be "undecided", since the range enclosure straddles zero by construction.

Proving a root exists

verified_no_roots returns "false" only with a proof in hand, and the proof is the intermediate value theorem. The full-box enclosure succeeding is already a continuity certificate — a Taylor model is only ever built where every elementary step stayed strictly inside its domain — and a box is convex. So if two points of the box can be found where f is rigorously proven to have opposite signs, the segment between them stays in the box and f vanishes somewhere on it.

The two points do not have to be the box’s own endpoints, and that is what makes the test usable: the search subdivides the box, records the sign of any sub-box whose enclosure has a determined one, and samples the centres of the rest.

BoxRoots insideEndpoint signsVerdict
x²−2 on [-2,0]1+ → −"false"
x²−2 on [-2,2]2+ → +"false"
x²−2 on [-10,10]2+ → +"false"
(x²−2)(x²+1) on [-2,2]2+ → +"false"
x−y on [-1,1]²a whole line"false"
(x−1)² on [0,2]1 (double)+ → +"undecided"

The last row is the honest limit. A double root never changes sign, so no witness pair exists; "undecided" is the answer, and it is not upgraded to "false" on the strength of an enclosure that merely touches zero.

Inequalities that are tight at an endpoint

The interesting inequalities are usually the sharp ones, and sharp means the margin goes to zero somewhere. Subdivision alone cannot certify those: where the margin vanishes, every enclosure of the range straddles zero however fine the boxes get.

Two separate things are done about it. tol is an absolute width, so it is the wrong stopping rule for a sign question — an expression whose minimum is 10⁻¹³ meets a 1e-9 tolerance while its enclosure still straddles zero. verified_sign therefore re-runs the search with the sign itself as the goal, refining while the bound straddles zero rather than to a fixed width. And where the margin genuinely reaches zero, the box is split: a collar [a, a+δ] at the endpoint is handled by a truncated Taylor expansion with a proven Lagrange remainder, the rest by ordinary branch-and-bound. The pieces are closed and share the join point, so their union is the original box.

x = pool.symbol("x")
# Cusa–Huygens, denominator cleared: x(2 + cos x) − 3 sin x ≥ 0, tight at x = 0
f = x * (pool.integer(2) + ak.cos(x)) - pool.integer(3) * ak.sin(x)
ak.verified_sign(f, [(x, 0.0, 1.5)], "nonnegative")   # "true"

Mitrinović–Adamović, Wilker, Huygens and Jordan’s inequality behave the same way. The remainder is proven, not assumed: a Taylor coefficient counts as zero only when substitution and simplify land on a literal integer 0 — no numeric enclosure can prove a value is zero — and the tail is bounded by sup|g⁽ᵐ⁾|/m! enclosed over the whole collar, with analyticity certified by requiring every derivative up to g⁽ᵐ⁾ to enclose successfully there.

The limits are worth knowing:

CaseVerdictWhy
tight at an endpoint of the box"true"the expansion applies there
leading coefficient proven negative"false"g < 0 just inside the endpoint
"positive" where g provably vanishes"false"a strict claim fails at that point
tight in the interior"undecided"the expansion does not apply

(x − 7/10)²(x + 1) on [0, 3/2] is non-negative and touches zero in the middle; it stays "undecided" rather than being upgraded on the strength of an enclosure that merely touches zero.

Which functions are covered — ask before you build the workload

Taylor models reach the elementary fragment: exp, log, sqrt, sin, cos, tan, asin, acos, atan, sinh, cosh, tanh, asinh, acosh, atanh, abs, plus arithmetic and integer/rational powers — and, since 3.9.0, erf and erfc. Outside it are bessel_j0, bessel_j1, digamma, lambert_w, gamma, the elliptic integrals, floor and ceil, and so is any two-argument function such as atan2.

The three inverse hyperbolics carry the domain restriction their branch has: acosh needs the whole box strictly above 1 and atanh needs it strictly inside (-1, 1). A box that only touches the boundary is refused with E-VALIDATED-003, because the derivative is unbounded there and no Taylor remainder exists — that is a statement about the box, not about coverage, so bounds_supported still answers True.

That boundary is queryable, so a search loop can choose a certifiable route instead of discovering it by hitting E-VALIDATED-001:

ak.bounds_supported(ak.sin(x) * ak.exp(x))     # truthy
answer = ak.bounds_supported(ak.bessel_j0(x))
bool(answer), answer.functions                  # (False, ['bessel_j0'])
answer.blocker                                  # "function `bessel_j0`"

# Per primitive, in the agent contract:
{row["name"] for row in ak.capabilities()["primitives"] if row["taylor_model"]}

numeric_ball is not this flag. It reports pointwise ball arithmetic, which bessel_j0, digamma, lambert_w and floor all have; a Taylor model additionally needs a rule with a rigorous Lagrange remainder, which they do not. Both bits are honest — they answer different questions. taylor_model and bounds_supported are derived by running the Taylor evaluator, not from a maintained list, so neither can drift from what bound_on_box accepts.

A True answer means “will not be refused with E-VALIDATED-001”. It is not a promise of success: a covered function can still hit a domain violation or an infinite enclosure on a particular box, which is a property of the box and not of the expression.

Refusals

CodeMeaning
E-VALIDATED-001No rigorous Taylor model rule for some primitive in the expression (ask bounds_supported first — see above)
E-VALIDATED-002A free symbol has no interval in the box
E-VALIDATED-003A singularity or branch cut inside the box (e.g. 1/x over a box containing 0)
E-VALIDATED-004An enclosure overflowed to infinity
E-VALIDATED-005Malformed request (empty box, inverted interval, bad order)

For E-VALIDATED-003 the search first tries bisecting away from the trouble, since a domain violation on a wide box is often a boundary effect rather than a genuine interior pole. Only after the box has been bisected far enough for that explanation to be exhausted does it refuse — which is the right answer for a real interior singularity, where the range is not a bounded interval at all.

Removable singularities in verified_integral

∫₀¹ ln(1+x)/x dx = π²/12 has nothing singular about it — only the expression is singular at x = 0, and the integrand extends continuously to 1 there. A Taylor model still refuses, because the reciprocal’s enclosure contains zero.

verified_integral recognises this shape. If the integrand splits as N(x)/D(x) and there is a point p of the offending sub-interval at which N and D both vanish, it enclosures that piece with Cauchy’s mean value theorem instead:

N(p) = D(p) = 0,  D' ≠ 0 on J   ⟹   ∀ x ∈ J\{p} :  N(x)/D(x) = N'(ξ)/D'(ξ)  for some ξ ∈ J
                                ⟹   ∫_J N/D dx ∈ |J| · range(N'/D' on J)

so the piece is bounded by an enclosure of N'/D', which is perfectly regular. The number returned is the integral of the continuous extension.

Three guards keep this from swallowing a genuine pole:

  • N(p) = 0 and D(p) = 0 are established symbolically (substitute the exact rational p, simplify, require a literal zero). No numeric enclosure can prove a value is exactly zero, so none is asked to.
  • D' must be certified non-vanishing on the sub-interval. That is what fails for sin(x)/x², where the denominator has a double zero and the integral does not converge.
  • N and D must each have a successful enclosure over the whole sub-interval, which certifies they are analytic — and hence that the symbolic derivatives really are their derivatives.
ak.verified_integral(ak.log(pool.integer(1) + x) / x, x, 0.0, 1.0)  # ≈ π²/12
ak.verified_integral(ak.sin(x) / x, x, -1.0, 1.0)                   # ≈ 1.8921661
ak.verified_integral(pool.integer(1) / x, x, -1.0, 1.0)             # refuses: N(0) ≠ 0

What is still refused

An integrable but non-removable singularity is refused, and the message says so rather than implying the integral does not exist:

IntegralValueStatus
∫₀¹ ln(1+x)/x dxπ²/12enclosed (removable)
∫_{-1}^{1} sin(x)/x dx2·Si(1)enclosed (removable)
∫₀¹ −ln x dx1refused — log enclosure reaches 0, not a 0/0 quotient
∫₀¹ (ln x)² dx2refused, same reason
∫₀¹ dx/√(1−x²) dxπ/2refused — endpoint singularity, numerator does not vanish
∫₀¹ xˣ dx0.78343…refused — log enclosure reaches 0
∫₀¹ ln(x)·ln(1−x) dx2 − π²/6refused — singular at both ends

These need an integrable-tail bound or a singularity-removing substitution, neither of which can be derived rigorously from the expression alone today. The refusal is the honest answer; widening an enclosure to make them pass would break the contract that makes the module worth using.

Relation to the rest of the stack

This is the slow, certifying half of falsify fast, certify slow:

JIT / numpy_evalBall arithmeticValidated bounds
AnswersApproximate values, fastRigorous at a pointRigorous over a region
Cost~µs~msseconds, adaptive
UseKill 99% of candidatesDistinguish near-miss from hitPromote a survivor to a theorem

verified_integral complements the symbolic integrate_definite: use the symbolic path when you want a closed form, and this one when you want a guaranteed numeric interval — including for integrands with no elementary antiderivative.

Scope of this release

Shipped: Taylor model arithmetic over a box (arithmetic, powers, division, and the elementary functions with rigorous remainders), range enclosure by branch-and-bound, verified 1-D definite integrals including removable singularities, root absence, root existence and sign predicates.

Not shipped: multivariate verified quadrature (verified_integral is 1-D), improper integrals, integrable-but-not-removable singularities, and Taylor-model-based ODE enclosures.

ODE and DAE modeling

Alkahest provides symbolic infrastructure for ordinary differential equations (ODEs) and differential-algebraic equations (DAEs), including structural analysis and automatic index reduction.

Every snippet on this page is executed by tests/test_docs_ode_dae.py, so it runs against the version of Alkahest it ships with.

ODE

ODE represents an explicit first-order system d(state_vars)/dt = rhs. The constructor takes three positional arguments — state variables, right-hand sides, and the independent variable — and ODE(...) and ODE.new(...) are the same thing:

from alkahest import ExprPool, ODE

pool = ExprPool()
t = pool.symbol("t")
x = pool.symbol("x")
v = pool.symbol("v")

# Simple harmonic oscillator x'' + x = 0, written as a first-order system:
#   x' = v,  v' = -x
ode = ODE.new([x, v], [v, pool.integer(-1) * x], t)

ode.order            # 2 — number of state variables
ode.state_vars()     # [x, v]
ode.rhs()            # [v, (x * -1)]
ode.is_autonomous()  # True — t does not appear on the right

Initial conditions are attached one at a time, and with_ic returns a new ODE rather than mutating:

ode_with_ic = ode.with_ic(x, pool.integer(1)).with_ic(v, pool.integer(0))

Lowering to first order

lower_to_first_order takes the pieces of a scalar higher-order equation — the unknown, the right-hand side, the order, and the independent variable — not an existing ODE:

from alkahest import lower_to_first_order

# x'' = -4x  →  [x' = x_1, x_1' = -4x]
ode = lower_to_first_order(x, pool.integer(-4) * x, 2, t)

ode.state_vars()   # [x, x_1]
ode.rhs()          # [x_1, (x * -4)]

The auxiliary states are named x_1, x_2, … for the successive derivatives.

DAE

DAE represents a system of implicit equations mixing differential and algebraic constraints, F(t, variables, derivatives) = 0. Build one with DAE.new(equations, variables, derivatives, time_var) — four positional arguments:

  • equations — each expression means g = 0. Write x' = f as dx - f.
  • variables — the dependent variables.
  • derivatives — a separate symbol standing for the time derivative of variables[i], e.g. pool.symbol("dx/dt"). Alkahest does not parse the name; the positional pairing is what makes it a derivative.
  • time_var — the independent variable.

A variable with no entry in derivatives is purely algebraic. That is how you declare a Lagrange multiplier, and it is what makes the system high-index:

from alkahest import DAE, ExprPool

pool = ExprPool()
t = pool.symbol("t")
x, y, u, w = (pool.symbol(n) for n in ("x", "y", "u", "w"))
lam = pool.symbol("lam")               # Lagrange multiplier — algebraic
dx, dy, du, dw = (pool.symbol(n) for n in ("dx/dt", "dy/dt", "du/dt", "dw/dt"))
one, two = pool.integer(1), pool.integer(2)

# Cartesian pendulum (index 3): x' = u, y' = w, u' = -lam*x, w' = -lam*y - 1,
# subject to x**2 + y**2 = 1.
dae = DAE.new(
    [dx - u, dy - w, du + lam * x, dw + lam * y + one, x**two + y**two - one],
    [x, y, u, w, lam],   # five variables …
    [dx, dy, du, dw],    # … but only four have derivatives
    t,
)

dae.n_equations   # 5
dae.n_variables   # 5
dae.equations()   # the five expressions, each meaning "= 0"
dae.derivatives() # [dx/dt, dy/dt, du/dt, dw/dt]
dae.time_var      # t

Pantelides algorithm

The Pantelides algorithm performs structural index reduction: it repeatedly differentiates the equations that a maximum bipartite matching leaves unmatched, until every equation is matched to a variable. It returns the reduced DAE — not a separate report object:

from alkahest import pantelides

reduced = pantelides(dae)

reduced.index        # 1 — differentiation rounds used
reduced.n_equations  # 6 — one more than the input
reduced.equations()[-1]
# ((x * dx/dt * 2) + (y * dy/dt * 2)) — the differentiated constraint

reduced.index is the number of rounds, so 0 means the input already had a perfect structural matching and nothing was differentiated. The equations appended by differentiation are visible in reduced.equations(), and the higher jets they introduce (ddx/dt/dt, …) in reduced.derivatives().

pantelides raises ValueError (E-DAE-002) above index 10. dae_index_reduce(dae) runs Pantelides first and falls back to rosenfeld_groebner when it hits that cap.

Because the algorithm looks only at which variables occur in which equations, it can reduce a system whose coefficients make it unsolvable; structural regularity is not numerical regularity.

Differential elimination

rosenfeld_groebner prolongs the system — differentiating each equation and treating the new jets as fresh indeterminates — and computes a Gröbner basis after each round. The basis holds the algebraic consequences of the differential system, which is what elimination-based work (input–output equations, structural identifiability) needs:

from alkahest import DAE, ExprPool, rosenfeld_groebner

pool = ExprPool()
t, x, dx = pool.symbol("t"), pool.symbol("x"), pool.symbol("dx/dt")

dae = DAE.new([dx - x], [x], [dx], t)          # x' = x
result = rosenfeld_groebner(dae, max_prolong_rounds=1)

result.consistent    # True — the unit ideal was never reached
result.truncated     # True — stopped at the round budget, not at saturation
result.variables()   # [t, x, dx/dt, ddx/dt/dt] — jets, in exponent-slot order

[str(e) for e in result.final_basis().to_exprs()]
# ['(x + (-1 * ddx/dt/dt))', '(dx/dt + (-1 * ddx/dt/dt))']

final_basis() returns a GroebnerBasis that knows its variable ordering, so to_exprs() reads the relations back as Expr (each meaning = 0). See Solving for the rest of the GroebnerBasis surface.

truncated=True means prolongation stopped because max_prolong_rounds ran out, not because the chain saturated. A truncated basis is a sound set of consequences but need not be complete — “not in the basis” then does not mean “not a consequence”. Nonlinear jets often do not saturate in finitely many algebraic steps, so this is the common case.

final_basis() returns None when consistent is False.

Sensitivity analysis

Sensitivity analysis computes how solutions depend on parameters. sensitivity_system(ode, params) augments the state with ∂x/∂p; adjoint_system(ode, objective_grad) takes the gradient of the objective with respect to the state, as a list parallel to ode.state_vars():

from alkahest import ExprPool, ODE, adjoint_system, sensitivity_system

pool = ExprPool()
t, y, k = pool.symbol("t"), pool.symbol("y"), pool.symbol("k")

ode = ODE.new([y], [pool.integer(-1) * k * y], t)   # y' = -k*y

sens = sensitivity_system(ode, [k])
sens.original_dim                # 1
sens.n_params                    # 1
sens.extended_ode.state_vars()   # [y, dS_y_k]

# Objective J = y(T)**2  →  dJ/dy = 2y
adj = adjoint_system(ode, [pool.integer(2) * y])
adj.state_vars()                 # [lambda_y]
adj.rhs()                        # [(k * lambda_y)]

Acausal modeling

Acausal component modeling lets you describe physical systems by their component equations without manually choosing which direction information flows:

from alkahest import AcausalSystem, ExprPool, capacitor, resistor, voltage_source

pool = ExprPool()
t = pool.symbol("t")

# Component constructors return {"name", "n_equations", "n_ports", "component"}.
src = voltage_source("V1", pool.symbol("Vs"))["component"]
res = resistor("R1", pool.symbol("R"))["component"]
cap = capacitor("C1", pool.symbol("C"))["component"]

circuit = AcausalSystem(pool)
circuit.add_component(src)
circuit.add_component(res)
circuit.add_component(cap)

# Wire the loop: Vs.p -> R.p, R.n -> C.p, C.n -> Vs.n
circuit.connect(src.port("V1.p"), res.port("R1.p"))
circuit.connect(res.port("R1.n"), cap.port("C1.p"))
circuit.connect(cap.port("C1.n"), src.port("V1.n"))

# Flatten into a DAE
dae = circuit.flatten(t)
dae.n_equations   # 10

Built-in components (resistor, capacitor, voltage_source, and others registered via the component API) generate their constitutive equations automatically. AcausalSystem.flatten assembles them, plus the Kirchhoff-style connection equations, into a DAE that Pantelides can reduce. See examples/acausal_and_laplace.py for a runnable end-to-end example.

Laplace transform

The Laplace transform lives in alkahest.experimental (the calculus/ODE/transform surface is not yet semver-frozen):

from alkahest import ExprPool
from alkahest.experimental import inverse_laplace_transform, laplace_transform

pool = ExprPool()
s, t = pool.symbol("s"), pool.symbol("t")
F = laplace_transform(pool.integer(1), t, s)     # s^-1
f = inverse_laplace_transform(F, s, t)           # back to 1

Hybrid systems

HybridODE adds event handling to an ODE: at a crossing event, the state is reset and integration resumes. Event.new(name, condition, reset_map) takes the reset map as a list of (variable, new_value) pairs, and both HybridODE.new and add_event return new objects:

from alkahest import Event, ExprPool, HybridODE, ODE

pool = ExprPool()
t, x, v = pool.symbol("t"), pool.symbol("x"), pool.symbol("v")

# Bouncing ball: x' = v, v' = -1; velocity reverses at floor contact
base_ode = ODE.new([x, v], [v, pool.integer(-1)], t)
bounce = Event.new("bounce", x, [(v, pool.integer(-1) * v)])

hybrid = HybridODE.new(base_ode).add_event(bounce)
hybrid.n_events   # 1
hybrid.guards()   # [x] — fires when x crosses 0

Event.rising() and Event.falling() restrict an event to one crossing direction; by default it fires in both.

Polynomial system solving

Alkahest solves systems of polynomial equations symbolically using Gröbner bases.

solve

solve finds the solutions of a system of polynomial equations in a list of variables. It uses the groebner Cargo feature, which is included in all PyPI wheels (default feature since 2.3.1) and in all source builds.

from alkahest import ExprPool, solve, sqrt

pool = ExprPool()
x = pool.symbol("x")
y = pool.symbol("y")

# Linear system
solutions = solve([x + y - pool.integer(1), x - y], [x, y])
# → [{x: 1/2, y: 1/2}]

# Circle intersected with a line: irrational solutions
solutions = solve(
    [x**2 + y**2 - pool.integer(1), y - x],
    [x, y]
)
# → [{x: sqrt(2)/2, y: sqrt(2)/2}, {x: -sqrt(2)/2, y: -sqrt(2)/2}]

# Parametric solve: free symbols omitted from `vars` stay as parameters
solutions = solve([x**2 - y], [x])
# → [{x: sqrt(y)}, {x: -sqrt(y)}]

Solutions are symbolic: irrational roots are returned as Expr trees (e.g. sqrt(2)/2) rather than floats. Quadratic elimination produces exact symbolic answers. Free symbols that appear in the equations but are not listed in vars are treated as parameters, so solutions may depend on those symbols.

Solution types

The return value is a list of dicts mapping Expr variable → Expr solution:

for sol in solutions:
    for var, val in sol.items():
        print(f"{var} = {val}")
        # Evaluate numerically if needed
        from alkahest import eval_expr
        numeric = eval_expr(val, {})

solve returns an empty list for inconsistent systems and a GroebnerBasis handle for parametric families (infinite solution sets).

Pass numeric=True to return float values directly: solve(eqs, vars, numeric=True).

GroebnerBasis

A GroebnerBasis can be constructed directly for ideal-theoretic operations:

from alkahest import GroebnerBasis

# Compute a Gröbner basis (lex by default)
polys = [x**2 + y**2 - pool.integer(1), x - y]
gb = GroebnerBasis.compute(polys, [x, y])

# Check ideal membership
print(gb.contains(x - pool.rational(1, 2)))  # False

# Reduce a polynomial modulo the ideal — the remainder is a GbPoly
reduced = gb.reduce(x**3 + y**3)
print(reduced.to_expr())                     # y

Reading a basis

A GroebnerBasis is a sequence of GbPoly, and each GbPoly converts back to an Expr. This is how you read an elimination result — the generators of a Lex basis that are free of the eliminated variables are the eliminated relations:

len(gb)                       # 2 — number of generators
gb.order                      # "lex"
gb.variables()                # [x, y] — what exponent slots 0, 1 refer to

for g in gb:
    print(g.to_expr(), "= 0")
# (y^2 + -1/2) = 0
# (x + (y * -1)) = 0

gb.to_exprs()                 # the same list in one call
gb[0].terms()                 # [((0, 0), Fraction(-1, 2)), ((0, 2), 1)]

terms() gives (exponent tuple, coefficient) pairs with the coefficient as an exact Python int or fractions.Fraction; the exponent tuple is parallel to variables().

The conversion runs the other way with expr_to_gbpoly, which is what reduce and contains accept alongside plain Expr:

from alkahest import expr_to_gbpoly

p = expr_to_gbpoly(x**2 + y**2 - pool.integer(1), [x, y])
p.n_terms                     # 3
gb.contains(p)                # True
GroebnerBasis.compute_raw([p])

A GbPoly stores exponent vectors, not names, so converting one back needs the variable list its slots refer to. Every basis Alkahest hands out carries that list — including the ones from solve (solve variables followed by the free parameters), triangularize and rosenfeld_groebner — so to_expr() normally takes no arguments. Naming too few variables raises ValueError rather than quietly misreading the exponent slots.

Monomial orders

Supported orders: Lex (lexicographic), GrLex (graded lexicographic), GRevLex (graded reverse lexicographic). GRevLex is generally fastest for basis computation; Lex is required for elimination.

Parallel F4

With --features "groebner parallel", Gröbner basis computation uses Rayon for parallel S-polynomial reduction via the F4 algorithm.

GPU-accelerated Macaulay matrix (groebner-cuda) — Rust only, not wired into the solver

--features "groebner-cuda" compiles a CUDA kernel for the mod-p row reduction of the Macaulay matrix, with multi-prime CRT lifts reconstructing rational coefficients, and falls back to pure-Rust row reduction when no CUDA device is present.

It does not accelerate anything on this page. GroebnerBasis.compute, solve and triangularize run Buchberger/F4 on the CPU regardless; the GPU routine is reachable only as the Rust function alkahest_cas::poly::groebner::compute_groebner_basis_gpu, and production dispatch deliberately does not prefer it until the benchmark harness says it wins. There is correspondingly no capabilities()["features"]["groebner_cuda"] bit — it used to exist and report that the kernel had been compiled in, which no Python observation could confirm or refute, so 3.8 removed it. See GPU support.

Because the Rust entry point falls back to CPU row reduction when no device is present, it returns a GpuBackendReport alongside the basis: let (basis, backend) = compute_groebner_basis_gpu(gens, order, Some(0))?; and backend.ran_on_gpu() is the only way to tell a real GPU run from a fallback, since the basis is identical either way.

Elimination ideals

GroebnerBasis.eliminate computes the elimination ideal I ∩ k[remaining vars] by dropping every generator whose support mentions one of the given variables. Under a lex basis with the eliminated variables ordered first, what is left is a Gröbner basis for that ideal:

# Implicitize the parametric curve (t, t**2): eliminate the parameter t.
gb = GroebnerBasis.compute([x - t, y - t**2], [t, x, y])
gb.to_exprs()                      # [(t + (x * -1)), ((y * -1) + x^2)]

implicit = gb.eliminate([t])
implicit.to_exprs()                # [((y * -1) + x^2)]  —  y = x**2

Note the variable order passed to compute: t comes first, so lex eliminates it. eliminate requires the basis to know its variables (gb.variables()), and rejects a variable it is not written over.

Coefficient fields: Q(params) instead of Q[vars, params]

GroebnerBasis.compute(polys, vars, params=[...]) moves the listed symbols into the coefficient field Q(params) instead of the polynomial ring. They never enter the monomial order and never generate S-pairs, which is the difference between eliminating states from Q[states, Y, params] and from Q(params)[states, Y] — the parameter count no longer inflates the staircase.

from alkahest import GroebnerBasis

# a lives in the coefficient field Q(a), not the ring Q[x, y, a]
gb = GroebnerBasis.compute([a*x - y, x + y - one], [x, y], params=[a])
type(gb)                      # ParametricGroebnerBasis
[g.to_expr() for g in gb]     # coefficients are rational functions of a

Measured on a catenary compartmental ODE model (a linear chain of n states, output the first compartment, eliminating the states from the jet equations down to the input–output relation): at n = 4 states / 7 rate constants the parametric route runs in 0.27s against 4.2s putting the rate constants in the ring (Lex, --release, ~15×) and leaves 5 total basis generators against 25; at n = 5 states / 9 rate constants the parametric route finishes in 6.9s while the direct computation had not finished after 240s. These are wall-clock numbers on one machine, illustrating the shape of the difference (S-pairs among the parameters are exactly what the ring route pays for and the coefficient-field route never generates) rather than a promised ratio.

The result is generic. A leading coefficient in Q(params) can be a non-zero rational function of the parameters and still vanish at a specific parameter point, and there the basis this computation built is not the one the same algorithm would build over ℚ at that point:

gb.conditions()               # [a + 1] — the basis says nothing at a = -1
gb.is_regular_at([3])         # True
gb.is_regular_at([-1])        # False

gb.specialize([3])            # an ordinary GroebnerBasis over Q
gb.specialize([-1])           # raises ParamGroebnerError, code "E-PARAMGB-004"

conditions() lists the hypersurfaces the computation assumed non-vanishing — every leading-coefficient inversion contributes its numerator and denominator, every input coefficient contributes its denominator — factored into irreducible, primitive pieces so the report is a list of conditions rather than one opaque polynomial in many parameters. The list is sufficient, not necessary: it can flag a point that turns out fine (a removable coincidence the bookkeeping cannot see), but it never misses a point where the generic basis is genuinely wrong. specialize refuses on the flagged locus with ParamGroebnerError (E-PARAMGB-004) rather than returning something that is not a basis; check is_regular_at first if a degenerate point is a normal outcome for your caller.

The read path matches GroebnerBasis: the object is a sequence of ParametricGbPoly, each with to_expr() / terms(), and the basis itself has to_exprs(), eliminate(vars) (same Lex-with-eliminated-variables-first contract, refuses to eliminate a coefficient-field parameter since there is nothing to eliminate), reduce, and contains. GroebnerBasis.compute(..., params=None) or params=[] is the unmodified Q[vars] path; ParametricGroebnerBasis.compute(polys, vars, params, order=None) is the equivalent direct constructor in alkahest.experimental.

This surface is experimental (alkahest.experimental.ParametricGroebnerBasis / ParametricGbPoly) and requires --features groebner.

Performance

On the solve_circle_line benchmark (2-variable quadratic system), Alkahest is approximately 40× faster than SymPy due to the FLINT-backed polynomial arithmetic and the compiled F4 core.

Upcoming (v2.0): F5 / signature-based Gröbner basis, real root isolation, primary decomposition, and other advanced algorithms.

Positivity certificates (SOS / Positivstellensatz)

decide answers real-algebraic questions by CAD, and pays doubly-exponential cost for it. Most positivity questions that actually arise — is this bound valid, is this Lyapunov candidate non-negative, is this inequality true on a box — do not need a decision procedure at all. They need a certificate: a short algebraic identity that makes the answer checkable by anyone, including a proof assistant.

decide is not complete in this implementation: on some sentences it refuses with E-CAD-001 rather than answering. See decide refuses rather than guessing below — this changed in 3.8 and it changed because the alternative was answering wrongly.

import alkahest as ak

pool = ak.ExprPool()
x, y = pool.symbol("x"), pool.symbol("y")

cert = ak.sos_decompose(x*x - pool.integer(2)*x*y + y*y, [x, y])
cert.kind        # "sos"
cert.identity    # p = 1*(x - y)^2
cert.verify()    # True — re-expands to the target exactly
cert.to_lean()   # Lean 4 rendering, or None

Constrained, on a basic semialgebraic set:

g1, g2 = x, pool.integer(1) - x          # the box 0 ≤ x ≤ 1
cert = ak.prove_nonneg(x - x*x, [x], constraints=[g1, g2])
cert.kind        # "handelman"  —  x − x² = x·(1 − x)

Three outcomes, deliberately kept apart

This is the part that matters for a search loop, and the part most CAS get wrong by collapsing:

OutcomeMeaningWhat a loop should do
PositivityCertificateProved non-negative, with a checkable witnessRecord it; cite it
SosError E-SOS-003Proved not non-negative — a witness point is in the messageThe conjecture is false; kill the branch
SosError E-SOS-002Undecided: no certificate of this shape at this degreeRecord unknown; raise the degree, or fall back to decide

E-SOS-002 must be recorded as unknown, never as “not SOS”. It reports that this search — the LP-representable subcone described below, at this basis_degree (or level) — found nothing. Three different worlds produce it and the error cannot tell them apart: p is SOS but its Gram matrix lies outside the subcone; p is SOS only at a higher basis degree; p is non-negative but genuinely not SOS. A loop that maps E-SOS-002 to “the conjecture is false” or “p is not a sum of squares” closes a branch on evidence that does not support it, and a wrongly closed branch is invisible — nothing downstream will ever contradict it. Only E-SOS-003 is a refutation, and it carries a witness point.

E-SOS-002 is not a claim that the polynomial is not a sum of squares, and certainly not that it is negative. The canonical illustration is the Motzkin polynomial x⁴y² + x²y⁴ − 3x²y² + 1, which is non-negative everywhere but provably not a sum of squares itself — asked to decompose it directly (no multiplier), this module refuses with E-SOS-002, correctly: it does not report it as negative, and it does not invent a decomposition. sos_decompose’s full pipeline does not stop there, though (see “What the search actually covers” below) — it also tries multiplying by a power of x²+y² before giving up, and that succeeds for Motzkin, so the end-to-end call returns a certificate, not a refusal. The homogeneous 3-variable form of Motzkin still refuses even through the full pipeline (multiplier search included), and — unlike the affine case above — this is not just “not yet reached”: the classical fact needs multiplier power N = 2, not N = 1, for this specific homogeneous ternary form, and N = 2 has now been attempted to closure by a genuinely harder search (symmetry reduction plus an exact algebraic zero-vector restriction, cutting 165 free parameters down to 16) without succeeding — see “What’s still open” below for the numbers. That is the actual reachable illustration of a genuine E-SOS-002 from this module today: the refusal is a property of the search, not of the polynomial, and which polynomials it applies to shifts as the search grows more complete.

The three-way branch a loop should write:

try:
    cert = ak.sos_decompose(p, [x, y])
    verdict, evidence = "nonneg", cert    # proved, with a checkable identity
except ak.SosError as e:
    if e.code == "E-SOS-003":
        verdict, evidence = "negative", str(e)   # refuted; witness point in the message
    else:
        verdict, evidence = "unknown", str(e)    # E-SOS-002 lands here — leave the branch open

What the search actually covers

A sum-of-squares decomposition p = zᵀQz over the monomial basis z exists iff there is a positive semidefinite Gram matrix Q matching p’s coefficients. Deciding general PSD feasibility is a semidefinite programme, and no floating-point SDP solver is ever trusted with a certificate here — a rounded Q is not a proof.

The search tries three things, in order, before refusing:

  1. The diagonally-dominant (DSOS) subcone — non-negative combinations of squares of a fixed generator set, (e_i)² and (a·e_i ± b·e_j)² for small coprime (a, b), solved with the exact rational simplex in real::sos::lp (Bland’s rule, so termination is unconditional and there are no epsilon tolerances). Every generator is literally a square, so a feasible point is sound by construction — but the cone is a strict subset of the SOS cone: diagonal dominance is not invariant under scaling the basis, so a perfect square as ordinary as (x/2 + 1/3)² has a Gram matrix — its only one — that is PSD but not DD.
  2. The full PSD Gram cone, when DSOS fails (real::sos::psd::psd_search). This subsumes DSOS but is not free: it leans on a floating-point search (Jacobi eigendecomposition, PSD-cone projection, an annealed schedule of shrinking eigenvalue floors with several random restarts — real::sos::sdp) to propose a Gram matrix, which is then rounded to nearby rationals and re-expanded to check it equals p exactly before anything is returned. A Some here is always sound regardless of what the numeric search converged to; a None means only “the search did not turn up a certificate”, never “not SOS”.
  3. A Reznick multiplier search, when even step 2 fails on p itself: tries (x_1²+…+x_n²)^N·p for N = 1..4 and reruns step 2 on the product. Some positive-definite forms are not SOS at all (Hilbert 1888 — this is what Motzkin’s polynomial witnesses), but Reznick’s theorem guarantees (Σxᵢ²)^N·p is SOS for some N; the search does not know N in advance and reports budget exhaustion honestly rather than a disproof.

E-SOS-002 at the end of all three is phrased as a statement about the search, not the polynomial — the search’s incompleteness, at any step.

Step 3’s search has to work harder than plain alternating projection, because the multiplier certificates it exists for are frequently tight — Motzkin’s polynomial and Robinson’s form (the textbook PSD-not-SOS examples) both have witnessing Gram matrices that are singular, sitting exactly on the boundary of the PSD cone rather than its interior. A first version of this search (annealed alternating projection with several random restarts) converged toward that boundary monotonically (confirmed by a diagnostic trajectory) but never reliably closed the last, asymptotically slow stretch — the textbook behaviour of alternating projection at a tangential (non-transversal) set intersection. The search now also tries Douglas–Rachford splitting with over-relaxation and a facial-reduction step — both standard escapes for exactly this stall — and with them, both (x²+y²)·Motzkin(x,y) and (x²+y²+z²)·Robinson(x,y,z) are found and exactly re-verified. What’s still open: the homogeneous 3-variable form of Motzkin at multiplier power N = 2, (x²+y²+z²)²·(x⁴y²+x²y⁴−3x²y²z²+z⁶) (N = 1 is not classically expected to work for this specific homogeneous ternary form at all), is not — and this has now been pushed well past a budget skip. A new fallback, symmetry reduction (real::sos::psd::symmetry_reduced_search), restricts the search to the subspace fixed by the target’s own signed-permutation symmetry (here, order 16: swap x, y, and independently flip the sign of each variable, since every exponent is even) whenever that subspace is genuinely smaller — cutting this case’s 165 free parameters to 26. An exact, non-numeric restriction on top of that — Motzkin’s known zero at (1,1,1) forces Q·z(1,1,1) = 0 on any witnessing Gram matrix Q, and z(1,1,1) is literally the all-ones vector, no rounding involved — cuts it again to 16. Even on that 16-parameter family, though, Douglas–Rachford converges only very slowly: 6,000,000 iterations bring the minimum eigenvalue to roughly −1.4·10⁻⁸, and rational rounding still fails at every stage even with denominators up to roughly 10⁹. This is now a genuine, quantified numerical-hardness finding, not an unexplored avenue or an under-tuned budget — checked to be a real search limitation and not a bug in the machinery the same way as before: an independent sanity check confirms the affine Gram-matrix family (and each reduced family) is constructed correctly, and a synthetic planted example with a singular Gram matrix of the same size is found and exactly re-verified. The remaining test for the 3-variable Motzkin form records undecided rather than a false certificate.

Constrained certificates

With constraints g_i ≥ 0, prove_nonneg searches for a Handelman certificate

p = Σ_α c_α · Π_i g_i^{α_i},    c_α ≥ 0 rational,   Σ_i α_i ≤ level

which is again an exact LP in the weights c_α. level is a user-visible parameter; exceeding it is a refusal (E-SOS-002), not a failure. Handelman is complete for polytopes given a high enough level, but the level needed is not known in advance — so raising it on refusal is a meaningful retry.

Verification

Every certificate is re-expanded in exact rational arithmetic and compared against the target identically before it is returned. A candidate that fails is refused (E-SOS-005), never returned with a caveat. verify() runs the same check on demand so a downstream consumer never has to trust the search that produced the certificate.

When to use this versus decide

sos_decompose / prove_nonnegdecide (CAD)
AnswersNon-negativity, with a certificateReal-algebraic sentences in ≤ 2 variables with a ≤ 2-quantifier prefix
CompletenessNo — refuses honestly (E-SOS-002)No — refuses honestly (E-CAD-001)
CostLP in exact rationalsDoubly exponential
OutputCheckable identity, Lean-exportableTruth value (+ witness)

The intended pattern is: try the certificate route first because it is cheap and its output is citable; fall back to decide on E-SOS-002 when you need a verdict rather than a certificate and can afford the cost. Note that neither route is complete, so “both refused” is a real and expected outcome — it means undecided by these methods, not false.

decide refuses rather than guessing

decide implements CAD over a bounded fragment: purely polynomial bodies over ℚ in one or two real variables, with a quantifier prefix of at most two. Outside that fragment it raises CadError (E-CAD-001). Inside it, there is one further refusal, and it is the important one.

The CAD sample set is built from rational points — bracket endpoints, refined brackets, midpoints. For a strict atom (<, >) that is complete: strict solution sets are open, so if a solution exists, a whole interval of rational points solves it too. For a non-strict atom (=, , , ) the solution set can be a single boundary point, and if that point is irrational it is never in the sample set. Concluding “no sample satisfied it, therefore unsatisfiable” would then be a claim about a point that was never tested — and via ∀x. φ ≡ ¬∃x. ¬φ, that fabricated false becomes a machine-checked-looking proof of a false universal theorem.

So when a boundary root has not been shown rational and the body has a non-strict atom, decide refuses:

import alkahest as ak

pool = ak.ExprPool()
x = pool.symbol("x")

# Rational double root: found exactly, so the verdict is real.
body = pool.gt((pool.integer(3) * x + pool.integer(2)) ** pool.integer(2), pool.integer(0))
ak.decide(ak.Forall(x, body))        # (False, None) — false at x = -2/3

# Irrational double root at ±sqrt(2): refuses instead of answering.
irr = pool.gt((x ** pool.integer(2) - pool.integer(2)) ** pool.integer(2), pool.integer(0))
try:
    ak.decide(ak.Forall(x, irr))
except ak.CadError as e:
    print(e.code)                     # E-CAD-001

Three consequences worth planning for:

  • E-CAD-001 is “I did not establish this”, not “false”. A search loop must not record it as a closed branch. It is the same class of answer as E-SOS-002.
  • Witnesses are verified. When decide reports (True, {...}) for an existential, the point is substituted back and checked; if it does not satisfy the sentence the witness is reported as None rather than as a certificate that fails. ∃x. 3x − 2 = 0 gives (True, {'x': '2/3'}); ∃x. x² = 2 gives (True, None), because no rational witness exists and a midpoint of the isolating interval is not one.
  • Mixed-alternation sentences refuse more often than same-flavour ones. ∀x∃y. p > 0 is decided through ¬∃x∀y. p ≤ 0, and De Morgan turns a strict body into a non-strict one, so it can land in the refusal case even though the original body was strict.

If you need an answer where decide refuses, the routes are: a positivity certificate (above), alkahest.smt with a nonlinear-real solver, or rigorous numerics (validated bounds) if a quantified-over-a-box statement is good enough.

Scope of this release

Shipped: exact rational SOS over the DSOS generator cone, a general PSD Gram search (floating-point proposal, exact rational verification) for cases DSOS alone refuses — with Douglas–Rachford splitting and a facial-reduction step alongside the original annealed alternating projection, specifically so boundary-only (singular Gram matrix) certificates are reachable — a Reznick multiplier search ((Σxᵢ²)^N·p for N ≤ 4) on top of that (finds both Motzkin’s polynomial and Robinson’s form), Handelman certificates on basic semialgebraic sets, exact verification, and Lean export.

Not yet shipped: reliable certification of every boundary-case example — the homogeneous 3-variable form of Motzkin specifically is still out of reach (see above), so this is a real but narrower gap than “Motzkin doesn’t certify” was in the prior release — a proper interior-point solver that would close it more systematically, and Putinar-style certificates with genuine SOS — rather than non-negative constant — multipliers on the constraints. CertificateKind::Putinar exists in the certificate type so those can be added without a shape change.

Interoperability

Alkahest integrates with the Python numerical ecosystem at well-defined boundaries.

NumPy

Batch evaluation

numpy_eval vectorises a compiled function over NumPy arrays with zero unnecessary copies:

import numpy as np
from alkahest import ExprPool, compile_expr, numpy_eval, sin

pool = ExprPool()
x = pool.symbol("x")

f = compile_expr(sin(x) ** 2 + x, [x])
xs = np.linspace(0, 2 * np.pi, 1_000_000)
ys = numpy_eval(f, xs)   # returns a NumPy array, shape (1_000_000,)

Two argument conventions meet here, and mixing them up is the most common way this call fails:

f = compile_expr(x ** 2 + y, [x, y])   # a CompiledFn — numpy_eval takes this, not the Expr

numpy_eval(f, xs, ys)                  # ✓ one positional argument per input variable
numpy_eval(f, [xs, ys])                # ✗ ValueError — that is one argument, not two
f([1.0, 2.0])                          # ✓ CompiledFn evaluates ONE point, given as one sequence
f(1.0, 2.0)                            # ✗ TypeError

Both wrong spellings raise an error naming the right one. numpy_eval(expr, xs) — the expression instead of the compiled function — is a TypeError telling you to call compile_expr first.

Inputs are converted to f64 arrays via DLPack or __array__. The call is vectorised through CompiledFn.call_batch_buffer in Rust: each array is read via the buffer protocol in one bulk copy (no per-element Python float boxing, no .tolist()), the native call runs with the GIL released, and the result is written directly into a preallocated output array — no Python loop, and no intermediate Python list on either side. numpy_eval_par additionally distributes points across CPU cores via Rayon — but only when the extension was built with --features parallel. Every published wheel is; parallel is not a Cargo default, so a source build that omits it gets a silent alias for numpy_eval: same result, no speedup, no warning. Check capabilities()["features"]["parallel"] before attributing a timing to it; see Getting started.

Array protocol

CompiledFn objects implement __array__ for direct NumPy coercion:

result = np.asarray(f([1.0]))  # scalar result as a 0-d array

PyTorch

PyTorch CPU tensors are accepted wherever NumPy arrays are (via __dlpack__):

import torch
xs = torch.linspace(0, 1, 10_000)
ys = numpy_eval(f, xs)   # returns a NumPy array

For GPU tensors, use the compile_cuda path (requires --features cuda; see GPU support). Note that its Python call_batch takes and returns host sequences: a CUDA tensor is copied to the host and back. The zero-copy device-pointer entry point (call_device_ptrs) exists in the Rust crate only and has no PyO3 binding.

JAX

numpy_eval with JAX arrays

JAX arrays implement __dlpack__ and are accepted by numpy_eval:

import jax.numpy as jnp
xs = jnp.linspace(0, 1, 10_000)
ys = numpy_eval(f, xs)

JAX primitive source (to_jax)

to_jax registers a symbolic expression as a JAX primitive, making it callable inside JAX computations including jax.jit, jax.grad, and jax.vmap:

from alkahest import to_jax, ExprPool, sin

pool = ExprPool()
x = pool.symbol("x")

jax_fn = to_jax(sin(x) ** 2, [x])

import jax
import jax.numpy as jnp

# Use inside jax.jit / jax.grad
jit_fn = jax.jit(jax_fn)
grad_fn = jax.grad(lambda x: jax_fn(x).sum())

The primitive registers:

  • A concrete def_impl that calls the Rust evaluator
  • An abstract evaluation rule for shape/dtype propagation
  • A JVP (forward-mode) rule derived from the symbolic gradient
  • A vmap batching rule

StableHLO / XLA

to_stablehlo emits textual MLIR in the StableHLO dialect, which XLA and JAX’s XLA backend can compile:

from alkahest import to_stablehlo

mlir_text = to_stablehlo(expr, [x, y], fn_name="my_kernel")
# Pass to xla_client.compile() or save to .mlir file

SymPy interop

Alkahest’s kernel does not import SymPy. Two supported bridges exist on top of it: alkahest.crosscheck.to_sympy translates an Expr into a SymPy expression, and alkahest.crosscheck drives SymPy as a differential-testing oracle. The test oracle in tests/test_oracle.py uses SymPy as a ground-truth reference. For ad-hoc mixed workflows, converting through the string representation is fine.

The interop trap: casus-irreducibilis cube roots

Read this before round-tripping a symbolic result into another CAS. An expression can be correct in Alkahest and evaluate to a wrong number somewhere else, because the two systems do not agree on which branch a cube root denotes.

Matrix.eigenvals() on a 3×3 with an irreducible cubic characteristic polynomial and three real roots returns the Cardano form, and in the casus irreducibilis one of the cube-root radicands is negative:

import alkahest as ak

pool = ak.ExprPool()
I = pool.integer
M = ak.Matrix.from_rows([[I(2), I(0), I(-2)], [I(2), I(0), I(-1)], [I(1), I(1), I(2)]])

for value in M.eigenvals():
    print(value)
# two conjugate-looking siblings, then:
# (4/3 + (sqrt(298/27) + -89/27)^(1/3) + (-89/27 + (-1 * sqrt(298/27)))^(1/3))

That expression denotes the eigenvalue under the real cube-root convention. Alkahest is consistent about this and honest at the boundary: eval_expr on it refuses with E-EVAL-009, and interval_eval returns ArbBall(1.629231 ± inf) — an enclosure that is true and useless, rather than a number that is neither.

Hand the same expression to a principal-branch evaluator — SymPy, NumPy, most calculators — and (negative)^(1/3) takes the principal complex root instead. You get a confident number back, and it is not an eigenvalue. In one sweep of 720 random integer matrices, 14 produced eigenvalues of this shape.

So, when a loop exports symbolic results to another tool:

  • Prefer transporting a verified numeric enclosure (refine_root, interval_eval, bound_on_box) rather than a radical expression, whenever the consumer only needs a number.
  • If you must transport the expression, evaluate it in Alkahest first. A refusal (E-EVAL-009, or an infinite ball) is the signal that the expression is branch-sensitive and must not be handed over as-is.
  • Never treat “the other tool produced a float” as confirmation. Substitute the value back into the characteristic polynomial (or whatever defined it) and check the residual.

This is the general shape of the hazard, not a quirk of eigenvals: an honest refusal inside Alkahest becomes somebody else’s silent error the moment the expression crosses the boundary. alkahest.crosscheck reports exactly this situation as incomparable rather than diverge, for the same reason.

DLPack

All DLPack-compatible arrays (NumPy, PyTorch, JAX, CuPy) are accepted at the numpy_eval boundary. The DLPack conversion is zero-copy for CPU arrays with matching dtypes; a device array is copied to the host first. There is no device-pointer boundary exposed to Python — call_device_ptrs is a Rust-crate API.

Exporting C code

emit_c generates a standalone C function for embedding in other projects:

from alkahest import emit_c

c_code = emit_c(
    sin(x) * exp(pool.integer(-1) * x),
    [x],
    var_name="x",
    fn_name="damped_sin",
)
print(c_code)
# double damped_sin(double x) { return sin(x) * exp(-x); }

The emitted code uses only standard <math.h> functions and has no Alkahest dependency.

Reinforcement learning environments

alkahest.rl turns the Alkahest CAS into verifiable RL environments: generators produce tasks, verifiers re-derive correctness (never storing reference answers in dataset rows), and optional curriculum schedulers advance Risch difficulty tiers.

The package has two layers:

LayerPathDependencies
Corealkahest.rl.coreAlkahest only — usable from veRL, TRL, OpenRLHF, custom loops
Environmentsalkahest.rl.envs.*Core + optional verifiers (Prime Intellect)

Install

The RL stack is an optional extra on the main PyPI package. It requires Python ≥ 3.10 because verifiers does not support 3.9.

pip install "alkahest[rl]"

This pulls verifiers and datasets. The integration environment itself ships inside the alkahest wheel — no separate install is required for local use.

From source (development):

maturin develop --manifest-path alkahest-py/Cargo.toml --release --features egraph
pip install "verifiers>=0.1.5" datasets

Build with the egraph feature when possible: the integration verifier uses e-graph simplification as its second verification layer and falls back gracefully when it is unavailable.

Quick start — symbolic integration

from alkahest.rl.envs.integration import IntegrationVerifier, load_environment

# Standalone verifier (any trainer)
verifier = IntegrationVerifier()
reward = verifier.verify(
    "x^2",
    {"f_expr": pool_expr, "is_elementary": True, "pool": pool},
)

# Prime Intellect verifiers environment (after pip install "alkahest[rl]")
env = load_environment(
    difficulty_tier=0,
    n_train=1000,
    n_eval=100,
    hard_negative_fraction=0.25,
    adaptive=True,
)
# env.curriculum.record(reward)  # inside your training loop

Dataset rows store a parseable f_str integrand (not live Expr objects) so HuggingFace / Arrow serialization works. The async reward function reconstructs expressions at scoring time.

Risch tiers

TierGrammarStatus
0Rational polynomialsImplemented
1exp / log towersImplemented
2ℚ(√d) coefficientsImplemented
3Rational exponentsPlanned
4Nested towersPlanned

Hard negatives (hard_negative_fraction) inject integrands certified NonElementary (e.g. exp(x²), sin(x)/x) so models learn to refuse honestly.

Core API

BaseGenerator

Produces (prompt, metadata) dicts. Never include a reference_answer key.

BaseVerifier

def verify(self, completion: str, metadata: dict) -> float:
    """Return reward in [-1, 1]."""

CurriculumScheduler

Tracks rolling pass rate at the current tier; advances when advance_threshold (default 0.70) is met over a sliding window (default 256 rewards).

Rubric

Framework-agnostic weighted reward functions (Rubric.score(**kwargs)). Prime Intellect’s vf.Rubric is used inside load_environment() for Hub compatibility.

veRL adapter

from recipes.verl_integration_reward import compute_score

# ground_truth = row dict from the integration dataset builder
score = compute_score(solution_str, ground_truth)

Pass compute_score to veRL’s reward_fn config. The ground_truth dict should include at least f_str, is_elementary, and fields the verifier expects after reconstruction (see IntegrationVerifier.verify).

Adding a new domain

  1. Create python/alkahest/rl/envs/<domain>/ mirroring integration/.
  2. Subclass BaseGenerator — emit unsolved tasks + metadata, no reference answer.
  3. Subclass BaseVerifier — check the model output with Alkahest primitives.
  4. Add load_environment() returning vf.SingleTurnEnv (optional verifiers import).
  5. Add a Hub pyproject.toml + README.md if publishing independently.

Publishing to the Prime Intellect Environments Hub

The integration environment includes Hub metadata at python/alkahest/rl/envs/integration/. See Hub checklist below.

After install, users run:

prime env install alkahest/alkahest-symbolic-integration
prime eval run alkahest/alkahest-symbolic-integration -m <model>

Hub checklist

  1. Alkahest on PyPI — the Hub package depends on alkahest>=3.7.0 (includes alkahest.rl). For local development before PyPI catches up, use maturin develop or a release wheel from GitHub.

  2. Install the Prime CLI and log in:

    uv tool install prime
    prime login
    
  3. Smoke-test locally from the Hub package directory:

    cd python/alkahest/rl/envs/integration
    pip install -e .                    # pulls verifiers + alkahest + datasets
    python -c "from alkahest.rl.envs.integration.env import load_environment; load_environment(n_train=4, n_eval=2)"
    
  4. Push to the Hub (from the same directory):

    prime env push
    # or: prime env push --team alkahest-cas --auto-bump
    

    Hub CI validates the package, load_environment() entrypoint, and dependencies.

  5. Run a Hub eval against a hosted model:

    prime eval run alkahest/alkahest-symbolic-integration -m <model>
    
  6. Iterate — bump version in pyproject.toml (or use --auto-bump) for each publish; tag releases in git when verifier tiers or reward logic change.

Monorepo note

Implementation code lives in the main alkahest package; the Hub directory is a thin installable manifest that pins dependencies and declares the [tool.verifiers] entrypoint. This avoids duplicating environment code while still letting prime env install resolve everything from PyPI.

Further reading: Prime Intellect — Create & Upload Environment, Verifiers Environments Hub.

Derivation logs

Most transformations in Alkahest return a DerivedResult that records the exact sequence of rewrite steps applied. This log is the foundation for both human inspection and Lean proof export.

DerivedResult

DerivedResult is the return type of diff, simplify, integrate, sum_*, and most other transforming operations. Notable exceptions: limit returns a bare Expr, and series returns a Series.

from alkahest import diff, sin

pool = ExprPool()
x = pool.symbol("x")

dr = diff(sin(x**2), x)

Attributes

AttributeTypeDescription
.valueExprThe result expression
.stepslist[dict]Ordered list of rewrite steps
.verificationdictEvidence status, artifact format, external-check status, and side conditions
.certificatestr | NoneGenerated Lean 4 source, when a derivation log exists

Methods

MethodDescription
.to_dict(mode="full")Versioned dict envelope combining .value/.verification/.certificate_status/.steps; see Machine-parseable output below
.to_json(mode="full")json.dumps(self.to_dict(mode=mode))

Rewrite steps

Each step in .steps is a dict with:

KeyValue
ruleRule name (string)
beforeExpression before the rewrite
afterExpression after the rewrite
side_conditionsSide conditions recorded for the rewrite
for step in dr.steps:
    print(f"  {step['rule']:25s}  {step['before']}  →  {step['after']}")

Side conditions

A side condition is a predicate that must hold for a rewrite to be sound:

  • Positive(x)x must be positive (e.g. for sqrt(x²) → x)
  • NonZero(x)x must be non-zero (e.g. for x/x → 1). For a symbolic x the rewrite fires and the condition is recorded; for a literal zero base it does not fire at all, since 0 · 0⁻¹ has no value (literal-zero carve-out)
  • Integer(n)n must be an integer (e.g. for some power rules)
  • BranchCut(f, x) — records that f may have a branch cut at x

Side conditions propagate into the derivation log as SideCondition entries and are aggregated in dr.verification["side_conditions"]. A generated Lean source artifact is evidence that can be checked; it is not a claim that the project has checked the artifact with Lean.

For antiderivatives, exactly_verified means that the in-kernel symbolic residual d/dx(F) - f simplified to zero. numerically_checked means only that the integration soundness gate found agreement at several floating-point samples; it is useful evidence, but it is not an exact proof. lean_checked remains reserved for an actual completed external Lean check.

evidence = dr.verification
if evidence["status"] == "certificate_available":
    assert not evidence["externally_verified"]
    lean_source = dr.certificate
    # Invoke a pinned Lean/Mathlib checker before treating this as lean_checked.

Inspecting a derivation

dr = diff(sin(x**2), x)

print(f"Result: {dr.value}")
print(f"Steps ({len(dr.steps)}):")
for step in dr.steps[:5]:
    rule = step['rule']
    before = step['before']
    after = step['after']
    print(f"  [{rule}]: {before} → {after}")
    for condition in step["side_conditions"]:
        print(f"    side condition: {condition}")

DerivationLog overhead

Logging is always on and is cheap — a Vec<RewriteStep> appended to during traversal. The benchmark group log_overhead in alkahest-core/benches/alkahest_bench.rs measures logging cost separately from computation.

For production workloads where you only need .value, the steps list is still populated but you can ignore it. There is no way to disable logging in the current API (disabling it would compromise the Lean certificate pipeline).

Combining logs

When you chain operations, the logs are separate:

simplified = simplify(expr)
derived = diff(simplified.value, x)

# Full derivation: simplify steps first, then diff steps
all_steps = simplified.steps + derived.steps

For operations like integrate that internally call simplify, the log includes the simplification sub-steps interleaved with the integration steps.

Machine-parseable output: to_dict / to_json

Agents pay for every character a call returns. .steps, .verification, and .certificate_status are convenient to poke at interactively, but stitching them into one payload for logging, RPC, or a context window means writing that glue yourself, on every call site, forever. DerivedResult.to_dict() and DerivedResult.to_json() give you the stitched, versioned envelope directly:

dr = diff(sin(x**2), x)

full = dr.to_dict()                     # mode="full" is the default
compact = dr.to_dict(mode="compact")    # short keys, token-efficient
json_str = dr.to_json(mode="compact")   # json.dumps(dr.to_dict(mode="compact"))

Envelope shape (mode="full")

{
  "kind": "alkahest.derived_result",
  "schema_version": 1,
  "steps_schema_version": 1,
  "value": "<display string>",
  "verification": { "status": "...", "evidence": "...", "externally_verified": false, "artifact_format": "...", "side_conditions": [...], "method": "..." },
  "certificate_status": { "certifiable": true, "reason": "...", "blocking_steps": [] },
  "steps": [ {"rule": "...", "before": "...", "after": "...", "side_conditions": [...]}, ... ],
  "has_certificate": true
}

verification and certificate_status are exactly the dicts returned by the .verification and .certificate_status getters; steps is exactly .steps. kind is a stable discriminator string — useful when logs or RPC payloads mix DerivedResult envelopes with other structured outputs (e.g. error envelopes carrying E-SUBSYSTEM-NNN codes).

Schema versions

Two independent version constants, both starting at 1:

ConstantGoverns
alkahest.RESULT_SCHEMA_VERSIONThe envelope: the set of top-level keys (kind, value, verification, certificate_status, steps, has_certificate, …)
alkahest.STEPS_SCHEMA_VERSIONOne entry of steps: full-mode field names and the compact-mode short-key mapping

Also available as DerivedResult.SCHEMA_VERSION / DerivedResult.STEPS_SCHEMA_VERSION class attributes, and documented alongside the field-name contract in alkahest._result_schema (STEP_FIELDS, STEP_FIELDS_COMPACT). Either constant is bumped independently if its shape ever changes, so pinning schema_version/steps_schema_version in your own parsing code is safe across upgrades that don’t touch the piece you depend on.

Compact mode

mode="compact" keeps the same top-level envelope shape but shrinks the biggest token costs:

  • Steps use short keys — r for rule, s for side_conditions — and omit before/after entirely. Those two expression strings are usually the largest part of a multi-step derivation and the single biggest win for token budget. s is itself omitted from a step’s dict when that step has no side conditions (the common case).
  • verification is pruned to status and externally_verified only. These are the two fields that carry the honesty signal — whether the result is verified, and whether that verification happened out-of-process — so they are never renamed, abbreviated, or dropped in compact mode.
  • certificate_status is pruned to certifiable and reason; the blocking_steps diagnostic list (which repeats before/after text) is dropped.
  • No mode ever includes Lean certificate source text. has_certificate (bool) plus certificate_status["reason"] is enough to know whether a certificate exists and, if not, why — without paying for the source. Use the .certificate getter when you actually need the Lean source.
dr.to_dict(mode="compact")
# {
#   "kind": "alkahest.derived_result",
#   "schema_version": 1,
#   "steps_schema_version": 1,
#   "value": "...",
#   "verification": {"status": "certificate_available", "externally_verified": false},
#   "certificate_status": {"certifiable": true, "reason": "emitted"},
#   "steps": [{"r": "diff_sin"}, {"r": "sqrt_of_square_positive", "s": ["x > 0"]}],
#   "has_certificate": true
# }

Prefer to_dict(mode="compact") / to_json(mode="compact") over reading .steps directly in hot loops — batch derivations, autoresearch search plumbing, or anywhere you’re serialising many DerivedResults and only need the rule names, side conditions, and verification status rather than full before/after expression text.

An invalid mode (anything other than "full"/"compact") raises ValueError.

Beyond one call

DerivedResult is per-call. To accumulate many results into a citable, serialisable, re-verifiable artifact — a DAG of claims with stable IDs, hypotheses, and certificate status — see claim graphs.

See also

Claim graphs

DerivedResult (derivation logs) is a per-call object. A research loop that runs for days produces thousands of them and has nowhere to put them, so its output degrades into a transcript — and transcripts do not survive context compaction.

alkahest.research supplies the session-level artifact that is missing: a directed acyclic graph of claims, serialisable to disk, diffable, re-verifiable, and renderable into a document a human referee can read.

import alkahest as ak

pool = ak.ExprPool()
x = pool.symbol("x")

with ak.research.session(title="Worked example", pool=pool, capture=True) as s:
    integrand = x / (x**pool.integer(2) + pool.integer(1))
    definite = ak.integrate(integrand, x, pool.integer(0), pool.integer(1))
    ak.simplify(pool.integer(2) * definite.value - ak.log(pool.integer(2)))

graph = s.graph
graph.save("claims.json")
print(graph.to_markdown())

What a claim carries

FieldMeaning
idContent-addressed identifier, clm_ + 16 hex chars
statement / latex / kindNormalised statement; "expr", "relation", or "text"
hypothesesPredicates from the governing Assumptions
methodOperation that produced it ("integrate", "simplify", "conjecture", …)
status / evidence / verificationCopied verbatim from DerivedResult.verification
derivationDerivedResult.steps
certificate / certificate_formatCertificate source, when one was emitted
depends_onIDs this claim was derived from
checkMachine-readable re-verification recipe
provenanceOperation, arguments, Alkahest version, build features
recorded_atISO-8601 UTC timestamp — volatile, excluded from the digest
auditRe-verification outcomes appended by graph.verify()

Stable identity

The ID is sha256 over a canonical JSON encoding of the normalised statement, the sorted hypotheses, and the method — nothing else. It deliberately excludes timestamps, dependency edges, and the library version, so the same claim derived twice in different sessions receives the same ID and citing across runs is meaningful:

from alkahest.research import claim_id

claim_id("(1/2 * log(2))", ["x > 0"], "integrate")   # 'clm_…', deterministic

Expression statements are put into the kernel’s normal form (via simplify) before hashing, so 1 + x**2 and x**2 + 1 produce the same ID. Pass normalize=False to session() in very hot loops; IDs then depend on how the expression was built.

Because IDs are content-addressed, re-deriving the same claim is not an error — the stored claim keeps its status and derivation and merely gains the new dependency edges.

Hypotheses travel with the claim

This is the whole point of Assumptions being first-class. The predicates in the active assumption context — whether set by session(assumptions=…) or by an enclosing alkahest.context(assumptions=…) — are recorded on every claim and are part of its identity. A claim proved under x > 0 and the same claim proved unconditionally are different claims with different IDs.

Recording

Automatic capture

session(capture=True) wraps the DerivedResult-producing functions in the alkahest module namespace, so every result computed inside the block is recorded, and dependency edges are inferred by looking for previously-recorded values among the subexpressions of each call’s arguments.

Automatic capture has a boundary, and it is stated rather than hidden:

  • it sees calls made through the module namespacealkahest.integrate(...);
  • it does not see a name bound before the hooks were installed (from alkahest import integrate) or methods on objects (Assumptions.simplify).

alkahest.research.captured_operations() lists exactly what is hooked, and session.capture_report() reports the mode, the hooked operations, the claim count, and any error raised inside the hook. Hooks are installed once and never removed — removing them when one session exits would silently disable capture for a session still running on another thread.

Explicit recording

For anything outside that boundary, record() is one line and always complete:

with ak.research.session(pool=pool) as s:
    result = ak.integrate(integrand, x)
    s.record(result, method="integrate", label="Antiderivative", sources=[integrand])

    # or, equivalently, letting the session make the call:
    s.run(ak.integrate, integrand, x)

Conjectures

conjecture() records a claim that is not proved. Its status is hard-wired to "unverified"; there is deliberately no parameter to say otherwise.

s.conjecture(
    "-2 * integral(x/(x^2+1), dx, 0, 1) + 1 * log(2) = 0",
    evidence="integer relation found by guess_relation at 60 digits",
)

The honesty invariant

The recording layer never upgrades a claim’s status.

  • A claim’s status is whatever DerivedResult.verification["status"] said.
  • conjecture() always produces "unverified".
  • verify() may only lower confidence: a failed re-check sets "refuted"; a successful one appends an audit entry and promotes nothing.

The renderers follow the same rule. An emitted-but-unchecked Lean certificate is marked [CERT ONLY, UNCHECKED], never as a proof, and every document opens with the exact machine-checkable fraction:

Machine-checkable subset: 1 of 4 claims (25%). Only claims marked verified were checked by a checker. Everything else is recorded evidence and must not be read as proved.

Claim.machine_checked is true only for exactly_verified and lean_checked.

Querying

graph.by_status("unverified")
graph.machine_checkable()
graph.dependencies(cid)     # direct
graph.ancestors(cid)        # transitive, what it rests on
graph.dependents(cid)       # direct
graph.impact(cid)           # transitive — "what dies if this claim is false?"
graph.roots(), graph.leaves(), graph.summary(), graph.topological_order()

impact() is the query a loop needs when a lemma turns out to be wrong: it returns every claim that transitively cited it.

Edges always point at claims already in the graph, so cycles are impossible by construction; graphs loaded from JSON are topologically checked and raise CycleError.

Serialisation

to_json() / from_json() round-trip losslessly, with an explicit schema_version. Output uses sorted keys, so a byte diff is a content diff. Reading a document whose schema_version is newer than this build raises ClaimGraphError rather than guessing.

graph.save("claims.json")
same = ak.research.ClaimGraph.load("claims.json")

graph.to_json(stable=True)   # drops timestamps: byte-identical across runs
graph.digest()               # sha256 of the stable form

Volatile data (recorded_at, started_at, finished_at, audit timestamps) is confined to explicitly-marked fields and never contributes to identity or to the digest, so two runs of the same computation produce identical stable=True documents and identical digests.

Rendering

to_markdown() emits a document with a verification summary table, then one section per claim carrying its statement (LaTeX where available), hypotheses, method, dependency links, derivation table, certificate status, and last re-check.

to_latex() emits an article (or just the body, with standalone=False) in which every claim carries \label{clm:<id>} and dependencies are \hyperref links — so a loop that ran for a week emits a writeup with every claim linked to its derivation and its certificate status. Both build on latex() and the DerivedResult machinery rather than reimplementing them.

Re-verification

graph.verify() walks the graph and re-checks whatever carries a recipe, re-parsing each claim’s recorded text into a fresh pool — so a graph loaded from disk can be revalidated against a newer library build rather than trusted blindly.

Recipe kindWhat is re-checked
antiderivativesimplify(diff(F, x) - f) == 0
definite_integralsimplify(F(b) - F(a) - value) == 0
derivativesimplify(diff(e, x) - value) == 0
identitysimplify(lhs - rhs) == 0
zerosimplify(e) == 0
numeric_relationabs(sum a_i c_i) <= tolerance — evidence only

Outcomes are ok (exact), numeric_ok (the exact check was inconclusive but a numeric residual passed — evidence, not a proof), failed, inconclusive, or skipped.

report = graph.verify()
report.ok            # False if anything was refuted
report.summary()     # {'ok': 2, 'numeric_ok': 1, 'skipped': 1}
print(report.to_markdown())

A long session accumulates, by design

Two things grow monotonically for as long as a session is open, and neither is a leak — both are the feature working.

  • The claim graph holds one claim per captured operation, plus one string per capture failure. That is the point of a provenance record, but it means capture=True around a million-call sweep builds a million-entry graph in memory. Snapshot with to_dict() / to_markdown() and start a new session periodically rather than running one session for the life of the process.
  • The pool never reclaims, and a session pins one for its whole lifetime. See ExprPool never reclaims — a session scoped to one problem, with its own pool, is the pattern that survives a multi-day run.

A complete loop

examples/pslq_research_loop.py runs the full experimental-mathematics loop — high-precision quadrature, guess_relation for the integer relation, a recorded conjecture, symbolic proof, Lean certificate, JSON round trip, and re-verification — and prints the rendered document.

See also

  • Autoresearch / agent loops — how claim graphs fit next to budgets, batch APIs, and compact result envelopes
  • Budgets — bound one candidate so a hard instance cannot stall the session
  • Batch — fan out many DerivedResult producers without aborting on one failure
  • Derivation logsto_dict(mode="compact") for token-efficient claim payloads

Lean certificates

Alkahest can emit Lean 4 source for derivations. Generated source becomes a machine-checked proof only after it typechecks with the pinned Lean/Mathlib toolchain and without admitted placeholders.

Three levels of evidence

Derivation logs — always on, always cheap. Records every rewrite rule applied, with rule name and arguments. Human-readable; machine-parseable; forms the basis for Lean export.

Lean certificate export — for computations expressible as sequences of rewrites tagged with Lean theorem names. The library emits a .lean file containing a proof term. Emission makes a certificate available; it does not by itself mean Lean has checked the source.

In the agent contract, certificate_available has this same meaning. Only a corpus artifact compiled by pinned Lean/Mathlib without admissions can be described as lean_checked — and because that compilation happens out of process, in CI, no DerivedResult ever reports lean_checked as its status. It is therefore not listed in capabilities()["verification"]["statuses"], which enumerates only the statuses the library can actually emit.

Algorithmic certificates — planned evidence for operations where rewrite sequences do not work. Do not treat a derivation log as an independently verified witness.

Theorem mapping

Every primitive in the registry is tagged with a Lean 4 / Mathlib theorem name:

Primitive ruleMathlib theorem
diff_sinReal.hasDerivAt_sin
diff_expReal.hasDerivAt_exp
diff_logReal.hasDerivAt_log
diff_chainHasDerivAt.comp
diff_addHasDerivAt.add
diff_mulHasDerivAt.mul
add_zeroadd_zero
mul_onemul_one

The full mapping lives in alkahest-core/src/lean/.

Exporting a certificate

from alkahest import diff, sin

pool = ExprPool()
x = pool.symbol("x", "real")

dr = diff(sin(x**2), x)

# The certificate is in dr.certificate when Lean export is enabled
if dr.certificate:
    with open("proof.lean", "w") as f:
        f.write(dr.certificate)

The emitted .lean file imports Mathlib and contains a proof term that Lean can verify:

import Mathlib.Analysis.SpecialFunctions.Trigonometric.Deriv

-- Alkahest certificate: d/dx sin(x²) = 2*x*cos(x²)
theorem alkahest_diff_sin_sq (x : ℝ) :
    HasDerivAt (fun x => Real.sin (x ^ 2)) (2 * x * Real.cos (x ^ 2)) x := by
  have h1 : HasDerivAt (fun x => x ^ 2) (2 * x) x := ...
  exact (Real.hasDerivAt_sin _).comp x h1

Strict Lean CI

The CI pipeline (.github/workflows/lean.yml) generates a deliberately small, strict corpus of basic arithmetic rewrites and d/dx x³. Every corpus entry must have the expected non-empty derivation log, must contain no sorry, admit, or axiom, and must typecheck with warnings treated as errors. The pinned Lean 4.9 compiler does not provide a --no-sorries command-line flag, so the source admission check is explicit in both the generator and CI.

  1. Generates proof files via tests/lean_corpus.py
  2. Compiles them with the pinned Lean/Mathlib toolchain (with Mathlib cached)
  3. Fails the build if a proof contains an admission or does not typecheck

Knowing where the line is, before you compute

The boundary above is not something to discover by running an operation and finding .certificate is None. It is tabulated in Certificate coverage — a table generated by running a corpus and recording what actually emitted, checked into the repo, and pinned by a CI drift check. Three ways to use it:

import alkahest as ak

p = ak.ExprPool()
x = p.symbol("x")

# 1. Ask before you commit to a route. Truthy iff a certificate is produced;
#    carries the reason when it is not.
answer = ak.certifiable("integrate", ak.log(x), x)
bool(answer)        # False
answer.reason       # 'class_withheld'

# `mode="ledger"` answers from the table alone, running nothing at all —
# for scoring many candidate routes. The default `mode="verify"` never says
# True without the certificate in hand, and hands the computed result back on
# `answer.result` so the check costs nothing extra.

# 2. Read the whole boundary.
[r["shape"] for r in ak.certificate_coverage("diff") if r["verdict"] == "certified"]

# 3. Refuse to degrade silently.
with ak.context(require_certificate=True):
    ak.integrate(ak.log(x), x)   # raises CertificateUnavailableError (E-CERT-001)

capabilities()["verification"]["coverage"] summarises the same ledger, and each primitive’s lean_theorem bit is read from it — so the agent contract and the coverage table cannot disagree.

Coverage

The strict CI corpus currently covers:

  • Basic arithmetic rewrites (add_zero, mul_one, mul_zero, constant folding, and pow_one)
  • The polynomial differentiation fast path for d/dx x³
  • Indefinite integrals of sin, cos, exp, and xⁿ, certified via the FTC derivative relation deriv (fun x => F) x = f
  • Definite integrals ∫ x in a..b, f x = F b - F a of the same base family (sin, cos, exp, xⁿ), plus finite sums and numeric-literal constant multiples of those terms (∫ (sin x + cos x), ∫ 3·cos x, ∫ -exp x, ∫ (x² + sin x + 3·cos x), …), certified via Mathlib’s interval-FTC lemma (intervalIntegral.integral_eq_sub_of_hasDerivAt) composed with HasDerivAt.add/.const_mul/.mul_const and the matching IntervalIntegrable combinators. A symbolic (non-literal) coefficient, or any addend outside the base family, withholds the whole certificate.

Other exports are generated source, not CI-qualified Lean proofs. In particular, non-polynomial differentiation, conditional logarithm/power rewrites, integration, limits, and unsupported expression forms can require side conditions or currently use a placeholder tactic. They must remain unverified until their proof encoding and strict corpus coverage are added.

Planned algorithmic certificates include:

  • Polynomial factoring
  • Polynomial GCD

Side conditions in proofs

Side conditions (domain constraints and branch-cut restrictions) are recorded in the derivation log. They are not yet translated into Lean hypotheses by the exporter, so conditional rewrites are excluded from the strict corpus.

Certificate coverage

DerivedResult.certificate returns Lean 4 source only where the emitter can prove the statement without sorry. This page is the map of that boundary. It is generated by running a corpus and recording what actually emitted — never hand-maintained — from tests/lean_corpus.py (the strict, CI-typechecked corpus) and tests/textbook_gate/ (first-course calculus and algebra).

Corpus: 240 observations over 156 shape classes — 66 certified, 2 conditional, 0 partial, 88 withheld.

Reading a row

A shape class is an operation plus a structural fingerprint of its arguments: which primitive functions occur (funcs), whether they are applied to the bare variable, to an integer power of it, or to something else (fn_arg), what kinds of exponents appear (pow, pow_base), how factors combine (mul), and the top-level form of the expression (form).

VerdictMeaningcertifiable()
✅ certifiedevery corpus observation emitted a certificateTrue
🟡 conditionalemits when the operation rewrites something; some observations had an empty derivation logFalse (class_conditional)
🟠 partialobservations disagree — the class is too coarse to separate themFalse (class_partial)
⛔ withheldno observation ever certifiedFalse (class_withheld)

certifiable() answers True only for certified classes, and in its default verify mode confirms by running the operation before saying so. It under-claims by construction: a shape the corpus has never reached answers False with reason unknown_shape.

diff

63 shape classes — 26 certified, 37 withheld.

VerdictShapeExamplesBlocked by
⛔ withheldform=apply, funcs=asin, fn_arg=var, mul=none, pow=none, pow_base=noneasin(x)diff_primitive_registry
⛔ withheldform=apply, funcs=atan, fn_arg=var, mul=none, pow=none, pow_base=noneatan(x)diff_primitive_registry
⛔ withheldform=apply, funcs=atan2, fn_arg=other, mul=none, pow=none, pow_base=noneatan2(x, 1)diff_primitive_registry
⛔ withheldform=apply, funcs=cos,sin, fn_arg=other, mul=none, pow=none, pow_base=nonesin(cos(x))diff_sin
✅ certifiedform=apply, funcs=cos, fn_arg=pow, mul=none, pow=nat, pow_base=varcos(x^2)
✅ certifiedform=apply, funcs=cos, fn_arg=var, mul=none, pow=none, pow_base=nonecos(x)
⛔ withheldform=apply, funcs=cosh, fn_arg=var, mul=none, pow=none, pow_base=nonecosh(x)diff_primitive_registry
⛔ withheldform=apply, funcs=exp, fn_arg=other, mul=scalar, pow=nat, pow_base=varexp((-1 * x^2))diff_exp
✅ certifiedform=apply, funcs=exp, fn_arg=pow, mul=none, pow=nat, pow_base=varexp(x^2)
✅ certifiedform=apply, funcs=exp, fn_arg=var, mul=none, pow=none, pow_base=noneexp(x)
⛔ withheldform=apply, funcs=log,sqrt, fn_arg=other, mul=none, pow=nat, pow_base=varlog((sqrt((-1 + x^2)) + x))diff_log, diff_sqrt, sum_rule
✅ certifiedform=apply, funcs=log, fn_arg=var, mul=none, pow=none, pow_base=nonelog(x)
✅ certifiedform=apply, funcs=sin, fn_arg=pow, mul=none, pow=nat, pow_base=varsin(x^2), sin(x^3)
✅ certifiedform=apply, funcs=sin, fn_arg=var, mul=none, pow=none, pow_base=nonesin(x)
⛔ withheldform=apply, funcs=sinh, fn_arg=var, mul=none, pow=none, pow_base=nonesinh(x)diff_primitive_registry
⛔ withheldform=apply, funcs=sqrt, fn_arg=other, mul=none, pow=nat, pow_base=varsqrt((1 + x^2))diff_sqrt
✅ certifiedform=apply, funcs=sqrt, fn_arg=var, mul=none, pow=none, pow_base=nonesqrt(x)
✅ certifiedform=apply, funcs=tan, fn_arg=var, mul=none, pow=none, pow_base=nonetan(x)
⛔ withheldform=apply, funcs=tanh, fn_arg=var, mul=none, pow=none, pow_base=nonetanh(x)diff_primitive_registry
✅ certifiedform=atom, funcs=-, fn_arg=none, mul=none, pow=none, pow_base=none7
✅ certifiedform=power, funcs=-, fn_arg=none, mul=none, pow=nat, pow_base=varx^10, x^2, x^3
⛔ withheldform=power, funcs=-, fn_arg=none, mul=none, pow=neg, pow_base=varx^-2power_rule
✅ certifiedform=power, funcs=sin, fn_arg=var, mul=none, pow=nat, pow_base=expr(sin(x))^2
✅ certifiedform=power, funcs=sin, fn_arg=var, mul=none, pow=neg, pow_base=expr(sin(x))^-1
⛔ withheldform=product, funcs=-, fn_arg=none, mul=general, pow=neg, pow_base=expr((1 + x)^-1 * x)power_rule, product_rule
⛔ withheldform=product, funcs=-, fn_arg=none, mul=scalar, pow=frac, pow_base=expr((x^(1/2))^3 * 2/3)power_rule, product_rule
✅ certifiedform=product, funcs=-, fn_arg=none, mul=scalar, pow=nat, pow_base=var(1/4 * x^4), (1/5 * x^5)
⛔ withheldform=product, funcs=-, fn_arg=none, mul=scalar, pow=neg, pow_base=expr((-1 + x)^-1 * -1)power_rule, product_rule
⛔ withheldform=product, funcs=-, fn_arg=none, mul=scalar, pow=neg, pow_base=var(-1 * x^-1)power_rule, product_rule
⛔ withheldform=product, funcs=atan, fn_arg=other, mul=scalar, pow=none, pow_base=none(1/2 * atan((1/2 * x)))diff_primitive_registry, product_rule
⛔ withheldform=product, funcs=cos,log, fn_arg=other, mul=scalar, pow=none, pow_base=none(-1 * log(cos(x)))diff_log, product_rule
✅ certifiedform=product, funcs=cos,sin, fn_arg=var, mul=general, pow=neg, pow_base=expr((cos(x))^-1 * sin(x))
✅ certifiedform=product, funcs=cos,sin, fn_arg=var, mul=general, pow=none, pow_base=none(cos(x) * sin(x))
⛔ withheldform=product, funcs=cos, fn_arg=other, mul=scalar, pow=none, pow_base=none(-1/4 * cos((2 * x)))diff_cos, product_rule
✅ certifiedform=product, funcs=cos, fn_arg=var, mul=scalar, pow=none, pow_base=none(-1 * cos(x))
⛔ withheldform=product, funcs=exp,log, fn_arg=other, mul=general, pow=neg, pow_base=expr((exp((-1 * log(x))))^-1 * C2)diff_exp, power_rule, product_rule
⛔ withheldform=product, funcs=exp,log, fn_arg=var, mul=general, pow=none, pow_base=none(exp(x) * log(x))product_rule
✅ certifiedform=product, funcs=exp,sin, fn_arg=var, mul=general, pow=none, pow_base=none(exp(x) * sin(x))
⛔ withheldform=product, funcs=exp, fn_arg=other, mul=general, pow=neg, pow_base=expr((exp((-1 * x)))^-1 * C2), ((exp((-1/2 * x^2)))^-1 * C2), ((exp((-3 * x)))^-1 * C2)collect_mul_factors, diff_exp, power_rule, product_rule
⛔ withheldform=product, funcs=exp, fn_arg=pow, mul=scalar, pow=nat, pow_base=var(1/2 * exp(x^2))product_rule
✅ certifiedform=product, funcs=exp, fn_arg=var, mul=general, pow=nat, pow_base=var(exp(x) * x^2)
⛔ withheldform=product, funcs=exp, fn_arg=var, mul=general, pow=neg, pow_base=expr((exp(x))^-1 * C2)product_rule
✅ certifiedform=product, funcs=exp, fn_arg=var, mul=general, pow=none, pow_base=none((-1 + x) * exp(x)), (C1 * exp(x))
⛔ withheldform=product, funcs=log, fn_arg=other, mul=scalar, pow=nat, pow_base=var(1/2 * log((-1 + x^2))), (1/2 * log((1 + x^2)))diff_log, product_rule
⛔ withheldform=product, funcs=sin, fn_arg=var, mul=general, pow=neg, pow_base=var(sin(x) * x^-1)power_rule, product_rule
⛔ withheldform=product, funcs=sqrt, fn_arg=other, mul=scalar, pow=neg, pow_base=expr(((sqrt((1 + x^2)) + x)^2 + -1)^-1 * -2)diff_sqrt, power_rule, product_rule, sum_rule
⛔ withheldform=sum, funcs=-, fn_arg=none, mul=none, pow=nat, pow_base=expr(3 + y^2)power_rule, sum_rule
✅ certifiedform=sum, funcs=-, fn_arg=none, mul=none, pow=nat, pow_base=var(C1 + x^2)
✅ certifiedform=sum, funcs=-, fn_arg=none, mul=scalar, pow=nat, pow_base=var(((((2 * x^2) * -1) + (3 * x^3)) + (5 * x)) + (-1 * 7))
⛔ withheldform=sum, funcs=asin,sqrt, fn_arg=other, mul=general, pow=nat, pow_base=var((((-1/2 * sqrt(((-1 * x^2) + 4)) * x) + (2 * asin((1/2 * x)))) * -1) + (4 * asin((1/2 * x))))diff_primitive_registry, diff_sqrt, product_rule, sum_rule
⛔ withheldform=sum, funcs=cos,sin, fn_arg=other, mul=general, pow=none, pow_base=none((-2 * C1 * sin((2 * x))) + (2 * C2 * cos((2 * x)))), ((C1 * cos((2 * x))) + (C2 * sin((2 * x))))diff_cos, diff_sin, product_rule, sum_rule
✅ certifiedform=sum, funcs=cos,sin, fn_arg=var, mul=general, pow=nat, pow_base=var((((-2 * cos(x) * x) + (2 * sin(x))) * -1) + (sin(x) * x^2))
✅ certifiedform=sum, funcs=cos,sin, fn_arg=var, mul=general, pow=none, pow_base=none((-1 * C1 * sin(x)) + (C2 * cos(x))), ((-1 * cos(x) * x) + sin(x)), ((C1 * cos(x)) + (C2 * sin(x)))
✅ certifiedform=sum, funcs=cos,sin, fn_arg=var, mul=none, pow=none, pow_base=none(cos(x) + sin(x))
⛔ withheldform=sum, funcs=exp, fn_arg=other, mul=general, pow=none, pow_base=none((-1 * C1 * exp((-1 * x))) + (-1 * C2 * exp((-1 * x)) * x) + (C2 * exp((-1 * x)))), ((-1 * C1 * exp((-1 * x))) + (-2 * C2 * exp((-2 * x)))), ((-1 * C2 * exp((-1 * x))) + (C1 * exp(x)))diff_exp, product_rule, sum_rule
✅ certifiedform=sum, funcs=exp, fn_arg=var, mul=general, pow=none, pow_base=none((C1 * exp(x)) + (C2 * exp(x) * x) + (C2 * exp(x))), ((C1 * exp(x)) + (C2 * exp(x) * x)), ((C1 * exp(x)) + C2)
⛔ withheldform=sum, funcs=log, fn_arg=other, mul=scalar, pow=nat, pow_base=var((-1 * log((-2 + x))) + (1/2 * log(((-4 * x) + 3 + x^2))))diff_log, product_rule, sum_rule
⛔ withheldform=sum, funcs=log, fn_arg=other, mul=scalar, pow=neg, pow_base=expr(((-1 + x)^-1 * -1/2) + (-1/4 * log((-1 + x))) + (1/4 * log((1 + x))))diff_log, power_rule, product_rule, sum_rule
⛔ withheldform=sum, funcs=log, fn_arg=other, mul=scalar, pow=none, pow_base=none((-1 * log((-1 + x))) + log((-2 + x))), ((-1/2 * log((-3 + x))) + (-1/6 * log((-1 + x))) + (1/2 * log((-2 + x))) + (1/6 * log((-4 + x))))diff_log, product_rule, sum_rule
⛔ withheldform=sum, funcs=log, fn_arg=var, mul=general, pow=nat, pow_base=expr(((log(x))^2 * x) + (-2 * log(x) * x) + (2 * x))power_rule, product_rule, sum_rule
⛔ withheldform=sum, funcs=log, fn_arg=var, mul=general, pow=nat, pow_base=var((-1/4 * x^2) + (1/2 * log(x) * x^2))product_rule, sum_rule
⛔ withheldform=sum, funcs=log, fn_arg=var, mul=general, pow=none, pow_base=none((-1 * x) + (log(x) * x))product_rule, sum_rule
⛔ withheldform=sum, funcs=sqrt, fn_arg=other, mul=scalar, pow=neg, pow_base=expr(((sqrt((1 + x^2)) + x) * 1/2) + ((sqrt((1 + x^2)) + x)^-1 * 1/2))diff_sqrt, power_rule, product_rule, sum_rule

integrate

41 shape classes — 14 certified, 27 withheld.

VerdictShapeExamplesBlocked by
⛔ withheldform=apply, funcs=cos, fn_arg=other, mul=scalar, pow=none, pow_base=none, definite=yescos((2 * x))
✅ certifiedform=apply, funcs=cos, fn_arg=var, mul=none, pow=none, pow_base=none, definite=nocos(x)
✅ certifiedform=apply, funcs=cos, fn_arg=var, mul=none, pow=none, pow_base=none, definite=yescos(x)
✅ certifiedform=apply, funcs=exp, fn_arg=var, mul=none, pow=none, pow_base=none, definite=noexp(x)
✅ certifiedform=apply, funcs=exp, fn_arg=var, mul=none, pow=none, pow_base=none, definite=yesexp(x)
⛔ withheldform=apply, funcs=log, fn_arg=var, mul=none, pow=none, pow_base=none, definite=nolog(x)
✅ certifiedform=apply, funcs=sin, fn_arg=var, mul=none, pow=none, pow_base=none, definite=nosin(x)
✅ certifiedform=apply, funcs=sin, fn_arg=var, mul=none, pow=none, pow_base=none, definite=yessin(x)
⛔ withheldform=apply, funcs=sqrt, fn_arg=other, mul=scalar, pow=nat, pow_base=var, definite=nosqrt(((-1 * x^2) + 4))
⛔ withheldform=apply, funcs=sqrt, fn_arg=var, mul=none, pow=none, pow_base=none, definite=yessqrt(x)
⛔ withheldform=apply, funcs=tan, fn_arg=var, mul=none, pow=none, pow_base=none, definite=notan(x)
⛔ withheldform=power, funcs=-, fn_arg=none, mul=none, pow=frac, pow_base=var, definite=nox^(1/2)
✅ certifiedform=power, funcs=-, fn_arg=none, mul=none, pow=nat, pow_base=var, definite=nox^2, x^3, x^4
✅ certifiedform=power, funcs=-, fn_arg=none, mul=none, pow=nat, pow_base=var, definite=yesx^2, x^3, x^4
⛔ withheldform=power, funcs=-, fn_arg=none, mul=none, pow=neg, pow_base=var, definite=nox^-2
⛔ withheldform=power, funcs=log, fn_arg=var, mul=none, pow=nat, pow_base=expr, definite=no(log(x))^2
⛔ withheldform=product, funcs=-, fn_arg=none, mul=general, pow=neg, pow_base=expr, definite=no((((((-1 * 1) + x) * ((-1 * 2) + x)) * ((-1 * 3) + x)) * ((-1 * 4) + x))^-1 * 1), (((((-1 * 1) + x) * ((-1 * 2) + x)) * ((-1 * 3) + x))^-1 * 1), ((((-1 * 1) + x) * ((-1 * 2) + x))^-1 * 1)
⛔ withheldform=product, funcs=-, fn_arg=none, mul=general, pow=neg, pow_base=expr, definite=yes((1 + x^2)^-1 * x)
✅ certifiedform=product, funcs=-, fn_arg=none, mul=scalar, pow=nat, pow_base=var, definite=yes(1/2 * x^2)
⛔ withheldform=product, funcs=-, fn_arg=none, mul=scalar, pow=neg, pow_base=expr, definite=no((((-1 * 1) + x)^2)^-1 * 1), ((1 + x^2)^-1 * 1), ((4 + x^2)^-1 * 1)
⛔ withheldform=product, funcs=-, fn_arg=none, mul=scalar, pow=neg, pow_base=expr, definite=yes((1 + x)^-1 * 1), ((1 + x^2)^-1 * 1), ((4 + x^2)^-1 * 1)
⛔ withheldform=product, funcs=-, fn_arg=none, mul=scalar, pow=neg, pow_base=var, definite=no(1 * x^-1)
⛔ withheldform=product, funcs=-, fn_arg=none, mul=scalar, pow=neg, pow_base=var, definite=yes(1 * x^-1)
⛔ withheldform=product, funcs=cos,sin, fn_arg=var, mul=general, pow=none, pow_base=none, definite=no(cos(x) * sin(x))
⛔ withheldform=product, funcs=cos, fn_arg=var, mul=general, pow=nat, pow_base=var, definite=no(cos(x) * x^2)
⛔ withheldform=product, funcs=cos, fn_arg=var, mul=scalar, pow=neg, pow_base=expr, definite=no(((cos(x))^2)^-1 * 1)
✅ certifiedform=product, funcs=cos, fn_arg=var, mul=scalar, pow=none, pow_base=none, definite=yes(3 * cos(x))
⛔ withheldform=product, funcs=exp, fn_arg=pow, mul=general, pow=nat, pow_base=var, definite=no(exp(x^2) * x)
⛔ withheldform=product, funcs=exp, fn_arg=var, mul=general, pow=nat, pow_base=var, definite=yes(exp(x) * x^2)
⛔ withheldform=product, funcs=exp, fn_arg=var, mul=general, pow=none, pow_base=none, definite=no(exp(x) * x)
⛔ withheldform=product, funcs=exp, fn_arg=var, mul=general, pow=none, pow_base=none, definite=yes(exp(x) * x)
✅ certifiedform=product, funcs=exp, fn_arg=var, mul=scalar, pow=none, pow_base=none, definite=yes(-1 * exp(x))
⛔ withheldform=product, funcs=log, fn_arg=var, mul=general, pow=none, pow_base=none, definite=no(log(x) * x)
⛔ withheldform=product, funcs=log, fn_arg=var, mul=general, pow=none, pow_base=none, definite=yes(log(x) * x)
✅ certifiedform=product, funcs=sin, fn_arg=var, mul=general, pow=none, pow_base=none, definite=no(sin(x) * x)
⛔ withheldform=product, funcs=sqrt, fn_arg=other, mul=general, pow=neg, pow_base=expr, definite=no((sqrt((1 + x^2)) * x^2)^-1 * 1), ((sqrt((1 + x^2)))^-1 * x)
⛔ withheldform=product, funcs=sqrt, fn_arg=other, mul=scalar, pow=neg, pow_base=expr, definite=no((sqrt(((-1 * 1) + x^2)))^-1 * 1), ((sqrt(((-1 * x^2) + 1)))^-1 * 1)
⛔ withheldform=product, funcs=sqrt, fn_arg=other, mul=scalar, pow=neg, pow_base=expr, definite=yes((sqrt(((-1 * x^2) + 1)))^-1 * 1)
✅ certifiedform=sum, funcs=cos,sin, fn_arg=var, mul=none, pow=none, pow_base=none, definite=yes(cos(x) + sin(x))
✅ certifiedform=sum, funcs=cos,sin, fn_arg=var, mul=scalar, pow=nat, pow_base=var, definite=yes((3 * cos(x)) + (sin(x) + x^2))
⛔ withheldform=sum, funcs=exp,log, fn_arg=var, mul=general, pow=neg, pow_base=var, definite=no((exp(x) * log(x)) + (exp(x) * x^-1))

simplify

16 shape classes — 5 certified, 2 conditional, 9 withheld.

VerdictShapeExamplesBlocked by
⛔ withheldform=apply, funcs=exp,log, fn_arg=other, mul=none, pow=noneexp(log(x)), log(exp(x))
⛔ withheldform=apply, funcs=exp, fn_arg=other, mul=none, pow=noneexp((x + y))
⛔ withheldform=apply, funcs=log, fn_arg=other, mul=general, pow=nonelog((x * y))
⛔ withheldform=power, funcs=-, fn_arg=none, mul=none, pow=frac(x^(1/2))^2
✅ certifiedform=power, funcs=-, fn_arg=none, mul=none, pow=nat(x^2)^3, x^0, x^1
🟡 conditionalform=power, funcs=-, fn_arg=none, mul=none, pow=neg(x^-1)^-1, (x^3)^-2, k^-2
⛔ withheldform=product, funcs=-, fn_arg=none, mul=general, pow=frac(x^(1/3) * x^(2/3))
✅ certifiedform=product, funcs=-, fn_arg=none, mul=general, pow=nat(x^2 * x^3)
✅ certifiedform=product, funcs=-, fn_arg=none, mul=general, pow=neg(x^-2 * x^2), (x^-2 * x^5)
✅ certifiedform=product, funcs=-, fn_arg=none, mul=scalar, pow=neg((k^2)^-1 * 3), ((x^3)^-1 * 1)
✅ certifiedform=product, funcs=-, fn_arg=none, mul=scalar, pow=none(0 * x), (1 * x), (3 * 4)
⛔ withheldform=product, funcs=gamma, fn_arg=other, mul=general, pow=none(gamma((1 + k)) * k)
⛔ withheldform=product, funcs=log, fn_arg=other, mul=scalar, pow=none(2 * log(x))
🟡 conditionalform=sum, funcs=-, fn_arg=none, mul=none, pow=none(0 + x), (2 + 3), (2 + x)
⛔ withheldform=sum, funcs=-, fn_arg=none, mul=scalar, pow=none((2 * k) + 1)
⛔ withheldform=sum, funcs=log, fn_arg=other, mul=scalar, pow=none((-1 * log(y)) + log(x))

simplify_log_exp

6 shape classes — 6 certified.

VerdictShapeExamplesBlocked by
✅ certifiedform=apply, funcs=exp,log, fn_arg=other, mul=none, pow=noneexp(log(x)), log(exp(x))
✅ certifiedform=apply, funcs=log, fn_arg=other, mul=general, pow=neglog((x * y^-1))
✅ certifiedform=apply, funcs=log, fn_arg=other, mul=none, pow=natlog(x^2)
✅ certifiedform=product, funcs=exp, fn_arg=other, mul=general, pow=none(exp(x) * exp(y))
✅ certifiedform=sum, funcs=exp,log, fn_arg=other, mul=none, pow=none(exp(log(y)) + log(exp(x)))
✅ certifiedform=sum, funcs=log, fn_arg=other, mul=none, pow=none(log(x) + log(y))

simplify_trig

21 shape classes — 15 certified, 6 withheld.

VerdictShapeExamplesBlocked by
✅ certifiedform=apply, funcs=cos, fn_arg=other, mul=scalar, pow=nonecos((-1 * x))
✅ certifiedform=apply, funcs=sin, fn_arg=other, mul=scalar, pow=nonesin((-1 * x))
⛔ withheldform=apply, funcs=sinh, fn_arg=other, mul=scalar, pow=nonesinh((-1 * x))
✅ certifiedform=apply, funcs=tan, fn_arg=other, mul=none, pow=nonetan(x)
✅ certifiedform=product, funcs=cos,sin, fn_arg=other, mul=general, pow=none((2 * sin(x)) * cos(x))
✅ certifiedform=product, funcs=cos,tan, fn_arg=other, mul=general, pow=none(cos(x) * tan(x))
⛔ withheldform=product, funcs=cos, fn_arg=other, mul=general, pow=neg(((-1 * cos((2 * x))) + 1) * 2^-1)
⛔ withheldform=product, funcs=cosh,sinh, fn_arg=other, mul=general, pow=neg((cosh(x))^-1 * sinh(x))
✅ certifiedform=product, funcs=cosh,sinh, fn_arg=other, mul=general, pow=none((2 * sinh(x)) * cosh(x))
✅ certifiedform=product, funcs=sin,tan, fn_arg=other, mul=general, pow=neg((tan(x))^-1 * sin(x))
✅ certifiedform=product, funcs=sin, fn_arg=other, mul=general, pow=neg(((sin(x))^-1 * 1) * sin(x))
✅ certifiedform=sum, funcs=cos,sin,tan, fn_arg=other, mul=general, pow=neg((((cos(x))^-1 * sin(x)) * -1) + tan(x)), (((cos(x))^-1 * sin(x)) + (-1 * tan(x)))
✅ certifiedform=sum, funcs=cos,sin, fn_arg=other, mul=general, pow=none((cos(x) * cos(x)) + (sin(x) * sin(x)))
✅ certifiedform=sum, funcs=cos,sin, fn_arg=other, mul=none, pow=nat((cos(x))^2 + (sin(x))^2)
✅ certifiedform=sum, funcs=cos,sin, fn_arg=other, mul=scalar, pow=nat((((((sin(x))^2 * 2) * -1) + 1) * -1) + cos((2 * x)))
⛔ withheldform=sum, funcs=cos, fn_arg=other, mul=scalar, pow=nat(((cos(x))^2 * 2) + (-1 * 1))
✅ certifiedform=sum, funcs=cosh,sinh, fn_arg=other, mul=scalar, pow=nat(((sinh(x))^2 * -1) + (cosh(x))^2)
⛔ withheldform=sum, funcs=cosh, fn_arg=other, mul=scalar, pow=nat(((cosh(x))^2 * 2) + (-1 * 1))
✅ certifiedform=sum, funcs=sin, fn_arg=other, mul=scalar, pow=nat((((sin(x))^2 * 2) * -1) + 1)
✅ certifiedform=sum, funcs=tan, fn_arg=other, mul=none, pow=nat((tan(x))^2 + 1)
⛔ withheldform=sum, funcs=tanh, fn_arg=other, mul=scalar, pow=nat(((tanh(x))^2 * -1) + 1)

sum_definite

8 shape classes — 8 withheld.

VerdictShapeExamplesBlocked by
⛔ withheldform=atom, funcs=-, fn_arg=none, mul=none, pow=none, pow_base=none, definite=yes5, kgosper_definite_telescope
⛔ withheldform=power, funcs=-, fn_arg=none, mul=none, pow=nat, pow_base=var, definite=yesk^2, k^3gosper_definite_telescope
⛔ withheldform=power, funcs=-, fn_arg=none, mul=none, pow=neg, pow_base=var, definite=yesk^-2, k^-4basel_zeta_even
⛔ withheldform=power, funcs=-, fn_arg=none, mul=none, pow=sym, pow_base=expr, definite=yes(1/2)^k, 2^kgosper_definite_telescope
⛔ withheldform=product, funcs=-, fn_arg=none, mul=general, pow=neg, pow_base=expr, definite=yes(((1 + k) * k)^-1 * 1)gosper_definite_telescope
⛔ withheldform=product, funcs=-, fn_arg=none, mul=scalar, pow=neg, pow_base=var, definite=yes(3 * k^-2)basel_zeta_even
⛔ withheldform=product, funcs=gamma, fn_arg=other, mul=general, pow=none, pow_base=none, definite=yes(gamma((1 + k)) * k)gosper_definite_telescope
⛔ withheldform=sum, funcs=-, fn_arg=none, mul=scalar, pow=none, pow_base=none, definite=yes((2 * k) + 1)gosper_definite_telescope

sum_indefinite

1 shape classes — 1 withheld.

VerdictShapeExamplesBlocked by
⛔ withheldform=atom, funcs=-, fn_arg=none, mul=none, pow=none, pow_base=nonekgosper_indefinite

Querying it

import alkahest as ak

p = ak.ExprPool()
x = p.symbol("x")

# Will this route give me something a referee can check?
answer = ak.certifiable("integrate", ak.log(x), x)
bool(answer)      # False
answer.reason     # 'class_withheld'

# Plan across candidate routes without computing any of them.
ak.certifiable("diff", ak.sin(x), x, mode="ledger")

# Fail loudly instead of degrading silently.
with ak.context(require_certificate=True):
    ak.integrate(ak.log(x), x)   # raises E-CERT-001

Error handling

Alkahest uses a structured exception hierarchy. Every error carries a stable diagnostic code, a human-readable message, an optional source span, and an optional remediation hint.

Exception hierarchy

AlkahestError (base)
├── ConversionError   (E-POLY-*)   — expression → polynomial/rational conversion
├── DomainError       (E-DOMAIN-*) — mathematical side conditions violated
├── DiffError         (E-DIFF-*)   — differentiation failed
├── IntegrationError  (E-INT-*)    — integration failed
├── MatrixError       (E-MAT-*)    — matrix shape / singularity / undecidable determinant
│   ├── LinearAlgebraError (E-LINALG-*) — elimination, decompositions, canonical forms
│   └── EigenError         (E-EIGEN-*)  — eigenvalues, eigenvectors, Jordan form
├── CadError          (E-CAD-*)    — real quantifier elimination, see [Positivity](./positivity.md#decide-refuses-rather-than-guessing)
├── OdeError          (E-ODE-*)    — ODE construction or lowering
├── DaeError          (E-DAE-*)    — DAE structural analysis
├── SolverError       (E-SOLVE-*)  — polynomial system solving
├── JitError          (E-JIT-*)    — LLVM/JIT codegen
├── CudaError         (E-CUDA-*)   — NVPTX compile, kernel launch, or driver, see [GPU support](./gpu.md)
├── PoolError         (E-POOL-*)   — ExprPool misuse
├── AnsatzError       (E-ANSATZ-*) — ansatz family construction or fitting, see [Ansatz families](./ansatz.md)
├── CrossCheckError   (E-XCHECK-*) — cross-CAS check could not be posed, see [Cross-CAS testing](./crosscheck.md)
├── SmtError          (E-SMT-*)    — SMT-LIB export, solver run, or model lift, see [SMT bridge](./smt.md)
└── BudgetExceededError (E-BUDGET-*) — budget/cancellation trip, see [Budgets](./budgets.md)

Error attributes

Every exception instance exposes:

AttributeTypeDescription
.codestrStable diagnostic code, e.g. "E-POLY-001"
.messagestrHuman-readable description
.remediationstr | NoneWhat the user should try
.spantuple[int, int] | NoneCharacter offset range in source expression
import alkahest
from alkahest import ExprPool, UniPoly, ConversionError

pool = ExprPool()
x = pool.symbol("x")

try:
    # sin(x) cannot be represented as a polynomial
    p = UniPoly.from_symbolic(alkahest.sin(x), x)
except ConversionError as e:
    print(e.code)          # E-POLY-001
    print(e.message)       # "expression contains non-polynomial term: sin(x)"
    print(e.remediation)   # "Use Expr directly, or expand sin(x) as a series first"

Common errors and remediations

ConversionError (E-POLY-*)

Raised when an expression cannot be converted to a polynomial or rational function.

CodeCauseRemediation
E-POLY-001Non-polynomial term (e.g. sin)Use Expr directly; or expand as series
E-POLY-002Non-integer exponentAlgebraic extension not yet supported
E-POLY-003Symbolic exponent (variable in exponent)Use Expr.pow, not UniPoly

DomainError (E-DOMAIN-*)

Raised when a mathematical side condition is violated.

CodeCauseRemediation
E-DOMAIN-001Division by zeroCheck denominator before dividing
E-DOMAIN-002log(0) or log(negative)Ensure argument is positive; use complex domain if needed
E-DOMAIN-003sqrt(negative)Use AcbBall or declare complex domain

IntegrationError (E-INT-*)

CodeCauseRemediation
E-INT-001No integration rule matchesResult may not have an elementary antiderivative
E-INT-002Algebraic extension requiredPlanned for v1.1 (algebraic Risch)
E-INT-003Risch gave up (transcendental tower too deep)Try numerical integration

SolverError (E-SOLVE-*)

CodeCauseRemediation
E-SOLVE-001System is inconsistentNo solutions exist
E-SOLVE-002High-degree univariate factor (> 2)Symbolic solution not supported; use numerical solve
E-SOLVE-003Gröbner basis did not terminateIncrease node/iteration limits

PrimaryDecompositionError (E-IDEAL-*)

CodeCauseRemediation
E-IDEAL-001No generators suppliedPass at least one generator
E-IDEAL-002Generators disagree on the variable listUse one variable list for every generator
E-IDEAL-003Saturation split exceeded its recursion depthSimplify the generating set
E-IDEAL-004FLINT could not factor a generatorReport the generating set as a minimal failing example

Refusals: when Alkahest declines to answer

A refusal is not a malfunction. These codes all mean “I could not establish this, and the alternative to saying so is a confident wrong answer” — the outcome an unattended loop must record as undecided, never as a negative result.

CodeClassWhat it means
E-LINALG-010LinearAlgebraErrorAn entry’s vanishing could be proven neither zero nor non-zero, so rank / rref / nullspace / eigenvects / jordan_form declined to pick a branch
E-MAT-004MatrixErrorSame, for a determinant: inverse() will not divide by something it cannot show is non-zero
E-CAD-001CadErrordecide is outside its fragment, or the only candidate solutions lie at an irrational boundary point it cannot test exactly
E-SOS-002SosErrorNo positivity certificate of this shape at this degree — a statement about the search, not a proof that none exists. Record it as unknown, never as “not SOS” or “the inequality is false”: p may be SOS outside the LP subcone searched, SOS at a higher basis_degree, or non-negative without being SOS (Motzkin). E-SOS-003, which carries a witness point, is the only SOS refutation. See Positivity certificates
E-IDEAL-005IdealRefusalradical cannot certify √I for this ideal. Only monomial, principal and zero-dimensional ideals — and anything whose primary decomposition is certified — are answered; the alternative is asserting √I = I with nothing behind it
E-IDEAL-006IdealRefusalprimary_decomposition reached a component it cannot show is primary, so it will not report the ideal itself with an unjustified associated_prime
E-SOLVE-004TriangularizeRefusaltriangularize extracted a chain that does not generate an ideal containing the input, i.e. one that cuts out a larger variety than the system. Splitting on the initials (Lazard–Kalkbrener) is not implemented
E-SERIES-003SeriesErrorseries ran past its work ceiling (or an active Budget) before reaching the requested order. Coefficients are formed by repeated differentiation without re-simplifying, so a nested radical’s derivatives grow by a constant factor each time; a shorter series would carry an O(h^order) label nothing bounded
E-INT-004IntegrationErrorProven non-elementary. This one is a verdict, not a refusal — keep it apart from the rest
E-BUDGET-001..003BudgetExceededErrorRan out of the time/steps it was given, or was cancelled

E-SERIES-003 travels out of band for the same reason (SeriesError is exhaustive) but is wired into the bindings: series returns SeriesError::InvalidOrder with calculus::series::take_series_refusal() pending, and the Python layer raises SeriesError with .code == "E-SERIES-003" — or BudgetExceededError when a budget was what stopped it.

E-IDEAL-005, E-IDEAL-006 and E-SOLVE-004 are new in 3.8 and travel out of band: PrimaryDecompositionError and SolverError are public exhaustive enums that cannot gain a variant in a patch release, so the refusal is returned inside an existing variant and the real code is available from ideal::take_ideal_refusal() / solver::regular_chains::take_triangularize_refusal(). The Python bindings consult both, so radical and primary_decomposition raise AlkahestError with .code == "E-IDEAL-005" / "E-IDEAL-006", and triangularize raises SolverError with .code == "E-SOLVE-004". AlkahestError subclasses ValueError, so code that catches ValueError is unaffected.

The takers are consuming, which is what keeps the carrier variant honest: a genuinely non-polynomial equation still reports E-SOLVE-001, because no refusal is pending for it. Both readings of the shared variant stay distinguishable.

The three-valued zero test behind E-LINALG-010 / E-MAT-004 is new in 3.8. Before it, “could not prove det ≠ 0” was silently read as “det = 0”, and Matrix.nullspace() returned a confident wrong basis for any 2×2 with a symbolic determinant.

import alkahest as ak

pool = ak.ExprPool()
a = pool.symbol("a")
zero, one = pool.integer(0), pool.integer(1)

# `mystery` has no evaluation rule, so its vanishing is genuinely undecidable.
opaque = pool.func("mystery", [a])
m = ak.Matrix([[opaque, zero], [zero, one]])

try:
    m.inverse()
except ak.MatrixError as e:
    print(e.code)          # E-MAT-004
    print(e.remediation)   # substitute concrete values for the parameters

try:
    ak.Matrix([[opaque, zero], [zero, zero]]).nullspace()
except ak.LinearAlgebraError as e:
    print(e.code)          # E-LINALG-010

LinearAlgebraError and EigenError are both subclasses of MatrixError, so except ak.MatrixError catches all three families; catch the subclass when you want to distinguish them. Note that eigenvects() raises EigenError — with code E-LINALG-010, because the code identifies what could not be decided, not which wrapper it surfaced through.

Catching errors by code

For programmatic error handling:

try:
    result = alkahest.integrate(expr, x)
except alkahest.AlkahestError as e:
    if e.code.startswith("E-INT-"):
        print(f"Integration failed: {e.remediation}")
    else:
        raise

Error taxonomy

Every error is classified on two independent axes: subsystem (determines the code prefix and exception class) and cause (informs the remediation hint).

Subsystem axis

PrefixClassScope
E-POLY-*ConversionErrorExpression → polynomial/rational-function conversion
E-DOMAIN-*DomainErrorSide-condition violations (div-by-zero, log of 0, sqrt of negative)
E-DIFF-*DiffErrorForward/reverse differentiation, unknown derivatives
E-INT-*IntegrationErrorSymbolic integration (Risch, heuristic, table)
E-MAT-*MatrixErrorMatrix shape, proven-singular, non-invertible, and (E-MAT-004) an undecidable determinant
E-LINALG-*LinearAlgebraError (subclass of MatrixError)Elimination, decompositions, canonical forms; E-LINALG-010 is the undecidable-entry refusal
E-EIGEN-*EigenError (subclass of MatrixError)Eigenvalues, eigenvectors, Jordan form, diagonalisation
E-CAD-*CadErrorReal quantifier elimination — outside the fragment, or an untestable irrational boundary point
E-ODE-*OdeErrorODE construction, lowering, event handling
E-DAE-*DaeErrorDAE structural analysis (Pantelides, index reduction)
E-SOLVE-*SolverErrorPolynomial system solving, Gröbner basis
E-JIT-*JitErrorLLVM/Cranelift codegen and linking
E-CUDA-*CudaErrorNVPTX compile, kernel launch, driver/runtime failures
E-POOL-*PoolErrorExprPool misuse (closed, cross-pool, persisted-handle mismatch)
E-PARSE-*ParseError (reserved)Parser integration — owns span() by default
E-IO-*IoError (reserved)Checkpoint/serde paths (PoolPersistError)
E-CERT-*CertificateUnavailableErrorA Lean certificate was required but withheld
E-BUDGET-*BudgetExceededErrorBudget/cancellation trip — see Budgets, cancellation, and determinism
E-ANSATZ-*AnsatzErrorAnsatz family construction and fitting — see Ansatz families
E-XCHECK-*CrossCheckErrorCross-CAS differential testing — see Cross-CAS testing
E-SMT-*SmtErrorSMT-LIB export, solver invocation, model lift — see SMT bridge
E-RESIDUE-*AlkahestErrorresidue — not a rational function, zero denominator, pole order out of range, or (E-RESIDUE-005) a point that is not an exact constant in ℚ(i)

E-RESIDUE-005 is raised only at the Python boundary — the Rust residue takes an already-parsed point and cannot reach that state — so it is deliberately absent from alkahest-core’s REGISTRY, on the same footing as E-SMT-001/003/004 in alkahest/smt.py and E-BATCH-001 in alkahest/_batch.py. It exists because residue(f, z, a) with a symbolic a reads perfectly well and used to escape as a bare AttributeError naming an attribute of the implementation, which is not an AlkahestError and so was invisible to except ak.AlkahestError.

Three of these describe outcomes that are results rather than malfunctions, and the wording of each is deliberate. E-ANSATZ-003 means no member of this family satisfies the constraints — for a search loop that is a closed branch worth recording, not a failure. E-XCHECK-002 means no oracle is installed, and exists so that a missing oracle can never be mistaken for agreement. E-SMT-003 refuses a model containing an algebraic number that cannot be lifted exactly, rather than truncating it to a float — a float witness recorded as an exact one is precisely the silent-error shape these subsystems exist to prevent.

E-CERT-* — certificate policy

CodeMeaningRemediation
E-CERT-001A result was required to carry a Lean certificate and none was availablePick a certifiable route — see Certificate coverage and alkahest.certifiable() — or drop the requirement

This one is unusual: the computation succeeded. What is missing is the machine-checkable evidence, so it is a policy failure rather than a mathematical one. It is raised only when you ask for it, by alkahest.require_certificate(result) or ambiently inside with alkahest.context(require_certificate=True):. The remediation names the blocking rewrite rules where they can be identified.

import alkahest as ak

p = ak.ExprPool()
x = p.symbol("x")

with ak.context(require_certificate=True):
    ak.diff(ak.sin(x), x)        # fine — certifies
    ak.integrate(ak.log(x), x)   # raises E-CERT-001

Cause axis

  1. User-input — the expression or argument is outside the supported fragment. Always has a remediation; carries a span once parsing lands.
  2. Domain — input is syntactically fine but violates a mathematical side condition. Remediation is “substitute a different value,” not “reformulate.”
  3. Unsupported — the operation is not implemented for this case. Must name the missing capability so users can file a feature request.
  4. Resource/environment — CUDA device absent, out-of-memory, JIT target mismatch, pool closed. Typically no span; remediation references the environment, not the expression.
  5. Internal invariant — a bug. Should never reach users in release; in debug it carries a backtrace. Use E-INTERNAL-001.

Adding a new error code

  1. Does it fit an existing subsystem? Add a variant and a code one higher than the current max for that prefix.
  2. Does it name a new subsystem? Add a prefix, a class, and an entry in REGISTRY in the same PR. Do not reuse prefixes across unrelated subsystems.
  3. Write the remediation before the message — if you cannot say what the user should do, the taxonomy is telling you this is an internal bug, not a user error.

Users match on subsystem (the exception class); triagers filter on cause (the code suffix and remediation text).

Autoresearch / agent loops

Alkahest is useful inside unsupervised or lightly-supervised math search loops because it is designed to be called many times under a budget, with results that stay auditable. The pieces below are the search plumbing that sits next to the mathematics:

NeedAPIGuide
Bound one candidate so a hard instance cannot stall the sweepBudget, context(budget=…), request_cancelBudgets
Fan out many candidates without one failure aborting the batchbatch_map, integrate_many, …Batch
Cheap, versioned payloads for logs / LLM contextDerivedResult.to_dict(mode="compact")Derivation logs
Accumulate claims across iterationsalkahest.research claim graphClaim graphs
Ask “will this call certify?” before spending computecertifiable, require_certificateCertificate coverage
Propose a parametric family and fit italkahest.ansatz (polynomial, rational, fit, …)Ansatz families
Differential-test a result against another CASalkahest.crosscheck (check, sweep)Cross-CAS testing
Hand a discrete / mixed int-real subproblem to a solveralkahest.smt (to_smtlib, solve, supported)SMT bridge
Ask whether a fitted recurrence is already in OEIS before calling it newalkahest.experimental.novelty (RecurrenceClaim, check_novelty)Novelty filtering

A minimal loop shape:

import alkahest as ak

pool = ak.ExprPool()
x = pool.symbol("x")
candidates = [x**2, ak.sin(x), ak.log(ak.log(x))]

with ak.research.session(title="Sweep", pool=pool, capture=True) as s:
    with ak.context(pool=pool, budget=ak.Budget(wall_ms=200, max_steps=50_000, seed=7)):
        for item in ak.integrate_many(candidates, x, parallel=True):
            if not item.ok:
                # E-BUDGET-* → deprioritize; E-INT-* → record and move on
                continue
            # Token-cheap record for the next iteration / a human referee
            _ = item.value.to_dict(mode="compact")

print(s.graph.to_markdown())

That snippet uses one pool for the whole sweep, which is right for a sweep that ends. It is wrong for a loop that runs for days: see running for days without dying below.

Honesty rules that matter in a loop:

  • A budget trip is a fine answer, not a crash — catch BudgetExceededError (E-BUDGET-*) and deprioritize that candidate.
  • A refusal is not a negative result. E-CAD-001, E-SOS-002, E-LINALG-010, E-MAT-004, E-SMT-003 and E-ANSATZ-003 all mean undecided by this route. Recording any of them as “proved false” or “no such object exists” is the most expensive mistake a search loop can make, because it closes a branch permanently. The only codes that are genuine mathematical verdicts are the ones documented as such — e.g. E-INT-004 (proven non-elementary). See Refusals.
  • A batch never drops a slot — failures become BatchItem(ok=False, error=…).
  • Compact mode never hides verification statusverification["status"] stays readable; Lean source is omitted on purpose.
  • Certificates are withheld rather than lied about — see certificate coverage.

Running for days without dying

Four limits bound an unattended run. None of them is a bug you can wait out; all four are properties of the design, and a loop has to be written around them.

1. Memory is not budgeted, and ExprPool never reclaims. A pool created once at startup grows linearly and forever — roughly 200 bytes per interned node, on the order of 2–3.5 KB per integrate — at flat per-call latency, so the run dies by OOM with no slowdown to warn you. Use one pool per problem, drop it when the problem is done, and carry to_dict() envelopes rather than live Expr handles between iterations (holding any Expr, Matrix or DerivedResult pins its entire pool). Full treatment.

2. wall_ms is cooperative, and its granularity is one primitive operation. The call stops at the first checkpoint after the deadline. On a high-degree integrand that operation is a FLINT call, which nothing short of an OS-level kill interrupts — a 300 ms budget can return after ~2 s there. Details.

3. run_with_wall_fallback does not bound wall time for an uncooperative callee. It joins its worker before raising, so it returns when the callee returns. run_with_wall_fallback(time.sleep, 3.0, budget=Budget(wall_ms=50)) raises after 3000 ms. The only hard bound is an outer process timeout. Details.

4. Some questions get refused, not answered. decide is not complete; it raises E-CAD-001 rather than fabricate a verdict it cannot justify, and the linear-algebra zero test refuses with E-LINALG-010 / E-MAT-004 rather than pick a branch. Budget for refusals in the loop’s control flow, not just for failures.

The skeleton that respects all four:

import alkahest as ak

def run_one(problem):
    pool = ak.ExprPool()                  # (1) fresh pool per problem
    x = pool.symbol("x")
    with ak.context(pool=pool, budget=ak.Budget(wall_ms=500, seed=7)):   # (2)
        try:
            result = ak.integrate(build(pool, problem), x)
        except ak.BudgetExceededError:
            return {"status": "undecided", "why": "budget"}
        except ak.AlkahestError as e:
            kind = "verdict" if e.code == "E-INT-004" else "undecided"   # (4)
            return {"status": kind, "code": e.code}
    return {"status": "ok", "result": result.to_dict(mode="compact")}    # no live Expr escapes

Run the driver itself under an OS-level timeout (3), not run_with_wall_fallback.

See also the runnable experimental-mathematics demo examples/pslq_research_loop.py.

Budgets, cancellation, and determinism

A fan-out loop trying thousands of candidate rewrites/integrals/Gröbner bases cannot afford one pathological candidate to hang the whole batch — and an orchestrator that decides a candidate isn’t worth more time needs a way to stop it now, not wait for an OS-level kill (SIGKILL, a process timeout). alkahest.Budget and alkahest.context(budget=...) give heavy engines a cheap, structured way to bail out honestly — raising BudgetExceededError — instead of running unbounded.

import alkahest as ak

p = ak.ExprPool()
x = p.symbol("x", "real")

with ak.context(pool=p, budget=ak.Budget(wall_ms=50, max_steps=10_000, seed=7)):
    try:
        ak.integrate(hard_expr, x)
    except ak.BudgetExceededError as e:
        assert e.code.startswith("E-BUDGET-")
        # ... deprioritize this candidate and move on to the next one ...

Model

A Budget is an immutable (wall_ms, max_steps, seed) triple. Every field is optional; Budget() never trips a check on its own — only alkahest.request_cancel() can stop a call entered with a bare Budget().

context(budget=...) pushes the budget into a thread-local stack on the Rust side (alkahest_cas::budget) for the scope of the with block, and pops it on exit — including on an exception, matching every other resource the context manager owns. Budgets nest like every other context(...) key: only the innermost frame is consulted, so a nested context(budget=...) shadows the outer one rather than combining limits with it. A nested context(...) that omits budget= leaves the outer one active (nothing is pushed, so nothing shadows it):

with ak.context(pool=p, budget=ak.Budget(seed=1, max_steps=1000)):
    ak.budget_seed()   # 1
    with ak.context(pool=p):
        ak.budget_seed()  # 1 — no budget= here, outer frame still active
    with ak.context(pool=p, budget=ak.Budget(max_steps=2)):
        ak.budget_seed()  # None — this frame set no seed; it does not inherit
    ak.budget_seed()   # 1 — back to the outer frame

What checks the budget today

The Rust engines call a cheap cooperative checkpoint (alkahest_cas::budget::check) at a handful of strategic points — not blanket-inserted into every loop:

  • alkahest.integrate — at the top-level entry (covers every route: algebraic, Risch/transcendental, rational-function, log-derivative); at the integrate_inner recursion boundary; at every integrate_raw entry, which the sum rule and the constant-multiple rule recurse through, so a long sum is bounded between summands; once per candidate of the derivative-divides u-substitution search (each surviving candidate runs a full recursive integrate, and there are up to twelve); at the stage boundaries of the rational-function route (normalisation, Hermite reduction, Rothstein–Trager, the partial-fraction pass and each of its irreducible factors); and inside the two Euclidean loops that dominate a hard rational integrand — the ℚ[x] GCD used to reduce A/D to lowest terms and the number-field GCD of Lazard–Rioboo–Trager. A trip raises BudgetExceededError — integration has a Result return type with a natural place to signal it.

    Those last few are not decoration. See how tightly wall_ms binds — before they existed a 300 ms budget on ∫ cos x·sin¹²x/(sin⁹x + sin x + 1) dx returned after 3.4 s, and the same integrand at degree 40 never returned at all.

  • alkahest.limit — at every limit_inner recursion boundary, in the Gruntz comparability sweep, in the pole-clearing loop of the x ↦ 1/t substitution, and between Taylor coefficients of the local expansion (the loop that can grow without bound on nested radicals). LimitError is an exhaustive public enum and cannot grow a Budget variant without a major semver break, so a trip is reported internally as LimitError::DepthExceeded and the E-BUDGET-* cause is recovered out-of-band (alkahest_cas::calculus::limits::last_budget_trip); the Python binding raises BudgetExceededError exactly as integrate does. With no budget active the same paths are bounded by an internal work ceiling, so an unsolvable limit refuses with LimitError / E-LIMIT-004 instead of running unboundedly.

  • alkahest.simplify (and simplify_with, simplify_batch) — once per full bottom-up rewrite pass. simplify has no error channel (DerivedExpr isn’t a Result), so a trip here stops further passes early and returns the best value simplified so far — exactly like running out of the existing max_iterations cap already does, silently. If you need a hard raise on a simplify call specifically, wrap it in alkahest.run_with_wall_fallback (below).

Other heavy primitives (Gröbner bases, homotopy continuation, …) do not yet check the budget; wiring them is a follow-up, not part of this cut. Calling check() is cheap when no budget is active and cancellation has not been requested (an atomic load, and — only if a budget is active — an Instant::now()), so it is safe to sprinkle at more call sites over time without a performance concern gating it.

How tightly wall_ms binds

wall_ms is cooperative: the call stops at the first checkpoint after the deadline, so it always overshoots by however long the engine had left in the stretch it was in. That makes the useful question “how long is the longest stretch”, not “is it exact” — and a budget whose overshoot grows without bound is not a budget at all.

Measured on ∫ cos x·sinⁿx/(sin^d x + sin x + 1) dx, the family that goes through the Weierstrass half-angle substitution and then Rothstein–Trager (elapsed until the trip, wall_ms=300):

integrandbeforenow
n=12, d=93384 ms344 ms
n=16, d=92107 ms360 ms
n=20, d=93967 ms345 ms
n=24, d=93148 ms313 ms
n=40, d=17never returned (killed at 90 s)305 ms
1/(sin⁹x + sin x + 1)110 s333 ms

and across budget sizes on the worst of them (n=40, d=17): 53 ms for wall_ms=50, 106 ms for 100, 305 ms for 300, 1071 ms for 1000, 3158 ms for 3000 — the overshoot is a small additive term, not a multiple of the budget and not a function of the problem size.

What is left, honestly. The residual granularity is one primitive polynomial operation, and past a certain degree that operation is a FLINT call — factorisation over ℤ, or a bivariate resultant. Those are single foreign-function calls: nothing short of an OS-level kill stops one part-way, and adding checkpoints around them cannot help. On a degree-62 integrand (1/(sin³¹x + sin x + 1)) one such call measured about 2 s, so a 300 ms budget there returns after roughly that long. That is the honest floor, and only an outer process timeout goes below it — not run_with_wall_fallback, which joins the same call rather than preempting it (below).

(The pure-Rust loops that used to dominate — the ℚ[x] and number-field Euclidean GCDs — are now checkpointed, which is what removed the growth. A per-step check was also tried on the ℚ long division underneath them and measured no further improvement, so it was dropped rather than kept for the look of it: it would only have made max_steps count faster for nothing.)

So the guarantee worth relying on is: the budget is checked between operations, and one operation on a high-degree integrand can take seconds. It is not a hard real-time deadline, and no cooperative mechanism can make it one.

Cancellation

alkahest.request_cancel() sets a single process-wide flag — deliberately not scoped to a thread or a Budget frame. It models “the orchestrator wants the current heavy operation to stop right now”, e.g. because a fan-out loop decided a candidate has used enough wall time, and the operation might be running on a different thread than the one that decided to give up on it. alkahest.is_cancelled() reads it; alkahest.clear_cancel() resets it — call this before starting the next candidate, or every subsequent call trips E-BUDGET-003 immediately.

import threading

def watchdog():
    time.sleep(0.05)
    ak.request_cancel()

threading.Thread(target=watchdog, daemon=True).start()
try:
    ak.integrate(hard_expr, x)
except ak.BudgetExceededError as e:
    assert e.code == "E-BUDGET-003"
finally:
    ak.clear_cancel()

The watchdog runs while the call runs

For that example to mean anything, the watchdog thread has to be able to execute during the call it is trying to cancel. alkahest.integrate and alkahest.limit — the two budget-honouring engines — therefore release the GIL around their core call (py.allow_threads, the same idiom simplify_par uses for its Rayon workers). Without that the flag was only ever observed if it had been set before the call: the watchdog could not run a single bytecode until the operation it wanted to stop had already finished, which is the opposite of what a fan-out search loop needs.

Two things that follow, and one that does not:

  • Cancellation is cooperative, not preemptive. The flag is observed at the checkpoints listed above, so the call stops at the next one — not instantly. An engine stretch with no checkpoint runs to its end.
  • Other calls still hold the GIL. Only integrate and limit release it (plus the parallel simplifiers and the compiled-function batch paths, for unrelated reasons). request_cancel() cannot reach a running Gröbner basis or homotopy continuation, because those do not check the budget at all yet.
  • Nothing about pool safety changes. ExprPool is Send + Sync and interns through a lock-free index; releasing the GIL around a call that holds only a shared &ExprPool is strictly weaker than the concurrent Rayon access simplify_par already performs on the same structure.

Determinism seed

Budget(seed=...) doesn’t do anything by itself — it makes the seed available via alkahest.budget_seed() (Rust: alkahest_cas::budget::seed()) to any RNG-consuming sampler that chooses to consult it, instead of threading an explicit seed parameter through every call in a pipeline. Two runs entering Budget(seed=7) observe the same budget_seed() at every call site that reads it, so a search loop that seeds its own sampling from the ambient budget is reproducible run-to-run.

Budgets and threads

The budget frame is thread-local; the cancellation flag is process-wide. Every surprise in this area follows from that pair, so it is worth stating plainly:

  • A worker thread does not inherit the budget its parent entered. Handing work to a concurrent.futures.ThreadPoolExecutor yourself runs it unbudgeted unless you re-enter the budget inside the worker.
  • alkahest.batch_map / batch_map_iter / the *_many helpers do that for you under parallel=True: the active budget is snapshotted on the calling thread and re-entered in each worker task, so a trip is reported as E-BUDGET-00x on the item that tripped, exactly as it would be sequentially. wall_ms stays a single sweep-wide deadline; max_steps becomes per-item (the Rust step counter is not readable from Python). See Batch and streaming evaluation.
  • alkahest.run_with_wall_fallback likewise enters its budget argument on the worker thread it spawns, so cooperative call sites there actually observe it.
  • request_cancel() needs no propagation, and that cuts both ways: it stops every in-flight cooperative call in the process, not just the one you had in mind. A single candidate’s budget trip therefore never sets it — nothing in batch_map touches the flag.

The Python-layer wall-clock fallback

Because simplify cannot raise through its own return type, context(budget=...) alone only bounds it the same way max_iterations already does — silently, by returning early. alkahest.run_with_wall_fallback turns that silent truncation into a raised, coded error: it runs the call on a worker thread (with budget entered on that thread) and raises BudgetExceededError (E-BUDGET-001) when the call overruns wall_ms.

result = ak.run_with_wall_fallback(ak.simplify, big_expr, budget=ak.Budget(wall_ms=200))

It does not bound wall time for an uncooperative callee

Read this before putting it in a loop. run_with_wall_fallback joins its worker before the exception propagates, so it returns control when the callee returns — not at wall_ms. Measured: run_with_wall_fallback(time.sleep, 3.0, budget=Budget(wall_ms=50)) raises E-BUDGET-001 after 3000 ms. The error message reports the real elapsed time (“returned control after 3000 ms”) precisely so this shows up in a log instead of being inferred later.

For a callee that does reach a cooperative checkpoint the wait is short, because the worker now runs inside the budget and stops on it (integrate on a hard integrand: wall_ms=300 returns in about 320 ms) — but that is the case where context(budget=...) alone would already have bounded it. The uncooperative case, the one this function looks like it exists for, is the one it cannot bound.

Why not return at the deadline and let the worker run on? Python cannot kill a thread, so “return early” means leaking a live thread that still takes the GIL in bursts, still allocates into the pool, and can only be asked to stop through the process-wide cancel flag — which aborts every unrelated in-flight call, and which nobody can then clear safely (clearing it before the orphan observes it is a no-op; leaving it set makes every later cooperative call fail with E-BUDGET-003). In a multi-day loop that trades a bounded stall for unbounded orphan accumulation plus collateral cancellation. Joining is the lesser evil, so it is what the function does.

What actually bounds wall time, in order of preference:

  1. context(budget=...) around an engine that checks the cooperative budget — integrate and limit today. This is the real mechanism; run_with_wall_fallback is a reporting shim over it.
  2. An OS-level bound for anything else: run the work in a subprocess with a timeout, or put a process-level watchdog around the loop. Nothing inside one Python process can preempt a thread mid-FLINT-call — see what is left, honestly.

So reach for run_with_wall_fallback to get a raise out of a cooperatively-budgeted call that would otherwise hand back a silently truncated answer. Do not reach for it to contain an unknown callee.

ExprPool never reclaims

Budget bounds time and steps. Nothing bounds memory, and the shape of the memory growth is the single most likely way a multi-day loop dies. This section is as important as everything above it.

The mechanism

ExprPool is an append-only hash-consed arena. It has no clear, no truncate, no refcount and no garbage collector; the underlying storage cannot shrink. The only way to reclaim interned nodes is to drop the entire pool. And every Expr, Matrix, Series and DerivedResult holds a strong reference to the pool it came from, so keeping one interesting result alive keeps every node ever interned alive with it — which is exactly the usage pattern a research loop has.

Measured on this machine, 20 000 integrate calls with a distinct integrand each time:

one shared pool for the whole loop ......  1 992 bytes/call, forever, linear
a fresh pool per iteration ..............      0 bytes/call

Two properties make this nastier than an ordinary leak:

  • Time stays flat. Per-call latency does not degrade as the pool grows, so there is no early warning — the loop runs at full speed until the OOM killer arrives. Growth is O(n) in memory with O(1) time.
  • You cannot measure it from Python. ExprPool exposes no __len__ and no stats(), so a loop cannot watch its own footprint and decide to recycle.

Per-call cost depends on the operation. As a rough guide, roughly 200 bytes of resident memory per interned node, and on the order of 0.8 KB/call for diff or simplify, 2–3.5 KB for integrate, ~8 KB for a crosscheck.check, ~12.5 KB for a series of order 6. At one integrate per second on one pool that is gigabytes within a day.

The supported pattern: one pool per problem

import alkahest as ak

for problem in problems:
    pool = ak.ExprPool()             # fresh pool per iteration
    x = pool.symbol("x")
    with ak.context(pool=pool, budget=ak.Budget(wall_ms=500)):
        result = ak.integrate(build(pool, problem), x)
        record(str(result.value))    # keep a *string* / dict, not the Expr
    del pool, x, result              # dropping the pool reclaims everything

The critical line is record(str(result.value)). Holding the Expr (or the DerivedResult, or a Matrix derived from it) pins the pool and defeats the whole scheme. DerivedResult.to_dict() / .to_json() exist partly for this: they give you a plain-Python envelope that outlives the pool. Do not carry live Expr handles between iterations of an unattended loop; re-parse or rebuild them in the new pool if you need them again.

One operation grows even on identical input

Matrix.eigenvals() mints a fresh gensym (__eigen_lambda_N) into the pool on every call, so re-asking the same eigenvalue question keeps allocating for no new information — measured at about 1.9 KB/call on the same 2×2 integer matrix over 20 000 calls, where simplify on identical input is exactly 0. Cache eigenvalue results yourself rather than recomputing them in a loop. (Every other Python-facing entry point measured is flat on repeated input.)

If you enable the LLVM JIT

The jit (LLVM) feature leaks a whole LLVM Context per compile — a true leak with no pool to drop, on the error paths as well as the success path. Cranelift (the default wheel’s JIT) is unaffected. Do not compile in a loop under a +jit / +full build.

Error codes

CodeCause
E-BUDGET-001The active budget’s wall-clock limit elapsed
E-BUDGET-002The active budget’s step counter exceeded max_steps
E-BUDGET-003request_cancel() was called and not yet cleared

All three are Cause::Resource in the Rust registry (alkahest_cas::errors::codes) — a budget/cancellation trip is an environment/policy limit, not a statement about the mathematics, so it is never conflated with e.g. IntegrationError::NonElementary (a proof that no elementary antiderivative exists). alkahest.integrate and alkahest.limit raise BudgetExceededError directly rather than wrapping it in IntegrationError / LimitError, so callers can catch it uniformly regardless of which engine tripped it:

try:
    ak.integrate(hard_expr, x)
except ak.BudgetExceededError as e:
    ...  # deprioritize and move on
except ak.IntegrationError as e:
    ...  # a genuine "no elementary antiderivative" or "not implemented" verdict

API reference

NameKindDescription
Budget(wall_ms=None, max_steps=None, seed=None)classImmutable budget triple
context(budget=...)context managerPush/pop the budget for a with block
active_budget()functionThe Budget from the innermost active context, or None
budget_seed()functionThe seed of the innermost active budget, or None
is_budget_active()functionTrue if a budget is active on this thread
request_cancel()functionSet the process-wide cancellation flag
clear_cancel()functionClear it
is_cancelled()functionRead it
run_with_wall_fallback(fn, *args, budget, **kwargs)functionRaises E-BUDGET-001 when fn overruns wall_ms — after joining its worker, so it does not bound wall time for an uncooperative callee (above)
BudgetExceededErrorexceptionE-BUDGET-001..003, subclass of AlkahestError

On the Rust side (alkahest_cas::budget): Budget, enter, BudgetGuard, check, seed, is_active, request_cancel, clear_cancel, is_cancelled, BudgetError.

See also

Batch and streaming evaluation

Search loops are embarrassingly parallel at the candidate level: try to integrate a hundred generated integrands, simplify a thousand rewrite targets, differentiate every entry in a lookup table. Every Alkahest entry point is one-call-one-answer, so today that fan-out is written by hand at every call site — and the first candidate that raises aborts the whole batch unless the caller remembers try/except around every single call.

alkahest.batch_map (and the *_many convenience wrappers over integrate, simplify, and diff) do that fan-out once. They never raise for a single bad element — the exception is caught and turned into a structured BatchItem carrying the failing exception’s stable E-* diagnostic code, so a loop can tell “this candidate has no elementary antiderivative” (a fine, expected answer) from “the whole batch process crashed”.

import alkahest as ak

pool = ak.ExprPool()
x = pool.symbol("x")

outs = ak.integrate_many([x**2, ak.log(ak.log(x)), ak.sin(x)], x)
for item in outs:
    if item.ok:
        print(item.index, "=>", item.value.value)
    else:
        print(item.index, "FAILED", item.error["code"], item.error["message"])
0 => (x^3 * 1/3)
1 FAILED E-INT-001 [E-INT-001] integrate: not implemented: ...
2 => (-1 * cos(x))

Honesty invariant

batch_map always returns exactly one BatchItem per input, in input order — a batch of 100 items yields a list of 100 items, full stop. Nothing in this module silently drops a failing candidate; a failure is recorded as ok=False with its error, never as a missing slot.

BatchItem

FieldTypeMeaning
indexintPosition in the original input sequence — stable under parallel=True and under streaming in completion order
okboolTrue iff the call returned normally
valueAny | NoneThe call’s return value (often a DerivedResult) on success; None on failure
errordict | None{"code", "message", "remediation", "type"} on failure; None on success
elapsed_msfloat | NoneWall-clock time spent inside the call for this item

Exactly one of value / error is populated: ok=True implies error is None.

error["code"] is the raised exception’s .code when it is an AlkahestError-like exception — including the native error types, which expose the same attribute — otherwise alkahest._batch.UNEXPECTED_ERROR_CODE ("E-BATCH-001"), the fallback for a failure whose exception carries no diagnostic code of its own (e.g. a plain ValueError raised by caller code passed to batch_map).

batch_map and batch_map_iter

def batch_map(fn, items, *, parallel=False, max_workers=None, **kwargs) -> list[BatchItem]: ...
def batch_map_iter(fn, items, *, parallel=False, max_workers=None, **kwargs) -> Iterator[BatchItem]: ...

Both call fn(item, **kwargs) once per item. parallel=True fans the calls out over a concurrent.futures.ThreadPoolExecutor; some Alkahest hot paths (integrate, limit, the parallel simplifiers, NumPy evaluation) release the GIL for their native work, so a thread pool can genuinely overlap them. For calls that hold the GIL throughout, parallel=True mainly helps when fn itself does I/O or otherwise yields the GIL — it never makes anything incorrect, only sometimes not faster.

Order guarantees

  • batch_map always returns results in input order, whether or not parallel=True. This is the guarantee to reach for when you need zip(items, batch_map(...)) to line up.
  • batch_map_iter documents two different behaviours by design:
    • parallel=False streams in input order — item i is fully computed and yielded before item i + 1 starts.
    • parallel=True streams in completion order, not input order. This is the whole point of streaming under fan-out: a fast failure surfaces immediately instead of waiting behind a slow item that happened to be submitted first. Every yielded BatchItem still carries its original index, so a caller that needs input order can sort by it, or just use batch_map.
# Streaming: react to failures as they arrive, without waiting for the slowest item.
for item in ak.batch_map_iter(ak.simplify, candidates, parallel=True):
    if not item.ok:
        log.warning("candidate %d failed: %s", item.index, item.error["code"])

integrate_many / simplify_many / diff_many

Thin batch_map wrappers over the three most common derivation entry points:

def integrate_many(exprs, var, *bounds, parallel=False, max_workers=None, **kwargs) -> list[BatchItem]: ...
def simplify_many(exprs, *, parallel=False, max_workers=None, **kwargs) -> list[BatchItem]: ...
def diff_many(exprs, var, *, parallel=False, max_workers=None, **kwargs) -> list[BatchItem]: ...

integrate_many accepts optional trailing bounds (a, b) for a batch of definite integrals, exactly like alkahest.integrate. **kwargs on every helper is forwarded to the underlying call (e.g. assumptions= for simplify_many).

outs = ak.simplify_many(candidates, parallel=True)
ok = [o.value for o in outs if o.ok]
failed = [(o.index, o.error) for o in outs if not o.ok]

Never raises — except for real interpreter signals

batch_map and batch_map_iter catch Exception, not BaseException: a KeyboardInterrupt or SystemExit still propagates and stops the batch, since swallowing those would make the process unkillable. Everything else — including every Alkahest E-* error and any exception your own fn raises — is captured.

Combining with budgets

Wrap the batch in context(budget=…) so each candidate inherits the same cooperative wall/step limit (and optional seed). A trip surfaces as BatchItem(ok=False, error={"code": "E-BUDGET-00x", …}) rather than aborting the rest of the batch — see Budgets.

with ak.context(pool=pool, budget=ak.Budget(wall_ms=50, max_steps=10_000, seed=7)):
    outs = ak.integrate_many(candidates, x, parallel=True)

This works under parallel=True as well as parallel=False, but the two are not identical field-for-field, because a Rust budget frame lives on a thread-local stack and a worker thread does not inherit its parent’s. batch_map therefore snapshots the active budget on the calling thread and re-enters it inside every worker task:

Fieldparallel=Falseparallel=True
wall_msone deadline for the whole sweep (the caller’s frame)one deadline for the whole sweep, captured at the batch_map call
max_stepsone counter for the whole sweepper item — the Rust step counter lives in the frame and is not readable from Python, so each worker counts from zero
seedsame value everywheresame value everywhere

The wall_ms deadline is captured when batch_map is called, not when context(budget=…) was entered — Python cannot read the frame’s start instant — so a batch launched partway through a budgeted block gets the full wall_ms again. That is one budget’s worth of slack for the whole fan-out, not per item.

A budget trip is not a mathematical verdict

This is the reason the propagation matters more than the speed-up. Before it, a fanned-out sweep ran completely unbudgeted, and the candidates a sequential sweep reported as E-BUDGET-001 came back as E-INT-001 instead — the integrator’s verdict that no elementary antiderivative exists. A research loop records that as a permanently closed branch, when in truth nothing was decided and the machine merely ran out of the time it was given. E-BUDGET-00x is Cause::Resource; keep the two apart when you interpret a BatchItem:

for item in outs:
    if item.ok:
        accept(item.value)
    elif item.error["code"].startswith("E-BUDGET-"):
        requeue(item.index)          # ran out of budget — undecided, try again with more
    else:
        close(item.index, item.error)  # a real verdict about the mathematics

Cancellation across a batch

request_cancel() needs no propagation — the flag is process-wide, so every worker already sees it and a caller can abort a whole in-flight sweep with it (each item then reports E-BUDGET-003). The converse is deliberate: one item tripping its budget never cancels its siblings. batch_map never sets the flag itself; the trip is recorded on the item that tripped, and the rest of the sweep runs out the shared deadline.

One pool for the batch, not for the process

A batch shares one ExprPool across all its items, which is right — the whole point is that the items are related. What is not right is reusing that pool for the next batch, and the next: ExprPool never reclaims, so a driver that keeps one module-scope pool and runs batch_map in a loop grows linearly and forever at flat latency. Construct the pool per batch and drop it, and carry item.value.to_dict(mode="compact") forward rather than the DerivedResult itself (holding one pins the whole pool). See ExprPool never reclaims.

See also

Creative telescoping (Zeilberger’s algorithm)

zeilberger decides combinatorial identities over a well-defined class and hands back a certificate you can re-check independently. That combination — a decision procedure whose output is a short, verifiable algebraic object — is why it matters for an autoresearch loop: it turns “this identity survived a numeric sweep” into “this identity is proved”, without a human in the loop.

import alkahest as ak

pool = ak.ExprPool()
n, k = pool.symbol("n"), pool.symbol("k")
one = pool.integer(1)

# F(n, k) = C(n, k), written as the ratio of gammas the engine recognises.
F = ak.gamma(n + one) / (ak.gamma(k + one) * ak.gamma(n - k + one))

cert = ak.zeilberger(F, n, k)
cert.order          # 1
cert.coeffs         # [a_0(n), a_1(n)] — here proportional to [-2, 1]
cert.certificate    # R(n, k)
cert.boundary       # "vanishes" — so the recurrence holds for the *sum*

The result says: with S(n) = Σ_{k=k_lo}^{k_hi} F(n,k),

Σ_i a_i(n)·S(n+i) = b(n)

where b(n) is a boundary term that the summand-level identity does not by itself make zero. Here it is zero — cert.boundary says so, and says it after computing it — so the recurrence reads S(n+1) − 2·S(n) = 0, which together with S(0) = 1 gives Σ_k C(n,k) = 2^n. When it is not zero the inhomogeneous recurrence is returned instead, and when neither can be proved nothing is claimed about the sum at all. That three-way verdict is the subject of its own section below, and it is the difference between a certificate and a theorem.

What the certificate asserts

The returned certificate is a rational function R(n, k) such that, with G(n,k) = R(n,k)·F(n,k),

Σ_i a_i(n)·F(n+i, k) = G(n, k+1) − G(n, k)

holds identically. That identity in k is the whole of what is verified. Because it is a rational-function identity, a reader — or a referee, or another CAS — can check it by clearing denominators and expanding, with no reference to how it was found.

The boundary hypothesis, and the verdict on it

Summing that identity over k = k_lo .. k_hi telescopes the right-hand side to G(n, k_hi+1) − G(n, k_lo) — a boundary difference, not zero. The familiar homogeneous recurrence for S(n) therefore holds only when that difference vanishes: the natural boundary hypothesis, which Zeilberger’s algorithm does not establish.

It holds in the usual case, where F vanishes outside 0 ≤ k ≤ n, and that covers every classical identity in this chapter. It fails, for instance, for F(n,k) = C(n,k)/(k+1): there G(n,0) = −1, and the true relation is (n+2)·S(n+1) − (2n+2)·S(n) = 1, not 0. Reading the homogeneous recurrence off the certificate there gives a false lemma.

zeilberger decides it rather than leaving it stated. cert.boundary is one of three values:

cert.boundarywhat may be claimed about S(n)
"vanishes"proved: Σ_i a_i(n)·S(n+i) = 0
"nonzero"proved: Σ_i a_i(n)·S(n+i) = b(n), with b(n) in cert.boundary_rhs
"unknown"nothing. The certificate is still a true statement about the summand
cert = ak.zeilberger(F, n, k)          # limits default to k = 0..n
cert.boundary            # "vanishes"
cert.boundary_rhs        # None — the right-hand side is 0
cert.limits              # (0, n), echoed back so the assumption is on the record
cert.boundary_reason     # why the verdict came out this way
cert.implies_sum_recurrence   # True for "vanishes" and "nonzero"

"nonzero" is a result, not a refusal. OEIS A279013, a(n) = Σ_{k=0}^{n} C(2k,k)/(k+1)·C(2n−1,n−k), gets a verified order-2 certificate in a tenth of a second; the homogeneous recurrence read off it fails against the sequence at the very first term. The engine now returns the inhomogeneous recurrence, which does hold:

cert.boundary       # "nonzero"
cert.boundary_rhs   # b(n), an explicit hypergeometric term in n
# Σ_i a_i(n)·S(n+i) = b(n) — checked exactly against 2, 8, 35, 161, 768, 3773

The summation range is part of the claim

The verdict is about the range in cert.limits, and it changes with it — truncating a sum by one term generally turns "vanishes" into "nonzero". Pass limits=(k_lo, k_hi) (each an Expr or an int) to say what you are summing:

cert = ak.zeilberger(F, n, k, limits=(0, n - pool.integer(1)))
cert.boundary                         # "nonzero" — the k = n term was dropped

# Or ask the same certificate about another range, without re-running the search:
cert.boundary_at(0, n)["boundary"]    # "vanishes"

Two things this design does deliberately. The default (0, n) is stated and echoed back, not inferred from the summand, so a caller summing over something else can see the mismatch. And a range the analysis cannot place — endpoints that are not integer-affine in n — is "unknown", never "vanishes".

What "vanishes" is worth

It is a proof, not a numeric check. Each endpoint of G is evaluated by exact order counting in Q(n): the multiplicity of the endpoint as a root of the certificate’s numerator and denominator, plus −e for every Γ(a·n+b·k+c)^e factor whose argument lands on a non-positive integer there (a pole of Γ, or a zero of 1/Γ). A strictly positive total order is an exact zero. A negative one means G is unbounded at the endpoint, and that is reported as "unknown". Nothing that merely looks like zero can produce "vanishes".

The verdict also accounts for a subtlety that is easy to miss: when the limits move with n, Σ_{k=0}^{n} F(n+i,k) is not S(n+i). For Σ_{k=0}^{n} C(n,k) the telescoped difference alone is −1, and it is the missing term C(n+1,n+1) = 1 that cancels it. The full statement is

b(n) = G(n, k_hi+1) − G(n, k_lo) + Σ_i a_i(n)·D_i(n)

with D_i the finitely many values of F between the range at n and the range at n+i, and it is b(n) that the verdict is about.

Symmetrically, "nonzero" needs a witness: an integer n₀ at which b(n₀) is nonzero in exact rational arithmetic. Sampling that finds only zeros proves nothing and yields "unknown".

cert.boundary_term still returns G(n,k) = R(n,k)·F(n,k) if you want to discharge the hypothesis yourself.

side_conditions is a list[str], the same shape as DerivedResult.verification["side_conditions"]: things the result depends on that were assumed rather than proved. It tracks the verdict — a discharged hypothesis, a refuted one and an open one read differently, so a loop that only reads this list still cannot mistake the three. It is never empty: even a proved boundary is a statement about the n at which everything involved is defined, and a permanent record of which range was assumed.

Verification is not optional

Every certificate is re-checked as an exact identity in Q(n)(k) before it is returned. A candidate that fails verification is discarded and the search continues; it is never returned with a caveat. This is the same withhold-rather-than-lie discipline as the Lean certificate exporter: in a loop, one unverified certificate becomes a false lemma that every downstream derivation inherits.

Verification runs in exact rational arithmetic — no floating point is involved at any stage of the algorithm.

The class it decides, and where it refuses

The supported class is proper hypergeometric terms:

F(n, k) = R(n, k) · z^k · w^n · ∏_j Γ(a_j·n + b_j·k + c_j)^(e_j)

with R a rational function, z, w nonzero rationals, a_j, b_j integers and c_j rational. Factorials, binomials and Pochhammer symbols are recognised and normalised into this form.

Everything else is refused, with a structured error rather than a guess:

CodeMeaningWhat a loop should do
E-HOLO-001Not a proper hypergeometric termClose this branch — Zeilberger does not apply
E-HOLO-002Search bounds exhaustedRetry with larger max_order / max_degree, or deprioritise
E-HOLO-003A candidate failed exact verificationReport as a bug with the term
E-HOLO-004Malformed call (n and k not distinct, non-positive bounds)Fix the call

E-HOLO-002 is worth dwelling on. It does not mean “no recurrence exists” — it means none was found within the bounds you set. The distinction matters: an agent can raise the bounds and retry, whereas E-HOLO-001 is a permanent answer about the input and the branch can be closed for good.

Cost and bounds

max_order (default 4) and max_degree (default 16) bound the search. Solving the linear system over Q(n) at one (J, d) pair gets rapidly more expensive as either grows — measured on Σ (−1)^k C(n,k)³ at order 1, a single probe goes from 0.7 ms at d = 0 to 0.6 s at d = 7 to 84 s at d = 12, and one extra order costs about what three extra degrees cost.

Both are upper bounds, not starting points. The (J, d) pairs are visited by iterative deepening, cheapest estimated candidate first, and the first relation that passes exact verification is returned. Raising a bound therefore widens the reach without moving where the search starts — Σ (−1)^k C(n,k)³ is decided at order 2 in 0.8 s at the defaults and 0.6 s at max_order=6, max_degree=64 (a hand-tuned max_order=2, max_degree=4 costs 0.2 s, because a tight bound also truncates the cheap probes the deepening would interleave first):

# Same answer, same order of magnitude — the bounds are not a starting point.
cert = ak.zeilberger(F, n, k)                            # defaults
cert = ak.zeilberger(F, n, k, max_order=6, max_degree=64)

What the bounds do control is the price of a refusal: a term with no recurrence inside them pays the full grid before raising E-HOLO-002. Set them to the largest search you are willing to wait through when the answer is “no”.

cert = ak.zeilberger(F, n, k, max_order=2, max_degree=6)

Is the order minimal?

Usually the interesting part of a certified recurrence is not that it exists but that it is short: an order-4 relation where the literature records a guessed order-5 is a result, and an order-4 relation that might have been order 3 is a coincidence. So the question has to be asked explicitly, and the answer is on the certificate:

cert.order_is_minimal   # True only when the search established it

Cheapest-first is not order-ascending. The deepening above orders probes by estimated cost, 3·(J−1) + d, so it can reach a cheap order-2 probe long before an expensive order-1 one — which is exactly what makes Dixon, Franel and Apéry decidable at the default bounds. A returned order 2 therefore does not establish that no order-1 relation exists, and order_is_minimal is False to say so. False means not established, never “a lower order exists”: a lower-order relation that had been found would have been the one returned.

It is True for free at order 1, and True whenever the cost-ordered plan happened to spend every lower order before the probe that succeeded — which it does at narrow max_degree, since the plan interleaves less there. To get the claim in general, ask for it:

cert = ak.zeilberger(F, n, k, max_degree=6, minimal=True)
cert.order_is_minimal   # True — every degree ≤ 6 at every lower order was refused

minimal=True walks the grid order-major: every degree 0..max_degree at order J is probed and refused before order J+1 is tried at all. Same bounds, same exact verification, same certificate — only what was ruled out along the way differs. The flag is computed from the probes that actually happened rather than from the mode, so it cannot drift away from what the search did.

The price is the whole hopeless low-order sweep the default plan exists to avoid, and it is charged against max_degree because that is the bound minimality is claimed relative to. Measured on this machine at max_order=4:

Summandmax_degreedefaultminimal=True
Σ C(n,k)³ (Franel)40.15 s0.14 s — default already minimal
Σ C(n,k)³ (Franel)60.23 s0.23 s — default already minimal
Σ C(n,k)³ (Franel)80.23 s0.56 s
Σ C(n,k)³ (Franel)160.23 s9.7 s
Σ C(n,k)²C(n+k,k)² (Apéry)40.07 s0.07 s — default already minimal
Σ C(n,k)²C(n+k,k)² (Apéry)60.08 s0.11 s
Σ C(n,k)²C(n+k,k)² (Apéry)80.08 s0.29 s
Σ C(n,k)²C(n+k,k)² (Apéry)160.08 s13.1 s

The default column is flat in max_degree — that is the deepening doing its job — and the minimal=True column is not, because it is the column that has to sweep.

The default is unchanged, deliberately — the cost-ordered plan is what makes these terms decidable at all, and minimal=True is an opt-in for when minimality is the result you intend to publish. Note the shape of the table: the sweep grows like 3^d, so the honest move is usually to claim minimality against the smallest max_degree you are willing to state rather than against the default 16.

Guessing the recurrence first

The other half of the loop is guess_holonomic, which fits a P-recursive recurrence to the first terms of a sequence in exact rational arithmetic — the guess in guess-then-prove, with zeilberger supplying the proof:

motzkin = [1, 1, 2, 4, 9, 21, 51, 127, 323, 835, 2188, 5798, 15511,
           41835, 113634, 310572, 853467, 2356779, 6536382, 18199284, 50852019]

guess = ak.guess_holonomic(motzkin)
guess.order, guess.degree   # (2, 1)
guess.surplus_terms         # 14 equations confirmed it without being needed
guess.confirmed             # True

A fitted recurrence is a conjecture, and the number that says how much of one is surplus_terms. See Guessing recurrences for the guard and what it refuses.

The q-analogue (alkahest.experimental.q_zeilberger)

q-hypergeometric sums — Gaussian binomials [n;k]_q, q-Pochhammer symbols (a;q)_n — are not proper hypergeometric terms in (n,k), so zeilberger refuses them (correctly) with E-HOLO-001. q_zeilberger is the q-shifted twin of the same algorithm, and the same discipline: the certificate is re-checked as an exact identity in Q(q)(qⁿ)(q^k) before it is returned.

import alkahest as ak
from alkahest.experimental import q_zeilberger, qbinomial

pool = ak.ExprPool()
q, n, k = pool.symbol("q"), pool.symbol("n"), pool.symbol("k")

# Σ_k [n;k]_q² · q^{k²} = [2n;n]_q  — the q-analogue of Σ_k C(n,k)² = C(2n,n).
b = qbinomial(pool, n, k)
cert = q_zeilberger(b * b * q ** (k * k), q, n, k)

cert.order            # 1
cert.boundary         # "vanishes"
cert.support          # ("0", "n") — where the summand is proved to live
cert.sum_term(3)      # the exact q-series value S(3), a polynomial in q

sum_term(n0) is the part worth reaching for. It evaluates the sum from the definition of the q-Pochhammer symbol, not through the shift quotients the search used, so checking Σ_i a_i(qⁿ)·S(n+i) = 0 against it is an independent check of the returned recurrence rather than a restatement of the certificate.

What it accepts

F(n,k) = R(qⁿ, q^k) · z^k · w^n · q^{A·k² + B·n·k + C·n² + D·k + E·n}
         · Π_j (q^{u_j}; q^{d_j})_{v_j}^{e_j}

with u_j, v_j integer-affine in n, k. Written as an expression: the heads qbinomial(N, K) and qpochhammer(u, d, v) (meaning (q^u; q^d)_v), powers of q whose exponent is a degree-≤2 polynomial in n and k, powers with a base free of n and k, and any rational function of q, qⁿ, q^k. Half-integer quadratic coefficients are fine — q^{k(k−1)/2} is not rational in q^k but all of its shift quotients are, which is the property the algorithm needs.

CodeMeaningWhat a loop should do
E-HOLO-020Not a q-hypergeometric termClose this branch
E-HOLO-021Search bounds exhaustedRaise max_order / max_degree and retry
E-HOLO-022A candidate failed exact verificationReport as a bug with the term
E-HOLO-023Malformed call (q, n, k not distinct; non-positive bounds)Fix the call
E-HOLO-024In the shape of the class, outside it in substanceClose this branch

E-HOLO-024 is the interesting one. (q^k; q²)_n shifted in k moves its first argument by 1, which the base does not divide, so the shift quotient is an infinite product and no algorithm in this family applies. That is a permanent answer about the input, like E-HOLO-020, not a budget problem.

The boundary verdict is two-valued here

cert.boundary is "vanishes" or "unknown" — there is no "nonzero" arm. The sum it is about is S(n) = Σ_{k ∈ Z} F(n,k), which the analysis also proves is a finite sum, over the window in cert.support. Fixing the range at all of Z is what makes the proof short: the range does not move with n, so there are no D_i correction terms, and "vanishes" follows from two structural facts about the summand alone — that it vanishes outside an affine window in k, and that it is finite at every integer k.

The certificate is not evaluated at an endpoint, and that is deliberate rather than lucky: R genuinely has poles at integer k — on the summand above it has a double pole exactly where the summand has a double zero, and G(n, n+1) is a finite non-zero limit of 0·∞. What the proof does instead is find one k far to the right that is past both the window and the (finitely many) poles, where G = R·0 = 0 with no indeterminacy, and then induct downwards on G(n,k) = G(n,k+1) − Σ_i a_i(qⁿ)·F(n+i,k), whose right-hand side the support analysis has already shown is finite everywhere. That gives every G a finite value, poles included, without evaluating the product at one; G is then constant and zero beyond the window at both ends, and the sum over Z telescopes to zero. (Read analytically at generic q with 0 < |q| < 1; the conclusion is an identity between rational functions of q that holds on an open set, so it holds in Q(q).)

What is not implemented is the inhomogeneous arm: computing b(n) for a q-sum needs endpoint values of G that are not rational in qⁿ, so a summand whose support the analysis cannot bound gets "unknown" and no claim about its sum, not a guessed inhomogeneity.

One more caveat, and it is on every verdict’s side_conditions: q is treated as transcendental. Everything here is an identity in Q(q). Specialising q to a root of unity — which is what the q-supercongruence literature does — is a separate step with its own hypotheses.

Specialising at a root of unity (specialize_at_root_of_unity)

A proved Q(q) recurrence does not, by itself, license setting q = ζ_d for a primitive d-th root of unity: a coefficient or a sum value can have a pole there, and specialising anyway is exactly the q-analogue of the A279013 failure mode — a certificate that re-checks perfectly while the specialised claim is false. QZeilbergerCertificate.specialize_at_root_of_unity(d, n) takes that step as a decision, not an assumption:

from alkahest.experimental import cyclotomic_polynomial, q_zeilberger, qbinomial

pool = ak.ExprPool()
q, n, k = pool.symbol("q"), pool.symbol("n"), pool.symbol("k")
b = qbinomial(pool, n, k)
cert = q_zeilberger(b * b * q ** (k * k), q, n, k)

spec = cert.specialize_at_root_of_unity(3, 2)  # q = zeta_3, at n = 2
spec.status                       # "specializes" / "obstructed" / "unknown"
spec.sum_value(0)                 # S_zeta(2), the canonical rep in Q[q]/(Phi_3)
spec.sum_valuation(0)             # the exact Phi_3-adic valuation of S(2)
spec.modulus()                    # Phi_3(q) = q^2 + q + 1, exposed for a by-hand check

The hypotheses — no pole in any coefficient a_i(qⁿ) or sum value S(n+i) at ζ_d — are decided exactly, by polynomial divisibility by Φ_d(q) over Q in the cyclotomic field Q(ζ_d) = Q[q]/(Φ_d(q)); nothing is evaluated numerically at any stage. Φ_d is irreducible over Q, so “does p vanish at ζ_d” is exactly “does Φ_d divide p”, which is what makes the valuation — and therefore the decision — exact rather than approximate. cyclotomic_polynomial(pool, d) returns Φ_d(q) directly, so a caller can redo the whole check by hand.

status is three-valued, and the three are not interchangeable:

  • "specializes" — proved: every coefficient and every sum value has non-negative Φ_d-adic valuation, so the specialisation map is defined on all of them, and the specialised identity was re-checked as an exact statement in Q(ζ_d) before being returned. Three further things are reported on this verdict rather than folded into it, because each of them makes a true verdict mean less than it looks:
    • is_vacuous — every coefficient died at ζ_d (the q → 1 limit at d = 1 is always like this), so the recurrence is 0 = 0. Still a theorem; it constrains nothing.
    • leading_coefficient_survivesFalse means the specialised recurrence no longer determines the last value from the earlier ones, even though it is not vacuous.
    • support_shrinks / effective_support — the q-Lucas phenomenon: [2;1]_q = 1 + q is non-zero in Q(q) and zero at ζ_2, so the surviving window at a root of unity can be a strict subset of the generic one. It can never grow.
  • "obstructed" — a pole at ζ_d was exhibited: some coefficient or sum value has negative Φ_d-adic valuation (available via sum_valuation even on this verdict, since a negative valuation is the obstruction). Nothing is offered — sum_value and coefficient raise — and this is not a claim that the specialised identity is false, only that this route to it is blocked.
  • "unknown" — the generic boundary verdict was already "unknown", so there is no proved Q(q) statement to specialise in the first place.

sum_valuation(i) is the q-supercongruence content in its exact form: it is the integer v with Φ_d(q)^v dividing S(n+i) and Φ_d(q)^{v+1} not — so v ≥ r is precisely the divisibility statement Φ_d(q)^r | S(n) that a q-supercongruence asserts, decided exactly rather than checked at finitely many numeric points.

Double-sum telescoping (alkahest.experimental.telescope2d)

Everything above is a single sum over one index k. telescope2d is the Apagodu–Zeilberger generalization to two bound indices: given a proper hypergeometric F(n, j, k) — hypergeometric in each of n, j, k individually, the direct generalization of the class zeilberger decides — it finds a_0(n), …, a_J(n) (not all zero) and two rational certificates c_1, c_2 ∈ Q(n,j,k) such that

Σ_i a_i(n)·F(n+i,j,k) = Δ_j(c_1·F) + Δ_k(c_2·F)

checked as an exact identity in Q(n,j,k) before it is ever returned, the same non-negotiable discipline as the single-sum engine.

import alkahest as ak
from alkahest.experimental import telescope2d

pool = ak.ExprPool()
n, j, k = pool.symbol("n"), pool.symbol("j"), pool.symbol("k")

def binom(top, bot):
    one = pool.integer(1)
    return ak.gamma(top + one) / (ak.gamma(bot + one) * ak.gamma(top - bot + one))

# F(n,j,k) = C(n,j)*C(j,k) — a genuinely non-separable double sum:
# C(j,k) couples to the *outer* sum's own index j.
f = binom(n, j) * binom(j, k)
cert = telescope2d(f, n, j, k)
cert.order          # 1
cert.coeffs          # [3, -1]  ->  S(n+1) = 3*S(n)

Σ_k C(j,k) = 2^j, so S(n) = Σ_j C(n,j)·2^j = 3ⁿ by the binomial theorem — exactly what the order-1 relation says, and this is checked in the test suite by direct exact summation (Fraction/Rational, never floats) against the actual double sum, not just against the telescoping identity.

Method: undetermined coefficients, not a 2-D Gosper reduction

There is no standard two-dimensional analogue of Gosper’s normal form for a general proper hypergeometric F(n,j,k), so unlike the single-sum engine this does not attempt one. It follows Apagodu–Zeilberger directly: posit a certificate ansatz of bounded polynomial degree over a fixed, search-independent denominator built from F’s own shift-ratio denominators, clear it, and solve the resulting linear system by Gaussian elimination over Q. The denominator is not just the raw denominator of the ratio being telescoped in that direction — a certificate built from a product of two single-sum WZ pairs needs a factor from the other direction’s n-shift ratio too (c_1 ∝ R_A(n,j)·B(n+1,k)/B(n,k) for F = A(n,j)·B(n,k)) — which is why the ansatz denominator for c_1 is D_j·∏_i D_{n,i}, not just D_j.

The boundary is four strip sums, not four corner evaluations

cert.boundary_status(j_lo, j_hi, k_lo, k_hi) decides whether the telescoping identity above implies a recurrence for S(n) = Σ_{j=j_lo}^{j_hi} Σ_{k=k_lo}^{k_hi} F(n,j,k). Telescoping each difference operator over its own index gives

Σ_i a_i(n)·S(n+i)
  = Σ_k [G_1(n,j_hi+1,k) − G_1(n,j_lo,k)]
  + Σ_j [G_2(n,j,k_hi+1) − G_2(n,j,k_lo)]

four one-dimensional sums along the rectangle’s edges, not four corner-point evaluations — the naive generalisation of the 1-D endpoint story is simply the wrong formula. Summing a strip in closed form is in general its own creative-telescoping problem, so this version proves the sufficient (not necessary) condition that each strip is identically the zero function of its remaining free variable, via the same 1/Γ non-positive-integer-argument identity the single-sum boundary analysis uses — checked on F’s own gamma factors (the natural-boundary case) or on the certificate’s own numerator (the classical-WZ-certificate case, e.g. a certificate proportional to k that vanishes at k = 0 even though F itself does not).

Two restrictions are real, not unfinished polish:

  • Only constant (not n-dependent) rectangles are supported. The single-sum engine’s b(n) formula has a D_i(n) correction term precisely because summing F(n+i, k) over k’s range at n is not what S(n+i) means when the range moves with n; the 2-D case needs the same correction, doubled for two independently moving bounds, and this version does not implement it. For a natural n-dependent range like j = 0..n, pick a fixed bound safely larger than any n you check and let F’s own combinatorial vanishing do the rest — exactly what the example above does with boundary_status(0, 40, 0, 40).
  • No inhomogeneous boundary term. boundary_status is three-valued in shape ("vanishes" / "nonzero" / "unknown", matching the single-sum engine), but this version never produces "nonzero": an unresolved strip is always "unknown", never guessed as zero and never resolved to an explicit b(n).

Multi-sum telescoping for m ≥ 1 bound indices (alkahest.experimental.telescope_md)

telescope2d reaches exactly two bound indices. telescope_md is the same engine generalized to an arbitrary number m ≥ 1m = 1 degenerates cleanly to a single-sum-shaped search, m = 2 behaves identically to telescope2d (which is now a thin wrapper over the general engine, not a separate implementation), and m ≥ 3 is genuinely new capability. Given a proper hypergeometric F(n, x_1, …, x_m), it finds a_0(n), …, a_J(n) and m rational certificates c_1, …, c_m such that

Σ_i a_i(n)·F(n+i,x) = Σ_t Δ_t(c_t·F)

checked as an exact identity in Q(n,x_1,…,x_m) before it is ever returned — same discipline, same fixed (non-minimal) denominator ansatz, generalized from two axes to m + 1.

import alkahest as ak
from alkahest.experimental import telescope_md

pool = ak.ExprPool()
n, x, y, z = pool.symbol("n"), pool.symbol("x"), pool.symbol("y"), pool.symbol("z")

def factorial(e):
    return ak.gamma(e + pool.integer(1))

# F(n,x,y,z) = n! / (x! y! z! (n-x-y-z)!) — the 4-category multinomial
# coefficient, genuinely non-separable: all three bound indices interact
# through the shared (n-x-y-z) term.
rest = n - x - y - z
f = factorial(n) / (factorial(x) * factorial(y) * factorial(z) * factorial(rest))
cert = telescope_md(f, n, [x, y, z])
cert.order          # 1
cert.coeffs()        # [-4, 1]  ->  S(n+1) = 4*S(n)
cert.certs()          # [c_1, c_2, c_3]

Σ_{x,y,z} F = 4ⁿ by the multinomial theorem (the number of length-n strings over a 4-letter alphabet, grouped by letter counts) — exactly what the order-1 relation says, checked in the test suite by direct exact summation against the actual sum, not just against the telescoping identity.

The boundary is 2m face sums, not 2^m corner evaluations

cert.boundary_status([(lo_1, hi_1), …, (lo_m, hi_m)]) generalizes telescope2d’s “four strip sums, not four corners” result: telescoping an m-dimensional box gives 2m sums, each over an (m − 1)-dimensional face where one bound index is fixed to a boundary value — not 2^m point evaluations at the box’s corners. The same sufficient (not necessary) pointwise-vanishing criterion applies, generalized mechanically: fix one axis to a constant and check that a gamma factor’s argument no longer depends on n or on any other bound index.

A real scaling cliff, and the resource ceilings that bound it

Raising m or the certificate degree bound grows the ansatz search space far faster than the numbers suggest: a certificate numerator spans a box of (max_cert_degree + 1)^(m+1) unknowns, and there are m certificates. Worse, the underlying exact linear solve (rational_nullspace) is a plain dense O(rows · cols²) Gaussian elimination over unbounded-precision rationals, and both dimensions grow with m and the degree bound well past what the box size alone implies. Measured directly: at m = 3, certificate degree 2 means a ≈10,000-row, 245-unknown system whose elimination step alone took ≈47 seconds per probe; certificate degree 3 (770 unknowns) was still running after several minutes. This is genuine arithmetic cost on a real, correctly-posed linear system — not a bug, an infinite loop, or unbounded coefficient blowup — but a caller still needs protection from it, since the search tries every (order, a_degree, cert_degree) combination within the stated bounds and would otherwise pay that same cost repeatedly. Two ceilings apply: a single probe above 400 total unknowns is refused outright, and the total work spent on probes at or above 150 unknowns is capped to 300 across one whole search call — capping the number of genuinely expensive elimination attempts to about one, regardless of how large max_order / max_a_degree / max_cert_degree are. Neither ceiling touches the m = 2 search, whose default probes never exceed ≈140 unknowns. A search that hits a ceiling still reports SearchExhausted, exactly like one that genuinely found nothing — except the message says explicitly when a ceiling, not genuine non-existence, is the reason, so raising the bounds further is not silently misrepresented as a path to success.

Method

The implementation is the standard Gosper-style reduction (Petkovšek–Wilf– Zeilberger, A=B, ch. 6; Koepf, Hypergeometric Summation, ch. 7), carried out over the field Q(n) rather than Q:

  1. Compute the exact shift quotients p(k) = F(n,k+1)/F(n,k) and c_i(k) = F(n+i,k)/F(n,k) — both rational functions, which is precisely the property that defines the proper hypergeometric class.
  2. Take D(k), a common denominator of the c_i, and work with W(n,k) = F(n,k)/D(k), so that Σ_i a_i·F(n+i,k) = N(k)·W(n,k) with N(k) = Σ_i a_i·D(k)·c_i(k) polynomial and linear in the unknowns.
  3. Decompose the shift ratio of W, ρ(k) = p(k)·D(k)/D(k+1), into Gosper normal form ρ = A(k)·C(k+1)/(B(k)·C(k)).
  4. Gosper’s key equation is then the polynomial identity A(k)·X(k+1) − B(k−1)·X(k) = C(k)·N(k). Comparing coefficients of each power of k gives a linear system over Q(n); solving it yields both the a_i and the certificate R = B(k−1)·X(k) / (C(k)·D(k)).
  5. The solved pair is substituted back and checked exactly. Only then is it returned.

Scope of this release

Shipped: Zeilberger’s algorithm with exact certificate verification, the Q(n) / Q(n)(k) arithmetic tower it rests on, proper-hypergeometric recognition, the three-valued boundary verdict over a stated summation range, explicit minimal-order certification, guess_holonomic — recurrence guessing from finite data — the q-analogue q_zeilberger over Q(q)(qⁿ)(q^k) with its own two-valued boundary verdict, specialize_at_root_of_unity — the step from a Q(q) identity to q = ζ_d, decided exactly in the cyclotomic field Q(ζ_d) with its own three-valued verdict — telescope2d, the Apagodu–Zeilberger generalization to two bound indices, with its own 2-D boundary analysis (four strip sums, not four corner evaluations) — and telescope_md, the further generalization to an arbitrary number m ≥ 1 of bound indices, with the m-dimensional boundary analysis (2m face sums, not 2^m corner evaluations) and the two resource ceilings that keep a search with no certificate in reach a fast, honest refusal rather than an unbounded computation as m or the certificate degree bound grow.

Not shipped on the q side: multivariate (q-)telescoping and an inhomogeneous boundary arm. A q-sum whose support cannot be bounded is answered "unknown", never guessed. Root-of-unity specialisation covers a single certificate at a single (d, n) pair — it is the mechanical step the q-supercongruence literature needs, not a search over d or a prover for the wider congruence statements (e.g. uniform-in-n supercongruences, or p-adic statements not phrased as Φ_d-adic valuations) that literature contains.

Not shipped on the multi-sum side: arbitrary rational (not proper hypergeometric) summands, or a sum of several proper hypergeometric terms — i.e. no general Wegschaider reduction — a minimal multivariate Gosper certificate denominator (the ansatz uses a fixed, larger-than-necessary one built from F’s own shift ratios), an n-dependent box in the boundary analysis, and an inhomogeneous ("nonzero") boundary verdict. The bound-index count itself is no longer capped at two (telescope_md reaches any m ≥ 1), but the search’s two resource ceilings mean a caller cannot simply raise m or the certificate degree bound without limit and expect the search to keep running longer — past a point it refuses fast instead.

Not yet shipped, and tracked as follow-up work: Ore-operator closure properties for D-finite functions (sums and products of holonomic objects) and the differential half of the guessing front-end (fitting a linear ODE to a power series). sum_indefinite (Gosper) and verify_wz_pair remain the neighbouring tools for the indefinite and WZ-pair cases respectively.

Guessing recurrences

guess_holonomic fits a P-recursive (holonomic) recurrence to the first terms of a sequence, in exact rational arithmetic. It is the guess in guess-then- prove: fit a recurrence to the terms you can compute, then certify it with zeilberger when the sequence has a hypergeometric summand.

import alkahest as ak

motzkin = [1, 1, 2, 4, 9, 21, 51, 127, 323, 835, 2188, 5798, 15511,
           41835, 113634, 310572, 853467, 2356779, 6536382, 18199284, 50852019]

guess = ak.guess_holonomic(motzkin)
guess.order, guess.degree   # (2, 1)
guess.coeffs                # ((-3, -3), (-5, -2), (4, 1))
guess.confirmed             # True
guess.surplus_terms         # 14

The coefficients read lowest-degree-first, so that answer is

−(3n + 3)·M(n) − (2n + 5)·M(n+1) + (n + 4)·M(n+2) = 0

which is Motzkin’s recurrence.

The guard is the feature

An unguarded fitter is worse than no fitter. A recurrence of order J whose coefficients have degree D has U = (J+1)(D+1) unknowns, and a homogeneous linear system in U unknowns has a nonzero solution the moment it has fewer than U independent equations — whatever the numbers are. So some recurrence always fits, and a fit that used up all its data is not evidence, it is interpolation.

guess_holonomic therefore fits a candidate only when the terms over-determine it. A candidate needing U unknowns is tried only where the terms supply at least U + min_surplus equations, and min_surplus defaults to U itself — the data has to be twice what the ansatz needs. Everything below that is skipped rather than fitted.

What survives is then reported with the evidence attached:

AttributeMeaning
n_equationsequations the terms provided
equations_usedindependent equations the fit consumed (the matrix rank)
surplus_termsequations that were not needed and agreed anyway
dimensiondimension of the solution space; 1 for a genuine fit
untested_candidateslower (order, degree) candidates the terms could not test
confirmedenough surplus and dimension exactly 1

untested_candidates is the minimality caveat, and it is the same discipline as ZeilbergerCertificate.order_is_minimal. 0 means the returned order is the smallest that fits anywhere in the bounds; anything higher means it is the smallest among the candidates the data could decide, and a shorter relation may be hiding in the ones it could not.

surplus_terms is the number to judge a guess by, and evidence() returns all of it as a dict for logging next to the result. This is relation_confidence’s discipline applied to sequences: a fit is judged against what the data can actually support, rather than endorsed because the arithmetic came out even.

What it refuses, and what None means

Two different negative answers, kept apart on purpose:

ak.guess_holonomic(motzkin[:7])
# HolonomicError: E-HOLO-005 — 7 terms are not enough to test every recurrence
# in bounds …

Seven Motzkin terms give exactly the five equations needed to pin down the six unknowns of an order-2, degree-1 ansatz. The fit would be exact, and the same fit exists for any seven numbers. That is refused.

ak.guess_holonomic(first_sixty_primes) is None   # True

The primes are not P-recursive. With sixty of them every (order, degree) candidate inside the default bounds is over-determined and was actually tested, so None here is a genuine negative that a search loop may record as one.

None is returned only when the whole grid was swept with adequate surplus. If some candidates had to be skipped for lack of terms, the call raises E-HOLO-005 instead — including the message of how many terms the cheapest skipped candidate needs. A loop that reads “not holonomic” off a grid it never swept has closed a branch it never explored, and that failure has no symptom later.

Knobs

ak.guess_holonomic(terms, max_order=4, max_degree=4, *,
                   start=0, min_surplus=None, check_evidence=True)
  • max_order, max_degree bound the search. The sweep is order-major, so the order returned is the smallest one that fits within the bounds and that the terms were able to test.
  • start is the index n that terms[0] stands for; the coefficient polynomials are polynomials in that n.
  • min_surplus overrides the surplus demanded. 0 turns the requirement off while leaving the reporting intact.
  • check_evidence=False fits every candidate regardless of surplus and returns the first fit with confirmed set honestly. It is the escape hatch, in the same role check_precision=False plays on guess_relation — useful when the candidate is going somewhere else to be checked, never a way to make a weak fit look strong.

Terms must be exact: Python int of any size, or fractions.Fraction. A float is refused rather than converted, because every step after this one is exact and would happily certify a recurrence for the sequence you rounded to.

Guess, then prove

guess = ak.guess_holonomic(terms)
if guess is not None and guess.confirmed:
    assert guess.holds_for(more_terms)          # exact, on data it never saw
    cert = ak.zeilberger(F, n, k, minimal=True) # …and now prove it
    cert.order == guess.order
    cert.order_is_minimal

holds_for re-checks the recurrence exactly against a longer list, and to_exprs(pool, n) hands the coefficient polynomials to the rest of the library — most usefully to compare against ZeilbergerCertificate.coeffs once the same recurrence has been certified. A guessed order agreeing with a certified minimal order is the pair of facts worth reporting.

Supercongruences: sequences modulo p^k

A supercongruence is a claim about a P-recursive sequence at one index per prime — Beukers’ A(p−1) ≡ 1 (mod p³) for the Apéry numbers, or any of the several hundred open ones that OEIS records as “checked up to p = 499”. Producing evidence for one means evaluating the sequence at that index for every prime in a range.

Done the obvious way, that is expensive for a reason that has nothing to do with the mathematics: A(p−1) is an integer with Θ(p) digits, and the recurrence touches it Θ(p) times, so the cost is quadratic in p and the answer — a residue mod p⁴ — throws almost all of it away.

ModularRecurrence runs the recurrence in ℤ/p^K instead. Same relation, same arithmetic, machine words throughout, O(1) memory:

import alkahest as ak

# (n+2)³A(n+2) = (34n³+153n²+231n+117)A(n+1) − (n+1)³A(n)
apery = ak.ModularRecurrence(
    [[1, 3, 3, 1], [-117, -231, -153, -34], [8, 12, 6, 1]],
    [1, 5],
)

apery.value_mod(12, 13, 3)      # A(12) mod 13³
# 1

Coefficients are given lowest-degree first, one list per shift — the convention guess_holonomic already returns, so a fitted recurrence goes straight in:

motzkin = [1, 1, 2, 4, 9, 21, 51, 127, 323, 835, 2188, 5798, 15511,
           41835, 113634, 310572, 853467, 2356779, 6536382, 18199284, 50852019]
guess = ak.guess_holonomic(motzkin)
rec = ak.ModularRecurrence(list(guess.coeffs), motzkin[: guess.order], start=guess.start)
rec.value_mod(200, 10007, 3)

Sweeping

supercongruence_sweep is the loop, with the verdict bookkeeping attached:

primes = [p for p in range(5, 400) if all(p % q for q in range(2, int(p**0.5) + 1))]
sweep = ak.supercongruence_sweep(apery, primes, k=3, expect=1)

sweep.holds          # True  — no counterexample in the range
sweep.n_tested       # 76
sweep.n_skipped      # 0     — every prime produced a residue
sweep.valuations()   # {3: 76}
sweep.sharp          # True

holds is falsification failing, not a proof, and the documentation says so in those words. The one thing a sweep can settle is sharpness: valuations() is the histogram of v_p(LHS − RHS), and sharp is True when some prime hits exactly the claimed exponent — so here A(p−1) ≡ 1 (mod p⁴) is false, and the in Beukers’ theorem is best possible rather than merely cautious.

index and expect are callables of p, so the shifted statements work too:

ak.supercongruence_sweep(apery, primes, k=3, index=lambda p: p, expect=lambda p: 5)

Singular indices

Stepping forward solves for the top term,

S(n+J) = ( b(n) − Σ_{i<J} a_i(n)·S(n+i) ) / a_J(n),

and a_J(n) need not be invertible mod p. For Apéry, a_2(n) = (n+2)³ vanishes to order three at every n ≡ −2 (mod p) — which is exactly the index the sweep above crosses when it asks for A(p) rather than A(p−1). This is where a naive implementation goes quietly wrong: pow(a, -1, m) raises for a non-unit if you are lucky, and a hand-rolled inverse returns something if you are not.

Alkahest measures the loss before it computes anything. A first pass evaluates v_p(a_J(n)) at every step — dropping to exact integer arithmetic at the rare index a residue cannot decide — so the total loss L is known up front, and the forward pass runs at working precision k + L. Each singular step then divides numerator and denominator by p^v (checking, not assuming, that the numerator is divisible), spending exactly the v digits the budget already bought. ModularEvaluation reports the whole account:

report = apery.evaluate([13], 13, 3)
report.residues()           # [5]
report.singular_indices()   # [11]
report.n_singular           # 1
report.working_precision    # 6  — three digits lost at n = 11, three requested

Three cases never produce a residue:

CodeCause
E-HOLO-006the modulus is not a prime power the machine-word backend supports (p composite, k = 0, or p**k >= 2**62)
E-HOLO-007a step does not determine its next term as a p-adic integer: a_J(n) = 0 exactly there, or the sequence leaves ℤ_p — the harmonic numbers do, at H_p = H_{p−1} + 1/p
E-HOLO-008k + L needs a modulus past 2**62

The last is a real limit, not a formality. Reaching A(199) at p = 5 crosses 39 singular steps costing 141 digits between them, and 141 digits of 5 does not fit a 64-bit word, so that call refuses rather than answering:

apery.value_mod(199, 5, 1)
# HolonomicError: E-HOLO-008 — the 39 singular step(s) cost 141 digits of
# p-adic precision, so answering to p^1 needs a working modulus of 5^142,
# which is past the machine-word backend's ceiling of 2^62

The loss is intrinsic to running the recurrence over residues, not an artefact: at a singular index the residues of the earlier terms genuinely do not determine the next one, and only more precision recovers it. In the regime these sweeps live in — one index per prime, at or near p — there are at most one or two singular steps and the headroom is free.

supercongruence_sweep records E-HOLO-007 and E-HOLO-008 in skipped() and carries on, because those are facts about one prime. E-HOLO-006 is a fact about the call, so it propagates — a list of composites must not come back holds=True over zero primes.

Binomial coefficients

binomial_mod(a, b, p, k) is the same workload from the other side, and is what a closed form is spot-checked against:

ak.binomial_mod(2 * 11 - 1, 10, 11, 3)   # Wolstenholme: 1
ak.binomial_mod(1_000_000, 3, 7, 4)      # 2261
ak.binomial_mod(5, 9, 7, 4)              # 0 — b > a

At k = 1 this is Lucas’ theorem; for prime powers it is the Andrew Granville / Davis–Webb factorisation of n! into its p-free part. The p-free factorial is taken by a product tree over blocks of p consecutive integers rather than term by term, so the cost is O(p·k³ + log_p(a)·p·k) and a far larger than p is the ordinary case rather than the hard one.

Novelty filtering against OEIS

A search loop over this library can rediscover a known identity within the hour — the mathematics is not the hard part. The difference between “produced 400 certified recurrences” and “produced three that nobody had” is a filter that puts every claim into a canonical form, hashes it, and asks whether it is already written down somewhere before anything calls it a finding. alkahest.experimental.novelty is that filter, for P-recursive recurrences checked against OEIS.

Normal form and the hash

RecurrenceClaim takes a recurrence Σ_i p_i(n)·u(n+i) = 0 and quotients out everything that is presentation rather than content:

from alkahest.experimental.novelty import RecurrenceClaim

# (n+1)·u(n+1) − (4n+2)·u(n) = 0 — central binomial coefficients
a = RecurrenceClaim([(-2, -4), (1, 1)])

# the same relation, scaled by −2, stated about u(n+7) and u(n+8)
b = RecurrenceClaim([(-60, -8), (16, 2)], offset=7)

a.claim_hash == b.claim_hash   # True

Four things are quotiented out: scale (multiplying every coefficient by a nonzero rational — denominators are cleared and the integer content divided out, sign fixed by making the first nonzero coefficient positive), index shift (the window is moved to start at u(n)), a common polynomial factor ((n+1)·L and L are the same recurrence up to the finitely many n where the factor vanishes), and zero-padding at either end of the window. What is not quotiented out: a genuinely different relation, a different-order operator that happens to be a left multiple, or the range of n a source claims the relation holds on — two sources stating the same recurrence from different starting indices agree here, which is what a novelty filter wants and is not a claim that the statements are interchangeable at small n.

Build a claim straight from what zeilberger or guess_holonomic already produced:

guess = ak.guess_holonomic(terms, max_order=3, max_degree=4)
claim = RecurrenceClaim.from_recurrence(guess)

RecurrenceClaim.from_text reads OEIS’s own a(n) = … formula lines by recursive descent over + - * / ^ ( ), n and a(n±k). It refuses — returns None, never a guess — anything outside that shape: a reference to another sequence (a(n) = a(n-1) + A002026(n-1)), a sum, a generating function, an inhomogeneous relation, a nonlinear one. A parser that guesses at prose invents claims nobody made, so a line the parser does not fully cover is counted as unusable rather than truncated into a shorter claim that happens to parse.

holds_for / confirmations, and what start means

Both exactly re-check a claim’s normal form against concrete terms — fractions.Fraction arithmetic throughout, so True is a fact about the terms given, not a tolerance. holds_for requires every window to check out; confirmations counts only the trailing run, because a recurrence is routinely stated only for n past some initial segment and a mismatch at n = 0 says nothing about whether it is the relation that was meant.

start is the true index of terms[0] — nothing more forgiving than that. Because coefficients here are genuine polynomials in n (this is P-recursive, not constant-coefficient), a wrong start is not a small numeric error that shifts a few results: it evaluates every coefficient at the wrong point and generically fails the entire array, trailing windows included, even ones that never touch whatever made start wrong. If you slice, drop, or prepend elements relative to some original indexing, adjust start by the same amount — [junk, *real] needs start=-1, not the default 0, because junk sits where u(-1) would.

Checking a claim

from alkahest.experimental.novelty import OeisCache, OeisEntry, check_novelty

cache = OeisCache("my_oeis_cache.json")
verdict = check_novelty(claim, [cache], terms=terms[:12])

sources has no default — a check with an empty list, or no source able to answer, is unavailable, and nothing here reaches for the network on your behalf. Two source types:

  • OeisCache — file-backed, offline. Holds entries and the queries already put to OEIS, keyed by what was asked — the second is what makes an honest negative possible: a cache that only stores hits can never distinguish “asked and OEIS had nothing” from “never asked”, and reporting the second as the first is exactly the overclaim this module exists to prevent.
  • OeisWeb — live lookup, opt-in: nothing constructs one for you. It serves from its own OeisCache before touching the network, sleeps between requests, sends an identifying User-Agent, and returns unavailable rather than raising when the network is not there.
web = OeisWeb(cache=OeisCache())
web.lookup(ids=["A005259"])
web.cache.save("tests/data/oeis_novelty_fixture.json")

records a fixture once so later runs — and CI, which has no network guarantee — never need to touch oeis.org at all.

Reading the verdict

NoveltyVerdict.found is three-valued, in the manner of relation_confidence’s tri-state credible and GuessedRecurrence.confirmed:

foundstatusMeans
True"recorded" / "recorded_conjecturally"a source states this claim; hedged says whether as a theorem or a conjecture
False"not_found"the sources searched do not state it — not “novel”
None"unavailable"no source could answer; nothing was established either way

There is deliberately no novel attribute anywhere on NoveltyVerdict, and bool(verdict) raises rather than silently reading True, because if check_novelty(...): is the exact sentence this module exists to prevent:

bool(verdict)
# TypeError: a NoveltyVerdict has no truth value: `if verdict:` would read as
# 'is this novel?' and there is no such answer here. Test verdict.status (...)
# or verdict.found, which is True/False/None and whose False means 'not in
# the sources searched', not 'new'

verdict.hedged is the distinction the whole filter exists for. OEIS marks a formula Conjecture or Empirical when it was fitted rather than proved — restating a hedged recurrence is not a result, proving it is:

verdict = check_novelty(recorded_claim, [cache], terms=terms[:12])
verdict.status    # "recorded_conjecturally"
verdict.hedged    # True — OEIS has this, but never proved it

verdict.report() carries the scope of the search — entries_examined, statements_compared, statements_unusable — so the size of a negative is visible next to it: a "not_found" against zero entries examined means something quite different from one against fifty.

Testing without the network

tests/test_novelty.py never constructs OeisWeb; every OEIS-backed test runs against tests/data/oeis_novelty_fixture.json, a cache recorded once from oeis.org (© The OEIS Foundation Inc., licensed CC BY-NC-SA 4.0 — the license travels with every cache this module saves) and committed. The fixture carries the sequences this project already certifies recurrences for — Apéry (A005259), Motzkin (A001006), Catalan (A000108), central binomial coefficients (A000984) — plus A359643, a result this project’s own search found and which OEIS records only as an unproved Conjecture: the recorded statement is verdict.hedged is True, and a claim one order lower that OEIS does not have at all comes back "not_found".

Ansatz families and conjecture generation

Stage 1 of a search loop is generate: propose a structured family of candidates — every polynomial of degree ≤ 3 in x and y, a Padé approximant of type (2, 2), a quadratic Lyapunov candidate — and then either sweep it numerically or solve for the coefficients that make a residual vanish. Agents hand-roll this constantly, and the hand-rolled version is usually wrong in one of three specific ways: it loses the distinction between an unknown coefficient and an independent variable; it assumes the first m sample points give m independent equations; and it never substitutes the answer back, so a fit that only satisfies the sampled constraints is reported as if it satisfied the identity.

alkahest.ansatz is that plumbing, done once.

import alkahest as ak
from alkahest.ansatz import polynomial, fit

pool = ak.ExprPool()
x = pool.symbol("x")

A = polynomial(pool, [x], degree=2)      # c_0 + c_1*x + c_2*x^2
target = x**2 - pool.integer(3) * x + pool.integer(2)

sol = fit(A, A.expr - target)
sol.expr          # (2 + x^2 + (x * -3))
sol.rank, sol.free
sol.status        # 'exactly_verified'

Everything here is pure Python composed from primitives that are already fast in Rust (Matrix.rref, simplify, subs), so it works in a build without the groebner feature. The one path that needs Gröbner — a residual genuinely nonlinear in the unknowns — refuses with E-ANSATZ-004 rather than degrading silently.

Two limits that follow from the primitives it is built on:

  • Matrix.rref uses the three-valued zero test, so a fit whose coefficient matrix contains an entry that can be proven neither zero nor non-zero refuses with E-LINALG-010 rather than picking a pivot. Substituting concrete values for the parameters is the remedy.
  • Enumerating a family costs pool. enumerate_family and repeated fit calls intern every candidate permanently — ExprPool never reclaims. Build the pool inside the enumeration and drop it per family, or the search grows linearly and without bound. See ExprPool never reclaims.

Honesty invariants

Solving may be heuristic; checking is exact. The linear system is built by collocation — evaluating the residual at sample points — which proves identical vanishing only for polynomial residuals of bounded degree. So the fit is never trusted on its own: fit(..., certify="residual") (the default) substitutes it back and normalises.

Outcomeverification["status"]What it means
The residual normalises to 0exactly_verifiedA symbolic proof. The claim is machine-checked.
It does not, but samples are smallnumerically_checkedEvidence, not a proof. The surviving normal form is in verification["residual"].
certify="none"unverifiedNothing was checked. There is deliberately no way to call this “solved”.

Those are the existing research.STATUS_BADGES strings, so a fitted ansatz lands in a claim graph correctly labelled with no new vocabulary.

Inconsistent is a result, not a malfunction. When no member of the family can satisfy the constraints, fit raises AnsatzError E-ANSATZ-003. For a loop that is a closed branch — a positive finding worth recording — in the same spirit as a non-elementarity verdict from integrate.

Underdetermined is also a result. When the rank is below the number of unknowns, the members that work form a positive-dimensional family. AnsatzSolution.free returns the free parameters rather than picking an arbitrary member:

B = polynomial(pool, [x], degree=3, name="d")
under = fit(B, ak.diff(B.expr, x).value)      # d/dx of the family vanishes identically
under.rank                                    # 3
under.free                                    # (d_0,)  — still symbolic in .expr

The families

ConstructorFamilyTypical use
polynomial(pool, vars, degree)Σ c_α · x^α, total degree ≤ degreeUndetermined coefficients, invariants
rational(pool, vars, num_degree, den_degree)p / qPadé, rational-function reconstruction
linear_combination(pool, basis)Σ cᵢ · basisᵢThe escape hatch: any basis you can write down
exponential_polynomial(pool, var, rates, degree=…)Σ pᵢ(x)·e^{λᵢ x}ODE / recurrence ansätze with known characteristic roots
quadratic_form(pool, vars)Σ_{i ≤ j} q_ij · xᵢ xⱼLyapunov candidates

Every constructor takes name= (the coefficient prefix), max_terms= (a hard bound), and reserved= (extra symbols the coefficients must not collide with). reserved= reserves names only — it never adds an independent variable, so linear_combination(pool, basis, reserved=[y]) is still a family in the basis’s own symbols. Pass vars= to say otherwise.

Predictable coefficient names

Names are c_0, c_1, … for one variable and c_0_0, c_1_0, c_0_1, … (graded, then lexicographic with the first variable heaviest) for several. They are never gensym-ed: an agent that cannot predict the symbol names cannot write the follow-up call.

If a generated name collides with a symbol already in play, the constructor raises E-ANSATZ-001 instead of quietly fitting the wrong thing:

c0 = pool.symbol("c_0")
polynomial(pool, [x], degree=1, reserved=[c0])   # AnsatzError E-ANSATZ-001
polynomial(pool, [x], degree=1, name="k", reserved=[c0])   # fine: k_0, k_1

The collision check sees the family’s own variables, every free symbol of the expressions handed to the constructor, and anything passed as reserved=. An ExprPool exposes no symbol listing, so a symbol that appears in none of those is not detected at construction — fit’s back-substitution check is the backstop.

Bounds are mandatory, not advisory

C(n + d, d) is a combinatorial explosion, so every constructor is bounded and the bound is checked from the count before anything is materialised:

polynomial(pool, [x, y], degree=40, max_terms=32)
# AnsatzError E-ANSATZ-002: … needs 861 unknown coefficients, which exceeds max_terms=32

rational keeps the Padé case linear

p/q ≈ f is not linear in the coefficients of p and q, but p − f·q = 0 is linear in them jointly. rational() records the numerator/denominator split so that transform can be applied, and fit applies it automatically when it sees an unknown-bearing denominator:

A = rational(pool, [x], num_degree=1, den_degree=1)
A.expr                       # ((a_0 + (x * a_1)) * (1 + (x * b_1))^-1)
A.residual(target)           # a_0 + a_1*x - target*(1 + b_1*x)   — affine in the unknowns

sol = fit(A, A.expr - pool.integer(1) / (pool.integer(1) + x))
[s["rule"] for s in sol.steps]        # includes 'ansatz_clear_denominator'
sol.status                            # 'exactly_verified'

The denominator’s constant term is fixed to 1 by default (monic_denominator=True), because p/q and (λp)/(λq) are the same function — without a normalisation every rational fit would report a spurious extra free parameter.

A Padé approximant is a local match, not an identity, so it wants the exact-system route with an explicit degree bound:

A = rational(pool, [x], num_degree=2, den_degree=2, name="u", den_name="v")
sol = fit(A, A.residual(ak.exp(x)), certify="exact", degree_bound=4)
# u_0=1, u_1=1/2, u_2=1/12, v_1=-1/2, v_2=1/12  — the (2,2) Padé of exp
sol.status                   # 'numerically_checked': an approximant is not an identity,
sol.verification["residual"] # and the check says so rather than claiming otherwise

Asking for the same thing as an identity (fit(A, A.residual(ak.exp(x))), default certify) is correctly answered with E-ANSATZ-003: no rational function equals exp.

fit

def fit(ansatz, residual, *, certify="residual", seed=None, oversample=None,
        max_points=None, degree_bound=None, tolerance=1e-8, samples=5) -> AnsatzSolution: ...

residual is the expression that must vanish identically in ansatz.vars — usually ansatz.expr - target.

How a residual becomes a finite system. Because the residual is affine in the unknowns, each row is built by probing: evaluate it with every unknown set to 0 for the constant column, then with unknown j set to 1 for column j. That is subs and nothing else — no coefficient collection over a symbolic ring is required.

certify selects how the system is built as well as how it is graded:

  • "residual" (default) — collocation at sample points, then exact back-substitution.
  • "exact" — Taylor-coefficient extraction (∂^α R / α!), so the system itself is exact for polynomial residuals up to degree_bound; still back-substituted. Every multi-index of total degree ≤ degree_bound contributes an equation, and the bound that was reached is written into the derivation log — the system is never quietly cut short. max_points caps sample-point draws and therefore applies to collocation only; degree_bound is the knob that sizes this one.
  • "none" — no check at all; status="unverified" and no re-verification recipe. For hot loops that verify downstream, never for anything recorded as a result.

Rank is read off the reduction, never assumed. fit draws strictly more equations than there are unknowns (oversample, default max(4, len(ansatz))) and takes the rank from the reduced row echelon form. Assuming the first m points are independent is the specific bug in every hand-rolled version of this.

Points where the residual is undefined are skipped. A vanishing denominator is detected exactly (not by catching a float inf), the point is resampled, and the count of skipped points appears in the derivation log.

Determinism

Sample points come from a deterministic generator seeded from budget_seed(), so a fit is reproducible across runs and machines — see Budgets:

with ak.context(budget=ak.Budget(seed=7)):
    sol = fit(A, A.expr - target)
sol.points          # the exact points used, as rational strings

With no budget active the seed is ansatz.DEFAULT_SEED, a fixed constant, so two machines still agree. fit(..., seed=…) overrides both.

AnsatzSolution

FieldMeaning
expr / valueThe fitted member (value is the alias ResearchSession reads)
assignment{unknown: Expr} for the determined coefficients
freeUnknowns the constraints do not pin down
rankRank of the system; rank == len(ansatz) iff the fit is unique
statusMirrors verification["status"]
verification{"status", "evidence", "method", "residual", "max_abs_residual", …}
stepsDerivation log in the STEP_FIELDS schema
checkRe-verification recipe for ClaimGraph.verify()
pointsThe sample points used, enough to reproduce the system
certificateAlways None — this module emits no Lean certificate

It has the same shape as a DerivedResult where it matters, so it records unchanged:

with ak.research.session(title="ansatz", pool=pool) as s:
    sol = fit(A, A.expr - target)
    s.record(sol, method="ansatz.fit", check=sol.check)

s.graph.summary()          # {'exactly_verified': 1}

enumerate_family — stage 2 material

Enumeration and fitting stay separate. enumerate_family feeds the falsify stage (generate candidates, hammer them with compile_expr or batch_map); fit is the discover stage. Fusing them produces an API that does neither well.

from alkahest.ansatz import enumerate_family

A = polynomial(pool, [x], degree=1)
[str(m) for m in enumerate_family(A, [0, 1])]     # ['0', 'x', '1', '(x + 1)']

Enumeration is lazy and bounded: len(coeffs) ** len(ansatz) is checked against max_members before the first member is built, and exceeding it raises E-ANSATZ-002.

Positivity: hand off, don’t reimplement

The module’s job ends when it has produced the candidate. certify_nonneg is a one-line adapter onto prove_nonneg / sos_decompose, and every outcome — a PositivityCertificate, an E-SOS-003 refutation with a witness point, an E-SOS-002 “no certificate of this shape at this degree” — comes back unmodified:

from alkahest.ansatz import quadratic_form, certify_nonneg

V = quadratic_form(pool, [x, y], name="q")
sol = fit(V, V.expr - lyapunov_residual)
cert = certify_nonneg(sol)          # PositivityCertificate, or the SosError as raised

A solution that still carries free parameters is refused (E-ANSATZ-003): an undetermined form is a family, not a candidate. Instantiate first.

Error codes

CodeMeaning
E-ANSATZ-001A coefficient name collides with a symbol already in play (or an unknown name was passed to instantiate). You called it wrong.
E-ANSATZ-002The requested family exceeds max_terms / max_members. Refused before anything was materialised.
E-ANSATZ-003No member of this family satisfies the constraints. A result: a closed branch a loop should record. Also raised when no system could be built at all — the residual, or a derivative of it, is undefined everywhere the sampler looked — in which case the message says so instead of claiming anything about the family.
E-ANSATZ-004The residual is nonlinear in the unknowns and escalating to solve needs a groebner build, which this is not.

Only the first two mean “you called it wrong”; the other two are findings. See Error handling.

Known limits

  • Collocation is not a proof of identical vanishing except for polynomial residuals of bounded degree. That is exactly why the back-substitution check exists and why a fit that does not normalise to zero is never reported as verified.
  • Transcendental bases are only as exact as the simplifier’s zero test. When the collocation matrix is not over ℚ (an exponential family sampled at rational points), fit first tries Taylor extraction, which usually recovers an exact rational system; if it cannot, the reduction falls back to a symbolic elimination whose zero test is best-effort. In that case an apparent inconsistency is corroborated numerically before E-ANSATZ-003 is claimed — a confident “no member of this family works” is exactly the kind of wrong answer this package exists to avoid.
  • The pool exposes no symbol listing, so the E-ANSATZ-001 collision check covers the variables, the constructor’s inputs, and reserved= — not the whole pool.

See also

Cross-CAS differential testing

Lean certificates cover the fragment where Alkahest can prove its answer. Outside that fragment nothing is checking the answer at all, and the failure that matters for a search loop is not a crash but a silent error: a confident, plausible, wrong result that the loop then builds a hundred derived claims on top of. An independent implementation is the cheapest instrument that catches exactly those — the ones certificates do not cover.

alkahest.crosscheck runs one query through Alkahest and through an oracle and reports whether they agree.

import alkahest as ak
from alkahest.crosscheck import check, oracles

print(oracles())          # {'sympy': '1.14.0'} — or {'sympy': None} if not installed

pool = ak.ExprPool()
x = pool.symbol("x")

with ak.context(pool=pool):
    out = check("integrate", ak.sin(x) * ak.cos(x), x)

print(out.outcome, out.rung_name, out.reason)
agree invariant invariant_holds

sin(x)·cos(x) has three standard antiderivatives that differ by constants; that is why the check settled on the invariant rung and not by comparing forms.

Four outcomes, and two of them are not “clean”

CrossCheck.outcome is four-valued, never a boolean:

OutcomeMeaning
agreeBoth systems answered and a named rung settled it
divergeBoth answered and the answers are not the same
incomparableThe question could not be posed identically to both systems
unavailableNo oracle is installed

incomparable and unavailable are not weaker forms of agree. They say the check did not happen. Code that treats them as clean has reintroduced the exact failure this module exists to prevent: a loop that believes it is cross-checking, and is not, is worse off than one that knows it isn’t. Two API decisions enforce that:

  • CrossCheck deliberately defines no __bool__, so if check(...): does not compile into a silent “it agreed”;
  • CrossCheck.checked is True only for agree and diverge, so the common mistake has to be written out explicitly.

With SymPy absent, check returns outcome="unavailable" and reason="no_oracle". It never returns agree. Call oracles() at session start to find out before you plan around a check that will only ever return unavailable — it reports every known oracle, including the absent ones, as None.

The comparison ladder

Structural equality is useless: the two systems normalise differently, and a naive comparison of antiderivatives, solution sets or factorisations produces nothing but noise. So comparison is a ladder, it is per-operation, and the rung that settled a check is always recorded on CrossCheck.rung.

RungNameWhat it doesWhat it licenses
1syntacticCompare canonical forms after translationProof of agreement
2symbolicsimplify(a − b) == 0, attempted independently in both systems — either one proving it countsProof of agreement
3rigorous_numericSample and evaluate with ArbBall / interval_evalRigorous refutation; agreement only “not refuted at these points”
4invariantThe operation’s own defining property, checked on both answersProof of agreement

Rung 4 leads wherever it exists. It sidesteps equal-up-to-a-constant, up-to-ordering and up-to-a-unit entirely — the three things that make naive comparison useless.

Rung 3 is where Alkahest has an unfair advantage over a float-only harness: ball arithmetic distinguishes “differs by 1e-16 of float noise” from “genuinely differs by 1e-16”. Note the asymmetry, which the record carries: a ball is a rigorous enclosure of the Alkahest answer, so a value outside it is a real disagreement — but agreement at sampled points is only a failure to refute, and rung-3 agreement is therefore reported with conclusive=False rather than being promoted or discarded.

The invariants, per operation

OperationRungsRung-4 invariant
integrate (indefinite)4, 1, 2, 3d/dx F − f ≡ 0, in each system
simplify, simplify_expanded4, 1, 2, 3out − in ≡ 0 — a simplifier’s whole contract
sum_indefinite4, 1, 2, 3S(k+1) − S(k) − t(k) ≡ 0 — Gosper’s defining property
solve4Substitute every solution back, then compare set sizes
diff1, 2, 3— (integrating back is weaker than the derivative it would check)
limit1, 2, 3
series1, 2, 3— (the O() remainder is stripped first; that is a normalisation, not a rung)
integrate (definite)1, 2, 3

solve is worth spelling out. Solution sets compare badly by construction: ordering, radical form and branch choice all differ. Substituting back checks each system’s answers on their own terms, and only then does the set comparison mean something — with both sides verified, a size difference is a genuinely missed solution, not a formatting artefact.

An operation with no entry in OPERATIONS raises E-XCHECK-003 rather than falling back to a generic structural comparison. The fallback is deliberately absent: it is precisely how a harness starts reporting two normal forms as a divergence.

A divergence names two suspects

out = check("integrate", integrand, x)
if out.outcome == "diverge":
    d = out.divergence
    print(d.statement())
    print(d.point)             # {'x': 1.257...} — the witness
    print(d.alkahest_value, d.oracle_value)
    print(d.support)           # 'unresolved' | 'alkahest_supported' | 'oracle_supported'

The record carries the witness point and both values, and the wording never implies Alkahest is right. Divergence.support carries whatever the rigorous escalation could establish, and its default is unresolved:

  • alkahest_supported — the oracle’s answer fails the operation invariant while Alkahest’s satisfies it;
  • oracle_supportedAlkahest’s answer fails the invariant under rigorous ball arithmetic while the oracle’s satisfies it. This is a silent-error finding. The residual is built entirely from Alkahest expressions, so there is no oracle float in it to blame, and Divergence.silent_error_candidate is True exactly here;
  • unresolved — the two disagree and the evidence does not say which is at fault. Most findings start here, and that is the honest default.

When verified_sign can certify that the failing residual keeps one sign across the whole sampling box, Divergence.region records the box — upgrading a finding from “wrong at this point” to “wrong on this interval”, which is a much shorter argument to hand a reviewer.

A silent_error_candidate should be routed into tests/silent_errors/corpus.py. That routing is the whole point of the feature: it converts a fuzzing signal into a permanent regression gate.

An honest refusal is never a divergence. If Alkahest declines (E-INT-004: no elementary antiderivative exists, an interior-pole definite integral, a two-sided limit at a pole) the outcome is incomparable with reason="alkahest_refused". The same holds in the other direction: SymPy returning an unevaluated Integral is a refusal, scored reason="oracle_refused", not an answer to compare against.

One translator, total-or-refuse

A divergence is only informative if both systems were asked the same question, and the ways to accidentally ask a different one are well known: branch cuts, assumption handling, , unnormalised forms. So:

  • every tag Expr.node() can emit appears in an explicit table (Translator._DISPATCH, total over NODE_TAGS), and an unknown tag raises E-XCHECK-001;
  • every primitive is either in FUNCTION_MAP or in REFUSED_FUNCTIONS with the reason spelt outheaviside (SymPy fixes Heaviside(0) = 1/2 and Alkahest fixes nothing there), the elliptic integrals (modulus-vs-parameter convention), round (no documented half-way rule to compare against);
  • quantifiers refuse: SymPy has no term-level , and encoding one as something SymPy will accept produces an object no rung can use;
  • an active Assumptions context that cannot be mapped faithfully refuses too.

False divergences are worse than no signal — they train both the loop and the team to ignore the alarm, and a best-effort translator manufactures them by construction.

from alkahest.crosscheck import to_sympy

to_sympy(ak.sqrt(x**pool.integer(2)))                       # sqrt(x**2)
to_sympy(ak.sqrt(x**pool.integer(2)), assumptions=positive) # x
to_sympy(pool.func("heaviside", [x]))                       # raises E-XCHECK-001

to_sympy is the one translator this package ships. The four hand-rolled _expr_to_sympy helpers in tests/ (test_eigen_v217.py, test_oracle.py, test_gruntz_v217.py, test_diophantine_v219.py) are meant to migrate onto it; that duplication is what this item exists to remove. Building the QA harness and the runtime mode separately would produce two translators that disagree, which is the worst possible outcome for a tool whose entire job is detecting disagreement.

Assumptions

Only sign and non-zero conditions on a bare symbol map onto oracle symbol flags:

Alkahest predicateSymPy symbol flag
x > 0positive=True
x >= 0nonnegative=True
x < 0negative=True
x <= 0nonpositive=True
x != 0nonzero=True

Anything else — a relation between two symbols, a condition on a composite, a disjunction — has no per-symbol counterpart, so it raises rather than being dropped. Dropping it would ask the oracle a weaker question, and every legitimate refinement would then look like a divergence.

Opt-in per call site, not a context flag

There is deliberately no context(crosscheck=True). That is the obvious design and the wrong one: it puts an oracle round-trip on every call, and stage-2 falsification runs millions of times where SymPy is orders of magnitude slower. Call check where you want it — at claim-recording frequency, hundreds of times, not millions. That is “falsify fast, certify slow” applied to the QA layer.

Two tiers in CI

A randomised sweep cannot be a per-PR gate: it is nondeterministic, and a SymPy upgrade would turn it red for reasons unrelated to the pull request. So the arrangement mirrors how tests/silent_errors/ relates to agent-benchmark/:

Tier 1 — the seeded nightly sweep

from alkahest.crosscheck import sweep

report = sweep(cases=200, seed=None)   # seed defaults to budget_seed(), then to DEFAULT_SEED
print(report.summary())                # prints the seed — always
for finding in report.silent_error_candidates:
    print(finding.divergence.statement())

The seed is recorded on the report and printed by summary(), because a sweep is only useful as a bug report if the run that found something can be reproduced exactly. Under context(budget=Budget(seed=...)) the sweep takes its seed from the budget, so a nightly job and a local reproduction share one knob. SweepReport.to_dict() is JSON-serialisable and suitable for filing as a CI artifact.

Give each sweep its own pool. A sweep interns thousands of generated expressions, and check costs on the order of 8 KB of pool per call — the highest of any entry point, because it builds both sides plus the comparison. ExprPool never reclaims, so a nightly job that reuses one module-scope pool across runs grows without bound; construct the pool inside the sweep’s scope and drop it afterwards. (SymPy’s own global cache also warms up to a few MB and then stops; that part is bounded.) See ExprPool never reclaims.

Neither side of a check is bounded, and this module does not pretend otherwise. Most heavy engines hold the GIL, so a non-terminating call in one of them cannot be timed out from Python — a worker thread cannot be stopped, and abandoning one wedges the interpreter just the same. SymPy is no better placed. So:

  • run the nightly job under an OS-level timeout;
  • wrap the sweep in context(budget=…) for the engines that are cooperative (integrate and limit, and best-effort simplify — see Budgets), where a trip surfaces as reason="alkahest_refused" with an E-BUDGET-00x code, which is a fine answer. Those two also release the GIL for their core call, so request_cancel() from a watchdog thread reaches one that is already running;
  • SWEEP_OPERATIONS still excludes limit, now only because the comparator for it is weaker than for the three it does cover — not because the call cannot be bounded.

Tier 2 — the frozen corpus

FROZEN_CORPUS is a tuple of FrozenCases re-run on every pull request:

from alkahest.crosscheck import run_frozen_corpus

for case, outcome in run_frozen_corpus():
    if outcome is None:
        ...   # skipped: does not apply to the installed oracle version
    else:
        assert outcome.outcome == case.expected

Every case records the oracle version range its expectation was established against (oracle_versions=">=1.12,<2"). Without that the corpus rots silently the first time the oracle changes an answer — which it will — and a red gate would then be indistinguishable from a real regression. A case whose range excludes the installed version is skipped, never quietly passed. Cases carry found_by (a seed, or a provenance note) and note (what the case protects), and both are asserted non-empty.

The ratchet: a divergence the nightly sweep finds must be promoted into a FrozenCase with found_by naming the seed, or it only ever gets exercised by a job nobody reads. Cases are added, never silently deleted — an expectation that changes is a re-pin with a new oracle_versions range, and the old range records what used to be true.

Visible from the session-start probe

capabilities()["verification"] reports the installed oracles and SMT solvers, so an agent can see them from the probe it already makes at session start rather than importing alkahest.crosscheck to find out:

caps = ak.capabilities()["verification"]
caps["oracles"]      # {"sympy": "1.14.0"}  — absent oracles appear as None
caps["smt_solvers"]  # {"z3": "4.13.0", "cvc5": None}

Absent tools are reported negatively rather than omitted, so “not installed” stays distinguishable from “agreed”. Both keys probe the environment — detecting an oracle imports it — so they are cached for the life of the process; see the note on capabilities().

Not yet wired

  • A nightly workflow that runs sweep, prints the seed, and files SweepReport.to_dict() as an artifact. SymPy already ships in the ci-extras dependency group, so this is a job, not a new dependency.

Oracles are a plugin interface

Oracle is an ABC and SymPyOracle is the first implementation. The comparator talks to oracles only through that interface, so a second backend is a class plus one register_oracle call — no change to the ladder, the outcomes, or the corpus machinery.

from alkahest.crosscheck import Oracle, register_oracle

class WolframOracle(Oracle):
    name = "wolfram"
    ...

register_oracle(WolframOracle)

Designing the second oracle in later would mean rewriting the comparator. With two present, two-out-of-three voting turns “someone is wrong” into “Alkahest is probably wrong”, which is a materially more useful signal.

Every method may answer “I don’t know” — is_zero returns None, run raises — and the comparator turns that into incomparable rather than guessing.

Error codes

CodeMeaning
E-XCHECK-001A node, a primitive, or an active assumption has no faithful translation. Surfaces as outcome="incomparable", reason="untranslatable"
E-XCHECK-002No oracle is installed. Surfaces as outcome="unavailable"never as agreement
E-XCHECK-003The operation has no defined comparison rung — a caller error, raised before any oracle is consulted
E-XCHECK-004The oracle itself declined, raised, or returned an unevaluated form — not a divergence

An unknown operation raises out of check rather than becoming an outcome: that is a caller mistake, not a property of the mathematics. Untranslatable input and missing oracles are outcomes, because a loop has to be able to keep going.

See also

SMT/SAT bridge

Discrete and mixed integer/real/boolean subproblems are not Alkahest’s problem class, and the fastest way to make Alkahest worse would be to pretend otherwise. What a search loop needs is not an in-tree SAT solver but a way to hand the subproblem off — and, crucially, a way to bring the answer back in a form the rest of the toolchain can trust.

That is what this bridge is. alkahest.to_smtlib emits standard SMT-LIB 2 text; an external solver (z3, cvc5, …) consumes it; alkahest.smt.solve reads the answer back, lifts the model into exact rationals, and checks it. It is the same shape as Lean certificates: Alkahest emits a standard artifact, and an independently maintained tool it does not control does the hard part.

import alkahest as ak

pool = ak.ExprPool()
x = pool.symbol("x")
n = pool.symbol("n", "integer")

# x is real, n is an integer, x sits strictly between n and sqrt(10) — mixed
# integer/real, which is exactly what neither CAD nor `diophantine` handles.
f = ak.And(pool.gt(x, n), ak.And(pool.lt(x * x, pool.integer(10)), pool.gt(n, pool.integer(1))))

with ak.context(pool=pool):
    result = ak.smt.solve(f, budget=ak.Budget(wall_ms=5000))

print(result.status)        # 'sat'
print(result.model)         # {'x': Fraction(3, 1), 'n': Fraction(2, 1)}
print(result.engine)        # 'z3 Z3 version 4.13.0 - 64 bit'
print(result.badge)         # 'the solver's model was substituted back and ...'

The two asymmetries

Everything else on this page follows from these.

1. sat and unsat have different trust stories

A sat model is checkable inside Alkahest for free: substitute it back and evaluate the formula exactly. solve always does this. There is no flag to turn it off, and a model that fails raises SmtError E-SMT-004 rather than warning — a failure there means the emitter mistranslated the formula or the solver returned an unsound model, and neither belongs in a log line.

unsat is a different matter: checking it means consuming an unsat proof, which is a large project against unstable formats. So an unsat result carries the status externally_asserted, whose badge reads:

an external solver asserted this; NO proof was checked and nothing in Alkahest verified it

and which is deliberately absent from alkahest.research.MACHINE_CHECKED_STATUSES. That set means a checker actually ran in this process. Quietly widening it to include “z3 said so” would erode the one guarantee research.py makes.

Solver saysverification["status"]Counted as machine-checked?
satexactly_verifiedyes — the model was substituted back in-process
unsatexternally_assertedno — nothing checked it
unknownunverifiedno

2. Exactness is where a model reader breaks

Rationals lift cleanly. (/ 25.0 4.0) becomes Fraction(25, 4); 0.1 becomes Fraction(1, 10) — parsed from the string, so it is the exact decimal rational the solver meant and never the nearest binary double.

Algebraic numbers do not lift. Ask z3 for a witness to x² = 2 ∧ x > 0 and it answers (root-obj (+ (^ x 2) (- 2)) 2). The tempting move is to evaluate that to 1.41421356… and carry on. A float witness recorded as an exact one is precisely the silent error this bridge exists to prevent — a loop would go on to build a hundred derived results on a value that does not actually satisfy the constraints, and its own consistency checks would happily confirm them. So root-obj is refused with E-SMT-003, and lifting it into the existing real-algebraic machinery (RootInterval / refine_root) is future work.

Planning ahead: supported()

supported is to this module what certifiable is to Lean export: a loop must be able to choose a route before it commits.

support = ak.smt.supported(f)
bool(support)            # would solve() run?
support.exportable       # would to_smtlib() succeed? (independent of solver install)
support.logic            # 'QF_NIRA'
support.reason           # 'ok' | 'outside_fragment' | 'quantified' |
                         # 'not_exactly_checkable' | 'no_solver'
support.recommendation   # 'smt' | 'prefer_in_tree'
support.script           # the emitted script, so you don't pay for it twice

recommendation is the part worth reading, and it cuts against the usual instinct:

  • prefer_in_tree for real arithmetic with no integer variables (QF_LRA / QF_NRA). prove_nonneg / sos_decompose return a PositivityCertificate that composes with to_lean, and decide returns a verdict plus a verified witness. z3’s nlsat returns an answer and no artifact. Reach for SMT here as a fallback when the in-tree route refuses or exceeds its budget — and note that it genuinely does refuse: decide is not complete, it raises E-CAD-001 outside its fragment and on sentences whose only solutions sit at an irrational boundary point (details). nlsat is complete over the reals and is the right escalation when that happens.
  • smt for anything with integer variables — mixed integer/real/boolean is the genuinely new capability, and neither CAD nor diophantine covers it.

What gets emitted

to_smtlib produces a complete, runnable script:

print(ak.to_smtlib(ak.And(pool.gt(x, pool.integer(0)), pool.lt(x, pool.integer(3)))))
; alkahest SMT-LIB 2 export
(set-logic QF_LRA)
(set-option :produce-models true)
(declare-fun x () Real)
(assert (and (> x 0) (< x 3)))
(check-sat)
(get-model)

The emitter lives in Rust (alkahest-core/src/logic/smtlib.rs) next to Formula, for the same reason the Lean emitter lives in alkahest-core/src/lean/: it must be exhaustive over Formula and PredicateKind, and rustc’s match-exhaustiveness check is the enforcement mechanism. There is no _ => arm anywhere in that file, and tests/test_smt.py asserts that from the outside as well, so a node added to the kernel later fails to compile rather than silently emitting plausible-but-wrong SMT-LIB.

Logic selection

logic="auto" (the default) infers the weakest logic that fits:

FormulaLogic
linear, reals onlyQF_LRA
nonlinear, reals onlyQF_NRA
linear, integers onlyQF_LIA
nonlinear, integers onlyQF_NIA
mixed int/realQF_LIRA / QF_NIRA
any of the above with Forall/Existssame name without the QF_ prefix

You may name a logic explicitly (ak.to_smtlib(f, "QF_NRA")), and one that is too weak for the formula is an error, not a silent downgrade — sending a nonlinear problem under QF_LRA would ask a different question than the one you have. The names accepted are QF_LIA, QF_NIA, QF_LRA, QF_NRA, QF_LIRA, QF_NIRA, their quantified forms without the QF_ prefix, AUFLIRA, AUFNIRA, and ALL.

The mixed names are solver-facing, not catalog-standard

QF_LIRA / QF_NIRA / LIRA / NIRA are not in the official SMT-LIB 2.7 logic catalog, which stops at AUFLIRA / AUFNIRA for mixed Int/Real. Alkahest emits them anyway, and that is a deliberate contract rather than an oversight:

  • they are the names the solvers this bridge drives actually use for the mixed fragment. z3 accepts them silently (an unrecognised name draws ignoring unsupported logic), Yices documents QF_LIRA / QF_NIRA among the names it recognises beyond the official set, and SMT-COMP runs QF_LIRA / QF_NIRA divisions over SMT-LIB benchmarks;
  • the catalog alternatives are strictly worse for what is emitted here. AUFLIRA / AUFNIRA are quantified logics that additionally carry arrays and free function symbols, so naming one for a quantifier-free mixed formula discards the QF_ hint that decides which solver core runs and claims a far larger fragment than is in use.

If you are feeding a consumer that accepts catalog names only, ask for one explicitly: ak.to_smtlib(f, "AUFLIRA") (linear) or ak.to_smtlib(f, "AUFNIRA") (nonlinear) — both are sound supersets of everything the emitter produces — or ALL. tests/test_smt.py pins that the installed solver accepts the names alkahest emits, so a regression in that contract fails the suite rather than the user’s pipeline.

Getting this right is load-bearing, not cosmetic: under (set-logic QF_NRA) z3 has no Int sort at all, and under a Reals_Ints logic an integer numeral in a real position needs an explicit to_real. The emitter tracks sorts and inserts exactly the coercions the chosen theory requires — and none where they would not parse.

Translation table

AlkahestSMT-LIB 2
Symbol (Real / Positive / NonNegative / NonZero)(declare-fun x () Real)
Symbol (Integer)(declare-fun n () Int)
Positive / NonNegative / NonZero refinementan extra (assert (> x 0)) / (>= x 0) / (not (= x 0))
Integer(-3)(- 3)
Rational(-1, 3)(/ (- 1) 3)
Add / Mul(+ …) / (* …)
Pow(x, 3)(* x x x) — SMT-LIB 2 has no portable ^
Pow(x, -1)(/ 1 x) for a Real base; (/ (to_real 1) (to_real n)) for an Int base
Piecewise(ite c v …)
Lt/Le/Gt/Ge/Eq(< …) / (<= …) / (> …) / (>= …) / (= …)
Ne(not (= …))
And / Or / Not(and …) / (or …) / (not …)
Forall / Exists(forall ((x Real)) …) / (exists ((x Real)) …)

A refined domain travels with its binder under a quantifier: ∀ x:Positive . P becomes (forall ((x Real)) (=> (> x 0) P)) and the existential takes (and …). Getting that backwards would be a soundness bug, so both are written out explicitly in the emitter.

A negative power over an Int base is coerced rather than emitted as (/ 1 n), and the reason is semantic rather than cosmetic: in SMT-LIB / is real division and div is integer division, so n^-1 for an integer n is the real reciprocal the kernel means, not integer division. The emitter therefore lifts both operands with to_real — which is also why such a formula selects a Reals_Ints logic even though only one sort appears in the source expression.

What is refused

The emitter is total-or-refuse. Everything below raises SmtError E-SMT-002:

  • Float literals. 0.1 is a binary double, not the exact question it looks like. Write pool.rational(1, 10); exporting the dyadic expansion would silently change what is being asked.
  • Complex-domain symbols — SMT-LIB arithmetic is ordered and real.
  • Transcendental function heads (sin, exp, log, …). Only abs has an exact SMT-LIB rendering.
  • Non-integer or very large exponents. Powers become products, so the expansion is bounded (MAX_POW_EXPANSION, 128) — a loop must never be able to ask for a megabyte of x.
  • BigO, RootSum, predicates in term position, and symbol names containing |.
  • Two symbols sharing a name but not a domain — SMT-LIB has one namespace.

Driving a solver

Solver discovery, subprocess management, and model parsing live in Python (python/alkahest/smt.py), where they iterate faster. This mirrors the Lean split exactly: emission in Rust, harness in Python.

ak.smt.solvers()
# {'z3': 'Z3 version 4.13.0 - 64 bit', 'cvc5': None}

Absence is reported negatively and explicitly, so an agent can tell before it plans that the hand-off is unavailable. PATH is searched, plus the running interpreter’s script directory — a pip install z3-solver into a venv that was never activated still gets found.

If no solver is installed, solve raises E-SMT-001. It does not fall back to alkahest.satisfiable, the interval heuristic: that would answer Unknown and look for all the world like a solver had run and found nothing.

SmtResult

FieldMeaning
status"sat" / "unsat" / "unknown"
modeldict[str, Fraction] — the exact witness, and exactly what was verified
model_exprsthe same values interned into a pool, when one was passed or is active
enginewhich solver answered, and at what version
logicthe logic that was sent
sortssymbol name → the sort it was declared with, "Int" or "Real"
smtlib / certificatethe script that was sent — an artifact, not a checked proof
verificationDerivedResult-shaped, so ResearchSession.record takes it unchanged; also carries logic and sorts
badgethe honest one-line rendering of the status
machine_checkedTrue only when a checker ran in this process
reason_unknownthe solver’s own explanation, on unknown
elapsed_mswall-clock time in the solver process

SmtResult has no __bool__ on purpose: unsat and unknown would both be falsy, and a loop writing if not result: would conflate “proved impossible” with “gave up”. Branch on .status.

model is keyed by symbol name and holds Fractions rather than Exprs because an Expr carries no reference to its pool; model_exprs is populated when solve(..., pool=…) is given one or alkahest.context(pool=…) is active. The Fraction map is always present and always the thing that was checked, so nothing about the guarantee depends on ambient context.

Read sorts before you read status

status tells you the answer was checked. sorts tells you which question was answered — and no status field distinguishes the two:

result.sorts        # {'x': 'Int', 'y': 'Int', 'z': 'Int'}  — integer feasibility
result.sorts        # {'x': 'Real', 'y': 'Real', 'z': 'Real'} — the real relaxation

A symbol declared Real where you meant Int turns an integer feasibility question into its real relaxation. That is not a soundness bug — the model does satisfy the formula as emitted, so it back-substitutes cleanly and reports status='sat' with verification['status'] == 'exactly_verified' — but it answers a strictly weaker question. z = 252/13 is a perfectly good real witness and a useless integer one. Since every status field stays green either way, sorts (mirrored into verification so it survives recording into a claim graph) is the field that tells you.

The sort comes from the symbol’s domain, and both symbol constructors now agree about it:

with ak.context(pool=pool, domain=ak.Domain.Integer):
    x = ak.symbol("x")            # Int
    y = pool.symbol("y")          # Int — takes the ambient domain too
    z = pool.symbol("z", "real")  # Real — explicit argument still wins

Before this, pool.symbol ignored the ambient domain and always produced a Real symbol, so the two constructors silently disagreed. Outside a context(domain=…) block the default is unchanged: Domain.Real, hence Real.

How a sat model is checked

Two independent exact checks, both mandatory:

  1. Substitute the model into the original Alkahest formula and evaluate it exactly through the kernel. This is the invariant the bridge rests on, and tests/test_smt.py asserts it as a property test over generated formulas rather than a handful of examples.
  2. Evaluate every assertion in the script that was actually sent, over exact rationals. A mistranslation in the emitter would have to fool both to slip through, and this pass also re-checks the refined-domain side conditions, which are separate assertions.

A model missing a “don’t care” variable is completed with 0 and then checked, so the witness a caller receives is always total and always substitutable.

Because the guarantee is unconditional, solve refuses up front — before the solver runs — for a formula the kernel cannot evaluate exactly (E-SMT-002, reason not_exactly_checkable). abs is the case you will actually hit: it exports fine, so to_smtlib handles it, but evaluate(..., mode="exact") does not, and a refusal that arrives only after paying for the solver run reads like a bug in the solver.

For the same reason solve takes quantifier-free formulas only (E-SMT-002, reason quantified). to_smtlib exports quantified ones happily.

“Does there exist x, y, z such that…” — drop the quantifiers

Exists is exported at top level and wrapping the question in it is the natural way to write it, so this is the refusal you are most likely to hit first:

f = ak.Exists(x, ak.Exists(y, ak.Exists(z, body)))
ak.smt.solve(f)
# [E-SMT-002] solve() takes quantifier-free formulas only; this one is quantified

The fix is to delete them and pass body. solve asks a satisfiability question, so the free variables of body are already implicitly existentially quantified: ∃x∃y∃z. body and body ask solve the same question, and the sat model is the witness for x, y, z. Nothing is lost by dropping them.

result = ak.smt.solve(body)     # same question, and now it answers
result.model                    # {'x': Fraction(...), 'y': …, 'z': …} — the witness

This works only for a prefix of Exists over a quantifier-free body. Under a Forall — or for an Exists beneath one — the rewrite is not meaning-preserving and there is nothing to strip. solve does not strip the prefix for you either: its guarantee is that every sat model is back-substituted and checked exactly against the formula it was given, and silently answering about a different expression than the one handed in is the kind of substitution this bridge exists to avoid.

For a genuinely quantified question, export it with to_smtlib and drive the solver yourself, or use alkahest.decide for real quantifier elimination within its fragment (≤ 2 variables, ≤ 2-quantifier prefix, polynomial bodies, and refusing rather than guessing at irrational boundary points — see decide refuses rather than guessing).

Budgets

solve(..., budget=ak.Budget(wall_ms=…)) passes the limit to the solver’s own timeout flag and enforces a parent-side deadline as a backstop. A trip raises BudgetExceededError (E-BUDGET-001), not a bare unknown, so the loop gets the structured “hard, not hung” distinction budgets were built for:

try:
    result = ak.smt.solve(candidate, budget=ak.Budget(wall_ms=250))
except ak.BudgetExceededError:
    log.info("deprioritising: hard, not hung")

Without a budget, a solver unknown is returned as a result with reason_unknown set — a real answer (“I could not decide this”), distinct from a resource verdict.

Recording into a claim graph

SmtResult quacks like a DerivedResult, so it records unchanged:

with ak.research.session(title="mixed feasibility", pool=pool) as s:
    result = ak.smt.solve(f, budget=ak.Budget(wall_ms=5000))
    claim = s.record(result, statement="the system is feasible", method="smt.solve")

claim.status           # 'exactly_verified' for sat, 'externally_asserted' for unsat
claim.machine_checked  # True only for the checked sat case

What is not here, and why

  • No vendored solver. No libz3 in the Rust build: it keeps the wheel small, the licensing simple, and — the real reason — an in-tree solver would become a soundness liability the project has to defend, in a problem class that is not Alkahest’s.
  • No unsat-proof checking. See the first asymmetry above; the status vocabulary is honest about it rather than papering over it.
  • dpll_sat is not wired in. alkahest_cas::logic::dpll_sat remains a standalone CNF utility, sound and complete for the propositional problem it is handed, and it is deliberately not an engine behind this bridge. The only route from a Formula to it is to abstract each arithmetic atom to a fresh proposition, and that abstraction is sound in one direction only: it can confirm unsat, but it calls x > 0 ∧ x < 0 sat and hands back a meaningless model. Under a bridge whose whole premise is that every sat model is checked exactly, that is the silent error the design is built to exclude — so a missing solver is a refusal (E-SMT-001), not a degradation.

Error codes

CodeRaised byMeaning
E-SMT-001Python driverNo solver binary found. A refusal, never a fallback.
E-SMT-002Rust emitter + driverFormula outside the supported fragment (or an unusable logic, a quantified formula passed to solve, or one the kernel cannot check exactly).
E-SMT-003Python driverA model value (root-obj) cannot be lifted exactly. Refused, not rounded.
E-SMT-004Python driverA model failed back-substitution. Always raised, never warned.
E-BUDGET-001Python driverThe solver hit Budget.wall_ms.

Only E-SMT-002 appears in alkahest_cas::errors::codes::REGISTRY, because it is the only one Rust raises; scripts/check_error_codes.py requires the registry and the Rust AlkahestError impls to agree exactly, so codes raised only from Python stay out of it (E-BATCH-001 in alkahest/_batch.py is the same precedent).

See also

Stability policy

Alkahest follows semantic versioning starting at 1.0.

Stable surface

The stable surface is the API Alkahest commits to maintaining without breaking changes across a major version:

  • Rust: everything re-exported from alkahest_cas::stable
  • Python: every name in alkahest.__all__ at release time

Breaking changes to the stable surface require a major-version bump (e.g. 1.x → 2.0).

Experimental surface

  • Rust: alkahest_cas::experimental::*, plus anything not in stable
  • Python: alkahest.experimental.*, plus anything re-exported from the native module but not in __all__

Experimental APIs may change in any minor release. Pin a specific point release if you depend on them.

Deprecation policy

Removed stable symbols are kept as #[deprecated] shims for one full major cycle before deletion:

  1. Symbol is deprecated in 1.x with #[deprecated(since = "1.x", note = "use Y instead")]
  2. Symbol is removed in 2.0

Python deprecations emit DeprecationWarning from the point of deprecation.

Enforcement

  • cargo semver-checks — runs on every PR via .github/workflows/alkahest-semver-check.yml. Fails the PR if any stable Rust API breaks.
  • scripts/check_api_freeze.py — guards against removals from alkahest.__all__ within a major cycle.
  • CHANGELOG.md — Keep-a-Changelog format; every release documents additions, deprecations, and (in major bumps) removals.

Error codes

Diagnostic error codes (e.g. E-POLY-001) are also stable. A code introduced in 1.x will not be renumbered or removed until 2.0. New codes are added by incrementing within the existing prefix.

Diagnostic codes and their stability

Error codes are part of the stable surface from the version they first appear. See Error handling for the current code table.