L9 — Refusals
Goal: recognise every shape in which this library says no, and handle each one — because it says no often, on purpose, and in five different ways.
import it.grypho.scala.leonardo.core.*
import it.grypho.scala.leonardo.scalar.*
import it.grypho.scala.leonardo.scalar.Syntax.*
import it.grypho.scala.leonardo.matrix.*
import it.grypho.scala.leonardo.parser.Parser
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")
The principle
A computer algebra system is asked questions it cannot answer all day: integrals with no elementary antiderivative, matrices with no inverse, limits that need a continuity proof the engine cannot do. There are two ways to respond, and this library has picked one everywhere.
A refusal is better than a confident wrong answer. The bad outcome is not an error — it is a plausible reply to a question you did not ask.
You have already met several: curl outside three dimensions, a _Limit left opaque under differentiation, prob(2*X < 6) declined rather than divided by a coefficient of unknown sign, an essential singularity refused rather than truncated. Every one of them could have returned something.
The cost lands on you. Nothing throws, so nothing interrupts you — and a refusal you do not check for is a refusal that flows downstream looking like a result.
Shape 1 — Left: it stayed symbolic
L2’s dual model. Not an error:
expr("2 + x").eval(Environment())
// res0: Either[_Expression, _Value] = Left(
// Sum(a = _Number(2.0), b = _Variable("x"))
// )
Handle by: matching. Left carries the most-reduced form, so it is progress, and often the answer you wanted.
Shape 2 — the node returned unchanged
L6’s fixpoint convention. derive and integrate return the _Derivative / _Integral node itself when no rule fires:
expr("exp(x^2)").integrate(x)
// res1: _Expression = _Integral(
// e = Exp(Power(base = _Variable("x"), exp = _Number(2.0))),
// v = _Variable("x")
// )
Handle by: a traversal, because the node can be buried anywhere in a larger result:
/** True when `e` still contains an unsolved integral or derivative. */
def unresolved(e: _Expression): Boolean = e match
case _: _Integral | _: _Derivative => true
case other => other.children.exists(unresolved)
unresolved(expr("exp(x^2)").integrate(x))
// res2: Boolean = true
This is the shape that most needs a deliberate check, because the type is _Expression either way — there is nothing to force your hand.
Shape 3 — None
Used where the question itself has no answer, rather than an unhelpful one. collect asks for polynomial coefficients:
expr("sin(x)").collect(x)
// res3: Option[Vector[_Expression]] = None
compile asks for a closure and declines a structure it cannot reduce to arithmetic:
expr("integral(sin(t), t, 0, x)").compile(x).isDefined
// res4: Boolean = false
And in the matrix kernels, a singular matrix has no inverse:
_MatrixValue(2, 2, Array(1.0, 2.0, 2.0, 4.0)).inverse
// res5: Option[_MatrixValue] = None
Handle by: Option’s own combinators — this is the one shape Scala already forces you to deal with.
Shape 4 — an empty collection
solve returns a List[_Equation], and no solutions is a perfectly good answer:
import it.grypho.scala.leonardo.equation.solve
solve(expr("x^2 + 1 = 0").asInstanceOf[it.grypho.scala.leonardo.equation._Equation], x, Environment())
// res6: List[_Equation] = List()
Handle by: checking for empty before .head. An empty list and a one-element list are different answers, and neither is a failure.
Shape 5 — a parse failure
L1’s ParseResult, the only shape that is a genuine error, and the only foreign type:
Parser.parse("sin(").successful
// res7: Boolean = false
Handle by: converting at the boundary, once.
The one that is not a refusal
The trap L1 opened with, restated because it belongs in this catalogue more than anywhere else:
expr("sqrt(x)").toString
// res8: String = "(sqrt * x)"
There is no unknown-identifier error. An unrecognised name is a free variable and juxtaposition is multiplication, so this is the product of two variables — parsed, evaluable, round-trippable, and not what anyone meant.
Every other shape on this page is the library refusing. This one is the library succeeding at something you did not ask for, and it is the only one that can silently produce a wrong number.
Which is why validating names against Parser.ReservedWords is not optional if the text came from a person:
Parser.ReservedWords.contains("sqrt")
// res9: Boolean = false
Putting it together
The five shapes collapse into one question — did I get an answer? — and it is worth writing that funnel once:
enum Outcome:
case Value(d: Double)
case Symbolic(e: _Expression)
case Declined(reason: String)
/** Parses, evaluates, and reports which of the outcomes happened. */
def attempt(text: String): Outcome =
val parsed = Parser.parse(text)
if !parsed.successful then Outcome.Declined("does not parse")
else
val e = parsed.get
if unresolved(e) then Outcome.Declined("contains an unsolved functional")
else e.eval(Environment()) match
case Right(_Number(d)) => Outcome.Value(d)
case Right(v) => Outcome.Symbolic(v)
case Left(sym) => Outcome.Symbolic(sym)
attempt("2 + 2")
// res10: Outcome = Value(4.0)
attempt("2 + y")
// res11: Outcome = Symbolic(Sum(a = _Number(2.0), b = _Variable("y")))
attempt("sin(")
// res12: Outcome = Declined("does not parse")
attempt("integral(exp(t^2), t)")
// res13: Outcome = Declined("contains an unsolved functional")
Four inputs, four different shapes, one return type. Note that three of the four are not errors — and a funnel that mapped them all onto a thrown exception or an error string would be throwing away most of what the library told you.
Refusals you should not “fix”
A closing warning, because the temptation is real. When a tier declines, the fix is almost never to make your caller guess:
- Do not substitute a default for a
Nonefromcollect— a non-polynomial is not a polynomial with zero coefficients. - Do not treat an unsolved
_Integralas zero, or drop it from a sum. - Do not read the real part of a
_Complexbecause your downstream wants aDouble.
Each of those converts an honest refusal into exactly the confident wrong answer the refusal existed to prevent. If you genuinely need a number, say where it came from — a numeric integral instead of a symbolic one, a sampled value instead of a closed form — so the approximation is visible in your own code rather than hidden in a fallback.
Try it yourself
Extend attempt with a fifth outcome: a Declined("no solutions") for an equation that solves to nothing, so attempt("x^2 + 1 = 0") is distinguishable from an expression that merely stayed symbolic.
Answers
```scala import it.grypho.scala.leonardo.equation._Equation /** As `attempt`, but an unsolvable equation is reported rather than echoed. */ def attempt2(text: String, v: _Variable): Outcome = val parsed = Parser.parse(text) if !parsed.successful then Outcome.Declined("does not parse") else parsed.get match case eq: _Equation => solve(eq, v, Environment()) match case Nil => Outcome.Declined("no solutions") case sols => Outcome.Symbolic(sols.head.rhs) case e => attempt(text) ``` ```scala attempt2("2x + 6 = 0", x) // res14: Outcome = Symbolic(_Number(-3.0)) ``` ```scala attempt2("x^2 + 1 = 0", x) // res15: Outcome = Declined("no solutions") ``` ```scala attempt2("2 + 2", x) // res16: Outcome = Value(4.0) ``` The `case eq: _Equation` arm is doing the interesting work: a relation is an ordinary expression here, so *asking whether the input was an equation at all* is a pattern match rather than a separate parse. That is the same structural-dispatch habit L5 built. Worth noticing what this does **not** do: it reports "no solutions" for `x^2 + 1 = 0` over the reals rather than inventing `±i`. Whether that is right depends on your application — and the point is that the decision is now visibly yours, made in your code, instead of being a default buried in the library.What you learned
- A refusal beats a confident wrong answer, and this library refuses often and deliberately. Nothing throws, so an unchecked refusal flows downstream looking like a result.
- Five shapes:
Left(stayed symbolic), the unchanged node (derive/integrate),None(collect,compile, the matrix kernels), an empty collection (solve), and a parse failure. - The unchanged node is the one with no type-level signal — it needs a traversal, and it is the one people miss.
- An unknown name is not a refusal at all: it parses as a variable, which is the only case that can silently give you a wrong number. Validate against
Parser.ReservedWords. - Collapse them into one outcome type at your boundary, and remember most outcomes are not errors.
- Do not paper over a refusal with a default, a zero or a discarded imaginary part. If you need a number anyway, take it by a route that says so.
Previous: L8 — Embedding a Session · Next: L10 — A small application, end to end · All lessons