L6 — The calculus API
Goal: differentiate, integrate and tidy an expression from code — and read the answer correctly when the library declines to do what you asked.
import it.grypho.scala.leonardo.core.*
import it.grypho.scala.leonardo.scalar.*
import it.grypho.scala.leonardo.scalar.Syntax.*
import it.grypho.scala.leonardo.parser.Parser
// L1's reader, unchanged.
def expr(text: String): _Expression =
val parsed = Parser.parse(text)
if parsed.successful then parsed.get
else sys.error(s"lesson input failed to parse: $text")
val x = _Variable("x")
val y = _Variable("y")
They are all the same shape
def derive(e: _Expression, v: _Variable): _Expression
def integrate(e: _Expression, v: _Variable): _Expression
def simplify(e: _Expression): _Expression
def expand(e: _Expression): _Expression
def normalize(e: _Expression, v: _Variable): _Expression
Expression in, expression out. Note what is not in any of those signatures: no Environment, and no Either. These are syntactic transformations on a tree — they do not consult bindings, they do not evaluate, and they cannot fail in the sense L2 meant. Everything about how they refuse is in the returned expression, which is what most of this lesson is about.
Syntax makes them method calls, and chains read better that way:
expr("x^2").derive(x)
// res0: _Expression = Product(a = _Number(2.0), b = _Variable("x"))
Once you import Syntax, use the method form and not the function form. Importing both scalar.* and scalar.Syntax.* puts two derives in scope, and a bare derive(e, x) then fails to compile with “reference to derive is ambiguous”. It is not a clash worth avoiding by dropping either import — just pick the method form and stay with it, which is what the rest of this page does.
derive
Simple shapes come back already clean, because the rule table builds its results through smart constructors that drop the 1s and 0s as it goes:
expr("x^3 + sin(x)").derive(x)
// res1: _Expression = Sum(
// a = Product(
// a = _Number(3.0),
// b = Power(base = _Variable("x"), exp = _Number(2.0))
// ),
// b = Cos(_Variable("x"))
// )
That tidiness is local, though, and it stops at the node. A rule that assembles a larger expression leaves whatever its pieces happened to be — here the general power rule:
expr("x^x").derive(x)
// res2: _Expression = Product(
// a = Power(base = _Variable("x"), exp = _Variable("x")),
// b = Sum(
// a = Ln(_Variable("x")),
// b = Ratio(a = _Variable("x"), b = _Variable("x"))
// )
// )
Nothing is wrong with that; it is the derivative. But differentiating and simplifying are separate passes, so the tidying is yours to ask for:
expr("x^x").derive(x).simplifyFully()
// res3: _Expression = Product(
// a = Power(base = _Variable("x"), exp = _Variable("x")),
// b = Sum(a = Ln(_Variable("x")), b = _Number(1.0))
// )
Get into the habit of that pair. A raw derivative grows fast, and the growth compounds if you differentiate again without tidying in between.
Higher-order and mixed partials have their own entry points rather than being your fold:
expr("x^4").deriveN(x, 2).simplifyFully()
// res4: _Expression = Product(
// a = _Number(12.0),
// b = Power(base = _Variable("x"), exp = _Number(2.0))
// )
expr("x^2 * y^3").derive(x, y).simplifyFully()
// res5: _Expression = Product(
// a = Product(a = _Number(2.0), b = _Variable("x")),
// b = Product(
// a = _Number(3.0),
// b = Power(base = _Variable("y"), exp = _Number(2.0))
// )
// )
deriveN(v, n) repeats one variable; the varargs derive(e, v1, v2, …) folds left to right, so derive(f, x, y) is ∂/∂y(∂f/∂x) and derive(f, x, x) is the second derivative in x.
derive is memoised on (expression, variable) — it is pure, and expressions are immutable structural keys, so re-differentiating a shared sub-tree is free. You never need to cache it yourself.
The give-up convention
Here is the thing to learn before you use any of this in anger. Not every expression has a derivative in the rule table:
expr("digamma(x)").derive(x)
// res6: _Expression = _Derivative(
// e = Digamma(_Variable("x")),
// v = _Variable("x")
// )
It did not throw, it did not return None, and it did not lie. It returned the _Derivative node itself — “the derivative of digamma(x)” is a perfectly good answer to “what is the derivative of digamma(x)?”, and it is the honest one when the engine has no rule. Every functional in the library does this: a fixpoint rather than a failure.
The frontier is visible if you look at the neighbouring function. Gamma does differentiate, by Γ(x)·ψ(x):
expr("Gamma(x)").derive(x)
// res7: _Expression = Product(
// a = Gamma(_Variable("x")),
// b = Digamma(_Variable("x"))
// )
It differentiates precisely because digamma exists as a node to write the answer in — and digamma does not, because trigamma is not implemented. Every rule table ends somewhere, and that edge is a fact about this library rather than about mathematics.
The cost is that a caller has to look, and the library never does it for you. So write the check, which is L5’s traversal and three lines:
/** True when `e` still contains an unevaluated derivative. */
def containsDerivative(e: _Expression): Boolean = e match
case _: _Derivative => true
case other => other.children.exists(containsDerivative)
containsDerivative(expr("x^3").derive(x))
// res8: Boolean = false
containsDerivative(expr("digamma(x)").derive(x))
// res9: Boolean = true
That is exactly how the library guards itself: a Taylor expansion aborts entirely if any coefficient still contains a _Derivative, because a series carrying an unevaluated derivative is worse than no series at all. Adopt the same rule — check at the boundary of your own computation, and refuse rather than pass a half-answer downstream.
integrate
The counterpart, and much more likely to decline:
expr("2*x + cos(x)").integrate(x)
// res10: _Expression = Sum(
// a = Product(
// a = _Number(2.0),
// b = Ratio(
// a = Power(base = _Variable("x"), exp = _Number(2.0)),
// b = _Number(2.0)
// )
// ),
// b = Ratio(a = Sin(_Variable("x")), b = _Number(1.0))
// )
Two things to notice. There is no constant of integration — the result is an antiderivative, and inventing a + C as a free variable would put a name into your expression that you would then have to explain to everyone reading it. And again nothing tidied up:
expr("2*x + cos(x)").integrate(x).simplifyFully()
// res11: _Expression = Sum(
// a = Product(
// a = _Number(2.0),
// b = Ratio(
// a = Power(base = _Variable("x"), exp = _Number(2.0)),
// b = _Number(2.0)
// )
// ),
// b = Sin(_Variable("x"))
// )
When no rule fires you get the same fixpoint you got from derive:
expr("exp(x^2)").integrate(x)
// res12: _Expression = _Integral(
// e = Exp(Power(base = _Variable("x"), exp = _Number(2.0))),
// v = _Variable("x")
// )
/** True when `e` still contains an unsolved indefinite integral. */
def containsIntegral(e: _Expression): Boolean = e match
case _: _Integral => true
case other => other.children.exists(containsIntegral)
containsIntegral(expr("exp(x^2)").integrate(x))
// res13: Boolean = true
That expression genuinely has no elementary antiderivative, so the refusal is the mathematics and not a gap. But you cannot tell those two apart from the outside, and you do not need to — either way the answer is “not in closed form”, and your code takes the same branch.
Check an antiderivative by differentiating it back
This is the cheapest correctness test you can run against an integral, and it is what the library’s own rule-table harness does for every rule it ships. The obvious way to write it does not work, and finding out why is worth the detour — compare the derivative of the antiderivative with the integrand, structurally:
expr("2*x").integrate(x).derive(x).simplifyFully() == expr("2*x").simplifyFully()
// res14: Boolean = false
The antiderivative is perfectly correct and the test says no. ∫2x comes back as 2·(x²/2), whose derivative is 2·(2x/2) — equal to 2x as a number at every point, and a different tree. == on expressions is structural equality, and two expressions being equal as functions is not something a structural comparison can see.
So compare values, not shapes:
/** True when `a` and `b` agree numerically at every sample point. */
def agreesAt(a: _Expression, b: _Expression, v: _Variable, points: List[Double]): Boolean =
points.forall: p =>
val env = Environment().withBinding(v.variable, _Number(p))
(a.eval(env), b.eval(env)) match
case (Right(_Number(p1)), Right(_Number(p2))) => math.abs(p1 - p2) < 1e-9
case _ => false
/** An antiderivative of `text` in `v`, verified by differentiating it back. */
def antiderivative(text: String, v: _Variable): Option[_Expression] =
val f = expr(text)
val result = f.integrate(v)
Option.when(!containsIntegral(result) && agreesAt(result.derive(v), f, v, List(0.3, 1.7, 2.9)))(result)
antiderivative("2*x", x)
// res15: Option[_Expression] = Some(
// Product(
// a = _Number(2.0),
// b = Ratio(
// a = Power(base = _Variable("x"), exp = _Number(2.0)),
// b = _Number(2.0)
// )
// )
// )
antiderivative("cos(x)", x)
// res16: Option[_Expression] = Some(
// Ratio(a = Sin(_Variable("x")), b = _Number(1.0))
// )
antiderivative("exp(x^2)", x)
// res17: Option[_Expression] = None
Two refusals with different meanings, and the function collapses them deliberately: the first two closed and verified, the third never closed at all. Sampling is not a proof — a handful of points cannot distinguish two functions that agree on them — but for catching a wrong antiderivative it is both sufficient in practice and the only thing that scales.
simplify and simplifyFully
simplify is one structural pass: identity and absorbing elements, constant folding, same-operand rules (x + x → 2x, x * x → x^2), double negation, inverse function pairs, and known values at zero.
expr("x + 0 + 1*x").simplify()
// res18: _Expression = Product(a = _Number(2.0), b = _Variable("x"))
simplifyFully iterates it to a fixpoint, which terminates because a pass never makes an expression bigger. In your own code, call simplifyFully — a single pass is what the library composes internally, not what a caller usually wants.
Now the three things it does not do, each of which catches people.
It does not cancel common factors. It is a structural rewriter, not a normal form, and nothing in it knows how to divide:
expr("(x^2 - 1)/(x - 1)").simplifyFully()
// res19: _Expression = Ratio(
// a = Sum(
// a = Power(base = _Variable("x"), exp = _Number(2.0)),
// b = _Number(-1.0)
// ),
// b = Sum(a = _Variable("x"), b = _Number(-1.0))
// )
It does not evaluate. simplify and eval are different passes, so a functional node stays a node no matter how fully you simplify it:
_Derivative(expr("sin(x)"), x).simplifyFully()
// res20: _Expression = _Derivative(
// e = Sin(_Variable("x")),
// v = _Variable("x")
// )
_Derivative(expr("sin(x)"), x).eval(Environment())
// res21: Either[_Expression, _Value] = Left(Cos(_Variable("x")))
Both are right, and they answer different questions — tidy this tree against work this out. (The REPL’s simplify command runs the second pass for you, which is why a prompt session and a library call can look as though they disagree.)
It ignores bindings, having no environment to read them from:
expr("x + x").simplifyFully()
// res22: _Expression = Product(a = _Number(2.0), b = _Variable("x"))
That is not a limitation — it is what makes simplification safe to run on an expression you are going to evaluate later, under an environment you do not have yet.
expand and normalize
expand distributes * over +, expands integer powers, and collects like terms as it goes:
expr("(x + 1)*(x + 2)").expand()
// res23: _Expression = Sum(
// a = Sum(
// a = Power(base = _Variable("x"), exp = _Number(2.0)),
// b = Product(a = _Number(3.0), b = _Variable("x"))
// ),
// b = _Number(2.0)
// )
The terms keep the order they first appeared in, and a product whose expansion would be deeper than the library’s limit of 200 levels — a sum is as deep as it has terms — is left as written rather than built. Reading the result as a polynomial in one variable is a different question, because it needs to be told which variable that is:
expr("(x + 1)*(x + 2)").expand().normalize(x)
// res24: _Expression = Sum(
// a = Sum(a = _Number(2.0), b = Product(a = _Number(3.0), b = _Variable("x"))),
// b = Power(base = _Variable("x"), exp = _Number(2.0))
// )
Underneath is collect, which hands you the dense coefficient vector, lowest degree first — the same shape L4’s polynomial builder consumes, so the two compose into a round trip:
expr("(x + 1)*(x + 2)").expand().collect(x)
// res25: Option[Vector[_Expression]] = Some(
// Vector(_Number(2.0), _Number(3.0), _Number(1.0))
// )
And it returns an Option because not everything is a polynomial. This is the one function in the lesson that refuses with None rather than with a node, because “the coefficients of sin(x) in x” is not a question with an unhelpful answer — it is a question with no answer:
expr("sin(x)").collect(x)
// res26: Option[Vector[_Expression]] = None
normalize on the same input returns it unchanged rather than failing, which is the convention everywhere else. Two functions, two refusal styles, and the Option is the one to reach for when you need to branch.
Try it yourself
Write stationaryPoints(text: String, v: _Variable): Option[List[_Expression]] — differentiate, and solve the derivative for zero. Return None if the derivative did not close. equation.solve(eq, v, env) returns a List[_Equation] in the shape v = expr, and _Equation(lhs, rhs) builds the equation.
Answers
```scala import it.grypho.scala.leonardo.equation.{_Equation, solve} def stationaryPoints(text: String, v: _Variable): Option[List[_Expression]] = val d = expr(text).derive(v).simplifyFully() Option.when(!containsDerivative(d)): solve(_Equation(d, _Number(0)), v, Environment()).map(_.rhs) ``` ```scala stationaryPoints("x^2 - 4*x + 1", x) // res27: Option[List[_Expression]] = Some(List(_Number(2.0))) ``` ```scala stationaryPoints("x^3 - 3*x", x) // res28: Option[List[_Expression]] = Some(List(_Number(-1.0), _Number(1.0))) ``` ```scala stationaryPoints("digamma(x)", x) // res29: Option[List[_Expression]] = None ``` The three outcomes are the three you have to handle everywhere in this library: one answer, more than one answer, and *the question could not be reached*. Note that the last is `None` because **we** decided to make it one — `derive` handed back a `_Derivative` node, and solving that for zero would have produced a confident and meaningless list. The `simplifyFully` before solving is cheap insurance rather than decoration. The solver reads coefficients through `collect`, which recognises polynomial *structure* — and while this particular derivative arrives clean, the `x^x` case above is what a leftover `x/x` in the middle of one looks like. Tidying first costs a pass and removes the question.What you learned
- The calculus functions are expression in, expression out — no environment, no
Either. They transform trees; they do not evaluate them. - Derivatives and integrals come back untidy.
derivethensimplifyFullyis the pair to reach for, andderiveis memoised so repeating it is free. - The give-up convention: an unsolved functional is returned as itself. Nothing throws and nothing returns
None, so you must check — a three-line traversal overchildren, and the same one the library uses to guard its own series expansions. integrateyields an antiderivative with no+ C, and differentiating the result back is the cheapest test you can write against it.simplifyis structural: no common-factor cancellation, no evaluation of functionals, and no knowledge of bindings. CallsimplifyFully; useevalwhen you want the arithmetic done.expandmultiplies out and collects like terms —normalize(v)orders them as a polynomial inv, andcollect(v)gives the coefficient vector as anOption, the one refusal here that is not a node.
Previous: L5 — Traversal · Next: L7 — Numbers · All lessons