Symbolic Math in Pure Python

Dear reader, I have experimented with symbolic math problems since the early 1980s when I discovered the Reduce math system installed on my Xerox 1108 Lisp Machine. A few months ago I wrote a symbolic math chapter for my Common Lisp book. Here in this chapter, I implement useful symbolic math functionality in Python.

1. Why Symbolic Computation Matters

A calculator can tell you that the derivative of x^2 at the point x = 0.8 is approximately 1.6. But it cannot tell you why, and it cannot hand you back the general answer 2·x. That is the difference between numeric mathematics and symbolic mathematics.

  • Numeric computation operates on concrete values. It is fast and universal, but every answer is approximate, valid only at a single point, and carries floating-point rounding error.
  • Symbolic computation operates on expressions themselves. It applies the rules of algebra (the product rule, the chain rule, the power rule) as transformations on expression trees, producing exact results such as d/dx (x²) = 2x and ∫ x² dx = x³/3.

Symbolic systems (computer algebra systems, or CAS) are the engine behind Mathematica, SymPy, and the equation solvers inside graphing calculators. At their heart is a single, powerful idea: represent a mathematical expression as a data structure, and implement the rules of calculus as functions over that data structure.

This chapter builds a miniature CAS from nothing but the Python standard library. The result differentiates and integrates a useful subset of elementary functions, simplifies the results algebraically, and crucially verifies its own answers by comparing them against numeric finite-difference approximations. No sympy, no numpy, nothing to install.

The whole system is four ingredients:

  1. A representation consists of expressions encoded as nested tuples.
  2. A simplifier object consists of algebraic identities applied bottom-up.
  3. A differentiator and an integrator are recursive rule tables.
  4. A verifier object is a numeric evaluation checked against finite differences.

The central design choice: expressions as data

Just as a compiler turns source text into an abstract syntax tree before generating code, a CAS turns a formula into an expression tree. In Python, the cheapest honest tree is a nested tuple, and every node is tagged with a string naming the operation. Here is the entire vocabulary:

1 ('num', value)              constant (value is a fractions.Fraction)
2 ('var', name)               variable, e.g. ('var', 'x')
3 ('add', a, b)               a + b
4 ('mul', a, b)               a * b
5 ('pow', base, exp)          base ** exp
6 ('sin'|'cos'|'exp'|'log', arg)

A concrete formula is data you can print, inspect, and recurse over:

1 x^2 + 3*x   becomes
2 ('add',
3     ('pow', ('var', 'x'), ('num', 2)),
4     ('mul', ('num', 3), ('var', 'x')))

Compare this to the numeric approach, where x^2 + 3*x would be compiled to a function that only accepts a numeric x. In the symbolic approach the tree itself is the object of study; for example you can ask “what is the derivative?” because the structure of the expression is right there in the tuple, ready to be pattern-matched.

One deliberately un-Pythonic choice will seem odd at first: we do not overload + and * on a Symbol class. Instead we construct expressions with small builder functions (add, mul, pow). This keeps every node a plain tuple, which makes the code self-contained, trivially serializable, and dead simple to read: three virtues that outweigh the loss of operator sugar in a teaching implementation.

Why exact arithmetic via fractions.Fraction

If we stored 1/3 as the float 0.3333333333333333, then simplifying ∫ x² dx = (1/3)·x³ would quietly round the leading coefficient. After a few dozen rules fired, error would accumulate and the self-checks in this chapter would start failing. The fix is to store every numeric constant as a fractions.Fraction, an exact rational type in the standard library. Fraction(1, 3) is genuinely one third, not an approximation, so the coefficient 1/3 emerges from integration exactly.

This is the first lesson of the chapter in miniature: choose the representation to preserve the properties your algorithms need. Differentiation and integration are exact operations, so constants must be exact too.

A tour of the algorithm

With the tree defined, everything else is a recursive function that walks it:

  • simplify(e) rewrites a tree bottom-up: folds constant arithmetic, removes x + 0 and 1·x noise, and merges x^a · x^b into x^(a+b).
  • deriv(e, x) applies the calculus rules (sum, product, power, chain rule) and chains into simplify to keep output tidy.
  • integrate(e, x) recognizes the limited family it can handle (polynomials, powers and trig/exp/log of linear arguments) and raises NotImplementedError rather than guessing.
  • evaluate(e, env) turns a tree back into a number, which lets the program numerically check that its own symbolic answers are correct.

We now descend into each component, seeing the code in full before and after explaining it.


2. The Expression Vocabulary

The file begins with a docstring that is also a grammar: a complete list of the six node shapes the whole program will ever construct. In a CAS this is the analogue of a language’s grammar where every function that follows is simply a case analysis over these tags.

 1 #!/usr/bin/env python3
 2 """Symbolic differentiation and integration (no third-party libraries).
 3 
 4 Expressions are nested tuples:
 5     ('num', value)              constant (value is a Fraction)
 6     ('var', name)               variable
 7     ('add', a, b)               a + b
 8     ('mul', a, b)               a * b
 9     ('pow', base, exp)          base ** exp
10     ('sin'|'cos'|'exp'|'log', x)
11 """
12 
13 from fractions import Fraction

Only one import is needed. Fraction is the exact-rational workhorse; math is imported lazily later inside the numeric evaluator so that the symbolic core never touches floats.

Builder functions: the mini “DSL”

Because raw tuples are verbose, the program provides one small function per node type. These are the only constructors in the codebase, which means the invariant “every num holds a Fraction” can be enforced in exactly one place.

 1 # ---------------------------------------------------------------- builders
 2 
 3 def num(n):
 4     return ('num', n if isinstance(n, Fraction) else Fraction(n))
 5 
 6 
 7 def var(name):
 8     return ('var', name)
 9 
10 
11 def add(a, b):
12     return ('add', a, b)
13 
14 
15 def sub(a, b):
16     return ('add', a, ('mul', num(-1), b))
17 
18 
19 def mul(a, b):
20     return ('mul', a, b)
21 
22 
23 def div(a, b):
24     return ('mul', a, ('pow', b, num(-1)))
25 
26 
27 def pow(base, exp):
28     return ('pow', base, exp)
29 
30 
31 def neg(a):
32     return ('mul', num(-1), a)
33 
34 
35 def sin(a):
36     return ('sin', a)
37 
38 
39 def cos(a):
40     return ('cos', a)
41 
42 
43 def exp(a):
44     return ('exp', a)
45 
46 
47 def log(a):
48     return ('log', a)
49 
50 
51 ZERO = num(0)
52 ONE = num(1)
53 
54 
55 def is_num(e):
56     return e[0] == 'num'
57 
58 
59 def contains_var(e, x):
60     tag = e[0]
61     if tag == 'var':
62         return e[1] == x
63     if tag == 'num':
64         return False
65     return any(contains_var(c, x) for c in e[1:])

Why define sub, div, and neg at all? Because they are derived forms, not new node types. sub(a, b) becomes a + (-1)·b, div(a, b) becomes a · b^-1, and neg(a) becomes -1·a. Keeping only six node types dramatically shrinks the later case analyses: the simplifier, differentiator, and integrator never have to handle subtraction, division, or negation as special cases; they fall out of add, mul, and pow for free. This is a recurring theme in compilers and CAS alike: express features by desugaring (translating them into simpler forms) rather than by adding new cases.

Notice that every builder is a pure function with no state. A num(3) built anywhere is identical to every other num(3), which is why ZERO and ONE are precomputed as module-level singletons and compared with == throughout the code.

The two predicates round out the vocabulary. is_num(e) answers “is this a constant?” contains_var(e, x) answers “does the variable x occur anywhere in this tree?”, a structural question that drives the integration logic later, where deciding whether a multiplier is a constant depends on whether it mentions the variable of integration. The recursion is generic: for any non-leaf node, check the node’s children (e[1:]). This pattern, a recursive walk that terminates at leaves, is the backbone of every function in the file.


3. Simplification: the Algebraic Engine

Raw differentiation produces correct but ugly trees. Differentiating x^3 by the rules below yields, without cleanup, something like

1 1 · 3 · x^2 + 0

A human would never write that. Simplification exists to apply the identities everyone learns in school until the expression is in normal form: constants folded, additive and multiplicative identities removed, numeric factors normalized, and equal powers collected.

The workhorse is simplify, a single recursive dispatch over tags. Two small helpers precede it.

Helper: factoring a term into (coefficient, rest)

 1 # ---------------------------------------------------------------- simplify
 2 
 3 def _decomp(t):
 4     """Return (coeff, rest) such that t == coeff * rest."""
 5     if is_num(t):
 6         return (t, ONE)
 7     if t[0] == 'mul' and is_num(t[1]):
 8         return (t[1], t[2])
 9     return (ONE, t)
10 
11 
12 def _merge_add(a, b):
13     ca, ra = _decomp(a)
14     cb, rb = _decomp(b)
15     if ra == rb:
16         return simplify(mul(simplify(add(ca, cb)), ra))
17     return None

_decomp splits a term into a numeric coefficient and “the rest”. For 3*x it returns (3, x); for plain x it returns (1, x); for a constant like 4 it returns (4, 1). This matters because of collecting like terms: 3*x + 5*x should simplify to 8*x. _merge_add tries exactly that: it pulls both terms apart, and if the “rest” is identical (x and x), it adds the coefficients and rebuilds. If the rests differ (e.g. 3*x + 5*y), it returns None, signalling “no merge possible”.

The main simplifier

 1 def simplify(e):
 2     if not isinstance(e, tuple):
 3         raise TypeError(f"bad expr: {e!r}")
 4     tag = e[0]
 5 
 6     if tag in ('num', 'var'):
 7         return e
 8 
 9     if tag == 'add':
10         a = simplify(e[1])
11         b = simplify(e[2])
12         if is_num(a) and is_num(b):
13             return num(a[1] + b[1])
14         if a == ZERO:
15             return b
16         if b == ZERO:
17             return a
18         merged = _merge_add(a, b)
19         return merged if merged is not None else ('add', a, b)
20 
21     if tag == 'mul':
22         a = simplify(e[1])
23         b = simplify(e[2])
24         if is_num(a) and is_num(b):
25             return num(a[1] * b[1])
26         if a == ZERO or b == ZERO:
27             return ZERO
28         if a == ONE:
29             return b
30         if b == ONE:
31             return a
32         # flatten numeric factors: c1 * (c2 * x)
33         if is_num(a) and b[0] == 'mul' and is_num(b[1]):
34             return simplify(mul(num(a[1] * b[1][1]), b[2]))
35         if a[0] == 'mul' and is_num(a[1]) and is_num(b):
36             return simplify(mul(num(a[1][1] * b[1]), a[2]))
37         # normalize numeric factor to the left
38         if is_num(b) and not is_num(a):
39             return ('mul', b, a)
40         # combine equal bases
41         if a[0] == 'pow' and b[0] == 'pow' and a[1] == b[1]:
42             return simplify(pow(a[1], add(a[2], b[2])))
43         if a[0] == 'var' and b[0] == 'var' and a[1] == b[1]:
44             return pow(a, num(2))
45         if a[0] == 'var' and b[0] == 'pow' and b[1] == ('var', a[1]):
46             return simplify(pow(a, add(ONE, b[2])))
47         if b[0] == 'var' and a[0] == 'pow' and a[1] == ('var', b[1]):
48             return simplify(pow(b, add(ONE, a[2])))
49         return ('mul', a, b)
50 
51     if tag == 'pow':
52         base = simplify(e[1])
53         expo = simplify(e[2])
54         if is_num(base) and is_num(expo) and expo[1].denominator == 1:
55             return num(base[1] ** int(expo[1]))
56         if expo == ZERO:
57             return ONE
58         if expo == ONE:
59             return base
60         return ('pow', base, expo)
61 
62     if tag in ('sin', 'cos', 'exp', 'log'):
63         return (tag, simplify(e[1]))
64 
65     raise TypeError(f"unknown tag: {tag!r}")

Every case follows the same shape: first recursively simplify the children, then apply local rewrite rules. This bottom-up discipline guarantees that by the time a rule examines a child, that child is already in normal form.

Read each case as a small ordered list of identities:

  • add: fold 2 + 3 into 5; drop 0 (both sides); otherwise try to collect like terms via _merge_add.
  • mul: fold 2 · 3; annihilate with 0; drop ·1; then a set of structural rewrites that flatten nested numeric factors (2·(3·x) becomes 6·x), move any numeric factor to the left (x·3 becomes 3·x), and combine equal powers (x^a · x^b becomes x^(a+b)). The last four pow/var cases spell out the combinations x·x, x·x^b, and x^a·x explicitly.
  • pow: evaluate 2^3 into 8 only when the exponent is a whole number (denominator == 1); otherwise 1/2 would be rounded; then apply e^0 = 1 and e^1 = e.
  • sin/cos/exp/log: recurse into the argument only.

Two things are worth lingering on. First, the constant-folding guard expo[1].denominator == 1 is the entire reason exactness survives: it refuses to turn x^(1/2) into anything numeric, and only evaluates constant powers whose exponent is an integer. Second, the simplification rules do not need to be complete. The system does not attempt trigonometric identities (sin² + cos² = 1) or full polynomial canonicalization. A simplifier in a teaching CAS only has to be good enough that downstream output is readable and that the self-checks can compare trees reliably. Over-simplifying is a separate research project.


4. Pretty-Printing: Trees Back into Math

A CAS is judged as much by what it prints as by what it computes. The printer’s job is to render a tree as a human-readable linear string, 2*x^3 - 5*x + 4, 1/3*log(3*x + 2), with the right parentheses and no redundant + -, 1*, or *1.

 1 # ---------------------------------------------------------------- printing
 2 
 3 def _frac_str(n):
 4     return str(n.numerator) if n.denominator == 1 else f"{n.numerator}/{n.denominator}"
 5 
 6 
 7 def _paren(e):
 8     s = to_str(e)
 9     return f"({s})" if e[0] in ('add', 'mul') else s
10 
11 
12 def _flatten_add(e, out):
13     if e[0] == 'add':
14         _flatten_add(e[1], out)
15         _flatten_add(e[2], out)
16     else:
17         out.append(e)
18 
19 
20 def _exp_str(e):
21     if e[0] == 'num':
22         n = e[1]
23         return str(n.numerator) if n.denominator == 1 else f"({_frac_str(n)})"
24     if e[0] == 'var':
25         return e[1]
26     return f"({to_str(e)})"
27 
28 
29 def _is_neg(e):
30     if is_num(e):
31         return e[1] < 0
32     return e[0] == 'mul' and is_num(e[1]) and e[1][1] < 0
33 
34 
35 def _neg_str(e):
36     if is_num(e):
37         return to_str(num(-e[1]))
38     return to_str(mul(num(-e[1][1]), e[2]))
39 
40 
41 def to_str(e):
42     tag = e[0]
43     if tag == 'num':
44         return _frac_str(e[1])
45     if tag == 'var':
46         return e[1]
47     if tag == 'add':
48         terms = []
49         _flatten_add(e, terms)
50         s = to_str(terms[0])
51         for t in terms[1:]:
52             if _is_neg(t):
53                 s += f" - {_neg_str(t)}"
54             else:
55                 s += f" + {to_str(t)}"
56         return s
57     if tag == 'mul':
58         a, b = e[1], e[2]
59         if is_num(a):
60             c = a[1]
61             if c == -1:
62                 return f"-{_paren(b)}"
63             if c == 1:
64                 return _paren(b)
65             return f"{_frac_str(c)}*{_paren(b)}"
66         return f"{_paren(a)}*{_paren(b)}"
67     if tag == 'pow':
68         return f"{_paren(e[1])}^{_exp_str(e[2])}"
69     if tag in ('sin', 'cos', 'exp', 'log'):
70         return f"{tag}({to_str(e[1])})"
71     raise TypeError(f"unknown tag: {tag!r}")

The interesting decisions are all about notation, and each maps to a helper:

  • _frac_str renders a Fraction as 3 when it is whole and 3/2 otherwise, so the output never shows 3.0 or 1_000_000/500_000.
  • _flatten_add converts the left-leaning binary tree ('add', ('add', a, b), c) into the flat list [a, b, c]. Since simplify builds sums as left-leaning binary trees, flattening gives the printer a clean term list for free, and subtraction is printed as a - b whenever a term is recognized as negative by _is_neg.
  • _neg_str then renders a negative term by flipping its sign. _is_neg returns true for a negative constant or for a product whose left factor is a negative number. This is what produces 2*x^3 - 5*x + 4 (with an infix minus) rather than the correct-but-ugly 2*x^3 + -5*x + 4.
  • _paren and _exp_str implement minimal, conservative parenthesization: wrap a +/* child in parentheses when it appears as a factor or exponent, and wrap a fractional exponent such as 1/2 to avoid the ambiguous x^1/2.
  • The mul case drops the 1 coefficient entirely (1*x prints as x) and renders a coefficient of -1 as a bare leading minus (-x instead of -1*x).

Because every rule lives in one function, the printer can never disagree with the simplifier about what a “number” is; they both check is_num and the Fraction. The result is compact output that a reader could hand back to a calculator and verify.


5. Differentiation: Rules as Recursion

Differentiation is the cleanest algorithm in the book, because calculus gives us a complete, compositional set of rules. If you know the derivative of the parts, you know the derivative of the whole. The implementation is therefore a one-to-one transcription of the table:

Form Rule
constant 0
x 1 (or 0 if a different variable)
a + b a' + b'
a · b a'·b + a·b' (product rule)
f(x)^c c · f(x)^(c-1) · f'(x) (power rule)
c^f(x) c^f(x) · log(c) · f'(x)
sin u cos u · u' (chain rule)
cos u -sin u · u'
exp u exp u · u'
log u u' / u
 1 # ---------------------------------------------------------------- derivative
 2 
 3 def deriv(e, x):
 4     tag = e[0]
 5     if tag == 'num':
 6         return ZERO
 7     if tag == 'var':
 8         return ONE if e[1] == x else ZERO
 9     if tag == 'add':
10         return simplify(add(deriv(e[1], x), deriv(e[2], x)))
11     if tag == 'mul':
12         a, b = e[1], e[2]
13         return simplify(add(mul(deriv(a, x), b), mul(a, deriv(b, x))))
14     if tag == 'pow':
15         base, expo = e[1], e[2]
16         if not contains_var(expo, x):
17             return simplify(mul(mul(expo, pow(base, sub(expo, ONE))), deriv(base, x)))
18         if not contains_var(base, x):
19             return simplify(mul(mul(pow(base, expo), log(base)), deriv(expo, x)))
20         raise NotImplementedError(f"cannot differentiate {to_str(e)}")
21     inner = deriv(e[1], x)
22     if tag == 'sin':
23         return simplify(mul(cos(e[1]), inner))
24     if tag == 'cos':
25         return simplify(mul(neg(sin(e[1])), inner))
26     if tag == 'exp':
27         return simplify(mul(exp(e[1]), inner))
28     if tag == 'log':
29         return simplify(mul(div(ONE, e[1]), inner))
30     raise TypeError(f"unknown tag: {tag!r}")

Two details deserve emphasis, because they carry the whole design.

The power rule has two branches, distinguished by contains_var. When the exponent is free of x (x^2, (2x+1)^3, √x), we use the familiar power rule c · base^(c-1) · base'. When instead the base is free of x (2^x, a^sin(x)), we use the exponential rule base^expo · log(base) · expo'. When both base and exponent mention x (x^x), differentiation is genuinely harder and the honest answer is NotImplementedError, a decision we will see echoed in the integrator. The guard expo[1].denominator == 1 inside simplify guarantees that 2^3 (an integer exponent) collapses numerically, while 2^x stays symbolic so the log(2) branch can fire.

Every rule wraps its answer in simplify. Differentiation constructs trees; simplification normalizes them. Without the wrapper, the derivative of x^3 would be 3 · x^2 · 1 + ... (the ·1 and +0 terms from the product and power rules). With it, the output is 3*x^2. This pairing, generate, then normalize, is the standard idiom of symbolic computing, and it is why the simplifier was built first.

The chain rule appears entirely inside the four leaf cases: each computes the derivative of its argument (inner) exactly once and multiplies it onto the outer derivative. Because deriv is recursive, nesting works automatically: differentiating sin(3*x + 1) recurses through sin into add into mul and assembles 3·cos(3x+1) on the way back out.


6. Integration: Recognizing Patterns

Integration is where the chapter’s honesty shows. Unlike differentiation, there is no simple compositional algorithm for antiderivatives in general; a complete method (Risch-style) is the subject of graduate study. So this implementation integrates only a pragmatic subset and, crucially, it detects the boundary of that subset and refuses politely rather than returning a wrong answer.

The subset is built on one recurring idea: linear arguments. Many elementary antiderivatives are known in the form with x alone; the constant ∫ f(a·x + b) dx is handled by a change of variable that divides by a. The function linear_arg is the test that recognizes the pattern a·x + b:

 1 # ---------------------------------------------------------------- integrate
 2 
 3 def linear_arg(e, x):
 4     """Return (a, b) if e == a*x + b with a, b independent of x, else None."""
 5     e = simplify(e)
 6     if not contains_var(e, x):
 7         return (ZERO, e)
 8     if e[0] == 'var' and e[1] == x:
 9         return (ONE, ZERO)
10     if e[0] == 'add':
11         la = linear_arg(e[1], x)
12         lb = linear_arg(e[2], x)
13         if la is not None and lb is not None:
14             return (simplify(add(la[0], lb[0])), simplify(add(la[1], lb[1])))
15         return None
16     if e[0] == 'mul':
17         a, b = e[1], e[2]
18         if not contains_var(a, x):
19             rb = linear_arg(b, x)
20             if rb is not None:
21                 return (simplify(mul(a, rb[0])), simplify(mul(a, rb[1])))
22         if not contains_var(b, x):
23             ra = linear_arg(a, x)
24             if ra is not None:
25                 return (simplify(mul(b, ra[0])), simplify(mul(b, ra[1])))
26     return None

linear_arg returns the tuple (a, b) meaning “this expression equals a·x + b”, or None if it does not. A constant is 0·x + c; the variable x is 1·x + 0; a sum is linear if both sides are, with coefficients added; a product c·(a·x+b) is linear if the non-x factor is a constant and the other side is linear, with c folded into both coefficients. This is exactly the kind of “does this expression match this shape?” predicate that a CAS centralizes, so that several integration rules can share it.

Integrating powers: f(x)^c and c^(a·x+b)

 1 def integrate_pow(e, x):
 2     base = simplify(e[1])
 3     expo = simplify(e[2])
 4 
 5     if not contains_var(expo, x):          # f(x)^c
 6         la = linear_arg(base, x)
 7         if la is None:
 8             raise NotImplementedError(f"cannot integrate {to_str(e)}")
 9         a, _ = la
10         if a == ZERO:
11             return simplify(mul(e, var(x)))
12         if expo == num(-1):
13             return simplify(mul(div(ONE, a), log(base)))
14         return simplify(mul(div(ONE, mul(a, add(expo, ONE))), pow(base, add(expo, ONE))))
15 
16     if not contains_var(base, x):          # c^(a*x+b)
17         lb = linear_arg(expo, x)
18         if lb is None:
19             raise NotImplementedError(f"cannot integrate {to_str(e)}")
20         a, _ = lb
21         if a == ZERO:
22             return simplify(mul(e, var(x)))
23         return simplify(div(e, mul(a, log(base))))
24 
25     raise NotImplementedError(f"cannot integrate {to_str(e)}")

The two branches mirror the two branches of deriv’s power case. In the first (f(x)^c), if the base is linear, a change of variables gives ∫ (a·x+b)^c dx = (a·x+b)^(c+1) / (a·(c+1)), with the special case c = -1 handled separately as log(base)/a. The a == ZERO guard catches a base that is actually constant (e.g. 3^x reduced to a base free of x; that can’t happen here, but the guard keeps the code safe). In the second branch (c^(a·x+b)), ∫ c^(a·x+b) dx = c^(a·x+b) / (a·log(c)).

Integrating sin/cos/exp/log of linear arguments

 1 def integrate_linear_fn(e, x):
 2     tag = e[0]
 3     inner = e[1]
 4     la = linear_arg(inner, x)
 5     if la is None:
 6         return None
 7     a, _ = la
 8     if a == ZERO:
 9         return simplify(mul(e, var(x)))
10     inv = div(ONE, a)
11     if tag == 'sin':
12         return simplify(mul(neg(inv), cos(inner)))
13     if tag == 'cos':
14         return simplify(mul(inv, sin(inner)))
15     if tag == 'exp':
16         return simplify(mul(inv, exp(inner)))
17     if tag == 'log':
18         return simplify(mul(inv, sub(mul(inner, log(inner)), inner)))
19     return None

Each row of this table is a single calculus fact scaled by 1/a for the linear argument:

∫ sin(a·x+b) dx = -cos(a·x+b)/a, ∫ cos(a·x+b) dx = sin(a·x+b)/a, ∫ exp(a·x+b) dx = exp(a·x+b)/a, and ∫ log(u) dx = (u·log(u) - u)/a where u = a·x+b.

The log case is worth pausing on: the antiderivative of log(u) is u·log(u) - u (verify by the product rule and chain rule). This is the one rule in the file whose result is not just “scale the outer function”; it genuinely restructures the expression, and the self-check in the next section will confirm it numerically.

The top-level dispatcher

 1 def integrate(e, x):
 2     e = simplify(e)
 3     tag = e[0]
 4 
 5     if tag == 'num':
 6         return simplify(mul(e, var(x)))
 7     if tag == 'var':
 8         if e[1] == x:
 9             return simplify(mul(num(Fraction(1, 2)), pow(e, num(2))))
10         return simplify(mul(e, var(x)))
11     if tag == 'add':
12         return simplify(add(integrate(e[1], x), integrate(e[2], x)))
13     if tag == 'mul':
14         a, b = e[1], e[2]
15         if not contains_var(a, x):
16             return simplify(mul(a, integrate(b, x)))
17         if not contains_var(b, x):
18             return simplify(mul(b, integrate(a, x)))
19         raise NotImplementedError(f"cannot integrate {to_str(e)}")
20     if tag == 'pow':
21         return integrate_pow(e, x)
22     if tag in ('sin', 'cos', 'exp', 'log'):
23         res = integrate_linear_fn(e, x)
24         if res is None:
25             raise NotImplementedError(f"cannot integrate {to_str(e)}")
26         return res
27 
28     raise TypeError(f"unknown tag: {tag!r}")

The dispatcher has the same shape as deriv, with one crucial behavioral difference: its mul case is only partially recursive. If one factor is a constant (free of x), it pulls the constant out: ∫ c·f(x) dx = c·∫f(x) dx. But if both factors mention x (as in x·exp(x)), it raises NotImplementedError. That restriction is the honest boundary of the subset: integrating x·exp(x) needs integration by parts, which is out of scope.

This “recognize or refuse” discipline is the most important idea in the chapter after the representation itself. A symbolic integrator that guesses is far worse than one that says no; a wrong “simplified” answer silently corrupts everything downstream. The NotImplementedError carrying the expression (cannot integrate x*exp(x)) makes the system fail loudly and explain itself.


7. Numeric Evaluation: Closing the Loop

evaluate is the mirror image of the earlier functions: instead of building trees, it destroys them into floats. It is the bridge between the symbolic world and the numeric world, and it exists for one purpose: verification.

 1 # ---------------------------------------------------------------- numeric eval
 2 
 3 def evaluate(e, env):
 4     e = simplify(e)
 5     tag = e[0]
 6     if tag == 'num':
 7         return float(e[1])
 8     if tag == 'var':
 9         return env[e[1]]
10     if tag == 'add':
11         return evaluate(e[1], env) + evaluate(e[2], env)
12     if tag == 'mul':
13         return evaluate(e[1], env) * evaluate(e[2], env)
14     if tag == 'pow':
15         return evaluate(e[1], env) ** evaluate(e[2], env)
16     if tag == 'sin':
17         import math
18         return math.sin(evaluate(e[1], env))
19     if tag == 'cos':
20         import math
21         return math.cos(evaluate(e[1], env))
22     if tag == 'exp':
23         import math
24         return math.exp(evaluate(e[1], env))
25     if tag == 'log':
26         import math
27         return math.log(evaluate(e[1], env))
28     raise TypeError(f"unknown tag: {tag!r}")

The env argument is an ordinary dict mapping variable names to values, e.g. {'x': 0.8, 'y': 1.3}. A var node simply looks itself up; a num becomes a float (this is the one place exactness is deliberately sacrificed, and only for the numeric check). The math functions are imported inside the leaf cases so the symbolic core never imports math at all, a small encapsulation that keeps the “pure” part of the library free of floats.

This function is deliberately trivial because its correctness is assumed and then used to test the more interesting functions. That inversion, using simple code to check clever code, is the theme of the next section.


8. The Self-Verifying Demo

The main() function is not just a demo; it is a test suite written as a narrative. It builds thirty-one expressions, and for each one:

  1. prints the function and its symbolic derivative,
  2. checks the derivative against a central finite-difference estimate,
  3. integrates, prints the antiderivative,
  4. differentiates it back, and checks that d/dx(∫f) = f numerically.
 1 # ---------------------------------------------------------------- main
 2 
 3 def main():
 4     x = var('x')
 5     y = var('y')
 6 
 7     examples = [
 8         ("constant", num(7)),
 9         ("x", x),
10         ("3*x", mul(num(3), x)),
11         ("x^2", pow(x, num(2))),
12         ("x^3", pow(x, num(3))),
13         ("x^2 + 3*x", add(pow(x, num(2)), mul(num(3), x))),
14         ("2*x^3 - 5*x + 4",
15          add(mul(num(2), pow(x, num(3))), add(mul(num(-5), x), num(4)))),
16         ("1/x", pow(x, num(-1))),
17         ("1/x^2", pow(x, num(-2))),
18         ("sqrt(x)", pow(x, num(Fraction(1, 2)))),
19         ("x^(3/2)", pow(x, num(Fraction(3, 2)))),
20         ("sin(x)", sin(x)),
21         ("cos(x)", cos(x)),
22         ("exp(x)", exp(x)),
23         ("log(x)", log(x)),
24         ("sin(2*x)", sin(mul(num(2), x))),
25         ("cos(3*x + 1)", cos(add(mul(num(3), x), num(1)))),
26         ("exp(-2*x)", exp(mul(num(-2), x))),
27         ("log(2*x + 3)", log(add(mul(num(2), x), num(3)))),
28         ("2*sin(x)", mul(num(2), sin(x))),
29         ("3*x^2", mul(num(3), pow(x, num(2)))),
30         ("x^2/2", mul(num(Fraction(1, 2)), pow(x, num(2)))),
31         ("(2*x + 1)^3", pow(add(mul(num(2), x), num(1)), num(3))),
32         ("1/(3*x + 2)", pow(add(mul(num(3), x), num(2)), num(-1))),
33         ("2^x", pow(num(2), x)),
34         ("y", y),
35         ("y*x", mul(y, x)),
36         ("exp(x) + sin(x)", add(exp(x), sin(x))),
37         ("x*exp(x)", mul(x, exp(x))),          # needs integration by parts
38         ("x*sin(x)", mul(x, sin(x))),          # needs integration by parts
39         ("x*cos(x) + sin(x)", add(mul(x, cos(x)), sin(x))),  # d(x sin x)
40     ]
41 
42     x0, tol = 0.8, 1e-6
43     env = {'x': x0, 'y': 1.3}
44 
45     for name, f in examples:
46         f = simplify(f)
47         print(f"f = {name!r}  :  {to_str(f)}")
48 
49         d = simplify(deriv(f, 'x'))
50         print(f"  d/dx = {to_str(d)}")
51 
52         # numeric check of derivative (central finite difference)
53         h = 1e-6
54         fd = (evaluate(f, {'x': x0 + h, 'y': 1.3})
55               - evaluate(f, {'x': x0 - h, 'y': 1.3})) / (2 * h)
56         ok = abs(evaluate(d, env) - fd) <= tol * max(1.0, abs(fd))
57         print(f"  derivative check: {'OK' if ok else 'FAIL'}")
58 
59         try:
60             i = integrate(f, 'x')
61             print(f"  ∫ dx = {to_str(i)}")
62 
63             back = simplify(deriv(i, 'x'))
64             print(f"  d/dx(∫) = {to_str(back)}")
65             ok2 = abs(evaluate(back, env) - evaluate(f, env)) <= tol * max(1.0, abs(evaluate(f, env)))
66             print(f"  integral check: {'OK' if ok2 else 'FAIL'}")
67         except NotImplementedError as err:
68             print(f"  integrate: not supported ({err})")
69         print()
70 
71 
72 if __name__ == '__main__':
73     main()

Three engineering details here are the difference between “prints some output” and “actually proves correctness”.

Central finite differences. The derivative check estimates f'(x₀) as (f(x₀+h) - f(x₀-h)) / (2h) with h = 1e-6. The central form (as opposed to the one-sided (f(x₀+h)-f(x₀))/h) is second-order accurate, meaning its error shrinks as . At h = 1e-6 the error is around 1e-12 in the function values, comfortably inside the 1e-6 tolerance, provided h is not so small that floating-point cancellation in the subtraction round the difference to zero, which is why h = 1e-6 rather than 1e-30.

A relative tolerance. The check abs(symbolic - numeric) <= tol * max(1.0, abs(numeric)) scales the tolerance to the magnitude of the compared value. For large values (2^x at x = 0.8 times log 2, say) an absolute 1e-6 bound would be too strict relative to floating-point roundoff; the max(1.0, ...) keeps the test meaningful for both tiny and huge values.

Differentiating the antiderivative back. The integral is verified by the fundamental theorem itself: compute ∫f, differentiate it, and confirm the result equals f numerically. This is a beautifully self-contained check: it never requires a reference CAS to compare against, only the two operations the program already implements. If either the integrator or the differentiator were broken in a way that affected these expressions, the OK/FAIL lines would expose it.


9. Running the Code

Save the listing above as sym-math.py (the filename contains a hyphen, which is why the README shows how to load it with importlib rather than a plain import). Run it from the directory:

1 python3 sym-math.py

There is nothing to install; the program uses only fractions and math. The output is long, thirty-one examples each a small block, so below we reproduce it in full as the program actually prints it:

  1 f = 'constant'  :  7
  2   d/dx = 0
  3   derivative check: OK
  4    dx = 7*x
  5   d/dx() = 7
  6   integral check: OK
  7 
  8 f = 'x'  :  x
  9   d/dx = 1
 10   derivative check: OK
 11    dx = 1/2*x^2
 12   d/dx() = x
 13   integral check: OK
 14 
 15 f = '3*x'  :  3*x
 16   d/dx = 3
 17   derivative check: OK
 18    dx = 3/2*x^2
 19   d/dx() = 3*x
 20   integral check: OK
 21 
 22 f = 'x^2'  :  x^2
 23   d/dx = 2*x
 24   derivative check: OK
 25    dx = 1/3*x^3
 26   d/dx() = x^2
 27   integral check: OK
 28 
 29 f = 'x^3'  :  x^3
 30   d/dx = 3*x^2
 31   derivative check: OK
 32    dx = 1/4*x^4
 33   d/dx() = x^3
 34   integral check: OK
 35 
 36 f = 'x^2 + 3*x'  :  x^2 + 3*x
 37   d/dx = 2*x + 3
 38   derivative check: OK
 39    dx = 1/3*x^3 + 3/2*x^2
 40   d/dx() = x^2 + 3*x
 41   integral check: OK
 42 
 43 f = '2*x^3 - 5*x + 4'  :  2*x^3 - 5*x + 4
 44   d/dx = 6*x^2 - 5
 45   derivative check: OK
 46    dx = 1/2*x^4 - 5/2*x^2 + 4*x
 47   d/dx() = 2*x^3 - 5*x + 4
 48   integral check: OK
 49 
 50 f = '1/x'  :  x^-1
 51   d/dx = -x^-2
 52   derivative check: OK
 53    dx = log(x)
 54   d/dx() = x^-1
 55   integral check: OK
 56 
 57 f = '1/x^2'  :  x^-2
 58   d/dx = -2*x^-3
 59   derivative check: OK
 60    dx = -x^-1
 61   d/dx() = x^-2
 62   integral check: OK
 63 
 64 f = 'sqrt(x)'  :  x^(1/2)
 65   d/dx = 1/2*x^(-1/2)
 66   derivative check: OK
 67    dx = 2/3*x^(3/2)
 68   d/dx() = x^(1/2)
 69   integral check: OK
 70 
 71 f = 'x^(3/2)'  :  x^(3/2)
 72   d/dx = 3/2*x^(1/2)
 73   derivative check: OK
 74    dx = 2/5*x^(5/2)
 75   d/dx() = x^(3/2)
 76   integral check: OK
 77 
 78 f = 'sin(x)'  :  sin(x)
 79   d/dx = cos(x)
 80   derivative check: OK
 81    dx = -cos(x)
 82   d/dx() = sin(x)
 83   integral check: OK
 84 
 85 f = 'cos(x)'  :  cos(x)
 86   d/dx = -sin(x)
 87   derivative check: OK
 88    dx = sin(x)
 89   d/dx() = cos(x)
 90   integral check: OK
 91 
 92 f = 'exp(x)'  :  exp(x)
 93   d/dx = exp(x)
 94   derivative check: OK
 95    dx = exp(x)
 96   d/dx() = exp(x)
 97   integral check: OK
 98 
 99 f = 'log(x)'  :  log(x)
100   d/dx = x^-1
101   derivative check: OK
102    dx = x*log(x) - x
103   d/dx() = log(x) + 1 - 1
104   integral check: OK
105 
106 f = 'sin(2*x)'  :  sin(2*x)
107   d/dx = 2*cos(2*x)
108   derivative check: OK
109    dx = -1/2*cos(2*x)
110   d/dx() = sin(2*x)
111   integral check: OK
112 
113 f = 'cos(3*x + 1)'  :  cos(3*x + 1)
114   d/dx = -3*sin(3*x + 1)
115   derivative check: OK
116    dx = 1/3*sin(3*x + 1)
117   d/dx() = cos(3*x + 1)
118   integral check: OK
119 
120 f = 'exp(-2*x)'  :  exp(-2*x)
121   d/dx = -2*exp(-2*x)
122   derivative check: OK
123    dx = -1/2*exp(-2*x)
124   d/dx() = exp(-2*x)
125   integral check: OK
126 
127 f = 'log(2*x + 3)'  :  log(2*x + 3)
128   d/dx = 2*(2*x + 3)^-1
129   derivative check: OK
130    dx = 1/2*((2*x + 3)*log(2*x + 3) - (2*x + 3))
131   d/dx() = 1/2*(2*log(2*x + 3) + (2*x + 3)*(2*(2*x + 3)^-1) - 2)
132   integral check: OK
133 
134 f = '2*sin(x)'  :  2*sin(x)
135   d/dx = 2*cos(x)
136   derivative check: OK
137    dx = -2*cos(x)
138   d/dx() = 2*sin(x)
139   integral check: OK
140 
141 f = '3*x^2'  :  3*x^2
142   d/dx = 6*x
143   derivative check: OK
144    dx = x^3
145   d/dx() = 3*x^2
146   integral check: OK
147 
148 f = 'x^2/2'  :  1/2*x^2
149   d/dx = x
150   derivative check: OK
151    dx = 1/6*x^3
152   d/dx() = 1/2*x^2
153   integral check: OK
154 
155 f = '(2*x + 1)^3'  :  (2*x + 1)^3
156   d/dx = 6*(2*x + 1)^2
157   derivative check: OK
158    dx = 1/8*(2*x + 1)^4
159   d/dx() = (2*x + 1)^3
160   integral check: OK
161 
162 f = '1/(3*x + 2)'  :  (3*x + 2)^-1
163   d/dx = -3*(3*x + 2)^-2
164   derivative check: OK
165    dx = 1/3*log(3*x + 2)
166   d/dx() = (3*x + 2)^-1
167   integral check: OK
168 
169 f = '2^x'  :  2^x
170   d/dx = 2^x*log(2)
171   derivative check: OK
172    dx = 2^x*log(2)^-1
173   d/dx() = (2^x*log(2))*log(2)^-1
174   integral check: OK
175 
176 f = 'y'  :  y
177   d/dx = 0
178   derivative check: OK
179    dx = y*x
180   d/dx() = y
181   integral check: OK
182 
183 f = 'y*x'  :  y*x
184   d/dx = y
185   derivative check: OK
186    dx = y*(1/2*x^2)
187   d/dx() = y*x
188   integral check: OK
189 
190 f = 'exp(x) + sin(x)'  :  exp(x) + sin(x)
191   d/dx = exp(x) + cos(x)
192   derivative check: OK
193    dx = exp(x) - cos(x)
194   d/dx() = exp(x) + sin(x)
195   integral check: OK
196 
197 f = 'x*exp(x)'  :  x*exp(x)
198   d/dx = exp(x) + x*exp(x)
199   derivative check: OK
200   integrate: not supported (cannot integrate x*exp(x))
201 
202 f = 'x*sin(x)'  :  x*sin(x)
203   d/dx = sin(x) + x*cos(x)
204   derivative check: OK
205   integrate: not supported (cannot integrate x*sin(x))
206 
207 f = 'x*cos(x) + sin(x)'  :  x*cos(x) + sin(x)
208   d/dx = cos(x) + x*(-sin(x)) + cos(x)
209   derivative check: OK
210   integrate: not supported (cannot integrate x*cos(x))

Every example ends with OK on both checks, and the final three examples report integrate: not supported, exactly as designed.


10. Interpreting the Results

The output a sequence of small mathematical arguments, each independently verified:

The derivative check: OK lines prove the symbolic differentiator agrees with numeric reality. Every derivative (constant, power, product, chain rule, the two power-rule branches, and the four transcendental functions) is compared against a central finite-difference estimate at x = 0.8. If any rule were implemented with a wrong sign or a missing chain factor, that single numeric comparison would catch it. The fact that all thirty-one pass is strong evidence that the rule table is correct, not just that the code parses.

The integral check: OK lines prove the integrator inverts the differentiator, the deepest property of the whole system. The program does not rely on a reference table of integrals. It computes ∫f, then differentiates the answer, then confirms d/dx(∫f) = f at the same x. This is the Fundamental Theorem of Calculus used as a runtime assertion. Notice that this check exercises the two hardest rules jointly: for log(2*x + 3) the printed antiderivative is 1/2*((2*x + 3)*log(2*x + 3) - (2*x + 3)), and its rederivative, 1/2*(2*log(2*x + 3) + (2*x + 3)*(2*(2*x + 3)^-1) - 2), is an ugly expression that only simplifies numerically to the original. The OK on that line is genuine, non-trivial verification of the log integration rule.

The output also demonstrates the two design properties we set out to achieve. First, exactness: coefficients appear as 1/3, 2/5, 3/2, rational numbers, not 0.333333…. This is the Fraction-based representation paying off; nothing was ever rounded. Second, honesty at the boundary: the last three expressions (x·exp(x), x·sin(x), x·cos(x) + sin(x)) each get a correct derivative (x·exp(x)exp(x) + x·exp(x)) but then print integrate: not supported instead of a guessed answer. Those integrals require integration by parts, deliberately out of scope, and the system says so in plain text. A production CAS would integrate them; a teaching CAS is more correct for refusing than for silently returning a wrong result.

The 2^x case quietly demonstrates both power-rule branches working in tandem. Its derivative is 2^x*log(2) (the c^f(x) branch of deriv); its integral is 2^x*log(2)^-1 (the c^(a·x+b) branch of integrate_pow). The inverse relationship between the two is exactly the 1/log(2) factor flipping between numerator and denominator, a one-line illustration that differentiation and integration are true inverses here, confirmed numerically.

Two cosmetic oddities in the output are worth understanding, not “fixing”. First, 1/x prints as x^-1 and its integral prints as log(x) because negative exponents are kept as powers (they fall out of the power rule), so the printer never writes them as fractions. Second, in the log(x) example the line d/dx(∫) = log(x) + 1 - 1 shows an expression that is numerically log(x) but not fully simplified, because the simplifier does not cancel the +1 - 1 that arises from the product rule applied to x·log(x). The numeric check still passes; 1 - 1 cancels exactly in floating point; but it is a visible reminder that the simplifier is a subset of a real CAS, not a complete one.


11. What to Build Next

The architecture makes each enhancement a local change, which is the real test of a clean symbolic design:

  • More transcendental functions (tan, asin, sqrt as a distinct node): add one builder, one derivative case, one integral case, and one printer case.
  • Integration by parts: recognize the mul-of-two-x-dependent-factors pattern and apply ∫u dv = uv - ∫v du; this would turn the three not supported lines into answers.
  • A full simplifier: normalize polynomials into sorted monomial form, cancel +1 - 1, and combine log factors; this removes the cosmetic log(x) + 1 - 1 artifact.
  • A parser: turn the string "x^2 + 3*x" into the tree; this inverts to_str and rounds out the builder DSL into a true little language.

Each suggestion extends the same loop: represent → simplify → differentiate → integrate → verify. That loop, not any single rule, is the enduring takeaway. A computer algebra system is not magic; it is a data structure, a handful of recursive rewrite rules, and the discipline to refuse what it does not know.