Rounding and conditionals
Two additions to the node vocabulary -- rounding a formula at the position the method specifies, and selecting between two formulas by a numeric threshold -- plus a deliberately loud escape hatch for the one kind of rule that cannot be expressed honestly any other way. All three render, document and trace exactly like every other node this library ships; nothing here is a special case anywhere except in the one place it has to be.
The worked example is examples/rounding_and_conditionals.cpp; every block on
this page formatted as program output is copied verbatim from that program's
actual output, the same way Tracing and audit trails does for
examples/tracing.cpp.
The snippets on this page are written the way the example writes them, with
namespace unit = formula::unit;, using formula::var; and using
formula::DecimalPlaces, RoundingMode and SignificantDigits in effect: a
unit is unit::Millimetre, a variable is var<Diameter>, and every other
name the library offers keeps its formula:: prefix.
RoundingMode, DecimalPlaces, SignificantDigits and the plain-Rational
formula::round() free function already exist -- Exact numbers
covers them, including the intermediate-versus-final rounding argument made
there on bare numbers. What is new here is rounding as a position in a
formula tree: a node with a unit and a dimension, composable with +, *,
when() and everything else a Node supports, rather than a function you
call on a Rational you already have in hand.
Rounding is a node, and it names a unit
rounded<U, Places, Mode>(operand) and rounded_to_digits<U, Digits,
Mode>(operand) (rounding_node.hpp) round operand to Places decimal
places, or Digits significant digits, of the unit U, under the tie-break
rule Mode:
formula::rounded<unit::Millimetre, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero>(var<Diameter>)
formula::rounded_to_digits<unit::Millimetre, SignificantDigits { 2 }, RoundingMode::HalfAwayFromZero>(var<Diameter>)
The unit is not decoration. "To one decimal place" means nothing about a quantity until you say one decimal place of what: the evaluator works in the coherent unit of each dimension, so a length is normally carried in metres, and "one decimal place" of a metre and of a millimetre are different thresholds by three orders of magnitude. A rounding node converts into the unit it names, rounds there, and converts back -- and naming a unit that does not measure the operand's own dimension (rounding a mass "to 0.1 mm", say) is a compile error, the same way a dimensional mismatch anywhere else in this library is one.
RoundingMode itself is not new -- it is the mode rounding.hpp already takes
(see Numbers), and a rounding node is simply that mode exposed as
a position in the tree rather than a call you make on a number you already hold.
Naming a rounding once
A method that rounds the same way in several places repeats the same three
arguments each time. DecimalRounding (unit.hpp) names them once: which unit
the places are counted in, how many, and which way to go.
The example names the two it uses for decimal places, then uses them:
constexpr formula::DecimalRounding wholeMillimetre { unit::Millimetre, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero };
constexpr formula::DecimalRounding tenthMillimetre { unit::Millimetre, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero };
rounded<tenthMillimetre>(var<Diameter>) is the very node rounded<unit::Millimetre,
DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero>(var<Diameter>) builds --
the same type, not an equivalent one. Every factory that takes the three
arguments separately also takes the named value: rounded_to_digits takes a
SignificantRounding, and rounding_rule, with_rounding, rounded_output,
rounded_sqrt and rounded_elementwise take a DecimalRounding.
A SignificantRounding names a unit, a count of significant digits and a tie
rule, the way a DecimalRounding names a unit, a count of places and a tie
rule. The example states two significant digits of a millimetre once, and
rounded_to_digits takes it as it would the three arguments:
constexpr formula::SignificantRounding twoDigitsOfMillimetre {
unit::Millimetre, SignificantDigits { 2 }, RoundingMode::HalfAwayFromZero
};
constexpr auto toTwoSignificantDigits = formula::rounded_to_digits<twoDigitsOfMillimetre>(var<Diameter>);
The other factories take the named rounding the same way:
constexpr auto rule = formula::rounding_rule<tenthMillimetre>();
constexpr auto rootedEdge = formula::rounded_sqrt<tenthMillimetre>(var<Diameter> * var<Diameter>);
declared_rounding(unit, mode) builds a DecimalRounding from the places a
unit itself declares, so a rounding "to what a millimetre is shown to" does not
restate the number:
constexpr auto asDeclared = formula::declared_rounding(unit::Millimetre, RoundingMode::HalfAwayFromZero);
A unit that does not measure the operand's dimension is refused as it is when
the arguments are written out: rounded<wholeGrams>(var<Diameter>), with
wholeGrams in unit::Gram, draws the rounding node's one message.
rounded_elementwise<tenthMillimetre>(...) rounds every element of a series to
the same places; a PlacesTable still gives each element its own.
The reason this is a node at all
A method may specify "round the diameter to the nearest millimetre before doubling it" -- a coarse instrument that only ever reads whole millimetres -- or "double the diameter, then round the result to one decimal place". Both are legitimate, and they are not the same formula. A library that only lets you round the number you print at the end cannot express the first one at all; it can only ever give you the second answer, silently, regardless of which one the method actually calls for.
constexpr auto coarseInput = formula::rounded<wholeMillimetre>(var<Diameter>);
constexpr auto roundThenDouble = coarseInput + coarseInput;
constexpr auto doubleThenRound = formula::rounded<tenthMillimetre>(var<Diameter> + var<Diameter>);
roundThenDouble rounds the input to a whole millimetre first and doubles
the rounded value; doubleThenRound doubles first and rounds only the final
result. Same measured diameter, same shape of formula -- one rounding node,
one addition -- and, on 12.50 mm:
rendered: round(d + d, to 1 dp of mm)
12.50 mm, round to 0 dp then double = 26 mm
12.50 mm, double then round to 1 dp = 25 mm
26 and 25 are not close-enough-to-agree; they are two different numbers, from the same formula and the same input, because 12.50 mm rounds to 13 mm before doubling (half away from zero) but the doubled value, 25.00 mm, was already past the point where rounding to one decimal place could move it. Whichever of the two a specification calls for, this library can say which one it did.
Significant-digit rounding is the same node, spelled rounded_to_digits, and
is a genuinely different operation from rounding to a fixed number of
decimal places -- the two can and do disagree, exactly as
Exact numbers already shows for plain Rational values:
12.34 mm to 2 significant digits = 12 mm
A numeric threshold selects between two formulas
when(predicate, thenBranch, elseBranch) (conditional.hpp) evaluates
predicate; if it holds, it evaluates and returns thenBranch, and if it
does not, elseBranch -- and it evaluates only the branch it takes. A
formula guarded by when(v != 0, x / v, fallback) exists precisely because
the other branch is invalid for these inputs, so evaluating it anyway could
raise an arithmetic error that has nothing to do with the answer.
A predicate compares two expressions with <, <=, >, >=, == or !=
(predicate.hpp). It is deliberately not itself a Node -- a truth
value has no unit, and giving a predicate a dimension would mean either
inventing one to lie about or weakening what every other node in this
library promises. Both sides of a comparison must measure the same
dimension, and both branches of a when() must too -- each a compile-time
check, the same shape as every other dimensional-agreement check this
library makes.
A specimen reported to the nearest millimetre once it is large enough, and
to one decimal place below that, puts the threshold itself inside the
formula rather than in an if/else a caller has to remember to apply the
same way every time:
constexpr auto sizeAdjustedDiameter = formula::yields<Diameter>(formula::when(
var<Diameter> > formula::constant<unit::Millimetre>(17.3_r), coarseInput, formula::rounded<tenthMillimetre>(var<Diameter>)));
formula::yields<Diameter> binds the formula to the quantity it produces, so
that the example can render, evaluate and explain it without naming Diameter
again at each call (see Expressions). The formula renders,
and evaluates on both sides of its own threshold, as:
rendered: if d > 173/10 mm then round(d, to 0 dp of mm) else round(d, to 1 dp of mm)
12.34 mm, size-adjusted rounding = 12.3 mm
25.40 mm, size-adjusted rounding = 25 mm
One caveat worth knowing before it surprises you: document() walks both
branches of a when() for the symbol table, regardless of which one a given
evaluation actually took. That is deliberate and the opposite of evaluation's
own short-circuiting -- a formula's documentation describes the formula
itself, not the one run that happened to produce it, and a variable read
only in the branch not taken this time still belongs in the symbol table. A
generated page must not depend on which inputs happened to be passed in.
How rounding, numeric_value_of and conditionals render and trace
A rounding node renders as round(<operand>, to <places> dp of <unit>), or
sf in place of dp for significant digits; numeric_value_of (below)
renders as numeric(<operand>, in <unit>). A unit with no symbol is named
by its size in the coherent unit, round(m, to 2 dp of 1/1000 kg), and one
with an offset by its size and its zero, to 1 dp of 1 K from 5463/20 K, so
that the places say what they count in; only a dimensionless unit at scale 1
writes no unit clause, round(x, to 2 dp). The operand comes first and the
granularity second, comma-separated, deliberately -- not because it looks
tidier, but because the alternative shapes both have a real failure mode.
A trailing suffix with nothing separating it from the operand
(round(<operand> to 1 dp of mm)) misattaches to whichever branch of a
when() operand happens to render last, with no closing delimiter of its
own to stop it; and a [...] prefix (round[to 1 dp of mm](d)) collides
with CommonMark's inline-link syntax and makes a Markdown renderer drop the
operand from the visible page entirely. The comma form is safe against both
at once. See Citations and rendering for the other rule every
dialect follows the same way, for the same kind of reason -- wrapping a
symbol containing an underscore in backticks so Markdown does not read it as
emphasis.
A conditional renders as if <predicate> then <then> else <else> -- seen
above -- and a trace spells the step in that same shape, minus the branch
that did not run:
1. d = 127/5 mm
2. 173/10 mm
3. d = 127/5 mm
4. round(#3, to 0 dp of mm) = 25 mm [nearest, ties away from zero]
5. if #1 > #2 then #4 = 25 mm
Step 5 is the conditional. #1 and #2 are the predicate's two sides,
recorded and numbered exactly like any other step's operands even though
PredicateNode itself is not a Node and never gets a step of its own; >
is the comparison that was actually made, so the step can be checked against
the method on its own, away from the formula text; and #4 is the branch
that ran, named by the keyword in front of it. 25.40 mm is above the 17.3 mm
threshold, so the keyword is then; had the predicate not held it would read
if #1 > #2 else #4.
A conditional whose predicate never resolved has no branch to name, and that
is the one thing the body cannot say, so it keeps a trailing clause:
if #1 > #2 = (not measured) [no branch] when the diameter was never
measured. [no branch] is never "false" -- a predicate that never resolved
is not a predicate that resolved false, and reporting it as one would put a
branch in the record that was never taken. In the rarer case where the
predicate's own left side raises an arithmetic error, the right side is never
dispatched and nothing is ever compared, so the step drops the operator too
and reads if #3 = division by zero [no branch].
Step 4 carries a trailing clause of its own: [nearest, ties away from
zero], the RoundingMode that node rounded under. The mode is deliberately
absent from the rendered formula, in every dialect, and therefore from
document() too, which states its formula through the same renderer -- a
method states a granularity, "to one decimal place", without naming a tie
rule. The trace is the one place it appears, and the split is the point
rather than an inconsistency: a rendered formula states what a method says,
while a trace explains why one particular number came out as it did, and the
tie rule can be the entire reason a value is 13 rather than 12.
Note too that step 5's own value, 25 mm, reads in the unit of the branch it
names: a when() step's value is its chosen branch's, so it is shown in the
unit that branch's line shows, millimetres here -- one of the rules
Tracing and audit trails gives for the unit
a computed step borrows from the steps it read. For a second worked derivation
of a conditional -- a different formula, a different threshold, still naming
the branch it took -- see the gallery.
The traced escape hatch: numeric_value_of
Some published rules are fits whose coefficients only work when the input is expressed in one particular unit -- an empirical formula stated over "the numeric value of the strength in MPa" rather than over the strength itself. Read the same physical quantity in a different unit and the rule feeds a different number into the same coefficients, silently, because it was never meant to be evaluated in any other unit. That is not a quantity any more; it is a rule that only happens to be stated using one, and this library cannot make it dimensionally consistent because it genuinely is not.
numeric_value_of<U, Justification>(operand) (escape.hpp) is the one place
this library lets you drop a dimension on purpose, and it is built to make
that hole as narrow and as loud as it can be: it names the unit the number
must be read in, it produces a value that is honestly dimensionless rather
than a quantity wearing one, and it demands a Justification -- a
compile-time string that cannot be empty, cannot be blank, and cannot be
omitted -- recording why this one rule needed a bare number. Leaving it out,
or writing one that says nothing, is a compile error, not a lint warning.
Reaching for this should feel wrong, every time except the one time it is
actually the answer. It exists for a rule that is genuinely, unavoidably
stated over a bare number in one unit -- not for a dimensional mismatch you
would rather not fix. If you find yourself adding numeric_value_of to make
a static_assert about mismatched dimensions go away, stop: that assertion
is the library telling you two things do not measure what you think they
measure, and this escape hatch will happily carry that mistake forward
without ever converting it back, because converting it back is exactly what
it is built not to do.
constexpr auto empiricalCorrection = formula::yields<CorrectionFactor>(
formula::numeric_value_of<unit::Megapascal,
"Example Standard 9:2020 states this empirical coefficient over the numeric "
"value of strength in MPa">(var<Strength>)
* 0.0213_r
- 1.043_r);
which renders and traces as:
rendered: numeric(f, in MPa) * 213/10000 - 1043/1000
empirical correction factor at 70 MPa = 0.448
1. f = 70 MPa
2. numeric(#1, in MPa) = 70 (Example Standard 9:2020 states this empirical coefficient over the numeric value of strength in MPa)
3. 213/10000
4. #2 * #3 = 1491/1000
5. 1043/1000
6. #4 - #5 = 56/125
The justification is not merely carried -- it is rendered, on the step itself, in the trace above. That is the whole point of the escape hatch: a number left a named unit for a stated reason, and an audit trail that recorded the reason without ever showing it would be exactly the silent failure mode this node exists to prevent.