numeric_expr tutorial
This tutorial shows how to describe a computation once as a numeric_expr
tree and run it against any number type by supplying two callbacks: one that
reads a literal and one that executes an operation. By the end you can evaluate
expressions over Int, over BinFloat with IEEE flags, and report failures at
their source line. This is the mechanism the conformance frontends
(frontend/gda_expr and others) use to run test corpora.
Quick start
Add the package to moon.pkg:
import {
"Luna-Flow/floating/numeric_expr",
}
Build 1 + 2 and evaluate it over Int:
///|
test "quick start" {
let one_plus_two = @numeric_expr.Expr::invoke(
@numeric_expr.Operation::new("add"),
[
@numeric_expr.Expr::literal(@numeric_expr.Literal::new("1")),
@numeric_expr.Expr::literal(@numeric_expr.Literal::new("2")),
],
)
let result : Result[Int, @numeric_expr.EvalError[String]] = @numeric_expr.evaluate(
one_plus_two,
literal => if literal.raw() == "1" { Ok(1) } else { Ok(2) },
(_, arguments) => Ok(arguments[0] + arguments[1]),
)
inspect(result is Ok(3), content="true")
}
evaluate decodes both literals, then calls the operation callback with the
decoded arguments [1, 2].
Everyday tasks
Write a small interpreter
Most callers match on the operation name and the argument array. Matching the
array pattern also checks the arity, which numeric_expr itself does not do:
///|
fn int_literal(literal : @numeric_expr.Literal) -> Result[Int, String] {
match literal.raw() {
"0" => Ok(0)
"1" => Ok(1)
"2" => Ok(2)
"3" => Ok(3)
"10" => Ok(10)
other => Err("not a small integer: " + other)
}
}
///|
fn int_operation(
operation : @numeric_expr.Operation,
arguments : Array[Int],
) -> Result[Int, String] {
match (operation.name(), arguments) {
("add", [a, b]) => Ok(a + b)
("mul", [a, b]) => Ok(a * b)
("neg", [a]) => Ok(-a)
("sum", values) => Ok(values.fold(init=0, (acc, x) => acc + x))
(name, values) =>
Err(name + " does not take " + values.length().to_string() + " arguments")
}
}
///|
fn lit(raw : String) -> @numeric_expr.Expr {
@numeric_expr.Expr::literal(@numeric_expr.Literal::new(raw))
}
///|
fn call(name : String, arguments : Array[@numeric_expr.Expr]) -> @numeric_expr.Expr {
@numeric_expr.Expr::invoke(@numeric_expr.Operation::new(name), arguments)
}
///|
test "small interpreter" {
// -(2 * 3) + sum(1, 2, 3, 10)
let expression = call("add", [
call("neg", [call("mul", [lit("2"), lit("3")])]),
call("sum", [lit("1"), lit("2"), lit("3"), lit("10")]),
])
let result = @numeric_expr.evaluate(expression, int_literal, int_operation)
inspect(result is Ok(10), content="true")
}
sum shows that an operation may take any number of arguments; the callback
decides.
Evaluate over BinFloat and collect IEEE flags
The value type V can carry more than a number. Here every value is a
BinFloat together with the IEEE flags raised so far, and each operation merges
the flags of its arguments with its own:
///|
fn binary_literal(
literal : @numeric_expr.Literal,
) -> Result[(@bin_float.BinFloat, @bin_float.BinaryFlags), String] {
match
@bin_float.BinFloat::from_string_ctx(
literal.raw(),
@bin_float.BinaryContext::binary64(),
) {
Ok(pair) => Ok(pair)
Err(_) => Err("not a number: " + literal.raw())
}
}
///|
fn binary_operation(
operation : @numeric_expr.Operation,
arguments : Array[(@bin_float.BinFloat, @bin_float.BinaryFlags)],
) -> Result[(@bin_float.BinFloat, @bin_float.BinaryFlags), String] {
let context = @bin_float.BinaryContext::binary64()
match (operation.name(), arguments) {
("add", [(a, fa), (b, fb)]) => {
let (value, flags) = a.add_ctx(b, context)
Ok((value, fa.combine(fb).combine(flags)))
}
("div", [(a, fa), (b, fb)]) => {
let (value, flags) = a.div_ctx(b, context)
Ok((value, fa.combine(fb).combine(flags)))
}
_ => Err("unsupported " + operation.name())
}
}
///|
test "binary64 evaluation with flags" {
// 1/3 + 1/3 in binary64
let third = call("div", [lit("1"), lit("3")])
let result = @numeric_expr.evaluate(
call("add", [third, third]),
binary_literal,
binary_operation,
)
match result {
Ok((value, flags)) => {
inspect(value.to_hex(), content="0x15555555555555p-53")
inspect(flags.inexact(), content="true")
}
Err(_) => fail("expected a value")
}
}
The same tree could be evaluated over @decimal_gda.Decimal by swapping the two
callbacks; nothing in the expression depends on the number type.
Report errors at their source
Give every node the span of the text it came from. When a callback fails, the
EvalError returns the failing node, so the span locates the error:
///|
test "locate a bad operand" {
let span = @numeric_expr.SourceSpan::new("rows.txt", line=42, column=9)
let expression = @numeric_expr.Expr::invoke(
@numeric_expr.Operation::new("add", span~),
[
@numeric_expr.Expr::literal(@numeric_expr.Literal::new("2", span~)),
@numeric_expr.Expr::literal(@numeric_expr.Literal::new("two", span~)),
],
)
let message = match
@numeric_expr.evaluate(expression, int_literal, int_operation) {
Ok(_) => "ok"
Err(@numeric_expr.LiteralFailure(literal, error)) =>
literal.span().source() +
":" +
literal.span().line().to_string() +
": " +
error
Err(@numeric_expr.OperationFailure(operation, error)) =>
operation.name() + ": " + error
Err(@numeric_expr.UnsupportedExpression(_)) => "unsupported"
}
inspect(message, content="rows.txt:42: not a small integer: two")
}
See the evaluation order
The callbacks run in post-order, left to right, and evaluation stops at the first failure. A trace makes this visible:
///|
test "post-order with early stop" {
let trace : Array[String] = []
let expression = call("add", [
call("mul", [lit("2"), lit("3")]),
call("add", [lit("bad"), lit("1")]),
])
let _ = @numeric_expr.evaluate(
expression,
literal => {
trace.push(literal.raw())
int_literal(literal)
},
(operation, arguments) => {
trace.push(operation.name())
int_operation(operation, arguments)
},
)
inspect(trace.join(" "), content="2 3 mul bad")
}
The literal 1 and both add nodes are never visited, because bad fails
first.
Going further
- Corpus frontends.
frontend/gda_exprlowers each.decTestrowid operation operands -> expected conditionsintoExpr::invoke(Operation::new(op), operands.map(Expr::literal))and evaluates it with callbacks over@decimal_gda. Read gda_expr for a complete example of the pattern. - Richer values.
Vcan be an enum when operations return different kinds of results (numbers, booleans, strings), asgda_exprdoes forcompare,classandisnan. - Typed errors.
Ecan be your own enum instead ofString;EvalError[E]keeps it unchanged. - Checked pipelines. If you want domain errors to stay values instead of
stopping the evaluation, make
Va checked type such as@bin_float_checked.BinFloatResultand let the callbacks always returnOk.
Common pitfalls
- No arity check.
Expr::invokeaccepts any number of arguments. Check the arity ininvoke(an array pattern does it) and returnErron a mismatch. - Early stop. After the first failure no further callback runs. Do not rely on side effects of callbacks for nodes to the right of a failing node.
- Deep trees. Evaluation is recursive; the stack depth equals the tree
height. Very deep left-leaning trees (for example a sum of a million terms
built as nested
add) can exhaust the stack; build a flatsumnode instead. - Default spans. Without an explicit span a node reports
SourceSpan::new(""), whose line and column are0. - No literal parsing here. A
Literalstores raw text. Rounding, precision and flags of the conversion are decided entirely by yourdecodecallback.
Next steps
- numeric_expr API lists every item.
- numeric_expr design states the evaluation semantics and its invariants.
- gda_expr tutorial runs real decimal test rows through this package.