Skip to content

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.