L10 — A small application, end to end
Goal: build one real thing. Nine lessons of pieces, assembled into a program that takes a function typed by a person and reports what can be said about it — and that behaves properly when the answer is nothing.
import it.grypho.scala.leonardo.core.*
import it.grypho.scala.leonardo.scalar.*
import it.grypho.scala.leonardo.scalar.Syntax.*
import it.grypho.scala.leonardo.equation.{_Equation, solve}
import it.grypho.scala.leonardo.parser.Parser
What we are building
analyse(text, variable) → a report carrying the derivative, the stationary points, an antiderivative where one exists, and a few sampled values. Text in, a typed record out, every refusal represented rather than hidden.
The interesting design question is not how — every piece is one call from an earlier lesson — but what the record is allowed to contain. Get that wrong and the whole thing lies.
The record
case class Report(
input: String,
expression: _Expression,
variables: List[String],
derivative: Option[_Expression], // None: no rule fired
stationary: Option[List[_Expression]],// None: could not be asked; Some(Nil): none exist
antiderivative: Option[_Expression], // None: not in closed form
samples: Vector[(Double, Double)]
)
Three of the fields are Option, and one is an Option of a List — which is the field worth pausing on. None and Some(Nil) are different answers: we could not look for stationary points against we looked and there are none. Collapsing them to an empty list would make a failed derivative indistinguishable from a monotonic function, which is L9’s rule about not papering over a refusal, applied at the level of a data structure.
The pieces
L1’s reader, converting the foreign ParseResult at the boundary:
def read(text: String): Either[String, _Expression] =
val parsed = Parser.parse(text)
if parsed.successful then Right(parsed.get)
else Left(s"parse error: ${parsed.toString.linesIterator.next()}")
L5’s traversal, twice, for L6’s give-up convention:
def contains(e: _Expression)(p: _Expression => Boolean): Boolean =
p(e) || e.children.exists(contains(_)(p))
def hasDerivative(e: _Expression): Boolean = contains(e)(_.isInstanceOf[_Derivative])
def hasIntegral(e: _Expression): Boolean = contains(e)(_.isInstanceOf[_Integral])
And L6’s numeric check, because expression equality is structural and would reject a correct antiderivative:
def agreesAt(a: _Expression, b: _Expression, v: _Variable, pts: List[Double]): Boolean =
pts.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
Assembling
def analyse(text: String, v: _Variable): Either[String, Report] =
read(text).map: e =>
val d = e.derive(v).simplifyFully()
val derivative = Option.unless(hasDerivative(d))(d)
val stationary = derivative.map: dv =>
solve(_Equation(dv, _Number(0)), v, Environment()).map(_.rhs)
val anti = e.integrate(v).simplifyFully()
val antiderivative =
Option.when(!hasIntegral(anti) && agreesAt(anti.derive(v), e, v, List(0.3, 1.7)))(anti)
Report(
input = text,
expression = e,
variables = e.freeVars.toList.sorted,
derivative = derivative,
stationary = stationary,
antiderivative = antiderivative,
samples = e.sample(v, -2, 2, 5)
)
Nineteen lines, and every refusal in it is load-bearing: Option.unless on the derivative, .map on the stationary points so they inherit the derivative’s absence, and Option.when with a numeric verification on the antiderivative.
Running it
A polynomial, where everything closes:
analyse("x^3 - 3*x", _Variable("x"))
// res0: Either[String, Report] = Right(
// Report(
// input = "x^3 - 3*x",
// expression = Sum(
// a = Power(base = _Variable("x"), exp = _Number(3.0)),
// b = Product(
// a = _Number(-1.0),
// b = Product(a = _Number(3.0), b = _Variable("x"))
// )
// ),
// variables = List("x"),
// derivative = Some(
// Sum(
// a = Product(
// a = _Number(3.0),
// b = Power(base = _Variable("x"), exp = _Number(2.0))
// ),
// b = _Number(-3.0)
// )
// ),
// stationary = Some(List(_Number(-1.0), _Number(1.0))),
// antiderivative = Some(
// Sum(
// a = Ratio(
// a = Power(base = _Variable("x"), exp = _Number(4.0)),
// b = _Number(4.0)
// ),
// b = Product(
// a = _Number(-3.0),
// b = Ratio(
// a = Power(base = _Variable("x"), exp = _Number(2.0)),
// b = _Number(2.0)
// )
// )
// )
// ),
// samples = Vector(
// (-2.0, -2.0),
// (-1.0, 2.0),
// (0.0, 0.0),
// (1.0, -2.0),
// (2.0, 2.0)
// )
// )
// )
Now something whose antiderivative is not elementary:
analyse("exp(x^2)", _Variable("x")).map(_.antiderivative)
// res1: Either[String, Option[_Expression]] = Right(None)
analyse("exp(x^2)", _Variable("x")).map(_.derivative.isDefined)
// res2: Either[String, Boolean] = Right(true)
The derivative is there and the integral is not, reported separately — which is the whole reason they are separate fields.
And input that never becomes an expression at all:
analyse("sin(", _Variable("x"))
// res3: Either[String, Report] = Left(
// "parse error: [1.5] failure: '(' expected but end of source found"
// )
Rendering it
The record is the product; presenting it is a second, separable job — and this is where L7’s display rule lands:
def render(r: Report): String =
val lines = List(
s"input: ${r.input}",
s"parsed: ${r.expression}",
s"variables: ${r.variables.mkString(", ")}",
s"derivative: ${r.derivative.fold("— no rule applies")(_.toString)}",
s"stationary: ${r.stationary.fold("— not determined") {
case Nil => "none"
case sols => sols.mkString(", ")
}}",
s"integral: ${r.antiderivative.fold("— not in closed form")(_.toString)}",
// _Number(_).display, never f"%.3f": see the note below.
s"samples: ${r.samples.map((a, b) => s"(${_Number(a).display(1)}, ${_Number(b).display(3)})").mkString(" ")}"
)
lines.mkString("\n")
analyse("x^3 - 3*x", _Variable("x")).map(render).foreach(println)
// input: x^3 - 3*x
// parsed: ((x ^ 3.0) + (-1.0 * (3.0 * x)))
// variables: x
// derivative: ((3.0 * (x ^ 2.0)) + -3.0)
// stationary: -1.0, 1.0
// integral: (((x ^ 4.0) / 4.0) + (-3.0 * ((x ^ 2.0) / 2.0)))
// samples: (-2.0, -2.0) (-1.0, 2.0) (0.0, 0.0) (1.0, -2.0) (2.0, 2.0)
analyse("exp(x^2)", _Variable("x")).map(render).foreach(println)
// input: exp(x^2)
// parsed: exp((x ^ 2.0))
// variables: x
// derivative: (exp((x ^ 2.0)) * (2.0 * x))
// stationary: 0.0
// integral: — not in closed form
// samples: (-2.0, 54.598) (-1.0, 2.718) (0.0, 1.0) (1.0, 2.718) (2.0, 54.598)
Two reports, the same shape, and the second is honest about its two gaps instead of omitting the rows. A reader can tell the difference between “there is no antiderivative” and “this program did not try”, which is the property the whole design was arranged around.
Note where the rounding happens: the samples are full Doubles in the record and are formatted only in render, which is L3’s rule — never round a value before storing it.
And note how. The obvious f"($a%.1f, $b%.3f)" is wrong here, and this page caught it: the f interpolator formats through the default locale, so on a machine set to Italian it printed (-2,0, -2,000) and would have printed (-2.0, -2.000) on the Linux runner that publishes the site — the same document rendering differently depending on who built it. _Number(d).display(p) goes through the library’s own renderer, which is locale-independent and platform-independent; the second matters because Scala.js renders a bare Double the JavaScript way. If you are formatting a number out of this library, ask the library to do it.
What this deliberately does not do
Worth stating, because each is the moment you would reach for a further lesson or a further package:
- No classification of the stationary points. That needs the second derivative’s sign at each root —
e.deriveN(v, 2)and an evaluation, which is an exercise below. - No domain analysis.
domain(e, x)would say where the function is defined at all, and the samples silently drop non-finite points today. - No plotting. The samples are the data a plotter takes; the browser REPL draws them.
- It analyses one variable.
variablesreports the others, and a second free name makes every numeric row above depend on a binding nobody supplied.
Each is a small addition to the same record — which is what the record being a plain case class buys.
Try it yourself
Add a classified: List[(String, String)] field pairing each stationary point with "minimum", "maximum" or "inflection", from the sign of the second derivative there.
Answers
```scala /** Classifies a stationary point by the sign of the second derivative at it. */ def classify(e: _Expression, v: _Variable, at: _Expression): String = val second = e.deriveN(v, 2).simplifyFully() at.eval(Environment()) match case Right(_Number(p)) => second.eval(Environment().withBinding(v.variable, _Number(p))) match case Right(_Number(d)) if d > 1e-12 => "minimum" case Right(_Number(d)) if d < -1e-12 => "maximum" case Right(_Number(_)) => "inflection (second derivative vanishes)" case _ => "undetermined" case _ => "undetermined (point is not numeric)" ``` ```scala analyse("x^3 - 3*x", _Variable("x")).map: r => r.stationary.getOrElse(Nil).map(p => p.toString -> classify(r.expression, _Variable("x"), p)) // res6: Either[String, List[Tuple2[String, String]]] = Right( // List(("-1.0", "maximum"), ("1.0", "minimum")) // ) ``` A minimum at `1` and a maximum at `-1`, which is right for `x³ − 3x`. Two details that are the lesson rather than the calculus. The **tolerance** is unavoidable: the second derivative at a stationary point is a computed `Double`, so `> 0` would classify numerical noise, and `1e-12` is a threshold you have chosen and should be able to defend. And the **`case _ => "undetermined"`** arms are not padding — a symbolic root (from a solver given symbolic coefficients) is a perfectly ordinary outcome here, and this function refuses it the way the library would rather than forcing a number out of it.What you learned
- Design the record before the code.
Option[List[…]]for the stationary points is the whole design:NoneandSome(Nil)are genuinely different answers, and merging them would have made the program lie. - Every piece was one call from an earlier lesson — reading at the boundary (L1), traversal for the give-up convention (L5), numeric rather than structural verification (L6), refusals kept distinct (L9).
- Formatting is a separate job from computing. Full
Doubles in the record, rounding only inrender. - A program built this way degrades one field at a time: a declined integral costs you the integral and nothing else.
Where to go next
That is Branch L. You have the parser, the dual model, environments, tree construction, traversal, the calculus API, the numeric tiers, the REPL as an embeddable object, and the refusal catalogue — which is the whole of the library’s working surface.
- The reference pages document the domains this branch never opened: matrices, equations and inequalities, logic, transforms, differential equations, statistics, vector calculus and control theory. They all speak the same
_Expression/evallanguage you now know. - Lessons: at the prompt is the other branch, and L8 is the crossing point.
- The architecture and developer pages are for adding a node or a domain of your own.
Previous: L9 — Refusals · Next: L11 — Matrices · All lessons