L7 — Numbers

Goal: read a numeric result without silently throwing away what it knows. This is the subtlest thing a caller of this library has to get right, and the whole of it is one rule about one pattern match.

import it.grypho.scala.leonardo.core.*
import it.grypho.scala.leonardo.scalar.Sum   // the operation nodes live in `scalar`, not `core`
import it.grypho.scala.leonardo.parser.Parser

// L1's reader, and its exact-mode twin.
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")

/** Parses `text` with the exact tier switched on at `digits` working precision. */
def exact(text: String, digits: Int = 30): _Expression =
  val parsed = Parser.parse(text, exact = Some(digits))
  if parsed.successful then parsed.get
  else sys.error(s"lesson input failed to parse: $text")

_Value is a family

L2 said a Right holds a _Value. There are several, and they are siblings rather than a hierarchy — none of them is a special case of _Number:

Type What it is
_Number a Double
_Rational an exact fraction over two BigInts
_Based an integer that remembers the base it was written in
_Complex a real and an imaginary part
_Truth a degree of truth in [0, 1]
_MatrixValue a dense matrix

Three of those read as a real number, and the library has one mechanism that lets ~108 existing pattern matches see all three without being rewritten.

The widening extractor

_Number.unapply is hand-written, replacing the one a case class would synthesise, and it matches any value that reads as a real:

expr("0xFF").eval(Environment())
// res0: Either[_Expression, _Value] = Right(
//   _Based(value = 255.0, base = 16, balanced = false)
// )
expr("0xFF").eval(Environment()) match
  case Right(_Number(d)) => s"reads as $d"
  case _                 => "not a real number"
// res1: String = "reads as 255.0"

That value is a _Based, not a _Number, and the pattern matched it anyway. The same is true of an exact rational:

exact("1/3").eval(Environment()) match
  case Right(_Number(d)) => s"reads as $d"
  case _                 => "not a real number"
// res2: String = "reads as 0.3333333333333333"

case _Number(x) means “reads as the real number x“ — not “is a _Number”.

This is the single decision that let an exact-arithmetic tier be added to a library with a hundred-odd case _Number(x) sites, without editing them. And it fails in the safe direction: code that does not know about the exact tier gets the Double behaviour it always had, never a wrong answer.

Not everything widens, and the boundary is meaningful. A complex value is not a real number and does not match:

expr("2 + 3i").eval(Environment()) match
  case Right(_Number(d)) => s"reads as $d"
  case _                 => "not a real number"
// res3: String = "not a real number"

Which is what you want — silently handing you the real part would be the confidently wrong answer this library declines everywhere.

Exactness is opt-in, and the case order is load-bearing

The cost of the widening is that exactness is something you have to ask for. Write the obvious match and you get a Double:

/** WRONG when the value is exact: the first case swallows it. */
def naive(v: _Value): String = v match
  case _Number(d)   => s"double $d"
  case r: _Rational => s"exact ${r.exact}"
  case other        => other.toString
exact("1/3").eval(Environment()) match
  case Right(v) => naive(v)
  case Left(e)  => e.toString
// res4: String = "double 0.3333333333333333"

The value really is a _Rational and the second case never ran, because the first one matched first. Put the exact case first:

/** Right: the narrow case is tried before the widening one. */
def careful(v: _Value): String = v match
  case r: _Rational => s"exact ${r.exact}"
  case _Number(d)   => s"double $d"
  case other        => other.toString
exact("1/3").eval(Environment()) match
  case Right(v) => careful(v)
  case Left(e)  => e.toString
// res5: String = "exact 1/3"

Same value, same two cases, different order, different answer. The compiler cannot warn you — both matches are exhaustive and both typecheck — so this is a rule you keep by knowing it. The library keeps it in four places (scalar._Operation, scalar._Function, equation.Solve, cli.Session) and has a test pinning the ordering in each.

Exact mode is a parse-time decision

Notice that every exact example above went through Parser.parse(text, exact = Some(30)) rather than being switched on at evaluation. That is not an accident of the API — it is the only place the decision can be made, and the reason is worth understanding because it is not obvious.

A decimal literal and the Double nearest to it are different numbers:

_Rational.fromDecimalString("0.1").map(_.exact)
// res6: Option[String] = Some("1/10")
_Rational.fromDouble(0.1).map(_.exact)
// res7: Option[String] = Some("3602879701896397/36028797018963968")

The first is what 0.1 means; the second is what the hardware holds, since every finite Double is a dyadic fraction and one tenth is not one. Once a literal has been read as a Double the information is gone, and no later conversion can recover it — converting back gives you the dyadic, faithfully and uselessly.

So exactness has to start at the literal, which is why it is a parser flag:

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

At seventeen decimals of tolerance the Double sum is not 0.3 — L3’s point — and the exact one is, because it never stopped being three tenths.

There is no exact field on Environment, and now you can see why there could not be: by evaluation time the tier is carried by each value’s own type, so there would be nothing for such a field to do.

Float contagion

Mixing tiers demotes:

Sum(exact("1/3"), _Number(1)).eval(Environment())
// res10: Either[_Expression, _Value] = Right(_Number(1.3333333333333333))

Rational ⊕ rational stays exact; rational ⊕ _Number becomes a _Number. The asymmetry is deliberate and it is not the lossless choice. Absorbing the Double into the rationals would lose nothing numerically — every finite Double is a fraction — but it would take a value carrying representation error and dress it as exact. Better to demote the exact operand, where the loss is visible in the type, than to launder the inexact one.

A constant your algorithm invents has a tier

L4 met this in passing; here is the whole of it. _Number(-1) is an exactly representable integer living in the inexact tier, so multiplying an exact operand by it correctly demotes the result. Correct when the -1 came from the user; wrong when your code made it up.

Sum(exact("1/3"), _Rational.literalLike(1, exact("1/3"))).eval(Environment())
// res11: Either[_Expression, _Value] = Right(4/3)

literalLike(n, like) reads like and hands back a matching n — a _Rational if anything in like is exact, a _Number otherwise. The test underneath is public too:

_Rational.containsExact(exact("1/3"))
// res12: Boolean = true
_Rational.containsExact(expr("1/3"))
// res13: Boolean = false

If your code synthesises a numeric constant that will be combined with user input, build it with literalLike. Subtraction is the case that bites hardest, because a - b is Sum(a, Product(_Number(-1), b)) — so a hand-built -1 pulls every subtraction out of the exact tier before anything downstream can see it. This was the single most repeated bug in the exact tier’s own development.

The other precision, again

L3 flagged Environment.workingPrecision and deferred it. This is where it lives: it is the number of digits an irrational is approximated to before exact arithmetic continues.

pi has no exact rational value, so exact mode supplies a rational approximation at the working precision you asked for and carries on exactly from there. Asking for the fraction itself shows the difference — and reuses the case order from two sections ago:

/** The exact fraction behind a result, when it has one. */
def fraction(e: _Expression): Option[String] = e.eval(Environment()) match
  case Right(r: _Rational) => Some(r.exact)
  case _                   => None
fraction(exact("pi", 5))
// res14: Option[String] = Some("312689/99532")
fraction(exact("pi", 30))
// res15: Option[String] = Some(
//   "365336193380530624186556497862/116290122133776041922791064467"
// )

Two different rationals for the same constant, and raising the working precision is the remedy the exact tier offers — which is why its cap sits far above the useful range rather than at it.

One honest limit: functions with no arbitrary-precision kernel — the special functions, the statistical ones — are computed in Double and re-approximated, so they are capped at _Rational.DoubleReliableDigits:

_Rational.DoubleReliableDigits
// res16: Int = 15

Asking for thirty digits of such a result would manufacture fifteen that are not there, so the library declines to pretend. The arithmetic around those calls is still exact, which is where cancellation actually does its damage.

Display and storage are different paths

An exact value shows as a fraction while it is short, and falls back to a decimal when it is not — but it always serialises as the fraction, so nothing is lost across a save and reload. The 30-digit pi above is long enough to show both halves:

val piApprox: _Rational = exact("pi", 30).eval(Environment()) match
  case Right(r: _Rational) => r
  case other               => sys.error(s"expected an exact value, got $other")
piApprox.display(5)
// res17: String = "3.14159"
piApprox.exact
// res18: String = "365336193380530624186556497862/116290122133776041922791064467"

The first is what a reader should see and the second is what a file should hold — and they are not interchangeable, because only the second re-parses to the value you had. A short fraction displays as itself, which is why this distinction is easy to miss until the value gets long:

_Rational.of(1, 3).map(r => (r.display(5), r.exact))
// res19: Option[Tuple2[String, String]] = Some(("1/3", "1/3"))

If you are writing a front end: call display(p) for the screen and exact for anything you will read back. Never store what you displayed.

Try it yourself

Write describe(text: String, exactMode: Boolean): String that parses and evaluates text and reports the result as "exact n/d", "real d", or "not a real number" — getting the case order right, so an exact result is never reported as a double.

Answers ```scala def describe(text: String, exactMode: Boolean): String = val e = if exactMode then exact(text) else expr(text) e.eval(Environment()) match case Right(r: _Rational) => s"exact ${r.exact}" case Right(_Number(d)) => s"real $d" case Right(other) => s"not a real number: $other" case Left(sym) => s"symbolic: $sym" ``` ```scala describe("1/3 + 1/6", exactMode = true) // res20: String = "exact 1/2" ``` ```scala describe("1/3 + 1/6", exactMode = false) // res21: String = "real 0.5" ``` ```scala describe("2 + 3i", exactMode = false) // res22: String = "not a real number: (2.0 + 3.0i)" ``` ```scala describe("x + 1", exactMode = false) // res23: String = "symbolic: (x + 1.0)" ``` The exact answer is a half and says so; the inexact one is the `Double` nearest it. Both are correct answers to different questions, and the only way to get the first is to have written `case Right(r: _Rational)` above `case Right(_Number(d))`. Note also that the `Left` arm is still there. Nothing about the numeric tiers removes the dual model — an expression with a free variable stays symbolic whatever tier its constants are in.

What you learned

  • _Value is a family of siblings — _Number, _Rational, _Based, _Complex, _Truth, _MatrixValue — not a hierarchy under _Number.
  • case _Number(x) means “reads as the real number x“, matching _Rational and _Based too. It is what let the exact tier arrive without rewriting a hundred call sites, and it fails toward the old Double behaviour rather than toward a wrong answer.
  • Exactness is opt-in, so order your cases: case r: _Rational must come before case _Number(d) or the widening one silently swallows it. Nothing warns you.
  • Exact mode is a parser flag, because a literal read as a Double can never be repaired — fromDecimalString("0.1") is a tenth and fromDouble(0.1) is the dyadic. There is no Environment.exact, and there could not be.
  • Float contagion demotes, deliberately choosing the visible loss over laundering representation error into an exact-looking value.
  • A constant your code invents carries a tier — use _Rational.literalLike(n, like), especially anywhere a subtraction is being built.
  • workingPrecision is how many digits an irrational is approximated to; functions with no exact kernel are capped at DoubleReliableDigits rather than inventing digits.
  • display(p) is for the screen and exact for storage. Never store what you displayed.

Previous: L6 — The calculus API · Next: L8 — Embedding a Session · All lessons


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