L2 — The dual evaluation model
Goal: evaluate an expression, read the one type every result comes back in, and understand why half of it is not an error.
This is the idea the rest of the library is built on. If you take one lesson from this branch, take this one.
import it.grypho.scala.leonardo.core.*
import it.grypho.scala.leonardo.parser.Parser
// L1's reader, compressed: these lesson inputs are ones we control, so a failure is a bug
// in the lesson rather than something a caller should handle.
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")
Evaluating
Every node has the same method:
def eval(env: Environment): Either[_Expression, _Value]
Environment carries the variable bindings and the precision; the default has neither bound nor anything unusual:
expr("2 + 3").eval(Environment())
// res0: Either[_Expression, _Value] = Right(_Number(5.0))
Right holds a _Value — a concrete result. Now the same call on something with an unknown in it:
expr("2 + x").eval(Environment())
// res1: Either[_Expression, _Value] = Left(
// Sum(a = _Number(2.0), b = _Variable("x"))
// )
Why is Left not an error?
Because in this library it is an answer.
That inverts the convention you are used to: Left normally means failure. Here it means “this reduced as far as it can, and what is left is still an expression” — which for a computer algebra system is the ordinary outcome, not the sad one. 2 + x has no numeric value, and saying so by returning the expression is the correct and complete reply.
Right= it became a value.Left= it stayed symbolic. Neither is a failure.
The distinction that matters to a caller is therefore not success versus error, but “can I read a number out of this yet?” — and the type answers exactly that question, at compile time, every time.
A genuine failure looks different: a parse error (L1), or a None from a function that declines (L9). Nothing throws.
What counts as a _Value
_Value is a marker trait on the concrete results: _Number, _Complex, _Bool, _MatrixValue, _Rational and friends.
A free variable is deliberately not one. _Variable is a symbolic atom, so an expression that reduces to a bare variable is still a Left:
expr("x").eval(Environment())
// res2: Either[_Expression, _Value] = Left(_Variable("x"))
That is what keeps the two sides meaningful: Right really does mean “there is a number here”, with no cases where it is a symbol wearing a value’s clothes.
Binding a variable
withBinding returns a new environment — nothing is mutated:
val bound = Environment().withBinding("x", _Number(3))
expr("2 + x").eval(bound)
// res3: Either[_Expression, _Value] = Right(_Number(5.0))
Same expression, same call, different environment, and now a Right. The tree is immutable and reusable: parse once, evaluate under as many environments as you like.
Partial evaluation is the interesting case
Binding some of what an expression mentions does not fail and does not give a number — it gives you a smaller expression:
expr("a * b + 1").eval(Environment().withBinding("a", _Number(2)))
// res4: Either[_Expression, _Value] = Left(
// Sum(a = Product(a = _Number(2.0), b = _Variable("b")), b = _Number(1.0))
// )
a was substituted and folded; b could not be, so the answer is the simplified remainder. That is the dual model earning its keep: Left carries progress, not failure.
Collapsing the result back
When you need an _Expression again regardless of which side you got, toExpression is the extension for it:
expr("2 + x").eval(Environment()).toExpression
// res5: _Expression = Sum(a = _Number(2.0), b = _Variable("x"))
expr("2 + 3").eval(Environment()).toExpression
// res6: _Expression = _Number(5.0)
It works because every _Value is also an _Expression — the concrete results are nodes like any other. That is what lets you feed a partly-evaluated result straight back into another node, which is how the library rebuilds trees internally.
Reading a result properly
The idiom is an ordinary match, and writing it out once is worth it:
def report(text: String, env: Environment): String =
expr(text).eval(env) match
case Right(v) => s"value: $v"
case Left(e) => s"symbolic: $e"
report("1 + 1", Environment())
// res7: String = "value: 2.0"
report("1 + y", Environment())
// res8: String = "symbolic: (1.0 + y)"
Resist the urge to write .getOrElse with a sentinel, or to map Left onto an error type at this level. You would be throwing away the answer.
Try it yourself
Evaluate a * b + 1 with b = 5 bound but not a, then with both bound. Predict each result type before you run it.
Answers
```scala val justB = Environment().withBinding("b", _Number(5)) ``` ```scala expr("a * b + 1").eval(justB) // res9: Either[_Expression, _Value] = Left( // Sum(a = Product(a = _Variable("a"), b = _Number(5.0)), b = _Number(1.0)) // ) ``` ```scala expr("a * b + 1").eval(justB.withBinding("a", _Number(2))) // res10: Either[_Expression, _Value] = Right(_Number(11.0)) ``` The first is a `Left` holding a smaller expression; the second a `Right` holding `11`. Note that `withBinding` chains, because each call returns a fresh environment.What you learned
eval(env)returnsEither[_Expression, _Value]— one type for both outcomes.Leftis not an error.Rightmeans it became a value,Leftthat it stayed symbolic; the question the type answers is “can I read a number out of this yet?”- A free variable is not a
_Value, which is what keepsRighthonest. - Environments are immutable;
withBindingchains, and one parsed tree can be evaluated under many. Leftcarries progress — partial binding yields a smaller expression, not a failure.toExpressioncollapses either side back, because every_Valueis an_Expression.
Previous: L1 — Reading an expression · Next: L3 — Environment · All lessons