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.

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:

constexpr auto coarseInput =
    formula::rounded<unit::Millimetre, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero>(var<Diameter>);
constexpr auto toTwoSignificantDigits =
    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 SI 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 has been part of rounding.hpp since phase 2, 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.

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<unit::Millimetre, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero>(var<Diameter>);
constexpr auto roundThenDouble = coarseInput + coarseInput;
constexpr auto doubleThenRound = formula::rounded<unit::Millimetre, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero>(
    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.000000 mm
12.50 mm, double then round to 1 dp = 25.000000 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.000000 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::when(var<Diameter> > formula::constant<unit::Millimetre>(formula::Rational { 20 }),
                 formula::rounded<unit::Millimetre, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero>(var<Diameter>),
                 formula::rounded<unit::Millimetre, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero>(var<Diameter>));

which renders, and evaluates on both sides of its own threshold, as:

rendered: if d > 20 mm then round(d, to 0 dp of mm) else round(d, to 1 dp of mm)
12.34 mm, size-adjusted rounding    = 12.300000 mm
25.40 mm, size-adjusted rounding    = 25.000000 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>). 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 review actually caught. 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 the fix that was tried before this one, a [...] prefix (round[to 1 dp of mm](d)), collided with CommonMark's inline-link syntax and made 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. 20 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 = 1/40

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 20 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, 1/40, carries no mm -- a when() step is a computed value like any other, and every computed step is shown in the coherent SI unit of its dimension with no symbol at all, the same rule Tracing and audit trails explains for #1 / #2 in a plain division. 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::numeric_value_of<unit::Megapascal,
                              "Example Standard 9:2020 states this empirical coefficient over the numeric "
                              "value of strength in MPa">(var<Strength>)
        * formula::Rational { 2, 100 }
    - formula::Rational { 1 };

which renders and traces as:

rendered: numeric(f, in MPa) * 1/50 - 1
empirical correction factor at 70 MPa = 0.400000
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. 1/50
4. #2 * #3 = 7/5
5. 1
6. #4 - #5 = 2/5

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.