Expressions and evaluation
formula-cpp provides a formula written with ordinary operators
(formula::var, +, -, *, /, formula::pow, formula::sqrt,
formula::cbrt, formula::root, formula::pi), a set of inputs keyed by
quantity (formula::Environment), and two ways to turn a formula and an
environment into a number (formula::checked_evaluate_si,
formula::checked_evaluate). The result is never a bare number: it is
formula::Outcome<Q>, a value, an absence, or -- once a later phase adds
constraints -- a verdict or an invalidation, each carrying where it came from.
This page explains how to write a formula, where a dimensional mistake shows
up, how the environment supplies and withholds values, how absence and
provenance travel through evaluation, and how to choose between an exact and
an approximate answer. The worked example is examples/expressions.cpp;
every block on this page formatted as program output is copied verbatim from
that program's actual output, not worked out by hand.
Writing a formula
A formula is a type, not a computation. formula::var<Q> is the spelling of
a variable, and the arithmetic operators build a tree out of it at compile
time:
inline constexpr auto waterCementRatio = formula::var<WaterVolume> / formula::var<CementVolume>;
waterCementRatio's type is
formula::BinaryNode<formula::BinaryOperator::Divide, formula::VarNode<WaterVolume>, formula::VarNode<CementVolume>>.
Nothing has been computed by this line -- decltype(waterCementRatio) is the
whole formula, and every node publishes
static constexpr Dimension dimension, computed at class scope from its
operands', so the dimension is known before any value is. A node declares no
members of its own; its whole
shape is in the type. It is not literally an empty class, though: a node
holds its children by value, and an empty child still occupies a byte, so
a tree costs roughly one byte per leaf and nothing per level -- measured, a
five-leaf tree was 5 bytes on cl and clang-cl and 9 on g++
(test/expression_tests.cpp,
"expression: a deep tree carries no state beyond its constants"). The exact
number is not portable and is not the point; the point is that traversing the
tree inlines away and there is no per-node state beyond the constants a
formula actually names. formula::ConstantNode is the one node with real
state, because a coefficient may arrive from a table at runtime rather than
being written into the formula.
Where a dimensional error appears
Because the operator the user wrote is what instantiates the node, a
dimensional mistake is a compile error on the line the formula is written on,
not a runtime one discovered when the formula finally runs.
test/negative/expression_add_dimension_mismatch.cpp pins this:
struct Volume: formula::Quantity<Volume, "V", "a volume", formula::unit::Litre>
{
};
struct Length: formula::Quantity<Length, "L", "a length", formula::unit::Metre>
{
};
// A volume plus a length has no meaning, and must not compile.
inline constexpr auto broken = formula::var<Volume> + formula::var<Length>;
Attempting this gives, verbatim, on MSVC's cl.exe (19.51, from Visual
Studio's cl-debug preset):
D:\formula-cpp\include\formula-cpp/expression.hpp(135): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree'
D:\formula-cpp\include\formula-cpp/expression.hpp(135): note: the template instantiation context (the oldest one first) is
D:\formula-cpp\test\negative\expression_add_dimension_mismatch.cpp(13): note: see reference to function template instantiation 'auto formula::operator +<formula::VarNode<Volume>,formula::VarNode<Length>>(Left,Right) noexcept' being compiled
with
[
Left=formula::VarNode<Volume>,
Right=formula::VarNode<Length>
]
D:\formula-cpp\include\formula-cpp/expression.hpp(216): note: see reference to class template instantiation 'formula::BinaryNode<formula::BinaryOperator::Add,formula::VarNode<Volume>,formula::VarNode<Length>>' being compiled
D:\formula-cpp\include\formula-cpp/expression.hpp(191): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree<formula::BinaryOperator::Add,Left,Right>' being compiled
with
[
Left=formula::VarNode<Volume>,
Right=formula::VarNode<Length>
]
D:\formula-cpp\include\formula-cpp/expression.hpp(152): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree<Left,Right>' being compiled
with
[
Left=formula::VarNode<Volume>,
Right=formula::VarNode<Length>
]
The message names the two operand types (VarNode<Volume>, VarNode<Length>)
and the formula's own source line, not an opaque template instantiation
failure. The check is written as a named class template,
formula::detail::RequireAddendsAgree<Left, Right>, templated on the
operands rather than on the operator, entirely for this diagnostic's sake:
an assertion whose condition mentions the operator makes clang print
(formula::BinaryOperator)0 in its "due to requirement" clause, because an
enumerator used as a value in a dependent expression is rendered as a cast.
Written this way, all three compilers (cl, clang-cl and g++) name the two
operand types instead. examples/expressions.cpp shows the same mistake as a
comment rather than as compiled code, since it must not fail the build:
// Diameter measures length; Area measures length squared. Adding them has no
// meaning, and the expression layer refuses it at the formula's own source
// line, not at evaluation:
//
// constexpr auto broken = formula::var<Diameter> + formula::var<Area>;
//
// Uncommenting the line above does not compile.
Multiplication and division impose nothing on the operands' dimensions -- a
volume divided by a mass is a perfectly good density -- so only + and -
carry this check; * and / combine the two dimensions instead of requiring
them to agree.
The environment
formula::Environment is a set of inputs keyed by quantity type, not by
name. It is built with formula::environment(...), one entry per quantity,
each either a plain formula::Measured<Q> (an observation) or
formula::entered(Measured<Q>{...}) (a value a person typed in). Asking for a
quantity the environment does not hold is a compile error naming that
quantity, never a runtime lookup failure and never a zero.
test/negative/environment_missing_quantity.cpp pins this:
struct WaterVolume: formula::Quantity<WaterVolume, "V_w", "effective water content", formula::unit::Litre>
{
};
struct Ratio: formula::Quantity<Ratio, "w/c", "water/cement ratio", formula::unit::One>
{
};
// An environment that does not hold Ratio; asking for it is a compile error.
inline constexpr auto env = formula::environment(formula::Measured<WaterVolume> { formula::Rational { 180 } });
int main()
{
return env.get<Ratio>().has_value() ? 0 : 1;
}
which gives, verbatim, on MSVC's cl.exe (19.51, cl-debug preset):
D:\formula-cpp\include\formula-cpp/environment.hpp(126): error C2338: static assertion failed: 'formula: this environment provides no value for this quantity; the quantity and the environment appear in this diagnostic as the template arguments of RequireProvided'
D:\formula-cpp\include\formula-cpp/environment.hpp(126): note: the template instantiation context (the oldest one first) is
D:\formula-cpp\test\negative\environment_missing_quantity.cpp(17): note: see reference to function template instantiation 'formula::Measured<Ratio> formula::Environment<formula::Measured<WaterVolume>>::get<Ratio>(void) noexcept const' being compiled
D:\formula-cpp\test\negative\environment_missing_quantity.cpp(17): note: see the first reference to 'formula::Environment<formula::Measured<WaterVolume>>::get' in 'main'
D:\formula-cpp\include\formula-cpp/environment.hpp(171): note: see reference to class template instantiation 'formula::detail::RequireProvided<Ratio,formula::Environment<formula::Measured<WaterVolume>>>' being compiled
The prototype this layer replaced answered a missing input with a runtime
assert, reachable only by actually running the failing path. Here, the
question "does this environment supply everything the formula needs" is
answered once, while the formula is being written, for every input the
formula names -- there is no code path left in which it is answered wrong.
Supplying the same quantity twice is refused the same way, by
formula::detail::RequireDistinctQuantities: first-wins and last-wins are
equally arbitrary, and the caller meant exactly one of them, so neither is
guessed.
Absence propagates
formula::Measured<Q> may be absent -- not zero. Zero is a measurement;
an unmeasured quantity starting at zero would produce a confident wrong
answer, which is exactly the failure this layer exists to prevent. Evaluation
carries that rule all the way through a formula: if any input a formula
actually needs is absent, the whole result is absent, never a number computed
from whatever happened to be present.
constexpr auto partial =
formula::environment(formula::Measured<WaterVolume>::absent(), formula::Measured<CementVolume> { rat(300) });
constexpr auto computed = formula::checked_evaluate<Ratio>(ratio, partial);
(test/evaluate_tests.cpp,
"evaluate: an absent input makes the whole result empty, not zero".) The
same rule holds through a power:
examples/expressions.cpp measures no diameter and asks for the resulting
area:
area with no diameter measured: empty
Absence and an arithmetic error are different facts and are not conflated:
when one side of a binary operation is absent and the other side's own
arithmetic fails, the arithmetic error wins, because it is still an error
even though the other side happened to have nothing to say
(test/evaluate_tests.cpp,
"evaluate: an arithmetic error on one side outranks absence on the other").
Absence is only ever reported once both sides have actually been evaluated.
The outcome
formula::checked_evaluate<Result> returns
std::expected<Outcome<Result>, ArithmeticError>, and formula::Outcome<Q>
is a sum type with four alternatives (formula::OutcomeKind):
Value-- a number, together withformula::ValueSource:Derived(computed by the library),Measured(an observation), orManuallyEntered(typed in by a person, replacing what would have been computed).Empty-- no value, because an input the formula needs was never measured. This isOutcome::value(...)given an absent measurement, not a separate code path -- so a caller checkingis_value()cannot receive an emptyValueand call it a number.VerdictandInvalid-- a decision rather than a number ("reject the specimen") or a reason a result was discarded entirely. Nothing in the expression layer produces either yet: they exist so that a later phase's constraints have somewhere to put their answer, without a fifth alternative meaning "everything above, but different".
Outcome::is_overridden() is true exactly when is_value() and the source
is ManuallyEntered -- there is deliberately no separate Overridden
alternative, so provenance has exactly one home and a kind can never
contradict a source. When the environment holds an entered value for the
quantity being evaluated, checked_evaluate returns that value with
ValueSource::ManuallyEntered without evaluating the formula at all --
proven in test/evaluate_tests.cpp by an override whose formula would
divide by zero: the override still wins, because the formula is never
reached. examples/expressions.cpp overrides a computed ratio and prints
both the value and the fact that it was entered:
w/c = 0.500000 (entered)
Choosing a representation
Two entry points evaluate a formula, and they answer different questions:
formula::checked_evaluate_si<Rep>(node, environment)is the representation-agnostic core. Every leaf is converted to the coherent SI unit of its dimension on the way in, the tree is evaluated there, and the answer comes back asRep-- exactformula::Rationalby default, ordoublewhen a formula needs values an exact rational cannot hold.formula::checked_evaluate<Result>(expression, environment)is the auditable entry point, and it is always exact: it evaluates inRational, converts the answer once intoResult's own declared unit, and returnsOutcome<Result>. A result that goes into an audit trail as binary floating point would have to explain itself every time it was read back, so this entry point does not offer the choice.
formula::RepTraits<Rep> teaches each representation its own arithmetic --
Rational's through the checked_ functions in rational.hpp, double's
directly, with Overflow deliberately not reported by that arithmetic itself
(inf is what a double says, and the caller asked for double). That is
not the same as saying a double evaluation never sees Overflow: every leaf
is converted to the coherent SI unit in exact Rational before it is handed
to RepTraits<double>, and that conversion can overflow -- a quantity whose
declared unit puts it near the edge of the representable range reports
Overflow from checked_evaluate_si<double> exactly as it would from the
exact representation, before the double arithmetic ever runs. The primary
template is undefined, so a representation nobody has taught the library
fails at the point of use, naming itself.
Both entry points also convert every leaf as a point on its unit's scale,
never as a difference -- checked_convert's own documentation says so, and
the evaluator does not qualify it further. An affine unit, of which this
library ships one, degrees Celsius, therefore behaves as an absolute
temperature inside a formula, not as a delta: 20 °C minus 15 °C is exactly 5 K
once both leaves have been converted to the coherent SI unit (kelvin) and
subtracted there, but reading that same computed 5 K back through a result
quantity declared in degrees Celsius gives −268,15, because the conversion
adds the offset the point 5 K sits at, not the offset the interval spans. A
quantity that represents a difference -- a temperature swing, not a
temperature -- must declare a non-offset unit such as kelvin; declaring it in
an affine unit asks the library a different question than the one intended.
test/evaluate_tests.cpp's "evaluate: an offset unit converts a point, not a
difference" pins today's behaviour, so a later phase changes it deliberately
rather than by accident.
Powers, roots and pi
formula::pow<Exponent>(operand), formula::sqrt(operand),
formula::cbrt(operand), formula::root<Degree>(operand) and formula::pi
are nodes, not free functions applied to an already-evaluated number, because
a power or root acts on the dimension as well as the value: the square of a
length is an area, the cube root of a volume is a length, and a root can
produce a fractional exponent -- exactly why Dimension's exponents are
rational rather than integer (see docs/dimensions.md).
examples/expressions.cpp computes a circular area from a constant (pi)
and a power (d^2), with the result declared in a different unit
(SquareMetre) from the input (Millimetre):
constexpr auto circularArea = formula::pi * formula::pow<2>(var<Diameter>) / formula::Rational { 4 };
circular area of a 100 mm diameter = 0.007854 m2 (computed)
formula::Pi is a documented rational convergent -- 245850922/78256779,
which differs from pi by less than 8e-17 -- and is deliberately not pi
itself: it is the one approximation the exact layer makes on purpose, written
once so every caller gets the same number and the trace states which number
it was.
checked_exact_nth_root answers only when the root is a rational number.
The root of 4 is 2 and the root of 9/4 is 3/2, but the root of 2 is
ArithmeticError::Inexact, not a nearby fraction:
constexpr auto inputs = formula::environment(formula::Measured<Area> { rat(2) });
constexpr auto computed = formula::checked_evaluate<Edge>(formula::sqrt(var<Area>), inputs);
STATIC_REQUIRE_FALSE(computed.has_value());
STATIC_REQUIRE(computed.error() == formula::ArithmeticError::Inexact);
(test/function_tests.cpp,
"function: an inexact root is refused by the exact representation".)
Inexact is not a limitation the library
apologises for: a layer whose whole promise is "never a wrong number" has no
business rounding an irrational root silently into a rational one. A formula
that genuinely needs an irrational root is evaluated in a representation that
has room for one -- formula::checked_evaluate_si<double> answers the same
formula, approximately, on purpose. As a bounded fact rather than program
output -- pinned in the companion test
"function: the double representation answers where the exact one cannot" --
checked_evaluate_si<double> of that same sqrt(Area) formula lands
strictly between 1.41421356 and 1.41421357.
There is one further refusal in the same function, for a different reason.
checked_exact_nth_root rejects the most negative representable numerator
(IntMin) with ArithmeticError::Overflow rather than Inexact: IntMin's
cube root exists and is exactly representable, but negating IntMin to reach
a positive intermediate is signed overflow, undefined behaviour, before the
root is ever taken. Overflow names what actually goes wrong; treating it as
Inexact would blame the wrong layer.
A root of degree zero names no operation at all and is refused at compile
time, the same way a dimensional mismatch is, by the library's own
static_assert message, not by a runtime check:
constexpr auto broken = formula::root<0>(formula::var<Area>);
// static_assert message: "formula: a root of degree zero describes no
// operation" -- pinned verbatim in test/negative/function_zero_root_degree.cpp.
Composing a formula from other formulas
A formula is an ordinary value, so it stands wherever a variable or a constant stands. Give one a name and it can be an operand of the next:
constexpr auto waterCementRatio =
formula::documented(var<WaterVolume> / var<CementVolume>,
{ .title = "Water/cement ratio", .reference = "Example Standard 1:2020", ... });
// The second formula uses the first by name. Nothing about the first
// declaration anticipated being reused.
constexpr auto mixCost =
formula::documented(var<UnitPrice> * waterCementRatio,
{ .title = "Cost of a mix at a given water/cement ratio", ... });
There is no separate composition step and no wrapper type. The outer formula
is simply a larger expression tree, so the dimension check, evaluation,
rendering, tracing and document() all treat the reused sub-tree the way
they treat any other node.
Provenance travels upward through the seam. The outer formula was never
told about the inner one's citation, but document() walks the whole tree
and finds it:
citations on the outer formula: 2
- Cost of a mix at a given water/cement ratio [Example Standard 9:2021]
- Water/cement ratio [Example Standard 1:2020]
The trace names the reused formula as its own step, which is what an
auditor needs and what the rendering alone does not show — render() gives
c_u * V_w / V_c, flattened, because * and / share a precedence and no
parenthesis is needed to preserve the meaning:
1. c_u = 250 EUR
2. V_w = 180 l
3. V_c = 300 l
4. #2 / #3 = 3/5
5. #4 = 3/5 [Water/cement ratio, Example Standard 1:2020, 5.4.2, (3)]
6. #1 * #5 = 150
7. #6 = 150 [Cost of a mix at a given water/cement ratio, Example Standard 9:2021, 2.1]
Step 5 is the reused formula, carrying its own citation; step 6 consumes it.
One asymmetry is worth knowing before you rely on it. Using the same
sub-formula twice in one tree reaches its citation twice, and the citation
list reports it twice — while the symbol table still reports each symbol once.
If you are building a reference list from .citations, collapse duplicates
yourself.
examples/composition.cpp is this, complete and runnable; its output is
where the blocks above come from.