Constraints and verdicts
A standard routinely states a relationship purely to validate a result, not
to compute one -- "the specimen shall be rejected below 30 MPa", "the two
replicates shall agree within 5%". formula::constraint() pairs a predicate
(predicate.hpp, also covered in Rounding and conditionals)
with what to do when it does not hold, and formula::check() reports what
happened as one of four outcomes, not two. This page explains why there
are four, works through the one case that is the entire reason this feature
exists, and shows how a constraint renders and traces.
The worked example is examples/constraints.cpp; every block on this page
formatted as program output is copied verbatim from that program's actual
output, the same way Rounding and conditionals
does for examples/rounding_and_conditionals.cpp.
A Constraint is deliberately not a Node, for the same reason a
predicate is not one: checking it produces a verdict, which has no
dimension, and giving it one would mean inventing a dimension to lie about.
That has real consequences below -- a constraint cannot sit inside a
formula's own operand tree, and cannot be wrapped by documented(): it
carries its own Citation directly instead, precisely so that it never
needs wrapping. document() itself, though, has its own overload for a
standalone Constraint, alongside the one for Node -- see
below.
A predicate paired with a verdict
formula::constraint(predicate, verdict, citation = {}) builds a
Constraint<P>: a plain aggregate holding the predicate that must hold,
the Verdict that applies when it does not, and where the rule comes from.
The predicate is stated the way the standard states it -- as the condition
that must hold, not as the failure -- so the declaration reads the way the
standard reads:
constexpr auto minimumStrength =
formula::constraint(var<Strength> >= formula::constant<unit::Megapascal>(formula::Rational { 30 }),
formula::Verdict { "reject the specimen" },
formula::Citation { .title = "Minimum compressive strength",
.reference = "Example Standard 7:2020",
.section = "5.1" });
There is deliberately no separate verdict for success: a satisfied constraint is silent by nature, and a slot for a pass verdict would only invite noise an auditor then has to read past.
Four outcomes, not two
Checking a constraint (formula::check(constraint, environment)) returns a
ConstraintOutcome, whose kind() is one of four ConstraintOutcomeKind
values:
Satisfied-- the predicate held.Violated-- the predicate did not hold;outcome.verdict()carries theVerdictthe constraint was declared with.NotChecked-- the predicate never resolved, because an input it needed was never measured.Invalid-- evaluating the predicate raised an arithmetic error;outcome.error()carries it.
ConstraintOutcome has no operator bool() and no plain bool satisfied()
convenience accessor, and that absence is deliberate rather than an
oversight. Either answer such an accessor could give for NotChecked or
Invalid is a lie: true collapses "held" together with "never checked" or
"broke while checking", and false says the specimen failed the check when
in fact no check ran at all. A caller who "just wants to know if it passed"
is exactly the caller who must instead read kind(), or is_satisfied()
and is_violated() and is_not_checked() and is_invalid(), and
decide what an unresolved check means for their own report.
The case this phase exists for: a measurement nobody took
Here is the same constraint checked against three environments -- one where strength was measured at 45 MPa, one at 20 MPa, and one where strength was never measured at all:
constexpr auto satisfied = formula::check(minimumStrength, strengthOf(45));
constexpr auto violated = formula::check(minimumStrength, strengthOf(20));
constexpr auto notChecked = formula::check(minimumStrength, nothingMeasured());
which report:
45 MPa: satisfied
20 MPa: violated (reject the specimen)
no strength measured: not checked
The third line is the one this whole phase exists for. Nobody measured the
strength, so the predicate f >= 30 MPa never resolved -- it is not true and
it is not false, because there is no f to compare against 30 MPa at all.
check() reports NotChecked, never Satisfied.
Consider what a library that reported Satisfied there would be
producing. This library's intended use is a laboratory or a compliance
report: a constraint like minimumStrength exists so that someone, reading
the generated report, can trust that "satisfied" means a measured specimen
was checked against 30 MPa and passed. If an unmeasured input silently
reported Satisfied, that trust would be misplaced every time a
measurement was skipped, forgotten, or arrived late -- the report would read
identically whether the specimen was tested and passed, or never tested at
all. That is not a rounding error or a display quirk; it is a false record
of verification, and a false record of verification is precisely what a
constraint exists to prevent, not produce.
The code backs this up directly, not only the printed word:
bool const notCheckedIsHonest = notChecked.is_not_checked() && !notChecked.is_satisfied() && !notChecked.is_violated();
NotChecked is not a failure either. is_violated() is false for it too --
a specimen that was never tested has not failed a test, and reporting it as
though it had would send someone chasing a rejection that never happened.
It is its own, honest, third thing.
A fourth state: arithmetic can break while checking, too
NotChecked is not the only way a predicate can fail to resolve. Checking a
constraint whose predicate divides by a measured zero raises an arithmetic
error instead of ever comparing anything:
constexpr auto dividesByZero =
formula::constraint((var<Strength> / formula::number(formula::Rational { 0 }))
> formula::constant<unit::Megapascal>(formula::Rational { 1 }),
formula::Verdict { "specimen result is unusable" });
divides by zero: invalid (division by zero)
NotChecked and Invalid stay distinct states rather than collapsing into
one "could not check" bucket, because they call for different action from
whoever reads the report: "nobody measured the input" is a data-collection
problem, and "we tried to check and the arithmetic broke" is a defect in the
rule or the data feeding it. A reader who sees only Satisfied and
Violated in a report will end up writing code that treats every other
state as one of those two -- which is exactly the failure this four-state
design exists to prevent.
How a constraint renders and documents
A constraint renders as its rule alone, require <lhs> <comparison> <rhs>,
never its verdict:
std::printf("rendered: %s\n", formula::render(minimumStrength).c_str());
std::printf("rendered (LaTeX): %s\n", formula::render<formula::Dialect::LaTeX>(minimumStrength).c_str());
rendered: require f >= 30 MPa
rendered (LaTeX): \text{require } f \geq 30 MPa
require names no public function -- constraint(predicate, verdict,
citation) is a three-argument call, so nothing here reads as a mis-spelled
invocation of it -- and it is the same keyword the trace step below uses, so
a reader who has seen a constraint in one surface recognises it in the
other. The verdict stays out of both renderings on purpose: this text states
the condition a standard asks a reader to check, and a verdict is not part
of that condition, it is what a checker does once the condition is already
decided. check() reaches Satisfied or Violated from the predicate
alone; the verdict is a label attached afterwards, not an ingredient the
predicate needed.
A Constraint cannot be wrapped by documented() -- it carries its own
Citation precisely so that it never needs wrapping -- but document()
itself has its own overload for a standalone Constraint, alongside the one
for Node, so a constraint documents exactly the way a formula does:
formula::Documentation const documentation = formula::document(minimumStrength);
formula::Citation const& citation = documentation.citations.front();
std::printf("documented: %s\n", documentation.formula.c_str());
std::printf("cited: %.*s, %.*s, %.*s\n",
static_cast<int>(citation.title.size()),
citation.title.data(),
static_cast<int>(citation.reference.size()),
citation.reference.data(),
static_cast<int>(citation.section.size()),
citation.section.data());
for (formula::SymbolEntry const& entry: documentation.symbols)
std::printf("symbol: %.*s = %.*s [%s]\n",
static_cast<int>(entry.symbol.size()),
entry.symbol.data(),
static_cast<int>(entry.description.size()),
entry.description.data(),
std::string { formula::view(entry.unit.symbolText) }.c_str());
documented: require f >= 30 MPa
cited: Minimum compressive strength, Example Standard 7:2020, 5.1
symbol: f = measured compressive strength [MPa]
documentation.formula is the same require f >= 30 MPa the plain
rendering above already showed -- document() reaches it by calling the
identical render<D>() this page has already used, not a second renderer --
and the citation and the one-row symbol table come from walking the
constraint's predicate, the same detail::collect() machinery every other
node in this library goes through.
How a constraint traces
Where the verdict does appear is the trace. check() records a
constraint as its own step, in the same require #1 >= #2 shape as the
rendering above, with a bracketed suffix naming what checking it concluded --
present for every one of the four outcomes, because nothing else in the
line carries that distinction:
template <typename P, typename Env>
[[nodiscard]] std::string tracedCheck(formula::Constraint<P> const& subject, Env const& environment)
{
formula::Trace<> trace {};
formula::RecordingSink<> sink { trace };
[[maybe_unused]] auto const outcome = formula::check(subject, environment, sink);
return formula::render_trace(trace, { .maxSteps = 5 });
}
Called once per outcome above, this prints:
1. f = 45 MPa
2. 30 MPa
3. require #1 >= #2 [satisfied]
1. f = 20 MPa
2. 30 MPa
3. require #1 >= #2 [reject the specimen]
1. f = (not measured)
2. 30 MPa
3. require #1 >= #2 [not checked]
1. f = 0 MPa
2. 0
3. #1 / #2 = division by zero
4. require #3 [division by zero]
#1 and #2 are the predicate's own left and right sides, recorded and
numbered exactly like any other step's operands even though Constraint is
not a Node and never gets a step of its own from entered/produced --
it goes through the parallel constraint_entered/constraint_produced hook
RecordingSink defines for it. [satisfied] and [not checked] are fixed
words; [reject the specimen] is the constraint's own Verdict label,
carried into the trace only once it actually applies; and [division by
zero] is the arithmetic error that made checking impossible, the same text
formula::describe(ArithmeticError::DivisionByZero) returns elsewhere in
this library. The last trace also shows why a constraint's step can have
only one operand rather than two: the left side, f / 0, itself raised
the division-by-zero error, so the right side was never dispatched and
nothing was ever compared -- require #3, with no comparison token at all,
for the same reason a Conditional step drops its own comparison in the
matching one-operand case (see Rounding and conditionals).
Checking a set: no short-circuit
A method rarely states one constraint in isolation. formula::constraints(a,
b, ...) bundles several into a ConstraintSet, and formula::check_all()
checks every one of them against the same environment, reporting one
ConstraintOutcome per constraint at the same index it was declared at.
Alongside minimumStrength, a second, independent constraint over a
different quantity:
constexpr auto maximumDiameter =
formula::constraint(var<Diameter> <= formula::constant<unit::Millimetre>(formula::Rational { 100 }),
formula::Verdict { "specimen exceeds diameter tolerance" });
constexpr auto setOutcomes =
formula::check_all(formula::constraints(minimumStrength, maximumDiameter), strengthOnly(20));
Here strength (20 MPa) violates minimumStrength, and diameter was never
measured, so maximumDiameter cannot resolve:
set[0] (minimumStrength): violated
set[1] (maximumDiameter): not checked
Every constraint is evaluated. There is no short-circuit. Both outcomes
above are reported; neither suppresses the other, and the outcome at index 1
being unresolved has no effect on index 0 being reported as violated, or
vice versa. This is deliberately the opposite of when()
(Rounding and conditionals), which evaluates
only the branch its predicate selects -- and the reason differs rather
than the rule being inconsistent. when() skips a branch because evaluating
it could raise an arithmetic error that has nothing to do with the answer a
caller asked for; every constraint in a set, by contrast, is about the
answer, so skipping one to save work would be discarding a check the caller
actually asked for, not avoiding a meaningless one. A specimen can fail two
checks at once, and a report naming only the first sends someone back for a
second round of testing they should not have needed.
Every citation here is invented
Every citation used to demonstrate constraints on this page and in
examples/constraints.cpp names a fictional Example Standard, never a
real one, for the reason Citations and rendering gives in
full: a real standard's clause numbers and thresholds are copyrighted
material, and this is a public repository.