Symbolic Math: Differentiate and Integrate

Neural networks operate on numeric parameters adjusted by gradient descent. Symbolic mathematics, by contrast, operates by manipulating symbolic expressions.

This chapter implements a small symbolic mathematics core in Scala 3: terms, polynomials, exact differentiation, and exact integration. There are no numeric approximations, no step size to tune, and no truncation error. The rules of calculus are encoded directly, and every result is an exact expression.

All code is in source-code/symbolic-math.

Two Ways to Do Calculus on a Computer

Most calculus software used in machine learning is numeric. It represents a function as code and estimates its slope by perturbing the input by a small amount:

math

This finite difference is simple to implement and applies to any function that can be evaluated, which is why it is ubiquitous in numerical work. The limitation is the approximation itself. If Code Test is too large, curvature biases the estimate. If Code Test is too small, cancellation between two nearly equal numbers destroys precision. No single value of Code Test is suitable for every function, and the result is always a decimal rather than a formula.

Symbolic calculus takes the opposite approach. It represents an expression as data, a tree of operators and symbols, and rewrites that tree using the standard rules of calculus. Differentiating Code Test returns the exact expression Code Test, not a table of slopes. Evaluating that expression at a point is a separate, optional step. Because the manipulation is exact, symbolic methods expose structure that numeric methods conceal: the location of zeros of the derivative, the form of the antiderivative, and the relationship between two expressions.

Symbolic manipulation is one of the oldest branches of artificial intelligence. Systems such as Macsyma at MIT in the 1960s, and later Mathematica, Maple, and SymPy, were built to perform algebra symbolically, and much of that lineage was written in Lisp. The code in this chapter is a direct port of a small Common Lisp calculus package, and it follows the same principle: represent the mathematics as data, then apply rewrite rules.

For AI in particular, symbolic differentiation is the precursor to automatic differentiation, the technique underlying every deep learning framework. Automatic differentiation applies the same rules (power, sum, product, chain) mechanically to a program while carrying numeric values through the computation. Understanding the symbolic core makes the autodiff engine easier to reason about. The implementation here is deliberately small, and the chapter closes by noting what a full computer algebra system would add.

Terms and Polynomials as Data

We start with the objects we can represent exactly. A term is a coefficient times a variable raised to a whole-number power:

math

A polynomial in one variable is a sum of such terms:

math

This representation covers a substantial part of introductory calculus and makes every rule exact. It consists of two case classes: one for a term and one for the polynomial that holds a list of terms. The complete data model, in Expr.scala, is as follows:

 1 //> using scala 3.6.4
 2 // Copyright 2026 Mark Watson. All rights reserved.
 3 
 4 package symbolicmath
 5 
 6 // Core data model. Port of data.lisp: a polynomial in one variable is
 7 // a list of terms, each term is coefficient * variable ^ exponent.
 8 final case class Variable(name: String)
 9 
10 final case class Term(coefficient: Double, variable: Variable, exponent: Int):
11   def negate: Term = copy(coefficient = -coefficient)
12   def scale(k: Double): Term = copy(coefficient = coefficient * k)
13   override def toString: String =
14     val c = if coefficient == coefficient.toLong then coefficient.toLong.toString else coefficient.toString
15     exponent match
16       case 0 => s"$c"
17       case 1 => s"${c}${variable.name}"
18       case n => s"${c}${variable.name}^$n"
19 
20 final case class Polynomial(variable: Variable, terms: List[Term]):
21   def degree: Int = terms.map(_.exponent).maxOption.getOrElse(0)
22   def normalize: Polynomial =
23     val grouped = terms.groupBy(_.exponent).view.mapValues(_.map(_.coefficient).sum).toMap
24     val kept = grouped.toList.filter(_._2 != 0.0).map { case (e, c) => Term(c, variable, e) }
25     copy(terms = kept.sortBy(-_.exponent))
26   def add(other: Polynomial): Polynomial =
27     require(variable == other.variable, "Cannot add polynomials in different variables.")
28     copy(terms = terms ++ other.terms).normalize
29   def subtract(other: Polynomial): Polynomial = add(other.copy(terms = other.terms.map(_.negate)))
30   def scale(k: Double): Polynomial = copy(terms = terms.map(_.scale(k))).normalize
31   def evaluate(x: Double): Double =
32     terms.map(t => t.coefficient * math.pow(x, t.exponent)).sum
33   override def toString: String =
34     if terms.isEmpty then "0"
35     else terms.mkString(" + ").replace("+ -", "- ")
36 
37 object Polynomial:
38   def zero(v: Variable): Polynomial = Polynomial(v, Nil)
39   def constant(v: Variable, c: Double): Polynomial = Polynomial(v, List(Term(c, v, 0))).normalize
40   def identity(v: Variable): Polynomial = Polynomial(v, List(Term(1.0, v, 1)))

The design mirrors the algebra. Variable is a name, which allows the code to reject an attempt to add a polynomial in Code Test to one in Code Test rather than producing an invalid result. Term bundles the three values that define a monomial and provides negate and scale, the only two operations a term requires in this chapter. Polynomial holds the variable and a list of terms, and its companion object provides the three constants the algebra requires: zero, an arbitrary constant, and the identity Code Test.

Canonical Form and the Role of normalize

The central method is normalize, and it must be understood before any calculus is attempted. When polynomials are added or scaled, the term list can contain like terms, terms with the same exponent, and terms whose coefficients cancel to zero. The raw list is correct but not canonical, so two polynomials that are mathematically equal may have different term lists. This breaks structural equality, and d/dx(int(p)) == p would fail even when the mathematics is correct.

normalize restores a canonical form in three steps:

  1. groupBy(_.exponent) collects terms that share an exponent.
  2. mapValues(_.map(_.coefficient).sum) adds their coefficients, so Code Test becomes Code Test.
  3. The result is filtered to drop any coefficient equal to zero, then sorted by descending exponent.

After normalization, Code Test becomes the empty term list, which prints as 0, and Code Test prints as Code Test. Sorting by descending exponent places the highest power first, the conventional order for polynomials. This is what allows the test suite to compare whole polynomials with ==: equal polynomials have equal normalized term lists.

The remainder of the class is small and purely functional. degree reports the largest exponent, or zero for the zero polynomial. add verifies that both polynomials share a variable, concatenates the term lists, and normalizes. subtract negates the second polynomial and adds. scale multiplies every coefficient. evaluate computes the numeric value at a point Code Test using math.pow, which handles fractional and negative inputs correctly because the exponent is an integer. Every method returns a new value and leaves the receiver unchanged, so a sequence of rewrites is safe to reason about.

The toString implementations contain two notable details. First, a coefficient that is a whole number is printed without a decimal point, so 5.0 prints as 5 while 0.5 prints as 0.5. Second, a term of exponent 0 prints as the bare coefficient, exponent 1 prints as 6x, and higher powers print as 3x^2. The polynomial joins terms with " + " and then replaces "+ -" with "- ", so negative coefficients appear as subtraction. This substitution is why 6x - 1 appears instead of 6x + -1.

Differentiation: The Power Rule and Linearity

The derivative measures the instantaneous rate of change, defined by the limit

math

For a power of the variable, expanding Code Test with the binomial theorem and letting Code Test go to zero leaves exactly one surviving term, which gives the power rule:

math

A constant multiple pulls out, and a sum differentiates term by term, the sum rule:

math

Together these two rules say differentiation is a linear operator, and they are all a polynomial needs. The code applies them per term, then collects the results. Here is Differentiate.scala in full:

 1 //> using scala 3.6.4
 2 // Copyright 2026 Mark Watson. All rights reserved.
 3 
 4 package symbolicmath
 5 
 6 // Symbolic differentiation of polynomials. Port of differentiation.lisp.
 7 // Power rule: d/dx(c * x^n) = n*c * x^(n-1); sum rule applies per term.
 8 object Differentiate:
 9   def differentiateTerm(term: Term): Option[Term] =
10     if term.exponent == 0 then None
11     else Some(Term(term.exponent * term.coefficient, term.variable, term.exponent - 1))
12 
13   def differentiate(poly: Polynomial): Polynomial =
14     val terms = poly.terms.flatMap(differentiateTerm)
15     if terms.isEmpty then Polynomial.zero(poly.variable)
16     else Polynomial(poly.variable, terms).normalize
17 
18   def differentiateN(poly: Polynomial, n: Int): Polynomial =
19     require(n >= 0, "Order of derivative must be non-negative.")
20     if n == 0 then poly else differentiateN(differentiate(poly), n - 1)
21 
22   def gradientAt(poly: Polynomial, x: Double): Double =
23     differentiate(poly).evaluate(x)
24 
25   def criticalPoint(poly: Polynomial, x: Double, tol: Double = 1e-9): Boolean =
26     math.abs(gradientAt(poly, x)) <= tol

differentiateTerm implements the power rule directly. It multiplies the coefficient by the exponent and decrements the exponent. A constant term has exponent zero, and its derivative is zero, so the function returns None rather than a term with a zero coefficient. That choice lets differentiate use flatMap, which transforms each term and discards the None results in a single pass. The Option return type expresses that a term either survives differentiation or vanishes.

differentiate applies that rule to every term and normalizes the survivors. The explicit if terms.isEmpty branch returns the zero polynomial when the input was constant; normalize would reach the same state, but the branch makes the intent clear. differentiateN applies the operator Code Test times by recursion, with a require guard that rejects a negative order. Its base case returns the polynomial unchanged, since the zeroth derivative of a function is the function itself.

The last two methods connect the symbolic representation to numeric values. gradientAt differentiates symbolically and then evaluates the exact derivative at a point, so the slope is computed from a formula rather than a finite difference. criticalPoint tests whether that slope is zero within a tolerance, the condition for a local maximum, minimum, or saddle point. For Code Test the derivative is Code Test, which is zero at Code Test, the vertex of the upward parabola. The test suite checks that point explicitly.

Integration: The Reverse Power Rule and the Fundamental Theorem

Integration runs the power rule backward. Since differentiating Code Test multiplies by Code Test, integrating must divide by it:

math

The added Code Test is the constant of integration. Any constant differentiates to zero, so an antiderivative is defined only up to an additive constant, and a complete answer must state it. The code returns the antiderivative with Code Test and lets the caller add Code Test when printing. For polynomials with non-negative exponents, Code Test is at least one, so the division is always defined. Like differentiation, integration is linear and applies term by term.

A definite integral goes from a lower limit Code Test to an upper limit Code Test. The fundamental theorem of calculus says the answer is the antiderivative evaluated at the endpoints:

math

The constant of integration cancels in the subtraction, which is why the code never has to track it. Here is Integrate.scala in full:

 1 //> using scala 3.6.4
 2 // Copyright 2026 Mark Watson. All rights reserved.
 3 
 4 package symbolicmath
 5 
 6 // Symbolic integration of polynomials. Port of integration.lisp.
 7 // Reverse power rule: integral c * x^n dx = (c/(n+1)) * x^(n+1).
 8 // Definite integrals use the fundamental theorem: F(b) - F(a).
 9 object Integrate:
10   def integrateTerm(term: Term): Term =
11     val e = term.exponent + 1
12     Term(term.coefficient / e, term.variable, e)
13 
14   def integrate(poly: Polynomial): Polynomial =
15     Polynomial(poly.variable, poly.terms.map(integrateTerm)).normalize
16 
17   def integrateN(poly: Polynomial, n: Int): Polynomial =
18     require(n >= 0, "Order of integral must be non-negative.")
19     if n == 0 then poly else integrateN(integrate(poly), n - 1)
20 
21   def definite(poly: Polynomial, a: Double, b: Double): Double =
22     val f = integrate(poly)
23     f.evaluate(b) - f.evaluate(a)

integrateTerm increments the exponent and divides the coefficient by the new exponent. It always returns a Term, never an Option, because no polynomial term integrates to zero. integrate applies it across the terms and normalizes. integrateN repeats the operation for higher antiderivatives, with the same non-negative guard as differentiateN. definite integrates once to obtain an antiderivative, then subtracts its value at Code Test from its value at Code Test, following the theorem directly.

The two operators are inverse in one direction. Differentiating an antiderivative returns the original polynomial, so the test suite asserts Differentiate.differentiate(Integrate.integrate(p)) == p. The reverse order holds only up to the constant: integrating a derivative returns the original plus an unknown Code Test, so no such equality is asserted in the other direction. This asymmetry is precisely why the constant of integration exists.

The Demo

Main.scala exercises every operation on a single polynomial:

 1 //> using scala 3.6.4
 2 // Copyright 2026 Mark Watson. All rights reserved.
 3 
 4 package symbolicmath
 5 
 6 @main def symbolicMathDemo(): Unit =
 7   val x = Variable("x")
 8   // p = 3x^2 - x + 5
 9   val p = Polynomial(x, List(Term(3.0, x, 2), Term(-1.0, x, 1), Term(5.0, x, 0))).normalize
10   println(s"p        = $p")
11   println(s"p'       = ${Differentiate.differentiate(p)}")
12   println(s"p''      = ${Differentiate.differentiateN(p, 2)}")
13   println(s"int(p)   = ${Integrate.integrate(p)} + C")
14   println(f"int[0,2] = ${Integrate.definite(p, 0.0, 2.0)}%.1f")
15   println(f"p(2)     = ${p.evaluate(2.0)}%.1f")

The polynomial is constructed from an explicit list of terms and normalized, so the printed order is canonical. The demo prints the polynomial, its first and second derivatives, its indefinite integral (with the + C appended by the print statement rather than the library), a definite integral, and a point evaluation. Running it from the example directory:

1 cd source-code/symbolic-math
2 scala-cli run . --main-class symbolicmath.symbolicMathDemo

produces:

1 p        = 3x^2 - 1x + 5
2 p'       = 6x - 1
3 p''      = 6
4 int(p)   = 1x^3 - 0.5x^2 + 5x + C
5 int[0,2] = 16.0
6 p(2)     = 15.0

The Test Suite

The project includes a self-checking test file that requires no test framework. It uses plain assert calls and a small close helper for floating point comparisons:

 1 //> using scala 3.6.4
 2 // Copyright 2026 Mark Watson. All rights reserved.
 3 
 4 package symbolicmath
 5 
 6 // Offline checks. Run with: scala-cli run . --main-class symbolicmath.symbolicMathTest
 7 @main def symbolicMathTest(): Unit =
 8   def close(a: Double, b: Double, tol: Double, label: String): Unit =
 9     assert(math.abs(a - b) <= tol, s"$label: expected $b, got $a")
10 
11   val x = Variable("x")
12   // p = 3x^2 - x + 5, p' = 6x - 1, p'' = 6
13   val p = Polynomial(x, List(Term(3.0, x, 2), Term(-1.0, x, 1), Term(5.0, x, 0))).normalize
14   assert(Differentiate.differentiate(p).toString == "6x - 1", s"p' wrong: ${Differentiate.differentiate(p)}")
15   assert(Differentiate.differentiateN(p, 2).toString == "6", s"p'' wrong: ${Differentiate.differentiateN(p, 2)}")
16   assert(Differentiate.differentiateN(p, 0) == p, "zeroth derivative is identity")
17 
18   // Constants differentiate to the zero polynomial.
19   val c = Polynomial.constant(x, 5.0)
20   assert(Differentiate.differentiate(c).terms.isEmpty, "d/dx(5) must be zero")
21 
22   // ∫ 3x^2 dx = x^3; ∫[0,2] 3x^2 dx = 8.
23   val q = Polynomial(x, List(Term(3.0, x, 2)))
24   assert(Integrate.integrate(q).toString == "1x^3", s"integral wrong: ${Integrate.integrate(q)}")
25   close(Integrate.definite(q, 0.0, 2.0), 8.0, 1e-9, "definite integral")
26 
27   // Round trip: differentiate(integrate(p)) == p.
28   assert(Differentiate.differentiate(Integrate.integrate(p)) == p, "d/dx ∫ p dx must equal p")
29 
30   // Numeric checks: p(2) = 15, gradient at x=1 is 5, x=1/6 is critical.
31   close(p.evaluate(2.0), 15.0, 1e-9, "evaluate")
32   close(Differentiate.gradientAt(p, 1.0), 5.0, 1e-9, "gradient")
33   assert(Differentiate.criticalPoint(p, 1.0 / 6.0), "vertex of parabola is critical")
34 
35   println("All symbolic math tests passed.")

The checks target the important behavior rather than coverage. The first two confirm the power rule and the sum rule on a concrete polynomial. The zeroth-derivative check confirms the base case of the recursion. The constant check confirms that a term of exponent zero disappears entirely, leaving an empty term list. The integration checks confirm the reverse power rule symbolically and the fundamental theorem numerically. The round trip is the strongest test in the file: it exercises both operators across all three terms and would catch an error in either rule. The final numeric checks connect the symbolic derivative back to the geometry, confirming Code Test, Code Test, and that Code Test is a critical point.

The close helper compares doubles within a tolerance rather than for exact equality. This is the correct approach for floating point arithmetic: Code Test is not exactly representable, and an antiderivative such as Code Test produces a repeating decimal. Exact comparison would fail on rounding error unrelated to the logic. The tolerance is Code Test for these small numbers, loose enough to absorb representation error and tight enough to detect a genuine mistake.

Run it with:

1 scala-cli run . --main-class symbolicmath.symbolicMathTest

and it prints one line:

1 All symbolic math tests passed.

Interpreting the Output

The demo output demonstrates that the rules agree with hand calculus.

p = 3x^2 - 1x + 5 is the input polynomial. The 1x is the coefficient-one term printed literally, a minor cosmetic limitation of toString. The mathematics is correct; a more careful printer would omit the 1 and write x, which is left as an exercise.

p' = 6x - 1 applies the power rule to each term: Code Test gives Code Test, Code Test gives Code Test, and the constant Code Test gives nothing. The sum of those results is the exact derivative, with no decimal approximation.

p'' = 6 differentiates once more. The Code Test term becomes Code Test and the Code Test term disappears. The second derivative of a quadratic is constant, the algebraic statement that its slope changes at a constant rate.

int(p) = 1x^3 - 0.5x^2 + 5x + C reverses the process term by term: Code Test integrates to Code Test, Code Test to Code Test, and Code Test to Code Test. The Code Test is appended by the demo because the library returns the Code Test antiderivative. Omitting Code Test is common in software but a frequent source of error, so the print statement makes it explicit.

int[0,2] = 16.0 evaluates the antiderivative at the endpoints. Substituting Code Test gives Code Test and substituting Code Test gives Code Test, so the definite integral is Code Test. This is the signed area under the curve between the two limits. The test file checks the simpler case Code Test with the same machinery.

p(2) = 15.0 is a direct evaluation: Code Test. This value does not involve calculus and confirms that evaluate agrees with arithmetic.

Taken together, the output shows the three requirements of a computer algebra system: rewrite an expression exactly, evaluate the result at a point, and remain consistent across inverse operations. The round-trip test verifies the last property.

What This Core Does Not Do

The limits of a small system are as important as its features, because they define the starting point for any larger implementation.

  • One variable only. Every polynomial carries a single Variable, and add refuses to mix variables. Multivariate polynomials would need a term to hold a monomial over several variables, and differentiation would become a partial derivative that names the variable to differentiate.
  • Polynomials only. There is no product rule, quotient rule, or chain rule, because there are no products, quotients, or compositions in the data model. Adding those rules means replacing the flat term list with a general expression tree whose nodes are Add, Mul, Pow, Sin, and so on. The pattern matching style carries over unchanged.
  • No simplification beyond collecting like terms. normalize combines coefficients, but it does not expand Code Test, factor Code Test, or cancel rational expressions. Each of those is a separate rewrite system.
  • The exponent is not constrained. Nothing stops a caller from constructing Code Test. Differentiation handles it, but integration divides by Code Test and would divide by zero. A require in the Term constructor or a check in integrateTerm would close the gap.
  • Floating point coefficients. Coefficients are Double, so a coefficient like Code Test is inexact. Collecting like terms compares against 0.0 with exact equality, which is safe for the halves and thirds produced by these rules but could misjudge a true cancellation that arrives by two rounding paths. A rational number type would make the algebra exact.

Each of these is a deliberate trade. The value of the small core is that every limitation is visible within a few dozen lines, and each has a clear path to a more capable implementation.

Wrap Up

This chapter implemented a working computer algebra core in pure Scala with no dependencies. The components are small, but together they cover the full path from representation to verification.

  • Terms and polynomials as case classes turn algebra into data. normalize restores a canonical form, which is what makes exact structural equality, and therefore round-trip testing, possible.
  • Differentiation is the power rule plus linearity. differentiateTerm returns an Option so vanishing constants are removed by flatMap, and differentiateN composes the rule for higher orders.
  • Integration is the reverse power rule plus the fundamental theorem of calculus. The constant of integration is dropped because the definite integral cancels it, and the round trip differentiate(integrate(p)) == p verifies that the two rules are inverse.
  • The test suite verifies each rule with a concrete value and one cross-check, using a tolerance for the floating point comparisons that exact equality cannot survive.

The broader point concerns representation. Here calculus is not a collection of formulas to memorize but a set of rewrite rules acting on a type. The same structure, small immutable value types with companion-object operations and pure functions, reappears later in this book for search states, grammar tags, and graph triples. When a later chapter addresses automatic differentiation or a neural-symbolic system, the symbolic component is the machinery built here, scaled up.

Optional Practice Problems

  1. Nicer printing. The demo prints 1x^3 and 1x where a mathematician writes x^3 and x. Modify Term.toString so a coefficient of 1 is omitted (except for a constant term, where it must still print), and update the assertion in symbolicMathTest that expects 1x^3.

  2. Guard the exponent. Add require(exponent >= 0, "Exponent must be non-negative.") to the Term case class. Confirm the existing tests still pass, then try constructing Term(1.0, Variable("x"), -1) and report the error. Explain why the guard is better placed on the type than inside Integrate.integrateTerm.

  3. Multiply two polynomials. Add def multiply(other: Polynomial): Polynomial to Polynomial. Multiply term by term, add the exponents and multiply the coefficients, and normalize the result. Test it on Code Test and confirm the answer is Code Test. Why can you not express this with the existing add and scale alone?

  4. Check the derivative against a finite difference. Write a function finiteDifference(p: Polynomial, x: Double, h: Double): Double that computes Code Test, and compare it to Code Test for Code Test at Code Test with Code Test, Code Test, and Code Test. Report the error at each step and explain why it eventually gets worse as Code Test shrinks.

  5. Numeric integration for comparison. Implement a Riemann sum or Simpson’s rule that approximates Code Test from evaluations of Code Test. Compare it to Integrate.definite for Code Test on Code Test and report the difference. Which method would you trust for a polynomial and why?

  6. Second derivative test. Add a function classifyCriticalPoint(p: Polynomial, x: Double): String that returns "minimum", "maximum", or "inflection" using the sign of the second derivative. Test it at Code Test for Code Test and confirm it reports a minimum.

  7. Higher-order round trip. Assert that Code Test for Code Test on a polynomial of degree at least three. Does the identity hold for every Code Test? Explain the role of the dropped constants.

  8. A general expression tree. Replace the flat polynomial with a sealed trait Expr and cases Const, Var, Add, Mul, and Pow. Implement derivative using the sum, product, and power rules. Test it on Code Test and compare the result to differentiating Code Test directly. What extra simplification would you need to make the two answers print identically?