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) returns Either[_Expression, _Value] — one type for both outcomes.
  • Left is not an error. Right means it became a value, Left that 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 keeps Right honest.
  • Environments are immutable; withBinding chains, and one parsed tree can be evaluated under many.
  • Left carries progress — partial binding yields a smaller expression, not a failure.
  • toExpression collapses either side back, because every _Value is an _Expression.

Previous: L1 — Reading an expression · Next: L3 — Environment · All lessons


This site uses Just the Docs, a documentation theme for Jekyll.