Skip to content

Latest commit

 

History

History
910 lines (781 loc) · 45.1 KB

File metadata and controls

910 lines (781 loc) · 45.1 KB

Tracing and audit trails

formula-cpp provides a way to record how a formula reached its answer, not only the answer itself: a sink, from sink.hpp, is a seam the evaluator calls at every node; RecordingSink, from trace.hpp, is the sink that turns those calls into a Trace -- a flat record of every step; and render_trace(), from trace_render.hpp, turns a Trace into text a person reads. This page explains what a sink is and why it costs nothing when nobody is listening, the two ways to evaluate a formula and when to reach for each, how to read a rendered derivation, why the renderer forces you to choose a bound, and what happens when a consumer's own node kind meets a sink it was never told about. The worked example is examples/tracing.cpp; every block on this page formatted as program output is copied verbatim from that program's actual output, exactly as docs/citations.md does for examples/citations.cpp. For a documentation page built the same way from several formulas, including a worked derivation, see the gallery.

Neither trace.hpp nor trace_render.hpp is included by the umbrella header, formula.hpp. trace.hpp pulls in <vector> for the arena a derivation is recorded into; trace_render.hpp pulls in <string> to format one. A consumer who only evaluates numbers must not compile either into a translation unit that never asks for a trace, so include whichever you need, by name:

#include <formula-cpp/trace.hpp>
#include <formula-cpp/trace_render.hpp>

What a sink is, and why it is passed by value

Every evaluator overload -- for a variable, a constant, a unary node, a binary node -- takes a third parameter, a sink, and calls two methods on it: entered before a node's operands are evaluated, produced after the node has its answer. sink.hpp states the seam as a concept:

template <typename S, typename N, typename V>
concept SinkFor = requires(S sink, N const& node, V const& value) {
    sink.entered(node);
    sink.produced(node, value);
};

and the default sink, the one every untraced evaluation uses, is NullSink: observes nothing, is empty, and is stateless:

struct NullSink
{
    template <Node N>
    constexpr void entered(N const&) noexcept
    {
    }

    template <Node N, typename V>
    constexpr void produced(N const&, V const&) noexcept
    {
    }
};

That sink is passed by value, not by reference, and that is a deliberate choice rather than a style preference. Passing an empty, stateless sink by reference still forces a compiler to materialise the address of an object nothing ever reads through, and at least one of the four compilers this library targets emits a real instruction for exactly that -- a leaq clang++ does not emit when the same sink is passed by value. sink.hpp's own file comment states the measurement (by value, across cl, clang-cl, clang++ and g++, at -O2//O2); the full table, with the one compiler whose behaviour has a narrow boundary condition, is in the design spec's traceability section (docs/superpowers/specs/2026-09-23-formula-cpp-design.md, §11). The point worth taking away without opening that table: adding a sink parameter, by value, changes nothing the untraced path emits, on every compiler measured, in the ordinary case where the call inlines.

That constraint has a consequence for anyone writing their own sink: keep it small and cheap to copy, because the evaluator copies it at every node it visits. A sink that owned a growable buffer would copy that buffer's contents at every node in the tree -- which is exactly why RecordingSink does not own the Trace it writes into. Its constructor takes one by reference and keeps only a pointer:

explicit constexpr RecordingSink(Trace<Rep>& trace, V vocabulary = V {}) noexcept:
    _trace { &trace },
    _vocabulary { vocabulary }

and that pointer, Trace<Rep>* _trace, is the whole of RecordingSink's storage when no vocabulary is given (trace.hpp): the default vocabulary is empty and takes no space. RecordingSink is a handle to the Trace, not its owner: the caller owns the Trace and it must outlive the walk. One pointer copies for free at every node; a Trace copied at every node would not. A jurisdiction's vocabulary, when one is given, is copied with the sink -- a few views of string literals, see Whose symbols.

There is a second consequence, and it is not optional the way "keep it small" is a matter of degree: a sink must not throw. Every checked_evaluate_si overload that calls a sink is noexcept, so an exception thrown out of entered or produced does not become an exception the caller can catch -- it calls std::terminate. This is a real risk, not a theoretical one: RecordingSink::produced itself allocates on every call (trace.hpp), because growing a Trace's steps is exactly what recording a derivation is. An allocating sink is fine; a sink that lets an allocation failure, or anything else, escape as an exception is not. Catch inside entered and produced, or otherwise guarantee they cannot throw, before handing a sink to the evaluator.

Two ways to evaluate, and when to reach for each

Both entry points walk the same tree with the same evaluator; only the sink composed into the walk differs. test/trace_tests.cpp evaluates one formula both ways and checks they agree:

auto const plain = formula::evaluate<Density>(density, environment);
auto const explained = formula::explain<Density>(density, environment);

// Memberwise equality across every Outcome alternative (kind, value,
// source, verdict and invalid-reason labels) -- not merely that both
// happen to hold a value. Tracing observes; it must not participate.
CHECK(explained.outcome == plain);
CHECK(explained.trace.steps.size() == 3);
// Check empty() before indexing with root() -- see below for when a Trace
// can be empty even though outcome holds a value.
REQUIRE_FALSE(explained.trace.empty());
CHECK(explained.trace.steps[explained.trace.root()].value == formula::Rational { 2 });

(test/trace_tests.cpp, "explain returns the same outcome evaluate would, plus the derivation".) formula::evaluate<Result> (and formula::checked_evaluate<Result>, its std::expected-returning form -- see Writing formulas for the two of those) take a sink parameter that defaults to NullSink, so calling either without a sink argument is the untraced path: no Trace is built, and nothing is allocated for one. formula::explain<Result> builds a RecordingSink for you, evaluates through it, and returns the outcome beside the trace it recorded (trace.hpp has the four-line body). explained.outcome is exactly what evaluate<Result>(expression, environment) would have returned -- tracing observes, it does not participate -- and explained.trace is the derivation. Reach for evaluate or checked_evaluate on a path that runs often and never shows its work to anyone; reach for explain at the point a derivation needs to be shown to a person -- a report, a review, a place where "here is the number" is not enough and "here is how" is what is actually being asked for.

explained.trace is not always populated, though. evaluate<Result> returns a manual override outright, without dispatching expression at all, when environment carries one for Result -- see Writing formulas, "The outcome". Nothing runs, so nothing is recorded: explained.outcome.is_overridden() is true and explained.trace.empty() is true at the same time. That is correct, not a bug -- an overridden number was not derived, so there is nothing to trace -- but it means explained.trace.steps[explained.trace.root()], the pattern the snippet above uses, reads past the end of an empty vector whenever the result happens to be an override. Check empty() before reading root(), the way the snippet above now does.

One difference is not about cost but about where the call can happen. evaluate and checked_evaluate are constexpr and remain usable in a constant expression -- test/sink_tests.cpp's constant-evaluation section pins this with static_assert, including the sink-carrying overload with an explicit NullSink. explain is simply not declared constexpr, and could not usefully be. A std::vector can be built and grown during constant evaluation -- that has been allowed since C++20 -- but what it builds there cannot survive past that evaluation into a runtime object: the standard requires every allocation a constant expression makes to be released again before the expression finishes. explain's whole purpose is to hand back a Trace that keeps its steps, which is exactly the kind of surviving allocation a constant expression is not allowed to produce. Writing the test's call above as a constant,

constexpr auto explained = formula::explain<Density>(density, environment);

fails to compile, verified with cl 19.51:

error C2131: expression did not evaluate to a constant
note: failure was caused by call of undefined function or one not declared 'constexpr'
note: see usage of 'formula::explain'

Evaluate at compile time when you can; explain is a run-time-only way to see the working.

Tracing any evaluation

explain traces a formula. The other verbs that take a sink -- a method, a curve, a rejection, a constraint, a conformity check -- have a twin of their own that returns the verb's result together with the trace it recorded. Over compressiveStrength, the method of three variants that examples/methods_and_overlays.cpp declares, and that example's specimen:

auto const derived = formula::explain_method<Cube>(compressiveStrength, specimen);
auto const verdicts = formula::explain_check_all(compressiveStrength.constraintSet, specimen);

derived.outcome is exactly what evaluate_method<Cube> returns, and derived.trace is the Trace a RecordingSink recorded while it did. explain_method, explain_check_method, explain_curve, explain_rejection, explain_check, explain_check_all and explain_conformity are the twins of evaluate_method, check_method, checked_evaluate_curve, checked_evaluate_rejection, check, check_all and check_conformity, and each takes the vocabulary to write the symbols in as an optional last argument, as explain does.

A verb without a twin -- or one of your own that takes a sink -- goes through traced, which gives the evaluation a RecordingSink and returns what the evaluation returned beside what the sink recorded. Over the ratio and the inputs of examples/tracing.cpp (Reading a derivation):

auto const run = formula::traced([&](auto recordingSink)
                                 { return formula::checked_evaluate<WaterCementRatio>(ratio, inputs, recordingSink); });

run.outcome is the std::expected that checked_evaluate returned, and run.trace the four steps below.

explain_series and explain_retry share the shape: outcome, then trace. A failure is in outcome, and trace holds the steps up to it; a value that was typed in rather than derived leaves trace empty, as it does for explain, which records in Rational: an evaluation that computes in double is traced by calling its checked_evaluate_si<double> with your own RecordingSink<double>.

Just the trace

Code that only shows how a number was reached has no use for the outcome, and traced spells the lambda out each time. trace_of gives the Trace alone, whether the evaluation succeeded or failed. With density and environment as in the explain example above:

auto const steps = formula::trace_of<Density>(density, environment);
auto const text = formula::render_trace(steps, { .maxSteps = 100 });

A failure while the formula is evaluated is the trace's last step (at every node that reports to its sink, as each of this library's does; see The extension point). One converting the result into Density's unit comes after it and is not in the trace, so read the outcome where that matters. When environment holds a value typed in for Density, that value is returned without evaluating and the trace is empty, as it is for explain.

A bound formula names its quantity already, so trace_of(boundFormula, environment) needs none. trace_of_si(density, environment) traces the evaluation in SI units with no result quantity named: it records the same steps for a derived result, and since it consults no value typed in for a result -- an input typed in is read as any other -- it traces the derivation even where trace_of<Density> is empty. All three take the vocabulary to write the symbols in as an optional last argument.

The outcome is deliberately not returned. A caller who needs it reads it with checked_evaluate, and one who needs it together with its trace uses checked_explain, which holds the trace on success and in its failure's trace on error. trace_of is for display; a number that matters is read where the failure can be handled.

Reading a derivation

examples/tracing.cpp builds the same water/cement ratio examples/citations.cpp evaluates -- 180 l of water, 300 l of cement, with the same invented citation attached by documented() -- and prints its trace:

auto const explained = formula::explain<WaterCementRatio>(ratio, inputs);

// render_trace has no default for maxSteps: TraceRenderOptions::maxSteps
// is a StepLimit, which has no default constructor, so a caller who
// writes render_trace(explained.trace, {}) does not compile, rather than
// risking an unbounded dump of a derivation many times this size.
std::print("{}", formula::render_trace(explained.trace, { .maxSteps = 10 }));

which prints, verbatim:

1. V_w = 180 l
2. V_c = 300 l
3. #1 / #2 = 3/5
4. #3 = 3/5 [Water/cement ratio, Example Standard 1:2020, 5.4.2, (3)]

Every line is one node the evaluator visited, numbered from one in the order each finished -- children before parents, so an operand's line always appears above the line that names it. A step that consumed earlier steps names them by number, #1 and #2; the citation on the last line is the one documented() attached, and it appears only on the step for the DocumentedNode itself, not on the division it wraps.

The two leaves read 180 l and 300 l, not the 9/50 and 3/10 cubic metres the arithmetic actually runs on. Every Step stores its value in the coherent unit of its dimension (the SI unit, times one of each named base dimension it carries) -- the one scale every step's value can be compared on -- but also remembers the unit it was declared in, and render_trace converts back before printing. Step's own comment explains why the recorder, not the renderer, has to be the one holding that unit:

/// The unit this step's value was **declared** in -- `Describe<Q>::unit`
/// for a variable or an overridden constant, the constant's own unit for
/// a constant, the node's own unit for a `Round`, `RoundSignificant`,
/// `RoundedRoot` or `RoundingRuleApplied` step, the unit of the step it
/// wraps for a `Documented`, `ReplacedVariant` or `VariantSelected` step --
/// each passes its operand's value through unchanged, so it states it as
/// that operand's line does, whenever that line is the wrapped node's own
/// and not the operands of a consumer's node -- and the coherent unit of
/// `dimension` for anything else computed, which has no declared unit of
/// its own.
///
/// `value` is always in the coherent unit, so that steps are
/// comparable; this is what a renderer converts back to before showing a
/// number to a person. Without it a derivation restates every input in a
/// unit nobody typed: someone who entered 180 l reads `9/50`, which is
/// the same volume and a worse record. The renderer cannot recover this
/// on its own -- by the time a `Step` exists the quantity type is erased,
/// so the recorder captures it here.

(trace.hpp.) A quantity's C++ type exists only while the evaluator is walking that quantity's own node; by the time RecordingSink::produced builds a Step for it, the type is gone and only the runtime Unit value survives. Capturing anything less at that point -- the coherent unit alone, say -- would make render_trace unable to ever show 180 l again; it would show 9/50 m3, arithmetically identical and a strictly worse record of what someone actually typed.

A step that is a plain computation, #1 / #2 above, carries no declared unit of its own -- it's whatever the coherent unit of its dimension is, which test/trace_render_tests.cpp pins directly for a squared mass over a volume:

1. m = 6 kg
2. #1^2 = 36
3. V = 3 m3
4. #2 / #3 = 12

(test/trace_render_tests.cpp, "a derivation renders one line per step, in order".) #1^2 and #2 / #3 carry no unit symbol at all -- and the reason is not that kg2 and kg2/m3 are awkward to spell. coherent() (evaluate.hpp) hands every computed step a Unit with no symbol at all, whatever its dimension: a computed mass prints no kg either, nor a computed length its m. A compound dimension is simply the case where the absence is most obvious, since there is no everyday symbol to miss; the behaviour itself applies to anything the evaluator computed rather than declared, with these exceptions, each of which takes its unit off a step it read:

  • A step that passes a value on unchanged -- a documented step, a jurisdiction's replacement, a variant's selection, a read from another record -- states it in the unit of the step it wraps, below.
  • A value that is a point on its operand's scale -- a mean, a pass's mean, a rejected determination -- reads in that operand's unit when it has a symbol, offset or not: a mean of Celsius readings is a Celsius reading.
  • A curve reads its points and values in the units of the steps it pairs, and a value read off it in its values' unit.
  • A sum, a range, a running total and a series scaled by a pure number read in their series' unit, and a rejection's deviation from the mean in its sample's, when that unit has a symbol and no offset (Series and grading curves).
  • An opaque output reads in an input's unit, or a quotient of two, under the same rule (Opaque operations and bounded retry).

A binary step whose left operand failed never evaluated its right one, and says so where the right operand would stand:

5. #1 / #4 = division by zero
6. #5 / (not evaluated) = division by zero

(test/trace_render_tests.cpp, "a binary step names the side that failed, the side never evaluated and a side that recorded no step".) A side computed by a consumer's node that records no step of its own reads (untraced).

A citation computes nothing, so a documented step states its value exactly as the line it names does -- the same number, in the same unit and spelling. Over a sample mass declared in grams:

1. m_s = 163/10 g
2. round(#1, to 0 dp of g) = 16 g [nearest, ties away from zero]
3. #2 = 16 g [Sample mass, Example Standard 1:2020, 4.1]

(test/trace_render_tests.cpp, "a documented step shows its value as the step it documents does".) A jurisdiction's replaced variant is the same: its line reads as the replacement's own. Over a consumer's node that hands the sink on to its operands (see below), there is no line of the node's own to read as -- only its operands', none of which holds its value -- so the documented step states its value in the coherent unit, as any computed step does.

A step that failed shows why instead of a value, and a step with no value at all -- an absent measurement, which is not an error -- says so rather than looking like one:

1. / = division by zero
2. m = (not measured)

(test/trace_render_tests.cpp, "a failing step renders its error, and an absent one renders absence".) The failing Divide above is a hand-built Step, not the recording of a real division by zero -- a real one runs both operands before the arithmetic fails, so it always records two. Zero operands is reachable from a real tree only when both children are untraced extension-point nodes (see below) that produce no step of their own for the outer node to claim. Step::operands holds exactly what the evaluator actually dispatched, not what the node's arity would predict -- when an operand fails, its parent returns without evaluating the remaining ones, so a Divide may hold one recorded operand, or, in the rare case above, none.

Which variant a method chose

A method reports one quantity by more than one formula -- a cube, a cylinder and a prism each have their own -- and which one applies is a property of the specimen, stated by the caller as a tag: evaluate_method<Cylinder>(...). Spec section 9.1 asks that an inspector reading the result can ask "why the cylinder formula?" and get an answer, so the selection is recorded as a step of its own, StepKind::VariantSelected, and a derivation says which variant fired and on what:

auto const derived = formula::explain_method<specimen::Cube>(compressiveStrength, inputs);
std::print("{}", formula::render_trace(derived.trace, { .maxSteps = 20 }));
1. F = 562 kN
2. 19321 mm2
3. #1 / #2 = 562000000000/19321
4. round(#3, in MPa) = 291/10 MPa [rounded to 1 dp (method default); nearest, ties away from zero]
5. #4 = 291/10 MPa [variant Cube (1st of 3), selected by tag]

(test/trace_render_tests.cpp, "a variant step reads as its operand, with the variant and its position in brackets", whose method has an invented cube, cylinder and prism.) The selection is the last line and the walk's one root: its operand is the variant that ran, rounded by the method's rule, and its value is exactly what evaluate_method returned. The bracket carries both halves of the answer. The tag's name is the discriminator the caller selected with; the position -- one-based here, zero-based in Step::variantIndex -- is what a reader counts back to in the method's variants(...), and it survives even where the name cannot be read. It is the position in the method as published: a jurisdiction's overlay that prunes the cube leaves the cylinder the 2nd of 3, not the 1st of 2, because the published variants(...) is the only one in the source to count in. selected by tag says how the choice was made rather than only that it was. Nothing in the line names a variant that was not taken: the neighbouring test selects the cylinder and checks that the word Cube appears nowhere in its derivation.

When a jurisdiction's overlay narrowed the variants first, the bracket says so in a second clause, with what the overlay cited: pin_variant<Cylinder>(annex) gives [variant Cylinder (2nd of 3), selected by tag; pinned by jurisdiction overlay: ...], and a prune gives ; 1 of 3 pruned by jurisdiction overlay: ... -- or, after prunes by more than one overlay, ; 2 of 3 pruned, the last by jurisdiction overlay: ..., naming what the last one cited. One overlay cannot both pin and prune, but one jurisdiction may prune what a later one pins, and then both clauses appear, the prune first. Both citations are required, and escaped as every other piece of author text is. (test/overlay_tests.cpp, "a pin says which jurisdiction made the variant mandatory" and the three cases after it.)

A method tells a sink about its choice through two optional members, variant_entered and variant_produced, with a VariantSelection (sink.hpp) -- a method is not a node, so it cannot come through entered and produced. A sink defines both or neither; NullSink defines neither and pays nothing.

The tag's name is recovered from the compiler, the way an enumerator's is, and it is the name as written, unqualified: Cube, whichever namespace or class declares it, and never with an anonymous namespace in front of it -- which the four compilers this library is measured on would otherwise spell in three different ways, and cl alone in two. A class template specialization keeps its arguments, Sized<163>, with their qualification stripped the same way. One difference cannot be evened out: cl prints a bool, char or enumeration argument as a number, Flag<1> where the others print Flag<true>, and it prints a defaulted argument the others leave out, Opt<Cube, void> for Opt<Cube>. And some tags have no reflected name that could be shown at all: a lambda or an unnamed class, or a specialization with one as an argument, or over a const type, a function type, a pointer or a cast. The compilers print those as file paths, as placeholders, as fragments, or -- for TagBox<const ns::A> -- as a name that, once its qualifiers are gone, is TagBox<A>: the name of a different type. A char, floating-point or class-type value as an argument is refused too, because the compilers print it differently: Ch<'x'> from clang and GCC is Ch<120> from cl, which cl then accepts, so such a tag compiles on cl and nowhere else until it is named. Rather than record a wrong name, the library refuses to compile such a tag and says to name it. An author who wants a tag to read the same everywhere, or to read the way a published method words the variant, specializes formula::TagName (tag.hpp), which has the shape and the refusals of EnumeratorName:

template <>
struct formula::TagName<Cylinder>
{
    static constexpr std::string_view of() noexcept { return "cylinder 135 x 271 mm"; }
};

Whose rounding rule, and whose constant

The rounding line above is a step of its own kind, StepKind::RoundingRuleApplied, rather than an ordinary rounding step. Spec section 9.1 asks the trace to record which rounding rule applied and where it came from, and rounded to 1 dp alone is true whether the method's author chose the rule or a jurisdiction did. So the bracket says whose it was: (method default) for the rule the method was declared with, and (jurisdiction overlay: ...) for one an overlay's with_rounding put in its place, followed by what the overlay cited:

8. round(#7, in MPa) = 601/100 MPa [rounded to 2 dp (jurisdiction overlay: Example Standard 12:2021 NA, NA.4.1); nearest, ties away from zero]

(test/overlay_tests.cpp, "the trace says where the rounding rule came from".) A jurisdiction that restates the method's own granularity still gets (jurisdiction overlay: ...): the rule is then its rule, and the trace does not decide whose it was by comparing numbers. The provenance is in Step::roundingProvenance, and the citation in Step::citation.

Every overlay operation takes a citation argument, but an empty one compiles: with_rounding<...>({}), pin_variant<Cube>({}), or an operation's aggregate built directly, such as VariantPin<Cube> {}. Every clause an overlay adds then says so, (jurisdiction overlay (no citation given)) here and [fixed by jurisdiction overlay (no citation given)] below, rather than a bare jurisdiction overlay that a reader could take for a cited one. (test/overlay_tests.cpp, "an operation given an empty citation says so in every clause".)

A constant an overlay fixed with with_constant is traced the same way, as StepKind::OverriddenConstant rather than as a variable. It reads as its quantity, but it says the value was not the specimen's:

1. k_s = 863/1000 [fixed by jurisdiction overlay: Shape factor, Example Standard 12:2021 NA, NA.2.3]

(test/overlay_tests.cpp, "an overridden constant is traced as fixed by the overlay, holding its value".) document() marks it too: the quantity's row in the symbol table carries fixedValue and fixedBy, so a documentation page does not ask a reader to supply a value the formula never reads. A formula assembled by hand that both fixes a quantity and reads it from the specimen gets a row saying both: alsoReadAsInput is set beside the fixed value.

A quantity a jurisdiction defines by an expression, with add_derived, is traced as StepKind::DerivedQuantity: the quantity, equal to the step its definition produced, marked as the overlay's:

3. #1 / #2 = 2/3
4. k_s = #3 = 2/3 [derived by jurisdiction overlay: Shape factor, Example Standard 12:2021 NA, NA.2.3]

(test/overlay_tests.cpp, "a derived quantity is traced as derived by the overlay".) Its row in document()'s symbol table carries the definition in the page's dialect as derivedAs, and the citation as derivedBy. A variant whose formula a jurisdiction replaced wholesale, with replace_variant, is traced as StepKind::ReplacedVariant, a step of its own under the variant selection whose line ends [replaced by jurisdiction overlay: ...]. It is a step of its own because what it marks is the formula that ran, not the choice of which variant ran.

Whose constraints

A method's constraints are checked with check_method, which answers one outcome per constraint the method holds -- check_all over the set it holds, handed over whole: check_all(m.constraintSet, inputs) for a method's own constraints, check_all(m.constraintSet.constraintSet(), inputs) for a jurisdiction's -- and tells a sink whose constraints they are. The verdicts are gathered under a step of their own, StepKind::AcceptanceChecked, whose operands are the verdicts in the order check_method returns them, and each verdict's bracket says whose check it was:

1. F = 90000 N
2. 47300 N
3. require #1 >= #2 [satisfied; the method's own constraint]
4. acceptance(#3) [the method's own constraints]

(test/overlay_tests.cpp, "each verdict says whether the method or a jurisdiction's overlay supplied it".) An overlay's with_constraints replaces the constraints wholesale, with as many as the jurisdiction states, and every verdict of the overlaid method then ends ; jurisdiction overlay: and what the overlay cited. A jurisdiction that removes every constraint still gets a line, so the removal is never silent:

1. acceptance(none) [jurisdiction overlay: Acceptance, Example Standard 12:2021 NA, NA.6]

(test/overlay_tests.cpp, "an overlay removes every constraint, and the trace says by whose authority".) The provenance is in Step::constraintProvenance, set on the gathering step and on each verdict it holds, with the overlay's citation in Step::citation. A constraint checked on its own, with check or check_all, belongs to no method, and its line reads as it always did. A method tells a sink about its constraints through two optional members, acceptance_entered and acceptance_produced, given a ConstraintOrigin read off the method's constraints; a sink defines both or neither.

Only the library states a provenance

The provenance a trace records in a Step's fields is only ever the library's to state. The nodes an overlay leaves behind -- a fixed constant, a derived quantity, a replaced formula -- can be built only by the overlay, and building one by hand is refused in the library's words. A RoundingRule claims a jurisdiction's overlay only when with_rounding produced it, and the rounding node a method applies is built only by evaluate_method, from the method's own rule, which it holds rather than a provenance of its own. A method's constraints are a jurisdiction's only when they are the OverlaidConstraints that with_constraints produced -- which carries the overlay's citation with the constraints themselves -- and building one by hand is refused. A variant's published position and count are stated only by variants(...) and carried by apply through a pin or a prune: a layout written by hand, { { 5, 7 }, 9 }, and one selected by hand from another pack's, published.select<5, 7>(), are both refused.

The structured fields are what is authoritative. A Step records its provenance in fields of its own -- kind, roundingProvenance, constraintProvenance, variantPinned, variantPrunedCount and the citations beside them -- and those are set only by the library. Code that has to decide whose a value was reads them, not the rendered line.

The rendered line is escaped so that author text cannot break its structure. A trace line is a numbered line whose provenance is a bracketed clause at its end, and some of the words in it are the author's: a quantity's symbol, a citation, a verdict's label, a justification, a unit's symbol, a variant's tag and a lookup key's name. render_trace escapes every one of them before it writes the line -- \ as \\, [ as \[, ] as \], ; as \;, a newline as \n, and any other control character as \x and two hex digits -- and writes its own clauses as they are. So a declared symbol k] [fixed by jurisdiction overlay: X reads

1. k\] \[fixed by jurisdiction overlay: X = 1

and cannot pass for the clause the library writes when an overlay did fix k, and a verdict labelled reject; jurisdiction overlay: X cannot name a second owner for a constraint. A TagName or EnumeratorName spelling, and a vocabulary's symbol, go further: holding [, ] or a control character, it is refused at compile time.

Author text may still contain any words. The escape stops a clause from being opened or closed, and a line from being ended; it does not stop a clause's words. A documented() citation titled replaced by jurisdiction overlay: Example Standard 9:2022 NA renders its Documented line exactly as a genuine replace_variant citing that standard renders its own, and nothing in the text tells them apart; Step::kind does. The method's author is trusted to cite what the method cites.

Both rules are byte-level and ASCII. Unicode look-alikes of the library's brackets, such as the fullwidth [ and ] (U+FF3B, U+FF3D), and the line and paragraph separators U+2028 and U+2029 are neither escaped nor refused. They cannot break the structure the library writes, which is ASCII throughout, though a viewer may draw them as a bracket or break the line at a separator.

What the guard governs is how a rule, a set of constraints or a layout is created, not where a copy travels, and a copy stays true of itself: a method holding a copy of an overlay's rule is traced as that overlay's rule, and a method built from an overlaid method's constraintSet checks the jurisdiction's constraints and says so, because they are the jurisdiction's. These routes remain, and no type can close them:

  • method(o.variantSet, o.rounding, o.constraintSet.constraintSet()) hands the jurisdiction's constraints over as a plain set in one call, which makes them the new method's own -- reading them has to be possible.
  • Assigning a method's public rounding member, m.rounding = rounding_rule<...>(), replaces a jurisdiction's rule with a rule of the method's own, and the trace then says "(method default)". The member is public so that a method stays an aggregate.
  • Copying a pack's layout, pack.published = other.published, or resetting it to declaration order with pack.published = {}, gives it a layout the library made for another pack -- positions, count and any pin or prune with what it cited. A pruned pack reset this way reports its variants as the 1st and 2nd of 2 rather than where they were published, and says nothing of the prune.
  • Reinterpreting an object's bytes makes it anything.
  • Explicitly specialising a library template, or a member of one, forges anything, and no C++ library can stop it. An explicit specialisation of a member -- a constructor, an accessor such as RoundingRule<...>::provenance(), a defaulted default constructor -- is a member definition, with a member's access to the private fields; friend injection names a detail:: type without spelling detail::. Both were measured making a method no overlay touched trace a jurisdiction's rounding rule. The only supported customisation points are TagName, EnumeratorName, Describe, RepTraits, OpaqueOperation (whose compute does its arithmetic through RepTraits and never throws; see Opaque operations and bounded retry) and the vocabulary. Specialising any other formula-cpp template or member is outside the contract, and can make the trace say anything.

Nor does the guard reach a sink's own hooks, which are public: code that calls them by hand, or fills in a Step by hand, writes whatever trace it likes.

Whose symbols

A Variable, OverriddenConstant or DerivedQuantity step records its quantity's symbol when the formula is evaluated, and render_trace only reads it back. So a jurisdiction's vocabulary (see Citations and rendering) has to be given to the sink, not only to render() -- a page rendered in one vocabulary and a trace recorded in another would name one quantity with two different letters. An explain_* twin hands the vocabulary it is given to the sink it builds. Over limit, crossedInputs and south, the fixtures of test/vocabulary_tests.cpp:

auto const southern = formula::explain_check(limit, crossedInputs, south);
1. E = 30 MPa
2. R = 12 MPa
3. require #1 >= #2 [satisfied]

(test/vocabulary_tests.cpp, "a constraint's trace names quantities in the sink's vocabulary", which gives south to a RecordingSink of its own.) explain takes the vocabulary as an optional third argument, and every explain_* twin and traced as an optional last one. Those three step kinds are the only ones that name a quantity. Every other step names none -- arithmetic, a lookup, a rounding rule, a constraint, a method's constraints, a variant selection and a replaced variant refer to their operands by number -- and so reaches the vocabulary through the steps beneath it.

The sink keeps its own copy of the vocabulary -- plain data holding views of string literals -- so, unlike the Trace, the vocabulary need not outlive the evaluation, and a temporary one is fine.

The bound is a required argument, not a default

struct StepLimit
{
    StepLimit() = delete;
    constexpr StepLimit(std::size_t steps) noexcept: value { steps } {}

    std::size_t value {};
};

struct TraceRenderOptions
{
    StepLimit maxSteps;
    NumberStyle numbers = NumberStyle::fraction();
};

maxSteps is a StepLimit, not a plain std::size_t, on purpose: a caller who writes render_trace(trace, {}) does not compile. A plain std::size_t member with no default initialiser would not achieve that -- TraceRenderOptions is an aggregate, so {} would still value-initialise it to zero and render nothing at all, silently, which is a worse outcome than either a diagnostic or an unbounded render. StepLimit has no default constructor, so there is no zero for {} to produce; {.maxSteps = 10} and {25} both still work, because StepLimit's own constructor is not explicit. Every other option this library exposes with a sensible default gets one -- numbers, the notation every value is written in, defaults to fractions, and Displaying numbers shows the decimal styles -- while this one does not, because a sensible default does not exist. An unbounded render of a derivation with a hundred thousand steps once collapsed into one wall of text long enough to be practically unusable -- the same failure mode trace.hpp's flat, index-addressed arena exists to make representable without recursion, just at the rendering end instead of the storage end. A default limit is a limit someone forgets to raise or lower for their own formula; a required one is a limit someone actually chose. When a trace is longer than the bound, render_trace shows the first maxSteps lines and then exactly one line saying how many were left out -- never a silent truncation and never all of them:

1. 0
2. 1
3. 2
... 97 further steps not shown

(test/trace_render_tests.cpp, "a derivation longer than the limit is cut, and says so", a 100-step trace rendered with maxSteps = 3.)

Walking a Trace more than once

Constructing a RecordingSink over a Trace begins a walk, and a Trace may hold the steps from more than one walk at once -- root() always names the most recent one. test/trace_tests.cpp evaluates the same formula twice into one Trace, with a fresh RecordingSink each time:

formula::Trace<> trace {};

{
    formula::RecordingSink<> sink { trace };
    auto const result = formula::checked_evaluate_si<formula::Rational>(density, environmentOf(6, 3), sink);
    REQUIRE(result.has_value());
}
REQUIRE(trace.steps.size() == 4);
REQUIRE(trace.unclaimed == std::vector<std::size_t> { trace.root() });

{
    formula::RecordingSink<> sink { trace };
    auto const result = formula::checked_evaluate_si<formula::Rational>(density, environmentOf(10, 5), sink);
    REQUIRE(result.has_value());
}

CHECK(trace.steps.size() == 8);
CHECK(trace.unclaimed == std::vector<std::size_t> { trace.root() });
CHECK(trace.root() == 7);

(test/trace_tests.cpp, "a second walk into the same Trace does not leave the first walk's root unclaimed forever".) What is not supported, and carries no runtime guard, is two sinks walking the same Trace at once: constructing a second RecordingSink clears bookkeeping the first walk is still using, and that first walk's next produced call reads out of an empty vector -- undefined behaviour. Nothing in this library does that to itself; it is a precondition on a consumer who shares one Trace across two evaluations that are not sequenced. Walk a Trace in sequence, never concurrently.

The extension point: your node evaluates, but is it traced?

Evaluation has a two-parameter extension point -- a consumer writes their own node kind and a checked_evaluate_si(node, environment) overload for it, found by ADL. Adding a sink parameter to every overload the library ships could have broken every such overload by making it invisible to the dispatcher; instead, detail::dispatch prefers a sink-aware, three-parameter overload where one exists for a node and falls back to the older two-parameter one where it does not:

template <typename Rep, typename N, typename Env, typename Sink>
[[nodiscard]] constexpr auto dispatch(N const& node, Env const& environment, Sink sink) noexcept
{
    if constexpr (requires { checked_evaluate_si<Rep>(node, environment, sink); })
        return checked_evaluate_si<Rep>(node, environment, sink);
    else
        return checked_evaluate_si<Rep>(node, environment);
}

(sink.hpp.) A node written against the older, two-parameter extension point therefore keeps evaluating correctly, with the right answer, composed with any other node exactly as before. What it does not do is contribute anything to a trace -- there is no overload to call the sink through, so entered and produced are simply never called for that node. test/sink_tests.cpp proves both halves of this at once, by counting: a LegacyNode added to a Mass gets the right sum, 12, but the sink only ever hears about the two nodes that know it exists:

auto const result = formula::checked_evaluate_si<formula::Rational>(expression, environmentOf(5, 1), sink);

REQUIRE(result.has_value());
REQUIRE(result->has_value());
CHECK(**result == formula::Rational { 12 });
// Two nodes reported, not three: the legacy node is evaluated but not
// traced, because nothing told the library how to trace it.
CHECK(entered == 2);
CHECK(produced == 2);

(test/sink_tests.cpp, "a node written against the two-parameter extension point still evaluates".) This is worth stating plainly rather than leaving it to be discovered: adding your own node kind to this library gets you correct arithmetic for free and a traced subtree for nothing -- no warning, no diagnostic, just a derivation with a gap in it exactly where that node stood. render_trace cannot even show the gap, because nothing was ever recorded to show; the tree beneath an untraced node vanishes from the derivation as completely as if the formula had been written without it.

That graceful degradation belongs to the two-parameter overload alone. It would be natural to conclude that a consumer who wants their node traced writes the three-parameter overload instead -- calling sink.entered(node) before evaluating its operands and sink.produced(node, result) after, the shape every evaluator overload in this library follows. Against NullSink, or a sink of the consumer's own, that compiles and works. Against RecordingSink it does not compile: RecordingSink looks up every node's kind in detail::StepKindOf (trace.hpp), a closed registry whose primary template is deliberately left undefined, and a consumer's node has no entry there. g++ 13.3 reports "incomplete type formula::detail::StepKindOf<AwareNode> used in nested name specifier", and cl 19.51 reports C2027, "use of undefined type". So today a consumer's own node cannot appear in a recorded trace at all. What does compile is a three-parameter overload that only hands the sink on to its operands' detail::dispatch and reports nothing of its own: its operands are traced, and it is not -- measured on the same two compilers. Opening the registry to consumers is a separate change from anything this guide describes. A computation of a consumer's own that the page need not spell out can be traced today as an opaque operation instead: its inputs and outputs are traced, and its line says its inside is not shown -- see Opaque operations and bounded retry.

Every citation here is invented

Every citation used to demonstrate tracing on this page -- and in examples/tracing.cpp and the gallery's derivation -- names a fictional Example Standard, never a real one, for the reason docs/citations.md gives in full: a real standard's clause numbers and equations are copyrighted material, and this is a public repository.