limes (Latin: "limit", "boundary") — a C++20 library for composable calculus expressions.
Limits are the foundation of calculus: derivatives and integrals are both defined through limits. This library makes calculus operations first-class composable objects with symbolic computation where possible and robust numerical methods as fallback.
1. Motivation
The Original Question
Can we build something for integration analogous to what autograd does for differentiation?
The Honest Answer
No—not in the same way. Differentiation is local (chain rule decomposes perfectly), while integration is global (no analogous decomposition exists). We cannot build an "autointegrate" that automatically computes integrals by understanding expression structure the way autograd computes gradients.
What We CAN Build
Instead of mimicking autograd's mechanism, we take inspiration from its design philosophy: expressions as first-class composable objects with deferred evaluation. The insight is:
Integrals are algebraic objects that compose according to mathematical laws.
This library makes those laws explicit in the API.
2. Core Vision
Integrals as First-Class Expressions
An Expr<T> is a lazy, composable, inspectable representation of an integration problem. It captures:
- The integrand (what to integrate)
- The domain (where to integrate)
- The structure (how integrals compose)
Evaluation is separate from construction—build complex expressions, then evaluate with chosen methods.
Pedagogical Goals
Users should learn:
- Integration concepts: bounds, domains, iteration, Fubini's theorem
- Numerical methods: quadrature rules, error estimation, convergence
- Generic programming: composable abstractions with concepts and policies
Design Principles
- Expressiveness: Complex integration problems should be writable clearly
- Composability: Expressions combine via algebraic laws (linearity, additivity, nesting)
- Inspectability: Structure is visible—print expressions, debug construction
- Separation: The what (expression) is separate from the how (numerical method)
3. API Overview
Building Integrands
Integrands are built from expression primitives, not passed as black-box functions:
auto x = arg<0>;
auto y = arg<1>;
auto f = sin(x) * exp(-y);
auto g = x*x + y*y;
auto h = sqrt(1 - x*x);
Constructing Integrals
auto I = integral(x*x).over(0, 0.0, 1.0);
auto J = integral(sin(x) * exp(-y))
.over(1, 0.0, x)
.over(0, 0.0, 1.0);
auto joint = exp(-(x*x + y*y) / 2);
auto marginal = integral(joint).over(1, -10.0, 10.0);
Composing Expressions
auto sum = 2.0 * I + 3.0 * J;
auto [left, right] = I.split(0, 0.5);
auto transformed = I.transform(0, phi, phi_jacobian);
Differentiation
Symbolic differentiation via chain rule:
auto f = sin(x*x);
auto df = derivative(f, 0);
auto g = exp(x) * sin(y);
auto grad_g = gradient(g);
auto F = integral(df).over(0, 0.0, x);
Evaluating Expressions
auto result = I.evaluate();
auto result = I.evaluate<AdaptiveGaussKronrod>();
double value = result.value();
double error = result.error();
bool ok = result.converged();
Inspecting Expressions
std::cout << I.to_string() << "\n";
std::cout << J.to_string() << "\n";
4. Core Types
<tt>Expr<T></tt>
The central type—a lazy integral expression.
Properties:
- Value type:
T (typically double or float)
- Arity: Number of free (unintegrated) variables, tracked at runtime
- Structure: Tree of expression nodes
Key operations:
.over(dim, lo, hi) — bind dimension dim to integration with bounds [lo, hi]
.evaluate<Method>() — compute numerical result
.to_string() — structural representation
<tt>Result<T></tt>
The result of evaluation.
Properties:
.value() — the computed integral value
.error() — estimated error (method-dependent)
.converged() — whether tolerance was achieved
.iterations() — number of function evaluations or subdivisions
Bounds
Bounds can be:
- Constant:
0.0, 1.0, inf
- Callable:
[](auto x){ return x*x; } — depends on outer variables
5. Composition Model
Algebraic Laws
The API exposes integration's algebraic structure:
| Law | Expression | API |
| Linearity | ∫(af+bg) = a∫f + b∫g | a*I + b*J |
| Additivity | ∫ₐᵇ + ∫ᵇᶜ = ∫ₐᶜ | left + right after .split() |
| Separability | ∫∫f(x)g(y) dxdy = (∫f dx)(∫g dy) | Future: auto-detected with expression nodes |
| Fubini | ∫∫f dxdy = ∫∫f dydx (when valid) | .swap(0, 1) |
Variable Model
Variables are positional (indices, not names):
arg<0> is the first variable
arg<1> is the second, etc.
When .over(dim, lo, hi) is called:
- Dimension
dim becomes the innermost integration variable
- Expression arity decreases by 1
- Remaining variables shift down if needed
Nesting Semantics
Chained .over() calls create iterated integrals, evaluated inside-out:
integral(f).over(1, a, b).over(0, c, d)
Means: for each x in [c,d], compute ∫ₐᵇ f(x,y) dy, then integrate that over x.
6. Expression Nodes
All integrands are built from expression nodes—there are no opaque black-box functions. This enables structural analysis, symbolic antiderivatives, and pattern detection (like separability).
Core Nodes
| Node | Meaning |
Const | A constant value |
Var | A positional variable reference arg<i> |
BinaryOp | +, -, *, /, ^ (power) |
UnaryOp | Negation, etc. |
Integral | Integration node with integrand + domain |
Bound | Bound expression (constant or callable) |
Primitive Functions (v1)
| Node | Meaning | Derivative | Antiderivative |
Exp | e^x | d/dx[e^x] = e^x | ∫e^x = e^x |
Log | ln(x) | d/dx[ln(x)] = 1/x | ∫ln(x) = x·ln(x) - x |
Sin | sin(x) | d/dx[sin(x)] = cos(x) | ∫sin(x) = -cos(x) |
Cos | cos(x) | d/dx[cos(x)] = -sin(x) | ∫cos(x) = sin(x) |
Sqrt | √x | d/dx[√x] = 1/(2√x) | ∫√x = (2/3)x^(3/2) |
| Abs | |x| | d/dx[|x|] = sign(x) | ∫|x| = (x·|x|)/2 |
Each primitive knows both its derivative and antiderivative. The chain rule composes derivatives automatically; when an integral has a known antiderivative, numerical integration is avoided.
Extensibility via Concepts
Users can define custom nodes by modeling the ExprNode concept:
template<typename N, typename T>
concept ExprNode = requires(N node, std::span<T> args) {
{ node.evaluate(args) } -> std::convertible_to<T>;
{ node.arity() } -> std::convertible_to<std::size_t>;
{ node.to_string() } -> std::convertible_to<std::string>;
};
template<typename N, typename T, std::size_t Dim>
concept IntegrableNode = ExprNode<N, T> && requires(N node) {
{ node.template antiderivative<Dim>() };
};
Custom nodes that model these concepts work seamlessly with the system.
7. Domain Operations
Split
Divide a domain at a point:
auto I = integral(f).over(0, 0.0, 1.0);
auto [left, right] = I.split(0, 0.5);
Transform (Change of Variables)
Apply a substitution x = φ(t):
auto transformed = I.transform(0,
[](auto t){ return t / (1 - t); },
[](auto t){ return 1 / ((1-t)*(1-t)); }
);
8. Evaluation
Method Selection
Numerical methods are specified as template parameters:
auto result = expr.evaluate<AdaptiveGaussKronrod>();
auto result = expr.evaluate<Romberg>();
auto result = expr.evaluate<MonteCarlo>();
The default evaluate() uses a sensible adaptive method.
Error Estimation
Methods that support error estimation populate result.error(). Methods without inherent error estimation may return an estimate of 0 or use heuristics.
Inside-Out Evaluation
For nested integrals, evaluation proceeds inside-out:
- Inner integral becomes a function of outer variables
- Outer integration evaluates that function at quadrature points
- Repeat outward
9. Namespace Organization
limes is the top-level namespace. The numerical algorithms (currently in calckit) become a subnamespace:
limes:: # Top-level: expression API
integral() # Expression construction
Expr<T> # Core expression type
Result<T> # Evaluation result
limes::algorithms:: # Numerical backend
accumulators:: # Precision control (Kahan, Neumaier, etc.)
quadrature:: # Node/weight generation
integrators:: # Direct numerical methods
The expression layer uses the algorithms internally. Users can:
- Use
limes::integral() for composable expressions (recommended)
- Use
limes::algorithms:: directly for low-level control
10. Scope and Phasing
v1: Core Expression System
Expression-only design — all integrands built from nodes, no black-box functions.
- Nodes: Const, Var, BinaryOp, UnaryOp, Integral, Bound
- Primitives: exp, log, sin, cos, sqrt, abs (with derivatives and antiderivatives)
- Differentiation:
derivative(expr, dim), gradient(expr) — symbolic via chain rule
- Integration:
integral(expr).over(dim, lo, hi) — symbolic when possible, numerical otherwise
- Multivariate: N-dimensional, chained
.over() for iterated integrals
- Dependent bounds: Lambda bounds for non-rectangular regions
- Arithmetic composition: +, -, scalar *, /
- Concepts:
ExprNode, DifferentiableNode, IntegrableNode for extensibility
- Evaluation: Adaptive Gauss-Kronrod with
Result<T> (value, error, convergence)
- Inspection: S-expression printing
v1.1: Domain Operations
- Split: Divide domain at a point
- Transform: Change of variables with Jacobian
- Separability detection: Auto-factor ∫∫f(x)g(y) → (∫f)(∫g)
v2+: Extended Features
Extended nodes:
Sum, Product — finite Σ and Π
Derivative — for mixed integration/differentiation
Conditional — piecewise functions
Limit, Convolution
Numerical methods:
- Additional methods (tanh-sinh, Monte Carlo, sparse grids)
- Automatic method selection based on expression structure
Symbolic integration techniques:
- U-substitution — algorithmic pattern matching: recognize ∫f(g(x))·g'(x)dx
- Integration by parts — ∫u·dv = uv - ∫v·du
- Partial fractions — for rational functions
- Trigonometric identities — sin²+cos²=1, etc.
Advanced features:
- Parallelization of multi-dimensional integrals
- Integration with automatic differentiation
- Caching/memoization of repeated subexpressions
11. Example: Bayesian Marginal
A motivating use case—computing a marginal distribution:
auto x = arg<0>;
auto y = arg<1>;
auto joint_pdf = exp(-(x*x + y*y) / 2) / (2 * pi);
auto marginal_expr = integral(joint_pdf).over(1, -10.0, 10.0);
for (double xval : {-2.0, -1.0, 0.0, 1.0, 2.0}) {
auto p_x = marginal_expr.bind(0, xval).evaluate();
std::cout << "p(" << xval << ") = " << p_x.value() << "\n";
}
auto total =
integral(marginal_expr).over(0, -10.0, 10.0);
std::cout << "∫p(x)dx = " << total.evaluate().value() << "\n";
constexpr auto integral(E expr)
Create an IntegralBuilder for fluent integral construction.
Why Expression-Based?
Because joint_pdf is built from primitives (exp, *, /), the system can:
- Detect that exp(-(x² + y²)/2) = exp(-x²/2) · exp(-y²/2) — separable!
- Factor the integral: (∫exp(-x²/2)dx)(∫exp(-y²/2)dy)
- Use symbolic antiderivatives where possible
- Choose optimal numerical methods for what remains
12. Summary
limes provides:
- A novel API for calculus as composable expressions — both differentiation and integration
- Symbolic computation where possible — chain rule for derivatives, known antiderivatives for integrals
- Numerical fallback — robust quadrature when symbolic methods don't apply
- Pedagogical clarity through inspectable expression trees and explicit algebraic laws
- Extensibility via concepts — define custom nodes that "just work"
The key insight: while we can't fully automate integration like autograd automates differentiation, we CAN build a unified expression system where both operations are first-class citizens with rich algebraic structure and Stepanov-style generic programming.