L3 — Environment

Goal: bind names, evaluate one tree under many environments, and know exactly what precision changes — which is less, and stranger, than you would guess.

import it.grypho.scala.leonardo.core.*
import it.grypho.scala.leonardo.parser.Parser

// L1's reader, compressed again: these inputs are ours, so a failure is a bug in the lesson.
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")

The one argument eval takes

eval(env: Environment) — the environment is the entire context an evaluation has. It carries the variable bindings and a small number of numeric settings, and nothing else; there is no global state anywhere in the library, so two evaluations under two environments cannot interfere.

It is immutable. withBinding returns a new one:

val base = Environment()
// base: Environment = it.grypho.scala.leonardo.core.Environment@785b3b72
val withX = base.withBinding("x", _Number(3))
// withX: Environment = it.grypho.scala.leonardo.core.Environment@5512def4
base.isBound("x")
// res0: Boolean = false
withX.isBound("x")
// res1: Boolean = true

get reads a binding back, as an Option:

withX.get("x")
// res2: Option[_Value] = Some(_Number(3.0))
withX.get("y")
// res3: Option[_Value] = None

Bindings are values, not expressions

This is the fact to take away from the lesson, and it is enforced by the type: a binding maps a name to a _Value — a number, a matrix, a truth value — never to a symbolic expression.

base.withBinding("f", expr("sin(x)"))
// error:
// Found:    it.grypho.scala.leonardo.core._Expression
// Required: it.grypho.scala.leonardo.core._Value
// base.withBinding("f", expr("sin(x)"))
//                       ^^^^^^^^^^^^^^

That is deliberate. An environment answers “what is this name worth?”, and a symbolic body is not a worth — it is another expression, which would have to be evaluated in some environment, which may contain more definitions, which is a different and much larger idea. Leonardo keeps the two apart: values here, named definitions above.

If what you want is f := sin(x) + x, you have two routes and neither is an environment:

  • scalar.substitute(e, definitions) rewrites names into bodies before evaluation — L4 and L6 use it;
  • cli.Session already implements the whole scheme, late binding included, and L8 embeds one.

One tree, many environments

Parsing is the expensive half and the tree is immutable, so parse once and evaluate as often as you like:

val parabola = expr("x^2 + 1")
(0 to 4).map(i => parabola.eval(Environment().withBinding("x", _Number(i))))
// res5: IndexedSeq[Either[_Expression, _Value]] = Vector(
//   Right(_Number(1.0)),
//   Right(_Number(2.0)),
//   Right(_Number(5.0)),
//   Right(_Number(10.0)),
//   Right(_Number(17.0))
// )

That is a table of five Rights from one parse. Evaluating a tree does not consume or alter it, and an environment built inside a loop costs a map insertion — this is the normal way to use the library, not an abuse of it. (For sampling a curve at many points there is a compiled fast path, scalar.sample; see Expressions. The shape above is what it does.)

A binding that is not needed is simply not read, and a binding that is missing leaves that part of the expression symbolic — which is L2’s point, and it is what makes partial environments useful rather than an error:

expr("a * b").eval(Environment().withBinding("a", _Number(7)))
// res6: Either[_Expression, _Value] = Left(
//   Product(a = _Number(7.0), b = _Variable("b"))
// )

precision is about display — and eval never rounds

An environment carries a precision, and the natural assumption is that it is the accuracy of the computation. It is not. Every computation runs at full Double precision, always; _Number.eval returns the number it holds and rounds nothing. Precision is what a renderer rounds to.

So it will not change a result:

expr("1/3").eval(Environment(precision = 2))
// res7: Either[_Expression, _Value] = Right(_Number(0.3333333333333333))
expr("1/3").eval(Environment(precision = 12))
// res8: Either[_Expression, _Value] = Right(_Number(0.3333333333333333))

Same value both times, and it is the full Double — all seventeen digits of it are sitting in the result, whatever the environment says. Nothing was rounded away, so nothing is lost.

Printing is a second, separate decision, and it does not consult the environment either:

expr("1/3").eval(Environment(precision = 12)).toExpression.toString
// res9: String = "0.33333"

Five decimals, from an environment that asked for twelve. _Number.toString is fixed at the library default, because toString is what serialisation writes and what the round-trip invariant re-parses — it cannot depend on a setting without the saved form depending on one too.

To show a number at a precision of your own, ask it to:

/** Evaluates `text` and renders a numeric result at `precision` decimal places. */
def shownAt(text: String, precision: Int): String =
  expr(text).eval(Environment(precision)) match
    case Right(n: _Number) => n.display(precision)
    case Right(v)          => v.toString
    case Left(e)           => e.toString
shownAt("1/3", 2)
// res10: String = "0.33"
shownAt("1/3", 15)
// res11: String = "0.333333333333333"

One computation, two renderings. If you are writing a front end, this is your job and not the library’s — carry the precision, call display, and never round a value before storing it.

Where precision does change the answer

Two places, and both are asking it a different question.

Equality is tolerance-based, and the tolerance is 0.5 · 10⁻ᵖ. Floating-point arithmetic makes exact comparison useless — sin(pi) is not 0 and 0.1 + 0.2 is not 0.3 — so an equation compares within the precision you said you cared about:

expr("0.1 + 0.2 = 0.3").eval(Environment())
// res12: Either[_Expression, _Value] = Right(_Bool(true))
expr("0.1 + 0.2 = 0.3").eval(Environment(precision = 17))
// res13: Either[_Expression, _Value] = Right(_Bool(false))

Both answers are correct, and they are answers to different questions: equal to five decimals, and equal to seventeen. Nothing has been hidden from you — the sum really does differ from 0.3 in the seventeenth place — but a program that compares numbers must choose a tolerance, and this is where you choose it.

The numeric tiers read it as an accuracy request. A definite integral is Simpson’s rule and its step count is scaled from precision, as is the Runge–Kutta solver behind ode. So the second of these is a slower and tighter computation than the first:

expr("integral(sin(x), x, 0, 1)").eval(Environment())
// res14: Either[_Expression, _Value] = Right(_Number(0.45969769413186257))
expr("integral(sin(x), x, 0, 1)").eval(Environment(precision = 12))
// res15: Either[_Expression, _Value] = Right(_Number(0.4596976941318603))

The exact answer is 1 - cos(1), which is 0.4596976941318602. The default run is right to about fourteen digits and the second to the last bit — a difference of roughly 2e-15, invisible at any precision you would display and decisive if you are going to subtract two of them.

Nothing here rounded; the second computation simply did more work. That is the whole of the distinction: precision never truncates a value, it either chooses a tolerance or buys accuracy, and what you see printed is a separate decision you make at the end.

The other precision

Environment also carries a workingPrecision, defaulting to 30, and it is a different quantity — the digits an irrational is approximated to inside the exact-arithmetic tier, which only matters once you are working with exact values. It is a separate field precisely so that turning up the display precision cannot silently change a result. L7 is where it belongs; see also the Expressions reference for the exact tier.

The remaining two settings, symmetricLogic and semantics, select the truth-value alphabet and the fuzzy t-norm family; they are for the logic domain and are described on the Logic page.

Try it yourself

Write valueOf(text: String, bindings: Map[String, Double]): Option[Double] — parse text, evaluate it with every binding applied, and return the number only if it fully reduced. Anything still symbolic is a None.

Answers ```scala def valueOf(text: String, bindings: Map[String, Double]): Option[Double] = val env = bindings.foldLeft(Environment()): case (acc, (name, d)) => acc.withBinding(name, _Number(d)) expr(text).eval(env) match case Right(_Number(d)) => Some(d) case _ => None ``` ```scala valueOf("a * b + 1", Map("a" -> 2, "b" -> 5)) // res16: Option[Double] = Some(11.0) ``` ```scala valueOf("a * b + 1", Map("a" -> 2)) // res17: Option[Double] = None ``` Two details worth noticing. `foldLeft` over the map is the idiom for building an environment from a collection, since each `withBinding` returns a fresh one. And matching `Right(_Number(d))` rather than `Right(v)` is what makes the contract honest: a matrix or a truth value is a perfectly good `_Value` and is not a `Double`. That pattern hides a subtlety you will meet in L7 — `_Number(d)` also matches an exact rational, reading it as a `Double`. That is intended, and it is why this function keeps working unchanged if its input ever becomes exact.

What you learned

  • The environment is the whole context of an evaluation: bindings plus a few numeric settings. There is no global state, and withBinding returns a new one.
  • A binding is a _Value, never an expression. Named symbolic definitions live above the environment — in substitute, or in a Session.
  • Parse once, evaluate many times. The tree is immutable and a missing binding leaves that part symbolic rather than failing.
  • precision is display precision and eval never rounds. It does not even change what toString prints — that is fixed, so serialisation cannot drift. Call display(p) yourself.
  • It does set the equality tolerance (0.5 · 10⁻ᵖ) and the step count of the numeric integrator and ODE solver — the two places it is read as “how right do you need this?”.
  • workingPrecision is a different quantity, for the exact tier, and deliberately separate.

Previous: L2 — The dual evaluation model · Next: L4 — Building trees without the parser · All lessons


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